Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ A persistent dark mode toggle with flash-free loading. Your users' preference is
- Click CLI documentation
- User Guide pages from `user_guide/` directory
- Custom sections (recipes, blog, tutorials, etc.)
- Custom HTML pages from `custom/` with passthrough or raw layouts

</td>
<td width="50%" valign="top">
Expand Down Expand Up @@ -207,6 +208,20 @@ sections:
navbar_after: User Guide
```

Custom HTML pages can live in a `custom/` directory and are discovered automatically during build.

```html
---
title: Landing Page
layout: passthrough
navbar: true
---
<section class="hero">...</section>
```

Use `layout: passthrough` to wrap the HTML body with the normal Great Docs shell, or `layout: raw` to copy the HTML file through unchanged.
Set `navbar: true` to add the page to the site navbar using its title, or use `navbar: {text: Showcase, after: Guide}` for explicit navbar label and placement.

See the [Configuration Guide](https://posit-dev.github.io/great-docs/user-guide/configuration.html) for the full reference.

## Deploying to GitHub Pages
Expand Down
73 changes: 73 additions & 0 deletions great_docs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@
# Custom sections (generic page groups: examples, tutorials, blog, etc.)
# Each entry: {"title": str, "dir": str, "index": bool, "navbar_after": str | None}
"sections": [],
# Custom static HTML pages.
# None: auto-discover from project_root/custom/
# False: disable custom page discovery entirely
# str: one source directory, output defaults to its basename
# dict: {"dir": str, "output": str | None}
# list[str | dict]: multiple source directories
"custom_pages": None,
# Homepage mode
# "index" (default): separate homepage from README / index source
# "user_guide": first user-guide page becomes the landing page
Expand Down Expand Up @@ -436,6 +443,55 @@ def sections(self) -> list[dict]:
"""Get the custom sections configuration."""
return self.get("sections", [])

@property
def custom_pages(self) -> list[dict[str, str]]:
"""Get normalized custom static page source directories.

Returns a list of dicts with ``dir`` and ``output`` keys.

- When ``custom_pages`` is omitted, falls back to ``custom/``.
- When ``custom_pages`` is ``false``, returns an empty list.
- When ``custom_pages`` is a string, that path is used and the output
prefix defaults to the basename of the path.
- When ``custom_pages`` is a dict, it may specify ``dir`` and optional
``output``.
- When ``custom_pages`` is a list, each entry may be a string or dict.
"""
raw = self.get("custom_pages")

if raw is None:
return [{"dir": "custom", "output": "custom"}]

if raw is False:
return []

entries: list[Any]
if isinstance(raw, list):
entries = raw
else:
entries = [raw]

normalized: list[dict[str, str]] = []

for entry in entries:
if isinstance(entry, str):
output = Path(entry).name or entry
normalized.append({"dir": entry, "output": output})
continue

if isinstance(entry, dict):
source_dir = entry.get("dir")
if not isinstance(source_dir, str) or not source_dir:
continue

output = entry.get("output")
if not isinstance(output, str) or not output:
output = Path(source_dir).name or source_dir

normalized.append({"dir": source_dir, "output": output})

return normalized

@property
def dark_mode_toggle(self) -> bool:
"""Check if dark mode toggle is enabled."""
Expand Down Expand Up @@ -1235,6 +1291,23 @@ def create_default_config() -> str:
# dir: blog
# type: blog # "blog" for Quarto listing, omit for card grid

# Custom Static Pages
# -------------------
# Add hand-written HTML pages that Great Docs should either wrap with the site
# shell (layout: passthrough) or copy through unchanged (layout: raw).
#
# Omit `custom_pages` to use the conventional `custom/` directory.
# Set `custom_pages: false` to disable discovery.
#
# custom_pages:
# - dir: marketing # Source directory (relative to project root)
# output: py # URL/output prefix (optional; defaults to dir basename)
# - dir: playgrounds
# output: demos
#
# Short form for a single directory:
# custom_pages: marketing

# Dark Mode Toggle
# ----------------
# Enable/disable the dark mode toggle in navbar (default: true)
Expand Down
191 changes: 191 additions & 0 deletions great_docs/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2207,6 +2207,186 @@ def _read_quarto_config(self, quarto_yml: Path) -> dict:

return config

# =========================================================================
# Custom Static Pages Methods
# =========================================================================

def _get_custom_page_sources(self) -> list[dict[str, Path | str]]:
"""Return configured custom page source directories that exist."""
sources: list[dict[str, Path | str]] = []

for entry in self._config.custom_pages:
source_dir = self.project_root / entry["dir"]
if not source_dir.exists() or not source_dir.is_dir():
if entry["dir"] != "custom" or self._config.exists():
print(f" ⚠️ Custom pages directory '{entry['dir']}' not found, skipping")
continue

sources.append(
{
"source_dir": source_dir,
"output": entry["output"],
}
)

return sources

def _split_frontmatter(self, content: str) -> tuple[dict, str]:
"""Split YAML frontmatter from file content when present."""
normalized = content.lstrip()

if not normalized.startswith("---"):
return {}, content

parts = normalized.split("---", 2)
if len(parts) < 3:
return {}, content

try:
frontmatter = parse_yaml(parts[1]) or {}
except ValueError:
return {}, content

if not isinstance(frontmatter, dict):
return {}, content

return frontmatter, parts[2].lstrip("\n")

def _derive_page_title(self, path: Path) -> str:
"""Derive a human-readable title from a source filename."""
name = path.stem.replace("-", " ").replace("_", " ")
return name.title()

def _get_custom_page_navbar_config(
self,
frontmatter: dict,
default_title: str,
) -> tuple[str, str | None] | None:
"""Resolve optional navbar configuration for a custom page."""
navbar = frontmatter.get("navbar")

if navbar in (None, False):
return None

if navbar is True:
return default_title, frontmatter.get("navbar_after")

if isinstance(navbar, str):
return navbar, frontmatter.get("navbar_after")

if isinstance(navbar, dict):
text = navbar.get("text", default_title)
after = navbar.get("after", frontmatter.get("navbar_after"))
if isinstance(text, str) and text:
return text, after if isinstance(after, str) else None

return None

def _add_project_resources(
self,
resources_to_add: list[str],
render_excludes: list[str] | None = None,
) -> None:
"""Add resource paths and render exclusions to _quarto.yml."""
if not resources_to_add and not render_excludes:
return

quarto_yml = self.project_path / "_quarto.yml"
config = self._read_quarto_config(quarto_yml)
project = config.setdefault("project", {})

resources = project.setdefault("resources", [])
if isinstance(resources, str):
resources = [resources]
project["resources"] = resources

for resource in resources_to_add:
if resource not in resources:
resources.append(resource)

if render_excludes:
if "render" not in project:
project["render"] = ["**"]
render = project["render"]
if isinstance(render, str):
render = [render]
project["render"] = render
for path in render_excludes:
exclude = f"!{path}"
if exclude not in render:
render.append(exclude)

self._write_quarto_yml(quarto_yml, config)

def _process_custom_pages(self) -> int:
"""Process configured custom HTML pages."""
sources = self._get_custom_page_sources()
if not sources:
return 0

resources_to_add: list[str] = []
raw_render_excludes: list[str] = []
processed = 0

for source in sources:
source_dir = source["source_dir"]
output_prefix = str(source["output"])
dest_dir = self.project_path / output_prefix
dest_dir.mkdir(parents=True, exist_ok=True)

for src_path in sorted(source_dir.rglob("*")):
if not src_path.is_file():
continue

rel_path = src_path.relative_to(source_dir)
dest_path = dest_dir / rel_path
output_rel_path = Path(output_prefix) / rel_path
dest_path.parent.mkdir(parents=True, exist_ok=True)

if src_path.suffix.lower() not in (".html", ".htm"):
shutil.copy2(src_path, dest_path)
resources_to_add.append(output_rel_path.as_posix())
continue

content = src_path.read_text(encoding="utf-8")
frontmatter, body = self._split_frontmatter(content)
layout = str(frontmatter.get("layout", "passthrough")).lower()
page_title = str(frontmatter.get("title") or self._derive_page_title(src_path))

if layout not in {"passthrough", "raw"}:
print(
f" ⚠️ Unsupported custom page layout '{layout}' in {output_rel_path}; "
"defaulting to passthrough"
)
layout = "passthrough"

if layout == "raw":
dest_path.write_text(body, encoding="utf-8")
raw_resource = output_rel_path.as_posix()
resources_to_add.append(raw_resource)
raw_render_excludes.append(raw_resource)
nav_href = raw_resource
else:
qmd_path = dest_path.with_suffix(".qmd")
qmd_frontmatter = dict(frontmatter)
qmd_frontmatter.pop("layout", None)
qmd_frontmatter.setdefault("title", page_title)
qmd_frontmatter.setdefault("bread-crumbs", False)
qmd_body = body if body.endswith("\n") else f"{body}\n"
qmd_content = f"---\n{format_yaml(qmd_frontmatter)}\n---\n\n{qmd_body}"
qmd_path.write_text(qmd_content, encoding="utf-8")
nav_href = output_rel_path.with_suffix(".qmd").as_posix()

navbar_cfg = self._get_custom_page_navbar_config(frontmatter, page_title)
if navbar_cfg is not None:
nav_text, navbar_after = navbar_cfg
self._add_section_to_navbar(nav_text, nav_href, navbar_after)

processed += 1

self._add_project_resources(resources_to_add, raw_render_excludes)
return processed

# =========================================================================
# CLI Documentation Methods
# =========================================================================
Expand Down Expand Up @@ -11028,6 +11208,17 @@ class Result:

traceback.print_exc()

# Step 0.9: Process auto-discovered custom HTML pages
try:
n_custom_pages = self._process_custom_pages()
if n_custom_pages:
print(f"✅ {n_custom_pages} custom page(s) processed")
except Exception as e:
print(f" ⚠️ Error processing custom pages: {e}")
import traceback

traceback.print_exc()

# Step 0.95: Copy assets directory if present
try:
assets_copied = self._copy_assets()
Expand Down
42 changes: 42 additions & 0 deletions test-packages/synthetic/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,13 @@
"gdtest_sec_deep", # 75
"gdtest_sec_index_opt", # 75b
"gdtest_sec_sidebar_single", # 75c
"gdtest_custom_passthrough_navbar", # 75d
"gdtest_custom_raw_navbar_after", # 75e
"gdtest_custom_mixed_modes", # 75f
"gdtest_custom_nested_combo", # 75g
"gdtest_custom_basename_output", # 75h
"gdtest_custom_nested_output", # 75i
"gdtest_custom_missing_dir_combo", # 75j
# 76–85: Reference config
"gdtest_ref_explicit", # 76
"gdtest_ref_members_false", # 77
Expand Down Expand Up @@ -1493,6 +1500,41 @@
"section (sidebar visible) and a 1-page FAQ section (sidebar "
"should be hidden, content takes full width)."
),
"gdtest_custom_passthrough_navbar": (
"Configured custom pages using a passthrough landing page under a "
"non-default output prefix. The navbar should link to the rendered "
"landing page rather than the conventional custom/ path."
),
"gdtest_custom_raw_navbar_after": (
"Configured custom pages using a raw HTML page published under a "
"custom output prefix. The page should be served unchanged and "
"inserted after the User Guide navbar item."
),
"gdtest_custom_mixed_modes": (
"Multiple configured custom page directories mixing passthrough and "
"raw pages, plus copied assets and a hidden page that should not "
"appear in the navbar."
),
"gdtest_custom_nested_combo": (
"Nested configured custom pages combined with a user guide and a "
"custom section. Navbar ordering and nested deployed paths should "
"all coexist cleanly."
),
"gdtest_custom_basename_output": (
"String-form custom_pages config pointing at a nested source dir. "
"The deployed path should default to the source basename, and a "
"source .htm file should still render correctly."
),
"gdtest_custom_nested_output": (
"Custom pages published under a nested output prefix like "
"products/python/. Both the page and copied assets should deploy "
"under that nested path."
),
"gdtest_custom_missing_dir_combo": (
"Multi-entry custom_pages config where one source directory is "
"missing. The missing entry should be skipped while the valid one "
"still renders and registers resources correctly."
),
# ── 76–85: Reference config ───────────────────────────────────────────
"gdtest_ref_explicit": (
"Reference config with explicit contents list defining which objects "
Expand Down
Loading
Loading