From efbb6fdc9e7be4671e9bef51a97815c95c4f5065 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 13:53:10 +0300 Subject: [PATCH 01/23] test(reference): require code-styled object names in titles Reference titles style object names through the doc-object-name span. Assert that span instead of the removed inline style attribute. --- tests/test_gdg_rendered.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index ae27c432..c8906bb6 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -3940,10 +3940,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" + # `span.doc-object-name` applies the title's code style; inline styles + # do not. + name_span = title_el.select_one("span.doc-object-name") + assert name_span is not None, ( + f"{page.name}: title is missing span.doc-object-name for code styling" ) From e4c83ceeb354a6eb92bb66c2b52265162f2dd97f Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 13:59:57 +0300 Subject: [PATCH 02/23] refactor(post-render): remove unreachable reference-title rewrites Five post-render rewrites cannot match current HTML: object names are wrapped in spans, the parenthesis rewrite has no effect, the targeted section-heading level is absent, title wrapping searches for markup produced later, and the inline font duplicates the stylesheet. Remove them. Rendered reference HTML changes only by losing the redundant inline style. --- great_docs/assets/post-render.py | 87 -------------------------------- 1 file changed, 87 deletions(-) diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index 1d076603..1908f0c6 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -1981,93 +1981,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) From f1ab92b7bbd2cd5ce22cb961b1c51af7f8f29b09 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 14:22:59 +0300 Subject: [PATCH 03/23] fix(reference): preserve one h1 and contiguous heading levels Two post-render transforms shifted object-page headings. The file-wide transform also changed navigation and footer headings, while the main-content transform demoted the title. Pages therefore rendered the title at h2, sections at h4, and members at h5. Remove the file-wide transform. Keep the title at h1 and shift only its sections and members to h2 and h3. Scope the compact title style to API object pages. --- great_docs/assets/great-docs.scss | 6 ++-- great_docs/assets/post-render.py | 24 ++++++++++------ tests/test_gdg_rendered.py | 46 +++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/great_docs/assets/great-docs.scss b/great_docs/assets/great-docs.scss index b421b170..f4f3f4a9 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-api-page h1.title { margin-top: 0.5rem; border-bottom: none; padding-bottom: 0; + font-size: 1.25rem; } .table a { diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index 1908f0c6..66fef6f8 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -2051,12 +2051,6 @@ 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) @@ -2116,9 +2110,9 @@ def fix_dataclass_attributes(content_str): 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. + # Quarto's global heading shift promotes the renderer's section markup to + # the same level as the page title. Move those sections down so the title + # remains the only `

