diff --git a/great_docs/_apiref/_render/api_page.py b/great_docs/_apiref/_render/api_page.py index a14eced6..6db8a655 100644 --- a/great_docs/_apiref/_render/api_page.py +++ b/great_docs/_apiref/_render/api_page.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from functools import cached_property from typing import TYPE_CHECKING, cast @@ -22,11 +23,23 @@ from .doc import RenderDoc +# Member headings render at `h3`, so the table of contents must reach depth 3 +# to list them. +_MIN_MEMBER_TOC_DEPTH = 3 + +# Use the default site depth when the caller does not provide the merged value. +_DEFAULT_SITE_TOC_DEPTH = 2 + + +@dataclass class __RenderAPIPage(RenderPageMixin, RenderBase): """ Render an API page object (`content.Page`) """ + toc_depth: int = _DEFAULT_SITE_TOC_DEPTH + """Merged site `toc-depth` used to select a page override""" + def __post_init__(self): self.page = cast("Page", self.node) """Page in the documentation""" @@ -69,12 +82,15 @@ def render_metadata(self) -> BlockContent: # Derive the title of the page from the first (top-level) object obj = self.render_objs[0] title = obj._title # pyright: ignore[reportPrivateUsage] - return Meta( - { - "title": f"{title}", - "body-classes": "doc-api-page", - } - ) + metadata: dict[str, object] = { + "title": f"{title}", + "body-classes": "doc-api-page doc-py-api-page", + "shift-heading-level-by": 0, + } + # Add a page override only when the site depth excludes members. + if self.toc_depth < _MIN_MEMBER_TOC_DEPTH: + metadata["toc-depth"] = _MIN_MEMBER_TOC_DEPTH + return Meta(metadata) def render_body(self) -> BlockContent: """ diff --git a/great_docs/_apiref/_render/reference_page.py b/great_docs/_apiref/_render/reference_page.py index 7961dbef..22c76f49 100644 --- a/great_docs/_apiref/_render/reference_page.py +++ b/great_docs/_apiref/_render/reference_page.py @@ -57,14 +57,17 @@ def render_description(self) -> BlockContent: ) def render_metadata(self) -> BlockContent: - return Meta( - { - "title": self.api_ref.title, - "body-classes": "doc-reference doc-reference-index", - "page-navigation": False, - "html-table-processing": "none", - } - ) + metadata: dict[str, object] = { + "title": self.api_ref.title, + "body-classes": "doc-reference doc-py-reference", + "page-navigation": False, + "html-table-processing": "none", + "shift-heading-level-by": 0, + } + # Subtitle headings render at `h3`; preserve any deeper site setting. + if self.api_ref.site_toc_depth < 3: + metadata["toc-depth"] = 3 + return Meta(metadata) def render_body(self) -> BlockContent: """ diff --git a/great_docs/_apiref/api_reference.py b/great_docs/_apiref/api_reference.py index 97d242ff..02288654 100644 --- a/great_docs/_apiref/api_reference.py +++ b/great_docs/_apiref/api_reference.py @@ -35,6 +35,10 @@ # consumed; dropped before parsing so they neither reach `Settings` nor error. _REMOVED_KEYS = {"style", "renderer", "render_interlinks"} +# Use the default site depth when a bare API reference config supplies no site +# settings. +_DEFAULT_SITE_TOC_DEPTH = 2 + @dataclass class Settings: @@ -89,9 +93,12 @@ class APIReference: options: SpecOptions | None settings: Settings items: list[InventoryItem] + site_toc_depth: int def __init__(self, config: dict[str, Any] | str | Path) -> None: - block = self._select_block(config) + cfg = self._load_config(config) + self.site_toc_depth = self._read_site_toc_depth(cfg) + block = self._select_block(cfg) block = {k: v for k, v in block.items() if k not in _REMOVED_KEYS} self.settings = Settings.make(block) @@ -112,13 +119,84 @@ def __init__(self, config: dict[str, Any] | str | Path) -> None: self._resolver.current_package = self.package @staticmethod - def _select_block(config: dict[str, Any] | str | Path) -> dict[str, Any]: - """Select the `api-reference:` (or legacy `quartodoc:`) mapping from a config dict, file path, or full _quarto.yml""" - if isinstance(config, (str, Path)): - loaded = read_yaml(str(config)) - cfg: dict[str, Any] = cast("dict[str, Any]", loaded) if isinstance(loaded, dict) else {} - else: - cfg = config + def _load_config(config: dict[str, Any] | str | Path) -> dict[str, Any]: + """ + Load a configuration mapping + + Return mapping arguments unchanged. Read YAML paths and return an + empty mapping when the document's top-level value is not a mapping. + + Parameters + ---------- + config + Configuration mapping or YAML file path. + + Returns + ------- + Configuration mapping. + """ + if not isinstance(config, (str, Path)): + return config + loaded = read_yaml(str(config)) + return cast("dict[str, Any]", loaded) if isinstance(loaded, dict) else {} + + @staticmethod + def _read_site_toc_depth(cfg: dict[str, Any]) -> int: + """ + Read the configured table-of-contents depth + + Use the integer at `format.html.toc-depth` when available, then the + integer at `site.toc-depth`. Return the built-in default when neither + setting is an integer. + + Parameters + ---------- + cfg + Configuration mapping. + + Returns + ------- + Configured depth or the built-in default. + """ + format_config = cfg.get("format") + html = ( + cast("dict[str, Any]", format_config).get("html") + if isinstance(format_config, dict) + else None + ) + depth = cast("dict[str, Any]", html).get("toc-depth") if isinstance(html, dict) else None + if isinstance(depth, int): + return depth + + site = cfg.get("site") + depth = ( + cast("dict[str, Any]", site).get("toc-depth") if isinstance(site, dict) else None + ) + return depth if isinstance(depth, int) else _DEFAULT_SITE_TOC_DEPTH + + @staticmethod + def _select_block(cfg: dict[str, Any]) -> dict[str, Any]: + """ + Select the API reference configuration + + Select a non-empty `api-reference` mapping before the legacy + `quartodoc` mapping. Treat the full configuration as the API reference + mapping when both sections are empty or absent. + + Parameters + ---------- + cfg + Configuration mapping. + + Returns + ------- + Copy of the selected API reference mapping. + + Raises + ------ + KeyError + If the selected value is not a mapping or omits `package`. + """ block = cfg.get("api-reference") or cfg.get("quartodoc") or cfg if not isinstance(block, dict) or "package" not in block: raise KeyError("No `api-reference:` section found in your _quarto.yml.") @@ -182,6 +260,7 @@ def build(self, page_filter: str = "*") -> None: rewrite_all_pages=s.rewrite_all_pages, header_level=s.header_level, page_filter=page_filter, + site_toc_depth=self.site_toc_depth, ) write_typing_information(s.typing_module_paths, self) diff --git a/great_docs/_apiref/write.py b/great_docs/_apiref/write.py index 9fd7d17c..0df8da19 100644 --- a/great_docs/_apiref/write.py +++ b/great_docs/_apiref/write.py @@ -312,6 +312,7 @@ def write_pages( rewrite_all_pages: bool, header_level: int, page_filter: str, + site_toc_depth: int, ) -> None: """Write API doc pages to `
{pdesc}
\n{rdesc}
\n{desc}
\nRaises: ValueError: desc. TypeError: desc.
Note: text
- Indented continuation text renders as `` blocks adjacent to the section ``. This
- function detects both patterns and emits the same ``/``/``/`- `/`
- ` markup
- that the renderer produces for NumPy-style sections.
+ Indented continuation text renders as `
` blocks adjacent to the section ``.
+ Translate both forms to the renderer's `` and `` structure,
+ using definition-list markup for fields.
"""
_PARAM_SECTIONS = {"Args", "Arguments", "Parameters", "Params"}
@@ -1404,6 +1467,7 @@ def _replace(m):
section = m.group("section")
body = (m.group("body") or "").strip()
pre_body = m.group("pre_body").strip() if m.group("pre_body") else None
+ lvl = _fallback_section_level(html_content, m.start())
# ── Args / Parameters ─────────────────────────────────────────
if section in _PARAM_SECTIONS:
@@ -1421,8 +1485,8 @@ def _replace(m):
dd = f"- \n
{pdesc}
\n " if pdesc else ""
items.append(f"{dt}\n{dd}")
return (
- '\n'
- f'{_t("parameters", "Parameters")}
\n'
+ f'\n'
+ f'{_t("parameters", "Parameters")} \n'
"\n" + "\n".join(items) + "\n
\n "
)
@@ -1435,8 +1499,8 @@ def _replace(m):
parts.append(_pre_to_html(pre_body))
content = "\n".join(parts)
return (
- '\n'
- f'{_t("returns", "Returns")}
\n'
+ f'\n'
+ f'{_t("returns", "Returns")} \n'
f"{content}\n "
)
@@ -1453,8 +1517,8 @@ def _replace(m):
dd = f"- \n
{desc}
\n " if desc else ""
items.append(f"{dt}\n{dd}")
return (
- '\n'
- f'{_t("raises", "Raises")}
\n'
+ f'\n'
+ f'{_t("raises", "Raises")} \n'
"\n" + "\n".join(items) + "\n
\n "
)
@@ -1467,8 +1531,8 @@ def _replace(m):
code_parts.append(pre_body)
code = "\n".join(code_parts)
return (
- '\n'
- f'{_t("examples", "Examples")}
\n'
+ f'\n'
+ f'{_t("examples", "Examples")} \n'
f"{code}
\n "
)
@@ -1491,8 +1555,8 @@ def _replace(m):
full = _dbl_bt(full)
content = f"{full}
" if full else ""
return (
- f'\n'
- f'{display}
\n'
+ f'\n'
+ f'{display} \n'
f"{content}\n "
)
@@ -1564,8 +1628,8 @@ def translate_bold_section_headers(html_content):
Examples:
- This function converts those into the same ``/`` structure that the renderer uses
- for NumPy-style sections so the page has a consistent look.
+ Translate these headings to the renderer's `` and ``
+ structure so all docstring styles share the same layout.
"""
# Map of recognized section names → CSS id / class suffix
@@ -1599,9 +1663,10 @@ def _replace_header(m):
slug = _SECTION_NAMES.get(name, name.lower().replace(" ", "-"))
i18n_key = _BOLD_I18N_KEY.get(name)
display = _t(i18n_key, name) if i18n_key else name
+ lvl = _fallback_section_level(html_content, m.start())
return (
- f'\n'
- f'{display}
'
+ f'\n'
+ f'{display} '
)
html_content = re.sub(
@@ -1981,93 +2046,6 @@ def fix_dataclass_attributes(content_str):
# Convert back to lines for line-by-line processing
content = content.splitlines(keepends=True)
- # Determine the classification of each h1 tag based on its content
- # Remove the literal text `Validate.` from the h1 tag
- # TODO: Add line below stating the class name for the method
- content = [
- line.replace(
- 'Validate.',
- '',
- )
- for line in content
- ]
-
- # Add `()` only to functions and methods in the h1 title
- # Uses object_types metadata when available, otherwise falls back to heuristics
- _CALLABLE_TYPES = {"function", "method"}
-
- for i, line in enumerate(content):
- # Use regex to find h1 tags (both class="title" and styled versions)
- h1_match = re.search(r'', line)
-
- if not h1_match:
- h1_match = re.search(r'', line)
-
- if h1_match:
- # Extract the content of the h1 tag
- start = h1_match.end()
- end = line.find("
", start)
- h1_content = line[start:end].strip()
-
- # Determine whether this item should get ()
- obj_type = object_types.get(item_name_from_file)
-
- # Replace the h1 tag with the modified content
- content[i] = line[:start] + h1_content + line[end:]
-
- # Wrap bare h1 tags (those with style attribute but no quarto-title wrapper) in proper structure
- for i, line in enumerate(content):
- # Look for h1 tags with style attribute that aren't already wrapped
- if "\n{h1_content}\n\n'
- content[i] = wrapped_h1
-
- # Add a style attribute to the h1 tag to use a monospace font for code-like appearance
- content = [
- line.replace(
- '',
- "",
- )
- for line in content
- ]
-
- # Some h1 tags may not have a class attribute, so we handle that case too
- # But skip "Attributes" and "Methods" section headings — they should look like
- # the Parameters section label (doc-section style), not code font.
- _SECTION_HEADINGS = {"Attributes", "Methods"}
- new_content = []
- for line in content:
- if "" in line:
- # Check if this is a section heading like Attributes or Methods
- h1_text_match = re.search(r"(.*?)
", line)
- if h1_text_match and h1_text_match.group(1).strip() in _SECTION_HEADINGS:
- # Style like Parameters: use doc-section class instead of code font
- line = line.replace(
- "",
- '',
- )
- else:
- line = line.replace(
- "",
- "",
- )
- new_content.append(line)
- content = new_content
-
# Fix return value formatting in individual function pages, removing the `:` before the
# return value and adjusting the style of the parameter annotation separator
content_str = "".join(content)
@@ -2138,21 +2116,15 @@ def fix_dataclass_attributes(content_str):
content = content_str.splitlines(keepends=True)
- # Turn all h3 tags into h4 tags
- content = [line.replace("", "") for line in content]
-
- # Turn all h2 tags into h3 tags
- content = [line.replace("", "
") for line in content]
-
# Add separator lines between class details and individual members,
# and between individual member sections.
# - Thin solid line after the Methods/Attributes summary table (before first member section)
# - Dotted line between each individual member section
for i, line in enumerate(content):
- # Detect — these are individual member sections
- if " in )
- # or is another level2 section close
+ # or closes another member section
for j in range(i - 1, max(0, i - 5), -1):
prev = content[j].strip()
if not prev:
@@ -2190,41 +2162,15 @@ def fix_dataclass_attributes(content_str):
_api_label = _t("api", "API")
_obj_name_match = re.search(r'([^<]+)', content_str)
_display_name = _obj_name_match.group(1) if _obj_name_match else item_name_from_file
+ # Use `h5` because this label is navigation; the page content owns `h1`.
_ref_title_html = (
- f''
+ f''
f'{_api_label}'
f'/'
f'{html.escape(_display_name)}'
- f"
"
+ f""
)
- breadcrumb_pattern = r''
- # Replace only the first breadcrumb (in the secondary nav bar, outside );
- # a second breadcrumb may exist inside the title-block-header — remove it.
- content_str = re.sub(breadcrumb_pattern, _ref_title_html, content_str, count=1, flags=re.DOTALL)
- content_str = re.sub(breadcrumb_pattern, "", content_str, flags=re.DOTALL)
-
- # Shift all heading levels down by 1 within content so that
- # reference page titles use instead of , differentiating them
- # from the top-level "Reference" heading on the index page.
- main_start = content_str.find("")
- if main_start != -1 and main_end != -1:
- before = content_str[:main_start]
- main_content = content_str[main_start : main_end + len(" ")]
- after = content_str[main_end + len("
") :]
-
- # Shift in reverse order (h5→h6, h4→h5, ..., h1→h2) to avoid
- # double-shifting (e.g. h1→h2→h3).
- for level in range(5, 0, -1):
- main_content = main_content.replace(f"", f" ")
- main_content = re.sub(
- rf'\bclass="level{level}\b',
- f'class="level{level + 1}',
- main_content,
- )
-
- content_str = before + main_content + after
+ content_str = replace_secondary_nav_title(content_str, _ref_title_html)
content = content_str.splitlines(keepends=True)
@@ -2272,60 +2218,29 @@ def convert_table_to_dl(match):
# Remove redundant "API Reference" top-level nav item
# Find the nav structure and flatten it by removing the top-level wrapper
- nav_pattern = r'()'
+ nav_pattern = (
+ r'()'
+ )
nav_replacement = r"\1\2\3"
content = re.sub(nav_pattern, nav_replacement, content, flags=re.DOTALL)
# Clean up Sphinx cross-reference roles in index descriptions
content = translate_sphinx_roles(content)
- # Shift section headings down by 1 within so that category headings
- # (Classes, Methods, etc.) render as , visually subordinate to the
- # "Reference" page title. Skip the page title itself (class="title").
- main_start = content.find("")
- if main_start != -1 and main_end != -1:
- before = content[:main_start]
- main_content = content[main_start : main_end + len(" ")]
- after = content[main_end + len("
") :]
-
- # Protect the title heading from being shifted by replacing it with a
- # temporary placeholder, then shifting everything else, then restoring.
- title_pattern = re.compile(r'(]*>.*?
)', re.DOTALL)
- title_placeholder = ""
- title_match = title_pattern.search(main_content)
- if title_match:
- saved_title = title_match.group(1)
- main_content = main_content.replace(saved_title, title_placeholder, 1)
-
- for level in range(5, 0, -1):
- main_content = main_content.replace(f"", f" ")
- main_content = re.sub(
- rf'\bclass="level{level}\b',
- f'class="level{level + 1}',
- main_content,
- )
-
- # Restore the title heading
- if title_match:
- main_content = main_content.replace(title_placeholder, saved_title, 1)
-
- content = before + main_content + after
-
# Translate renderer-rendered headings, TOC, and sidebar on the index page
content = translate_renderer_headings(content)
# Replace breadcrumb with an "API / Index" title bar label
+ # Keep the navigation label below the page's `h1` title.
_ref_idx_title = (
- ''
+ ''
'API'
'/'
'Index'
- "
"
+ ""
)
- _bc_pat = r''
- content = re.sub(_bc_pat, _ref_idx_title, content, flags=re.DOTALL)
+ content = replace_secondary_nav_title(content, _ref_idx_title)
with open(index_file, "w", encoding="utf-8") as file:
file.write(content)
@@ -2342,15 +2257,15 @@ def convert_table_to_dl(match):
with open(mcp_index_file, "r", encoding="utf-8") as file:
content = file.read()
+ # Keep the navigation label below the page's `h1` title.
_mcp_idx_title = (
- ''
+ ''
'MCP'
'/'
'Index'
- "
"
+ ""
)
- _bc_pat = r''
- content = re.sub(_bc_pat, _mcp_idx_title, content, flags=re.DOTALL)
+ content = replace_secondary_nav_title(content, _mcp_idx_title)
with open(mcp_index_file, "w", encoding="utf-8") as file:
file.write(content)
@@ -2381,18 +2296,17 @@ def convert_table_to_dl(match):
else os.path.basename(html_file).replace(".html", "")
)
+ # Keep the navigation label below the page's `h1` title.
_mcp_title_html = (
- f''
+ f''
f'MCP'
f'/'
f'{html.escape(_mcp_name)}'
- f"
"
+ f""
)
- # MCP pages use bread-crumbs: false, so Quarto renders the title inside
- # an h1.quarto-secondary-nav-title.no-breadcrumbs element
- _h1_pat = r'.*?
'
- content = re.sub(_h1_pat, _mcp_title_html, content, count=1, flags=re.DOTALL)
+ # MCP pages disable breadcrumbs, so replace Quarto's bare `h1` form.
+ content = replace_secondary_nav_title(content, _mcp_title_html)
with open(html_file, "w", encoding="utf-8") as file:
file.write(content)
@@ -2685,7 +2599,7 @@ def process_cli_reference_pages():
This adds the 'cli-title' class to h1 elements in CLI reference pages so they match the
monospaced font style of API reference pages.
"""
- cli_html_files = glob.glob("_site/reference/cli/*.html")
+ cli_html_files = glob.glob("_site/reference/cli/**/*.html", recursive=True)
if not cli_html_files:
return
@@ -2706,28 +2620,32 @@ def process_cli_reference_pages():
# Replace breadcrumb with a "CLI / great-docs cmd" title bar label
_cli_label = _t("cli", "CLI")
- _bc_pat = r''
+ # Keep the navigation label below the page's `h1` title.
if cmd_name != "index":
- # Extract full command name from the page title (e.g., "great-docs init")
- _title_match = re.search(r'([^<]+)
', content)
- _full_cmd = _title_match.group(1).strip() if _title_match else f"great-docs {cmd_name}"
+ # Read all text inside the title because the command name may be
+ # nested in a `span`.
+ _title_match = re.search(r'(.*?)
', content, re.DOTALL)
+ if _title_match:
+ _full_cmd = html.unescape(re.sub(r"<[^>]+>", "", _title_match.group(1))).strip()
+ else:
+ _full_cmd = f"great-docs {cmd_name}"
_cli_title_html = (
- f''
+ f''
f'{_cli_label}'
f'/'
f'{html.escape(_full_cmd)}'
- f"
"
+ f""
)
else:
# CLI index: show "CLI / Index" to mirror the API reference index ("API / Index").
_cli_title_html = (
- f''
+ f''
f'{_cli_label}'
f'/'
f'Index'
- f"
"
+ f""
)
- content = re.sub(_bc_pat, _cli_title_html, content, flags=re.DOTALL)
+ content = replace_secondary_nav_title(content, _cli_title_html)
with open(html_file, "w", encoding="utf-8") as file:
file.write(content)
diff --git a/great_docs/cli.py b/great_docs/cli.py
index 6963c366..342c31c6 100644
--- a/great_docs/cli.py
+++ b/great_docs/cli.py
@@ -811,7 +811,7 @@ def scan(project_path: str | None, docs_dir: str | None, verbose: bool) -> None:
click.echo(f"\n✅ Found in great-docs.yml ({len(reference_config)} section(s))")
if verbose:
for section in reference_config:
- title = section.get("title", "Untitled")
+ title = section.get("title") or section.get("subtitle") or "Untitled"
contents = section.get("contents", [])
click.echo(f" • {title}: {len(contents)} item(s)")
else:
diff --git a/great_docs/core.py b/great_docs/core.py
index abbd0320..a3c2db1c 100644
--- a/great_docs/core.py
+++ b/great_docs/core.py
@@ -424,9 +424,7 @@ def _prepare_build_directory(self) -> None:
html = index_html.read_text(encoding="utf-8")
if _MARIMO_AUTORUN_MARKER not in html:
index_html.write_text(
- html.replace(
- "