diff --git a/.gitignore b/.gitignore index 1b87ec45..b6629f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/great_docs/_versioned_build.py b/great_docs/_versioned_build.py index 7fc642ac..3e4a0c1e 100644 --- a/great_docs/_versioned_build.py +++ b/great_docs/_versioned_build.py @@ -1761,7 +1761,7 @@ 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) @@ -1769,6 +1769,7 @@ def run_versioned_build( # --- 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) @@ -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, @@ -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]]] = {} @@ -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: diff --git a/great_docs/assets/restore-freeze.py b/great_docs/assets/restore-freeze.py index 5d027978..3822e11f 100644 --- a/great_docs/assets/restore-freeze.py +++ b/great_docs/assets/restore-freeze.py @@ -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// (two levels below). +# In a versioned build this is _great_docs_build// (two levels below). # We find the project root by walking upward until we find great-docs.yml. build_dir = Path.cwd() diff --git a/great_docs/core.py b/great_docs/core.py index eb669468..da75606d 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -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/", diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index c9272cd9..f8eadf54 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -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 @@ -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 @@ -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) @@ -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 @@ -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", ) @@ -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 diff --git a/tests/test_versioned_build.py b/tests/test_versioned_build.py index ccc71992..4b5af66e 100644 --- a/tests/test_versioned_build.py +++ b/tests/test_versioned_build.py @@ -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("") + 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 == [] diff --git a/tests/test_versioning_e2e.py b/tests/test_versioning_e2e.py index 21a2ab3f..42e8e715 100644 --- a/tests/test_versioning_e2e.py +++ b/tests/test_versioning_e2e.py @@ -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]] = {} @@ -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 @@ -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 diff --git a/user_guide/30-multi-version-docs.qmd b/user_guide/30-multi-version-docs.qmd index 99bd9abd..19255a81 100644 --- a/user_guide/30-multi-version-docs.qmd +++ b/user_guide/30-multi-version-docs.qmd @@ -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/ ```