` and its sections and members nest beneath it. main_start = content_str.find("") if main_start != -1 and main_end != -1: @@ -2126,6 +2120,14 @@ def fix_dataclass_attributes(content_str): main_content = content_str[main_start : main_end + len("

")] after = content_str[main_end + len("") :] + # Exclude the title while shifting the remaining headings. + 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) + # 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): @@ -2137,6 +2139,10 @@ def fix_dataclass_attributes(content_str): main_content, ) + # Restore the title after shifting the remaining headings. + if title_match: + main_content = main_content.replace(title_placeholder, saved_title, 1) + content_str = before + main_content + after content = content_str.splitlines(keepends=True) diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index c8906bb6..9a5e6f6e 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,46 @@ def test_footer_text_not_in_header(pkg_name: str): ) +@requires_bs4 +@pytest.mark.parametrize("pkg_name", ["gdtest_minimal", "gdtest_google", "gdtest_sphinx"]) +def test_r1_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. These levels must match Quarto's table of + contents. + """ + 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" + + 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" + ) + + for section in soup.select("section.doc-parameters, section.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}" + ) + checked += 1 + + assert checked > 0, f"{pkg_name}: no docstring sections were checked" + + # ═══════════════════════════════════════════════════════════════════════════════ # R2: Docstring Rendering — parameters, returns, raises, examples # ═══════════════════════════════════════════════════════════════════════════════ @@ -3932,7 +3972,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) From df2a022a9ae9bc35a5988316b0e790602ccbaebd Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 16:07:50 +0300 Subject: [PATCH 04/23] fix(reference): protect decorated titles and limit title sizing Match the title class within any class list so Quarto's additional display classes cannot expose the title to the heading shift. Add a Python-reference body class and scope the compact title rule to it. MCP pages share the general API page class but retain their standard title size. Extend the heading test to cover member headings. --- great_docs/_apiref/_render/api_page.py | 2 +- great_docs/assets/great-docs.scss | 2 +- great_docs/assets/post-render.py | 7 +++++-- tests/test_gdg_rendered.py | 23 ++++++++++++++++++++++- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/great_docs/_apiref/_render/api_page.py b/great_docs/_apiref/_render/api_page.py index a14eced6..eb93b003 100644 --- a/great_docs/_apiref/_render/api_page.py +++ b/great_docs/_apiref/_render/api_page.py @@ -72,7 +72,7 @@ def render_metadata(self) -> BlockContent: return Meta( { "title": f"{title}", - "body-classes": "doc-api-page", + "body-classes": "doc-api-page doc-py-reference", } ) diff --git a/great_docs/assets/great-docs.scss b/great_docs/assets/great-docs.scss index f4f3f4a9..bbd6d7ec 100644 --- a/great_docs/assets/great-docs.scss +++ b/great_docs/assets/great-docs.scss @@ -815,7 +815,7 @@ html.theme-loading body { /* Keep reference object titles compact. The desktop `h1.title` rule otherwise gives 1.25rem text a 2.3rem line height. */ -.doc-api-page h1.title { +.doc-py-reference h1.title { margin-top: 0.5rem; border-bottom: none; padding-bottom: 0; diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index 66fef6f8..ac2854d5 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -2120,8 +2120,11 @@ def fix_dataclass_attributes(content_str): main_content = content_str[main_start : main_end + len("")] after = content_str[main_end + len("") :] - # Exclude the title while shifting the remaining headings. - title_pattern = re.compile(r'(]*>.*?)', re.DOTALL) + # Exclude the title while shifting the remaining headings. Match + # `title` within a longer class list because Quarto adds display classes. + title_pattern = re.compile( + r'(]*\bclass="[^"]*\btitle\b[^"]*"[^>]*>.*?)', re.DOTALL + ) title_placeholder = "" title_match = title_pattern.search(main_content) if title_match: diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index 9a5e6f6e..4a422d23 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -553,6 +553,17 @@ def test_r1_reference_page_heading_levels(pkg_name: str): f"{page.name}: page title was shifted to h2.title" ) + # Check `
` separately because the navigation title is another + # `h1` at this stage. + 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)" + ) + for section in soup.select("section.doc-parameters, section.doc-methods"): heading = section.select_one("h1, h2, h3, h4, h5, h6") assert heading is not None, f"{page.name}: section has no heading" @@ -561,7 +572,17 @@ def test_r1_reference_page_heading_levels(pkg_name: str): ) checked += 1 - assert checked > 0, f"{pkg_name}: no docstring sections were checked" + 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}" + ) + checked += 1 + + assert checked > 0, f"{pkg_name}: no docstring sections or members were checked" # ═══════════════════════════════════════════════════════════════════════════════ From 762011e12c9f9c26dc15dc8edce9766cc75ff132 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 14:47:03 +0300 Subject: [PATCH 05/23] test(reference): verify index group heading levels Require every reference-index group heading to render at h2, directly below the h1 page title. --- tests/test_gdg_rendered.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index 4a422d23..ab926c29 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -585,6 +585,28 @@ def test_r1_reference_page_heading_levels(pkg_name: str): assert checked > 0, f"{pkg_name}: no docstring sections or members were checked" +@requires_bs4 +@pytest.mark.parametrize("pkg_name", ["gdtest_minimal", "gdtest_google"]) +def test_r4_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" + + 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}" + ) + + # ═══════════════════════════════════════════════════════════════════════════════ # R2: Docstring Rendering — parameters, returns, raises, examples # ═══════════════════════════════════════════════════════════════════════════════ From 1720862bb197d16d9efec7c2566b9061d191fdd9 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 16:10:19 +0300 Subject: [PATCH 06/23] fix(reference): demote nav titles and share heading shifts Reference, CLI and MCP pages rendered both the page title and the secondary navigation label as h1. Render the navigation label as h5 and target its styles and status icon by class rather than heading tag. Use one helper to nest object-page and reference-index headings below their titles. Scope compact title sizing to Python object pages, excluding the reference index. --- great_docs/_apiref/_render/reference_page.py | 2 +- great_docs/assets/great-docs.scss | 10 +- great_docs/assets/page-status-badges.js | 4 +- great_docs/assets/post-render.py | 159 +++++++++---------- 4 files changed, 86 insertions(+), 89 deletions(-) diff --git a/great_docs/_apiref/_render/reference_page.py b/great_docs/_apiref/_render/reference_page.py index 7961dbef..3c9dfcf1 100644 --- a/great_docs/_apiref/_render/reference_page.py +++ b/great_docs/_apiref/_render/reference_page.py @@ -60,7 +60,7 @@ def render_metadata(self) -> BlockContent: return Meta( { "title": self.api_ref.title, - "body-classes": "doc-reference doc-reference-index", + "body-classes": "doc-reference doc-reference-index doc-py-reference", "page-navigation": False, "html-table-processing": "none", } diff --git a/great_docs/assets/great-docs.scss b/great_docs/assets/great-docs.scss index bbd6d7ec..89a6f7c9 100644 --- a/great_docs/assets/great-docs.scss +++ b/great_docs/assets/great-docs.scss @@ -814,8 +814,9 @@ html.theme-loading body { /* General heading weight -- handled by SCSS @extend .fw-medium */ /* Keep reference object titles compact. The desktop `h1.title` rule otherwise - gives 1.25rem text a 2.3rem line height. */ -.doc-py-reference h1.title { + gives 1.25rem text a 2.3rem line height. Require both classes because the + reference index shares `doc-py-reference` but keeps the standard title size. */ +.doc-api-page.doc-py-reference h1.title { margin-top: 0.5rem; border-bottom: none; padding-bottom: 0; @@ -8415,8 +8416,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 ac2854d5..22362c98 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -1866,6 +1866,62 @@ def _format_refs(m): return html_content +# Match the page title even when Quarto adds display classes, so the heading +# shift can exclude it. +_TITLE_HEADING_PATTERN = re.compile(r'(]*\bclass="[^"]*\btitle\b[^"]*"[^>]*>.*?)', re.DOTALL) + + +def _shift_main_headings_below_title(content_str): + """ + Shift headings inside `
` below the page title + + Quarto's site-wide heading shift promotes rendered docstring sections and + members to the title's level. Move them down one level while preserving + the `h1.title` heading. + + Parameters + ---------- + content_str + Complete page HTML. + + Returns + ------- + Page HTML with main-content headings shifted down one level. Return the + input unchanged when it has no `
` element. + """ + main_start = content_str.find("") + if main_start == -1 or main_end == -1: + return content_str + + before = content_str[:main_start] + main_content = content_str[main_start : main_end + len("
")] + after = content_str[main_end + len("
") :] + + # Replace the title temporarily so only the remaining headings shift. + title_placeholder = "" + title_match = _TITLE_HEADING_PATTERN.search(main_content) + if title_match: + saved_title = title_match.group(1) + main_content = main_content.replace(saved_title, title_placeholder, 1) + + # Process `h5` first so each heading moves exactly one level. + 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 after shifting the remaining headings. + if title_match: + main_content = main_content.replace(title_placeholder, saved_title, 1) + + return before + main_content + after + + def fix_dataclass_attributes(content_str): """Rebuild the Attributes table for dataclass pages using *_dataclass_attrs.json* metadata. @@ -2097,12 +2153,13 @@ 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
); @@ -2110,43 +2167,8 @@ def fix_dataclass_attributes(content_str): 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) - # Quarto's global heading shift promotes the renderer's section markup to - # the same level as the page title. Move those sections down so the title - # remains the only `

