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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ __marimo__/

# Great Docs build directory (ephemeral, do not commit)
great-docs/
_great_docs_build/
.great-docs-build/
.great-docs-cache/
.great-docs/
Expand Down
79 changes: 73 additions & 6 deletions great_docs/_versioned_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -1761,14 +1761,15 @@ def run_versioned_build(
"errors": ["No matching versions to build"],
}

build_root = project_root / ".great-docs-build"
build_root = project_root / "_great_docs_build"
if build_root.exists():
shutil.rmtree(build_root)
build_root.mkdir(parents=True)

# --- Stage 1: Preprocess each version ---
pages_by_version: dict[str, list[str]] = {}
build_dirs: list[Path] = []
errors: list[str] = []

for entry in targets:
ver_dir = _version_build_dir(build_root, entry, latest_tag)
Expand All @@ -1786,6 +1787,42 @@ def run_versioned_build(
pages_by_version[entry.tag] = pages
build_dirs.append(ver_dir)

# Map build dir to version tag (for pre-render diagnostics)
dir_to_tag_pre = {str(_version_build_dir(build_root, e, latest_tag)): e.tag for e in targets}

# --- Pre-render sanity check ---
# Verify each build directory has renderable .qmd files and a valid _quarto.yml.
# If not, report an error instead of silently producing an empty site.
for ver_dir in build_dirs:
qmd_count = sum(
1 for _ in ver_dir.rglob("*.qmd") if not str(_.relative_to(ver_dir)).startswith("_")
)
quarto_yml = ver_dir / "_quarto.yml"
if not quarto_yml.exists():
tag = dir_to_tag_pre.get(str(ver_dir), str(ver_dir))
errors.append(
f"Version {tag}: _quarto.yml missing from build directory. "
f"This indicates a preprocessing failure."
)
elif qmd_count == 0:
tag = dir_to_tag_pre.get(str(ver_dir), str(ver_dir))
errors.append(
f"Version {tag}: No .qmd files found in build directory after preprocessing. "
f"All pages may have been excluded by version scoping."
)

if errors:
# All versions have fatal pre-render issues; abort early.
if on_renders_done:
on_renders_done()
return {
"success": False,
"versions_built": [],
"pages_by_version": pages_by_version,
"timings_by_version": {},
"errors": errors,
}

# --- Stage 2: Parallel renders ---
render_results = render_versions_parallel(
build_dirs,
Expand All @@ -1794,7 +1831,7 @@ def run_versioned_build(
progress_callback=progress_callback,
)

errors: list[str] = []
errors_render: list[str] = []
versions_built: list[str] = []
timings_by_version: dict[str, list[dict[str, Any]]] = {}

Expand All @@ -1804,11 +1841,41 @@ def run_versioned_build(
for build_dir, returncode, stdout, stderr, page_timings in render_results:
tag = dir_to_tag.get(build_dir, build_dir)
if returncode == 0:
versions_built.append(tag)
if page_timings:
timings_by_version[tag] = page_timings
# Post-render validation: verify Quarto actually produced HTML pages.
# Quarto may exit 0 without rendering anything (e.g. if it cannot find
# renderable files or has a configuration issue). Detect this and report
# a meaningful error instead of silently producing an empty site.
site_dir = Path(build_dir) / "_site"
html_files = list(site_dir.rglob("*.html")) if site_dir.exists() else []
if not html_files:
# Gather diagnostic info
qmd_files = list(Path(build_dir).rglob("*.qmd"))
diag_parts = [
f"Version {tag}: Quarto exited successfully but produced no HTML pages.",
f" Build directory: {build_dir}",
f" .qmd files present: {len(qmd_files)}",
]
if stderr.strip():
# Limit stderr to avoid flooding logs
stderr_preview = stderr.strip()[:500]
diag_parts.append(f" Quarto stderr: {stderr_preview}")
else:
diag_parts.append(" Quarto stderr: (empty)")
diag_parts.append(
" This may indicate a Quarto configuration issue, missing dependencies, "
"or an incompatibility with the build environment."
)
errors_render.append("\n".join(diag_parts))
else:
versions_built.append(tag)
if page_timings:
timings_by_version[tag] = page_timings
else:
errors.append(f"Version {tag}: Quarto render failed (exit {returncode})\n{stderr}")
errors_render.append(
f"Version {tag}: Quarto render failed (exit {returncode})\n{stderr}"
)

errors.extend(errors_render)

# Notify caller that rendering is complete (e.g. to finish progress bars)
if on_renders_done:
Expand Down
2 changes: 1 addition & 1 deletion great_docs/assets/restore-freeze.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

# Quarto sets the working directory to the Quarto project directory.
# In a normal build this is great-docs/ (one level below project root).
# In a versioned build this is .great-docs-build/<version>/ (two levels below).
# In a versioned build this is _great_docs_build/<version>/ (two levels below).
# We find the project root by walking upward until we find great-docs.yml.
build_dir = Path.cwd()

Expand Down
1 change: 1 addition & 0 deletions great_docs/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ def _update_project_gitignore(self, force: bool = False) -> None:

# Versioning build artifacts (added when versions are configured)
versioning_entries = [
"_great_docs_build/",
".great-docs-build/",
".great-docs-cache/",
".great-docs/",
Expand Down
10 changes: 8 additions & 2 deletions tests/test_great_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7968,6 +7968,7 @@ def test_update_gitignore_force_creates_new():
content = gitignore.read_text()

assert "great-docs/" in content
assert "_great_docs_build/" in content
assert ".great-docs-build/" in content
assert ".great-docs-cache/" in content
assert ".great-docs/" in content
Expand All @@ -7986,6 +7987,7 @@ def test_update_gitignore_force_appends_to_existing():

assert "__pycache__/" in content
assert "great-docs/" in content
assert "_great_docs_build/" in content
assert ".great-docs-build/" in content
assert ".great-docs-cache/" in content
assert ".great-docs/" in content
Expand All @@ -7995,7 +7997,9 @@ def test_update_gitignore_skip_when_already_present():
"""_update_project_gitignore does not duplicate entries already present."""
with tempfile.TemporaryDirectory() as tmp_dir:
gitignore = Path(tmp_dir) / ".gitignore"
gitignore.write_text("great-docs/\n.great-docs-build/\n.great-docs-cache/\n.great-docs/\n")
gitignore.write_text(
"great-docs/\n_great_docs_build/\n.great-docs-build/\n.great-docs-cache/\n.great-docs/\n"
)

docs = GreatDocs(project_path=tmp_dir)
docs._update_project_gitignore(force=True)
Expand All @@ -8005,6 +8009,7 @@ def test_update_gitignore_skip_when_already_present():
lines = content.splitlines()

assert lines.count("great-docs/") == 1
assert lines.count("_great_docs_build/") == 1
assert lines.count(".great-docs-build/") == 1
assert lines.count(".great-docs-cache/") == 1
assert lines.count(".great-docs/") == 1
Expand Down Expand Up @@ -25582,7 +25587,7 @@ def test_update_gitignore_already_present():
with tempfile.TemporaryDirectory() as tmp_dir:
gitignore = Path(tmp_dir) / ".gitignore"
gitignore.write_text(
"great-docs/\n.great-docs-build/\n.great-docs-cache/\n.great-docs/\n",
"great-docs/\n_great_docs_build/\n.great-docs-build/\n.great-docs-cache/\n.great-docs/\n",
encoding="utf-8",
)

Expand All @@ -25593,6 +25598,7 @@ def test_update_gitignore_already_present():
lines = content.splitlines()
# Should not have doubled any entry
assert lines.count("great-docs/") == 1
assert lines.count("_great_docs_build/") == 1
assert lines.count(".great-docs-build/") == 1
assert lines.count(".great-docs-cache/") == 1
assert lines.count(".great-docs/") == 1
Expand Down
145 changes: 145 additions & 0 deletions tests/test_versioned_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -3439,3 +3439,148 @@ def test_removes_href_dict_with_missing_md_file(self, tmp_path: Path):
result = _prune_sidebar_contents(contents, tmp_path)
assert len(result) == 1
assert result[0]["href"] == "intro.md"


# ---------------------------------------------------------------------------
# run_versioned_build
# ---------------------------------------------------------------------------


class TestRunVersionedBuildEmptyRender:
"""Tests for detecting when Quarto exits 0 but produces no HTML."""

def test_quarto_exit_zero_no_html_reports_error(self, tmp_path: Path):
"""When Quarto succeeds (exit 0) but creates no HTML, report failure."""
from unittest.mock import patch

source = tmp_path / "source"
_make_source_tree(source, {"index.qmd": "---\ntitle: Hi\n---\nContent"})

project_root = tmp_path / "project"
project_root.mkdir()

def mock_render_no_output(build_dirs, **kwargs):
# Quarto exits 0 but creates NO _site directory (empty render)
return [(str(d), 0, "", "", []) for d in build_dirs]

with patch(
"great_docs._versioned_build.render_versions_parallel",
side_effect=mock_render_no_output,
):
result = run_versioned_build(
source_dir=source,
project_root=project_root,
versions_config=["0.3"],
)

assert result["success"] is False
assert result["versions_built"] == []
assert len(result["errors"]) >= 1
assert "no HTML pages" in result["errors"][0]

def test_quarto_exit_zero_empty_site_dir_reports_error(self, tmp_path: Path):
"""When Quarto creates _site but it's empty, report failure."""
from unittest.mock import patch

source = tmp_path / "source"
_make_source_tree(source, {"index.qmd": "---\ntitle: Hi\n---\nContent"})

project_root = tmp_path / "project"
project_root.mkdir()

def mock_render_empty_site(build_dirs, **kwargs):
results = []
for d in build_dirs:
# Create _site but with no HTML files
site_dir = d / "_site"
site_dir.mkdir(parents=True, exist_ok=True)
(site_dir / "sitemap.xml").write_text("<urlset/>")
results.append((str(d), 0, "", "", []))
return results

with patch(
"great_docs._versioned_build.render_versions_parallel",
side_effect=mock_render_empty_site,
):
result = run_versioned_build(
source_dir=source,
project_root=project_root,
versions_config=["0.3", "0.2"],
)

assert result["success"] is False
assert result["versions_built"] == []
assert len(result["errors"]) == 2

for err in result["errors"]:
assert "no HTML pages" in err

def test_pre_render_check_no_qmd_files(self, tmp_path: Path):
"""When preprocessing removes all .qmd files, abort before render."""
from unittest.mock import patch

source = tmp_path / "source"
source.mkdir(parents=True)
# Only _quarto.yml, no .qmd files
(source / "_quarto.yml").write_text(
"project:\n type: website\n output-dir: _site\nwebsite:\n title: Test\n"
)

project_root = tmp_path / "project"
project_root.mkdir()

render_called = []

def mock_render(build_dirs, **kwargs):
render_called.append(True)
return [(str(d), 0, "", "", []) for d in build_dirs]

with patch(
"great_docs._versioned_build.render_versions_parallel",
side_effect=mock_render,
):
result = run_versioned_build(
source_dir=source,
project_root=project_root,
versions_config=["0.3"],
)

assert result["success"] is False
assert "No .qmd files" in result["errors"][0]

# Render should NOT have been called
assert render_called == []

def test_pre_render_check_missing_quarto_yml(self, tmp_path: Path):
"""When _quarto.yml is missing from build dir, abort before render."""
from unittest.mock import patch

source = tmp_path / "source"
source.mkdir(parents=True)
# Create a .qmd file but no _quarto.yml
(source / "index.qmd").write_text("---\ntitle: Hi\n---\nContent")

project_root = tmp_path / "project"
project_root.mkdir()

render_called = []

def mock_render(build_dirs, **kwargs):
render_called.append(True)
return [(str(d), 0, "", "", []) for d in build_dirs]

with patch(
"great_docs._versioned_build.render_versions_parallel",
side_effect=mock_render,
):
result = run_versioned_build(
source_dir=source,
project_root=project_root,
versions_config=["0.3"],
)

assert result["success"] is False
assert "_quarto.yml missing" in result["errors"][0]

# Render should NOT have been called
assert render_called == []
6 changes: 3 additions & 3 deletions tests/test_versioning_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ def run_mock_versioned_build(
targets = list(versions)

# --- Stage 1: Preprocess ---
build_root = project_root / ".great-docs-build"
build_root = project_root / "_great_docs_build"
build_root.mkdir(parents=True)

pages_by_version: dict[str, list[str]] = {}
Expand Down Expand Up @@ -2049,7 +2049,7 @@ def site(self, tmp_path):
latest = get_latest_version(versions)
latest_tag = latest.tag

build_root = project_root / ".great-docs-build"
build_root = project_root / "_great_docs_build"
build_root.mkdir()

from great_docs._versioned_build import _rewrite_quarto_yml_for_version
Expand Down Expand Up @@ -2114,7 +2114,7 @@ def site(self, tmp_path):
latest = get_latest_version(versions)
latest_tag = latest.tag

build_root = project_root / ".great-docs-build"
build_root = project_root / "_great_docs_build"
build_root.mkdir()

from great_docs._versioned_build import _rewrite_quarto_yml_for_version
Expand Down
4 changes: 2 additions & 2 deletions user_guide/30-multi-version-docs.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -602,13 +602,13 @@ Because each version is rendered independently, the build is embarrassingly para

Versioned builds create two directories in your project root:

- **`.great-docs-build/`**: temporary staging directory where per-version source trees are prepared and rendered. Automatically cleaned and recreated at the start of every build. No manual maintenance needed.
- **`_great_docs_build/`**: temporary staging directory where per-version source trees are prepared and rendered. Automatically cleaned and recreated at the start of every build. No manual maintenance needed.
- **`.great-docs-cache/`**: persistent cache for API introspection results from Strategy B (`git_ref`). Stores one JSON snapshot per version so subsequent builds skip the expensive checkout-and-introspect step. You can safely delete this directory at any time to force a fresh introspection (the next build will just take longer).

Both directories should be added to your `.gitignore`. Great Docs does this automatically when you run `great-docs init`, but if you set up versioning on an existing project you can add them manually:

```{.text filename=".gitignore"}
.great-docs-build/
_great_docs_build/
.great-docs-cache/
```

Expand Down
Loading