` and its sections and members nest beneath it. - 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("
") :] - - # Exclude the title while shifting the remaining headings. Match - # `title` within a longer class list because Quarto adds display classes. - title_pattern = re.compile( - r'(]*\bclass="[^"]*\btitle\b[^"]*"[^>]*>.*?)', 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) - - # 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, - ) - - # Restore the title after shifting the remaining headings. - if title_match: - main_content = main_content.replace(title_placeholder, saved_title, 1) - - content_str = before + main_content + after + # Nest docstring sections and members below the page title. + content_str = _shift_main_headings_below_title(content_str) content = content_str.splitlines(keepends=True) @@ -2201,50 +2223,20 @@ def convert_table_to_dl(match): # 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 + # Render category headings at `h2`, below the `h1` Reference title. + content = _shift_main_headings_below_title(content) # 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) @@ -2264,12 +2256,13 @@ 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) @@ -2303,12 +2296,13 @@ 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 @@ -2629,25 +2623,26 @@ 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}" _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) From 8ddf9cfb78be8c2aa87a87bed2208bff5b0cc7cf Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 16:11:15 +0300 Subject: [PATCH 07/23] test(reference): verify section and member heading levels Count page-level sections and class members independently so a Parameters section cannot satisfy the member coverage guard. Add the mixed-docstring fixture, skip packages without members, and require the full fixture set to exercise the h3 assertion. Restrict section checks to level2 so member-nested sections are excluded. Remove requirement identifiers from the test names. --- tests/test_gdg_rendered.py | 66 ++++++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index ab926c29..53e475df 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -525,8 +525,10 @@ def test_footer_text_not_in_header(pkg_name: str): @requires_bs4 -@pytest.mark.parametrize("pkg_name", ["gdtest_minimal", "gdtest_google", "gdtest_sphinx"]) -def test_r1_reference_page_heading_levels(pkg_name: str): +@pytest.mark.parametrize( + "pkg_name", ["gdtest_minimal", "gdtest_google", "gdtest_sphinx", "gdtest_mixed_docs"] +) +def test_reference_page_heading_levels(pkg_name: str): """ Verify the reference object-page heading hierarchy @@ -541,7 +543,8 @@ def test_r1_reference_page_heading_levels(pkg_name: str): pages = [p for p in ref.glob("*.html") if p.name != "index.html"] assert pages, f"{pkg_name}: no reference object pages" - checked = 0 + sections_checked = 0 + members_checked = 0 for page in pages: soup = _load_html(page) @@ -553,8 +556,8 @@ def test_r1_reference_page_heading_levels(pkg_name: str): f"{page.name}: page title was shifted to h2.title" ) - # Check `
` separately because the navigation title is another - # `h1` at this stage. + # Limit the structural check to page content; navigation headings sit + # outside `
`. main = soup.select_one("main") assert main is not None, f"{page.name}: no
element" main_h1s = main.select("h1") @@ -564,13 +567,15 @@ def test_r1_reference_page_heading_levels(pkg_name: str): f"found {len(title_h1s)} (of {len(main_h1s)} h1 elements total)" ) - for section in soup.select("section.doc-parameters, section.doc-methods"): + # 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}" ) - checked += 1 + sections_checked += 1 for member in soup.select( "section.doc-methods section.level3, section.doc-attributes section.level3" @@ -580,14 +585,49 @@ def test_r1_reference_page_heading_levels(pkg_name: str): assert heading.name == "h3", ( f"{page.name}: expected h3 member heading, found {heading.name}" ) - checked += 1 + 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") - assert checked > 0, f"{pkg_name}: no docstring sections or members were checked" + +@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"]) -def test_r4_reference_index_heading_levels(pkg_name: str): +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(): @@ -4023,11 +4063,11 @@ def test_copy_page_widget_does_not_overlap_long_titles(): f"{page.name}: title text too short ({title_text!r}), expected long name" ) - # `span.doc-object-name` applies the title's code style; inline styles - # do not. + # 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 is missing span.doc-object-name for code styling" + f"{page.name}: title has no span.doc-object-name to style as code" ) From d223902d4560ae6ac6ede85daf9e4711c15e4455 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 16:12:04 +0300 Subject: [PATCH 08/23] fix(reference): emit headings at their final levels Disable the site-wide heading shift on API object pages and the reference index. Render titles at h1, docstring sections at h2, and members at h3 without post-render compensation. Update fallback docstring sections and member separators to use those final levels. Raise the table-of-contents depth to include members and subtitled index sections. --- great_docs/_apiref/_render/api_page.py | 2 + great_docs/_apiref/_render/reference_page.py | 2 + great_docs/assets/post-render.py | 114 +++++-------------- 3 files changed, 30 insertions(+), 88 deletions(-) diff --git a/great_docs/_apiref/_render/api_page.py b/great_docs/_apiref/_render/api_page.py index eb93b003..6ee9b731 100644 --- a/great_docs/_apiref/_render/api_page.py +++ b/great_docs/_apiref/_render/api_page.py @@ -73,6 +73,8 @@ def render_metadata(self) -> BlockContent: { "title": f"{title}", "body-classes": "doc-api-page doc-py-reference", + "shift-heading-level-by": 0, + "toc-depth": 3, } ) diff --git a/great_docs/_apiref/_render/reference_page.py b/great_docs/_apiref/_render/reference_page.py index 3c9dfcf1..2ec61d0e 100644 --- a/great_docs/_apiref/_render/reference_page.py +++ b/great_docs/_apiref/_render/reference_page.py @@ -63,6 +63,8 @@ def render_metadata(self) -> BlockContent: "body-classes": "doc-reference doc-reference-index doc-py-reference", "page-navigation": False, "html-table-processing": "none", + "shift-heading-level-by": 0, + "toc-depth": 3, } ) diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index 22362c98..2cb36326 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -1252,8 +1252,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' + '
\n' + f'

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

\n' "
\n" + "\n".join(items) + "\n
\n
" ) @@ -1276,8 +1276,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' + '
\n' + f'

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

\n' "
\n" + "\n".join(items) + "\n
\n
" ) @@ -1289,8 +1289,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' + '
\n' + f'

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

\n' "
\n" + "\n".join(items) + "\n
\n
" ) @@ -1312,9 +1312,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"} @@ -1421,8 +1421,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' + '
\n' + f'

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

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

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

\n' + '
\n' + f'

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

\n' f"{content}\n
" ) @@ -1453,8 +1453,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' + '
\n' + f'

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

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

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

\n' + '
\n' + f'

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

\n' f"
{code}
\n
" ) @@ -1491,8 +1491,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 +1564,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 @@ -1600,8 +1600,8 @@ def _replace_header(m): i18n_key = _BOLD_I18N_KEY.get(name) display = _t(i18n_key, name) if i18n_key else name return ( - f'
\n' - f'

{display}

' + f'
\n' + f'

{display}

' ) html_content = re.sub( @@ -1866,62 +1866,6 @@ def _format_refs(m): return html_content -# Match the page title even when Quarto adds display classes, so the heading -# shift can exclude it. -_TITLE_HEADING_PATTERN = re.compile(r'(]*\bclass="[^"]*\btitle\b[^"]*"[^>]*>.*?

)', re.DOTALL) - - -def _shift_main_headings_below_title(content_str): - """ - Shift headings inside `
` below the page title - - Quarto's site-wide heading shift promotes rendered docstring sections and - members to the title's level. Move them down one level while preserving - the `h1.title` heading. - - Parameters - ---------- - content_str - Complete page HTML. - - Returns - ------- - Page HTML with main-content headings shifted down one level. Return the - input unchanged when it has no `
` element. - """ - main_start = content_str.find("") - if main_start == -1 or main_end == -1: - return content_str - - before = content_str[:main_start] - main_content = content_str[main_start : main_end + len("
")] - after = content_str[main_end + len("
") :] - - # Replace the title temporarily so only the remaining headings shift. - title_placeholder = "" - title_match = _TITLE_HEADING_PATTERN.search(main_content) - if title_match: - saved_title = title_match.group(1) - main_content = main_content.replace(saved_title, title_placeholder, 1) - - # Process `h5` first so each heading moves exactly one level. - 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 after shifting the remaining headings. - if title_match: - main_content = main_content.replace(title_placeholder, saved_title, 1) - - return before + main_content + after - - def fix_dataclass_attributes(content_str): """Rebuild the Attributes table for dataclass pages using *_dataclass_attrs.json* metadata. @@ -2112,10 +2056,10 @@ def fix_dataclass_attributes(content_str): # - 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: @@ -2167,9 +2111,6 @@ def fix_dataclass_attributes(content_str): 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) - # Nest docstring sections and members below the page title. - content_str = _shift_main_headings_below_title(content_str) - content = content_str.splitlines(keepends=True) with open(html_file, "w", encoding="utf-8") as file: @@ -2223,9 +2164,6 @@ def convert_table_to_dl(match): # Clean up Sphinx cross-reference roles in index descriptions content = translate_sphinx_roles(content) - # Render category headings at `h2`, below the `h1` Reference title. - content = _shift_main_headings_below_title(content) - # Translate renderer-rendered headings, TOC, and sidebar on the index page content = translate_renderer_headings(content) From f0021ffd54b8c7bbed045ea8c5f46d5727d4ba70 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 17:01:33 +0300 Subject: [PATCH 09/23] fix(assets): align the mobile reference nav title Bootstrap adds a top margin to the new h5 navigation label, moving it below the sidebar toggle. Override that margin with the same importance as Bootstrap's rule. --- great_docs/assets/great-docs.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/great_docs/assets/great-docs.scss b/great_docs/assets/great-docs.scss index 89a6f7c9..492a7122 100644 --- a/great_docs/assets/great-docs.scss +++ b/great_docs/assets/great-docs.scss @@ -2256,6 +2256,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; From 2b14a127fb2dc1fc1247e208abc23e15820cab42 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 16:13:09 +0300 Subject: [PATCH 10/23] test(reference): require one h1 per rendered object page Extend the heading hierarchy test from main content to the complete document. Each object page must contain one h1 page title, while the secondary navigation label remains h5. --- great_docs/assets/post-render.py | 5 ++++- tests/test_gdg_rendered.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index 2cb36326..9d503842 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -2157,7 +2157,10 @@ 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) diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index 53e475df..82aca7ce 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -556,8 +556,14 @@ def test_reference_page_heading_levels(pkg_name: str): f"{page.name}: page title was shifted to h2.title" ) - # Limit the structural check to page content; navigation headings sit - # outside `
`. + # Require one document-level `h1`: the page title. The navigation label + # must remain `h5`. + 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)}" + ) + main = soup.select_one("main") assert main is not None, f"{page.name}: no
element" main_h1s = main.select("h1") From b4f08c325b9d087698371418f271a328291ab701 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 17:16:06 +0300 Subject: [PATCH 11/23] fix(reference): preserve subtitles and nested fallback headings Keep subtitle-only reference sections in generated configuration instead of renaming them Untitled. They now render at h3 and appear in the sidebar and table of contents beside h2 titled sections. Render fallback docstring sections at h4 inside h3 class members and at h2 for top-level objects. Add rendered fixtures and assertions for both paths. --- great_docs/assets/post-render.py | 66 ++++++++++++----- great_docs/core.py | 17 +++-- .../synthetic/specs/gdtest_mixed_docs.py | 11 +++ .../synthetic/specs/gdtest_ref_sectioned.py | 47 ++++++++++-- tests/test_gdg_rendered.py | 73 +++++++++++++++++++ 5 files changed, 186 insertions(+), 28 deletions(-) diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index 9d503842..f65d0c21 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -1158,6 +1158,33 @@ def _replace_if_not_gt(match): return colgroup_pattern.sub(_replace_if_not_gt, html_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`. The nearest preceding heading determines which level applies. + + Parameters + ---------- + html_content + Complete page HTML. + pos + Character offset where the fallback section starts. + + Returns + ------- + `4` after an `h3` member 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 == 3 else 2 + + def translate_sphinx_fields(html_content): """ Convert Sphinx field-list directives into structured doc sections. @@ -1229,6 +1256,7 @@ def _build_sections(m): raises.append((name, body)) parts = [] + lvl = _fallback_section_level(html_content, m.start()) # ── Parameters section ─────────────────────────────────────────── if params: @@ -1252,8 +1280,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 +1304,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 +1317,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
" ) @@ -1404,6 +1432,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 +1450,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 +1464,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 +1482,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 +1496,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 +1520,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
" ) @@ -1599,9 +1628,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( diff --git a/great_docs/core.py b/great_docs/core.py index abbd0320..38cf1e1b 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -8773,7 +8773,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 +8817,7 @@ 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 +9138,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 +9202,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 +13425,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", []): diff --git a/test-packages/synthetic/specs/gdtest_mixed_docs.py b/test-packages/synthetic/specs/gdtest_mixed_docs.py index 1deba489..fce334b3 100644 --- a/test-packages/synthetic/specs/gdtest_mixed_docs.py +++ b/test-packages/synthetic/specs/gdtest_mixed_docs.py @@ -58,6 +58,17 @@ 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 encode(data: str, encoding: str = "utf-8") -> bytes: """ diff --git a/test-packages/synthetic/specs/gdtest_ref_sectioned.py b/test-packages/synthetic/specs/gdtest_ref_sectioned.py index 3af5e523..832adfa3 100644 --- a/test-packages/synthetic/specs/gdtest_ref_sectioned.py +++ b/test-packages/synthetic/specs/gdtest_ref_sectioned.py @@ -1,13 +1,16 @@ """ -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": { @@ -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,6 +267,32 @@ def from_string(text: str) -> object: except ValueError: return text ''', + "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\nTest reference with 4 named sections.\n"), }, "expected": { @@ -268,12 +304,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/test_gdg_rendered.py b/tests/test_gdg_rendered.py index 82aca7ce..5a509b09 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -653,6 +653,79 @@ def test_reference_index_heading_levels(pkg_name: str): ) +@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_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" + + # ═══════════════════════════════════════════════════════════════════════════════ # R2: Docstring Rendering — parameters, returns, raises, examples # ═══════════════════════════════════════════════════════════════════════════════ From feee22c2dd5adb8c5db0ce74ef55339679049bf6 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 17:31:39 +0300 Subject: [PATCH 12/23] fix(reference): preserve subtitle headings in machine-readable docs Use a section's subtitle when its title is absent while generating llms.txt, the AI guide and the skill overview. Subtitle-only sections now retain their headings instead of merging into the preceding section. --- great_docs/core.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/great_docs/core.py b/great_docs/core.py index 38cf1e1b..1b1d020c 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: @@ -8817,7 +8815,11 @@ def _build_sections_from_reference_config( if section_contents: sections.append( { - **({"subtitle": subtitle} if subtitle is not None else {"title": title or "Untitled"}), + **( + {"subtitle": subtitle} + if subtitle is not None + else {"title": title or "Untitled"} + ), "desc": desc, "contents": section_contents, } @@ -13565,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 @@ -13714,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 @@ -13978,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: From 756adca821c2ec570033bda6a30ecc275fa91e8c Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 16:16:17 +0300 Subject: [PATCH 13/23] fix(post-render): keep chained fallback sections nested Fallback translators process the same member in sequence. After the first emits an h4 section, the next must still recognise member context instead of returning to h2. Treat any preceding heading at h3 or deeper as member context. Add a fixture whose method exercises field and bold-section fallbacks together. --- great_docs/assets/post-render.py | 7 ++-- .../synthetic/specs/gdtest_mixed_docs.py | 13 +++++++ tests/test_gdg_rendered.py | 35 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index f65d0c21..0fa46273 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -1166,7 +1166,8 @@ 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`. The nearest preceding heading determines which level applies. + at `h4`. A preceding `h4` also indicates member context because fallback + translators run in sequence over the same content. Parameters ---------- @@ -1177,12 +1178,12 @@ def _fallback_section_level(html_content, pos): Returns ------- - `4` after an `h3` member heading; otherwise `2`. + `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 == 3 else 2 + return 4 if last_level and last_level >= 3 else 2 def translate_sphinx_fields(html_content): diff --git a/test-packages/synthetic/specs/gdtest_mixed_docs.py b/test-packages/synthetic/specs/gdtest_mixed_docs.py index fce334b3..5231ad6c 100644 --- a/test-packages/synthetic/specs/gdtest_mixed_docs.py +++ b/test-packages/synthetic/specs/gdtest_mixed_docs.py @@ -69,6 +69,19 @@ def is_valid(self, data: str) -> bool: """ 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/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index 5a509b09..de9c5fa7 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -726,6 +726,41 @@ def test_fallback_docstring_section_nested_in_member_renders_as_h4(): 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" + + # ═══════════════════════════════════════════════════════════════════════════════ # R2: Docstring Rendering — parameters, returns, raises, examples # ═══════════════════════════════════════════════════════════════════════════════ From b52ca1fff99df81a69b3d5dbc5a25f28b50ed52f Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 17:31:48 +0300 Subject: [PATCH 14/23] docs(test-packages): count the subtitled reference section Update the synthetic package metadata and README to describe four titled sections and one subtitled section. --- test-packages/synthetic/specs/gdtest_ref_sectioned.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test-packages/synthetic/specs/gdtest_ref_sectioned.py b/test-packages/synthetic/specs/gdtest_ref_sectioned.py index 832adfa3..cebf8834 100644 --- a/test-packages/synthetic/specs/gdtest_ref_sectioned.py +++ b/test-packages/synthetic/specs/gdtest_ref_sectioned.py @@ -16,7 +16,7 @@ "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"], @@ -293,7 +293,10 @@ def format_label(name: str, upper: bool = False) -> str: """ return name.upper() if upper else name ''', - "README.md": ("# gdtest-ref-sectioned\n\nTest reference with 4 named sections.\n"), + "README.md": ( + "# gdtest-ref-sectioned\n\n" + "Exercise four titled reference sections and one subtitled section.\n" + ), }, "expected": { "detected_name": "gdtest-ref-sectioned", From 03b368d157e462c30d95d03f3fa617763b97803d Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 17:49:01 +0300 Subject: [PATCH 15/23] test(reference): cover subtitle outputs and member separators Verify that subtitle-only sections keep their own headings in llms.txt, llms-full.txt and skill.md. Check the section body so an entry under the preceding heading cannot pass. Verify that class pages contain one solid rule after the member summary and one dotted rule between each pair of members. --- tests/test_gdg_rendered.py | 103 +++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index de9c5fa7..2f2f10f6 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -689,6 +689,71 @@ def test_reference_index_subtitle_renders_as_h3_in_toc(): ) +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(): """ @@ -761,6 +826,44 @@ def test_chained_fallback_translators_stay_at_h4_in_member(): 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 # ═══════════════════════════════════════════════════════════════════════════════ From 88071f00e342c8c68268fcf00db73af4a982021d Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 16:20:09 +0300 Subject: [PATCH 16/23] fix(post-render): demote nav titles without breadcrumbs Quarto renders secondary navigation as a breadcrumb nav when breadcrumbs are enabled and as a bare h1 when they are disabled. Replace either form with the h5 navigation label and remove any duplicate breadcrumb in the title block. Use the shared replacement for API, MCP and CLI pages. Add a site-wide no-breadcrumb fixture that requires one h1 page title and the h5 navigation label. --- great_docs/assets/post-render.py | 55 ++++++--- test-packages/synthetic/catalog.py | 7 ++ .../synthetic/specs/gdtest_no_breadcrumbs.py | 105 ++++++++++++++++++ tests/test_gdg_rendered.py | 24 +++- 4 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 test-packages/synthetic/specs/gdtest_no_breadcrumbs.py diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index 0fa46273..0981e512 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -1158,6 +1158,40 @@ 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"]*)?>") @@ -2136,11 +2170,7 @@ def fix_dataclass_attributes(content_str): f'{html.escape(_display_name)}' 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) + content_str = replace_secondary_nav_title(content_str, _ref_title_html) content = content_str.splitlines(keepends=True) @@ -2210,8 +2240,7 @@ def convert_table_to_dl(match): '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) @@ -2236,8 +2265,7 @@ def convert_table_to_dl(match): '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) @@ -2277,10 +2305,8 @@ def convert_table_to_dl(match): 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) @@ -2594,7 +2620,6 @@ 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") @@ -2616,7 +2641,7 @@ def process_cli_reference_pages(): f'Index' 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/test-packages/synthetic/catalog.py b/test-packages/synthetic/catalog.py index 7d7312c4..6756cd00 100644 --- a/test-packages/synthetic/catalog.py +++ b/test-packages/synthetic/catalog.py @@ -414,6 +414,8 @@ "gdtest_complete_docstrings", # 205 # 206: Marimo notebook islands showcase "gdtest_marimo", # 206 + # 207: API reference pages without breadcrumbs + "gdtest_no_breadcrumbs", # 207 ] @@ -2312,6 +2314,11 @@ "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." + ), } 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/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index 2f2f10f6..f6c08646 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -526,15 +526,16 @@ 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"] + "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. These levels must match Quarto's table of - contents. + 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(): @@ -557,12 +558,16 @@ def test_reference_page_heading_levels(pkg_name: str): ) # Require one document-level `h1`: the page title. The navigation label - # must remain `h5`. + # 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" @@ -632,7 +637,7 @@ def test_reference_page_heading_levels_exercises_member_check(): @requires_bs4 -@pytest.mark.parametrize("pkg_name", ["gdtest_minimal", "gdtest_google"]) +@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" @@ -644,6 +649,15 @@ def test_reference_index_heading_levels(pkg_name: str): 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: From a61f488158a731a89ea53d8e1ae358b59acd67dc Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 19:15:37 +0300 Subject: [PATCH 17/23] fix(reference): emit one title heading on CLI and MCP pages CLI command pages wrote a body title in addition to the front matter title. Remove the duplicate, disable heading shifts on CLI pages, and render body sections at h2. MCP pages retain the site-wide shift, so write their body sections one level deeper. Add rendered coverage for CLI and MCP object pages and indexes. --- great_docs/_mcp_docs.py | 12 +- great_docs/core.py | 8 +- test-packages/synthetic/catalog.py | 6 + test-packages/synthetic/specs/gdtest_mcp.py | 69 +++++++++ tests/test_gdg_rendered.py | 157 ++++++++++++++++++++ 5 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 test-packages/synthetic/specs/gdtest_mcp.py diff --git a/great_docs/_mcp_docs.py b/great_docs/_mcp_docs.py index 4e4cfc01..a698e968 100644 --- a/great_docs/_mcp_docs.py +++ b/great_docs/_mcp_docs.py @@ -809,7 +809,7 @@ def _generate_tool_page(tool: dict[str, Any], server_name: str, language: str = # Parameters section — uses definition list format matching Python API style 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(): @@ -878,7 +878,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("") @@ -920,7 +920,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 +934,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}") @@ -982,7 +982,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 +1010,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/core.py b/great_docs/core.py index 1b1d020c..f23b5de3 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -4546,6 +4546,7 @@ def _generate_cli_index_page(self, cli_info: dict, entry_safe: str) -> str: 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("") @@ -4710,13 +4711,12 @@ def _generate_cli_command_page(self, cmd_info: dict, is_main: bool = False) -> s 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 diff --git a/test-packages/synthetic/catalog.py b/test-packages/synthetic/catalog.py index 6756cd00..60223cba 100644 --- a/test-packages/synthetic/catalog.py +++ b/test-packages/synthetic/catalog.py @@ -416,6 +416,8 @@ "gdtest_marimo", # 206 # 207: API reference pages without breadcrumbs "gdtest_no_breadcrumbs", # 207 + # 208: MCP object pages + "gdtest_mcp", # 208 ] @@ -2319,6 +2321,10 @@ "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/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index f6c08646..8e61ef9a 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -2087,6 +2087,75 @@ 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 +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.""" @@ -2213,6 +2282,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 # ═══════════════════════════════════════════════════════════════════════════════ From 1e82c2ac0b9df7bf2b67f5ec31a999e3897bdda4 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 18 Aug 2026 19:26:47 +0300 Subject: [PATCH 18/23] fix(cli): read marked-up titles and process nested commands CLI command titles wrap the command name in a span. Read the complete h1 text when building the navigation label instead of falling back to a filename-derived name. Process nested command pages recursively. Update sidebar assertions for the labelled CLI index entry and verify labels on flat and nested pages. --- great_docs/assets/post-render.py | 12 ++++++--- tests/test_gdg_rendered.py | 46 ++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index 0981e512..c8e7525b 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -2599,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 @@ -2622,9 +2622,13 @@ def process_cli_reference_pages(): _cli_label = _t("cli", "CLI") # 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'{_cli_label}' diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index 8e61ef9a..61f5ba01 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -2128,6 +2128,42 @@ def test_cli_command_page_heading_levels(pkg_name: str): 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(): @@ -2178,8 +2214,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}" @@ -2206,9 +2242,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 From e8929e6c4d1b42c9a4da26e3538669d5b8bbe1b7 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 00:35:36 +0300 Subject: [PATCH 19/23] fix(styles): style docstring sections independently of depth Top-level docstring sections render at h2, while sections inside members render at h4. Target the shared section class instead of a heading tag so standard and warning styles apply at either depth. --- great_docs/assets/great-docs.scss | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/great_docs/assets/great-docs.scss b/great_docs/assets/great-docs.scss index 492a7122..b5d0d735 100644 --- a/great_docs/assets/great-docs.scss +++ b/great_docs/assets/great-docs.scss @@ -1127,7 +1127,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; @@ -1143,8 +1145,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; @@ -1153,7 +1155,7 @@ h2.doc-section { padding-left: 0; } -.doc-section-warnings > h2::before { +.doc-section-warnings > :is(h1, h2, h3, h4, h5, h6)::before { content: "⚠️ "; } @@ -4012,7 +4014,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; } From 1d5b8560bf58c1e67e0989cf73f719722585ff87 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 00:35:44 +0300 Subject: [PATCH 20/23] fix(reference): preserve site TOC depth while listing members Class member headings require a table-of-contents depth of 3. Read the merged depth from generated Quarto configuration or source site configuration, pass it to each page, and add an override only when the site setting is shallower. Apply the same rule to subtitle headings on the reference index. Preserve user-configured depths above 3 and verify that class members appear in the rendered table of contents. --- great_docs/_apiref/_render/api_page.py | 30 +++++-- great_docs/_apiref/_render/reference_page.py | 21 ++--- great_docs/_apiref/api_reference.py | 95 ++++++++++++++++++-- great_docs/_apiref/write.py | 6 +- tests/renderer/test_api_reference.py | 22 +++++ tests/test_gdg_rendered.py | 23 +++++ 6 files changed, 170 insertions(+), 27 deletions(-) diff --git a/great_docs/_apiref/_render/api_page.py b/great_docs/_apiref/_render/api_page.py index 6ee9b731..4473c3ea 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,14 +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 doc-py-reference", - "shift-heading-level-by": 0, - "toc-depth": 3, - } - ) + metadata: dict[str, object] = { + "title": f"{title}", + "body-classes": "doc-api-page doc-py-reference", + "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 2ec61d0e..7ce30eb6 100644 --- a/great_docs/_apiref/_render/reference_page.py +++ b/great_docs/_apiref/_render/reference_page.py @@ -57,16 +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 doc-py-reference", - "page-navigation": False, - "html-table-processing": "none", - "shift-heading-level-by": 0, - "toc-depth": 3, - } - ) + metadata: dict[str, object] = { + "title": self.api_ref.title, + "body-classes": "doc-reference doc-reference-index 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/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 61f5ba01..ec9f51b5 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -703,6 +703,29 @@ def test_reference_index_subtitle_renders_as_h3_in_toc(): ) +@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 From 7f1c41a6e4940665dac8d5fbae07f404272a4cb9 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 00:35:48 +0300 Subject: [PATCH 21/23] docs(mcp): explain deeper source headings MCP pages retain the site-wide heading shift because disabling it prevents Quarto from hoisting the marked title and creates a duplicate title. Record why source sections start one level deeper than API reference sections. --- great_docs/_mcp_docs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/great_docs/_mcp_docs.py b/great_docs/_mcp_docs.py index a698e968..67e9c718 100644 --- a/great_docs/_mcp_docs.py +++ b/great_docs/_mcp_docs.py @@ -807,7 +807,9 @@ 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("") From 6a2a660c3509f8ff495a8258f8b227479457da75 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 00:35:52 +0300 Subject: [PATCH 22/23] fix(cli): use section subtitles in verbose scan output When a reference section has no title, display its subtitle in great-docs scan --verbose instead of Untitled. This matches the rendered index and other generated outputs. --- great_docs/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From e67f7af02627c815b544a39351466919ed3cfbe3 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 19 Aug 2026 17:42:06 +0300 Subject: [PATCH 23/23] refactor(reference): name generated pages by kind and family Every reference index and API page now carries a page-kind class plus that class prefixed by its family: doc-reference with doc-py-reference, doc-mcp-reference or doc-cli-reference, and doc-api-page with doc-py-api-page, doc-mcp-api-page or doc-cli-api-page. MCP object pages previously carried no family class, so a style could reach them only by excluding the other families. Python object pages and the Python reference index shared one class, which forced the compact title rule to match on two classes and left the index carrying a class no rule used. Drop doc-reference-index, which marked the same three pages as doc-reference and nothing else. The copy-page widget now skips index pages by that class. --- great_docs/_apiref/_render/api_page.py | 2 +- great_docs/_apiref/_render/reference_page.py | 2 +- great_docs/_mcp_docs.py | 10 +++++----- great_docs/assets/copy-page.js | 2 +- great_docs/assets/great-docs.scss | 19 ++++++++++++++----- great_docs/core.py | 4 ++-- tests/test_great_docs.py | 2 +- 7 files changed, 25 insertions(+), 16 deletions(-) diff --git a/great_docs/_apiref/_render/api_page.py b/great_docs/_apiref/_render/api_page.py index 4473c3ea..6db8a655 100644 --- a/great_docs/_apiref/_render/api_page.py +++ b/great_docs/_apiref/_render/api_page.py @@ -84,7 +84,7 @@ def render_metadata(self) -> BlockContent: title = obj._title # pyright: ignore[reportPrivateUsage] metadata: dict[str, object] = { "title": f"{title}", - "body-classes": "doc-api-page doc-py-reference", + "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. diff --git a/great_docs/_apiref/_render/reference_page.py b/great_docs/_apiref/_render/reference_page.py index 7ce30eb6..22c76f49 100644 --- a/great_docs/_apiref/_render/reference_page.py +++ b/great_docs/_apiref/_render/reference_page.py @@ -59,7 +59,7 @@ def render_description(self) -> BlockContent: def render_metadata(self) -> BlockContent: metadata: dict[str, object] = { "title": self.api_ref.title, - "body-classes": "doc-reference doc-reference-index doc-py-reference", + "body-classes": "doc-reference doc-py-reference", "page-navigation": False, "html-table-processing": "none", "shift-heading-level-by": 0, diff --git a/great_docs/_mcp_docs.py b/great_docs/_mcp_docs.py index 67e9c718..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") @@ -865,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") @@ -905,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") @@ -968,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") 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 b5d0d735..fe4284e3 100644 --- a/great_docs/assets/great-docs.scss +++ b/great_docs/assets/great-docs.scss @@ -814,9 +814,8 @@ html.theme-loading body { /* General heading weight -- handled by SCSS @extend .fw-medium */ /* Keep reference object titles compact. The desktop `h1.title` rule otherwise - gives 1.25rem text a 2.3rem line height. Require both classes because the - reference index shares `doc-py-reference` but keeps the standard title size. */ -.doc-api-page.doc-py-reference h1.title { + 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; @@ -1480,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 { @@ -1499,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; } @@ -1507,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 { diff --git a/great_docs/core.py b/great_docs/core.py index f23b5de3..a3c2db1c 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -4542,7 +4542,7 @@ 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") @@ -4707,7 +4707,7 @@ 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") 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