oct
Loading...
Searching...
No Matches
front_page.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/docs/front_page.py
4
5"""
6Purpose
7-------
8Render the workspace-mode docs hub front page. Resolves the template
9override at ``<workspace>/docs/_hub/index.html.j2`` and falls back to
10the bundled neutral default at ``oct/templates/docs_hub/index.html.j2``.
11Substitutes a manifest-driven ``subprojects`` context into the Jinja
12template and writes the result to ``<site_root>/index.html``. Also
13copies ``<workspace>/assets/`` (if present) into ``<site_root>/_assets/``,
14falling back to the bundled defaults bundled under
15``oct/templates/docs_hub/_assets/``.
16
17Responsibilities
18----------------
19- Resolve the active template via :func:`resolve_template_path`,
20 preferring the workspace override.
21- Build the Jinja render context from the loaded
22 :class:`oct.core.workspace.Workspace`.
23- Render the template and write ``index.html`` to the site root.
24- Copy assets from the workspace (or bundled defaults) into the
25 site's ``_assets/`` directory.
26
27Diagnostics
28-----------
29Domain: OCT-DOCS
30Levels:
31 L2 - render entry / exit
32 L3 - template resolution decision, asset directory selection
33 L4 - per-asset copy
34
35Contracts
36---------
37- Render is pure: same inputs produce the same HTML output (modulo the
38 ``generated_at`` ISO timestamp).
39- Asset copy uses :func:`shutil.copytree` with ``dirs_exist_ok=True`` -
40 no symlinks (Windows safety).
41- The renderer never reads or executes user code; the Jinja environment
42 has autoescape enabled for HTML safety.
43
44Dependencies
45------------
46- jinja2 (lazy; Environment, FileSystemLoader, select_autoescape - renders the template, already in via Sphinx)
47- oct.core.workspace (Workspace dataclass)
48- oct.core.diagnostics (_dbg structured logger)
49- oct.docs.doxyfile_init (lazy; has_doxyfile - whether a subproject gets a Doxygen card)
50- oct.docs.typedoc (lazy; has_typedoc - whether a subproject gets a TypeDoc card)
51"""
52
53from __future__ import annotations
54
55import shutil
56from datetime import datetime
57from pathlib import Path
58from typing import Any
59
60from oct.core.diagnostics import _dbg
61from oct.core.workspace import Workspace
62
63
64#: Bundled-default location relative to the installed ``oct`` package.
65_BUNDLED_TEMPLATE_DIR: Path = Path(__file__).resolve().parents[1] / "templates" / "docs_hub"
66
67#: Workspace override location relative to a workspace root.
68_WORKSPACE_TEMPLATE_REL: Path = Path("docs") / "_hub" / "index.html.j2"
69
70#: Workspace asset source relative to a workspace root. When this
71#: directory exists, its contents are copied into ``_site/_assets/``.
72_WORKSPACE_ASSETS_REL: Path = Path("assets")
73
74#: Bundled fallback assets, used only when the workspace has no
75#: ``assets/`` directory of its own.
76_BUNDLED_ASSETS_DIR: Path = _BUNDLED_TEMPLATE_DIR / "_assets"
77
78
79class FrontPageError(Exception):
80 """Raised when the front page cannot be rendered."""
81
82
83def resolve_template_path(ws_root: Path) -> Path:
84 """Return the active front-page template path.
85
86 Resolution order:
87 1. ``<ws_root>/docs/_hub/index.html.j2`` (workspace override)
88 2. ``oct/templates/docs_hub/index.html.j2`` (bundled default)
89
90 Raises
91 ------
92 FrontPageError
93 When neither template exists. Should be impossible in normal
94 installs because the bundled default ships with the package.
95 """
96 func = "resolve_template_path"
97 override = ws_root / _WORKSPACE_TEMPLATE_REL
98 if override.is_file():
99 _dbg("OCT-DOCS", func, f"using workspace override: {override}", 3)
100 return override
101 bundled = _BUNDLED_TEMPLATE_DIR / "index.html.j2"
102 if bundled.is_file():
103 _dbg("OCT-DOCS", func, f"using bundled default: {bundled}", 3)
104 return bundled
105 raise FrontPageError(
106 f"No front-page template found. Expected either "
107 f"{override} (workspace override) or {bundled} (bundled default)."
108 )
109
110
111def build_render_context(workspace: Workspace) -> dict[str, Any]:
112 """Build the Jinja context dict for the front page.
113
114 The shape is intentionally narrow so override templates can rely on
115 a stable contract:
116
117 - ``workspace_name`` -- string, from the manifest
118 - ``option_c_href`` -- string, relative URL to the Option C tier
119 - ``subprojects`` -- list of dicts ``{"name", "sphinx_href",
120 "doxygen_href", "typedoc_href"}``; ``doxygen_href`` is ``None`` for
121 a subproject that declares no Doxyfile or whose manifest entry sets
122 ``html_docs: {"doxygen": false}``, and ``typedoc_href`` is ``None``
123 for one with no ``typedoc.json``; the template shows no card for a
124 ``None`` href, and a row left with only its Sphinx card is laid out
125 at full width
126 - ``generated_at`` -- ISO-8601 timestamp (seconds precision)
127
128 Only subprojects the manifest declares as carded appear in
129 ``subprojects``: an entry with ``html_docs: false`` has no output to
130 link to, and one with ``html_docs: {"hub": false}`` is built and
131 reachable by URL but deliberately kept off the index. The template
132 therefore does not need to know the manifest schema - it renders
133 every entry it is given.
134
135 The Doxygen card follows the same declaration the build fan-out uses,
136 a Doxyfile at :data:`oct.docs.doxyfile_init.DOXYFILE_REL`. Before
137 2026-09-27 every card linked to ``<name>/doxygen/html/index.html``
138 unconditionally, which put five dead links on the front page - one per
139 subproject that never had a Doxyfile. Since 2026-09-28 the card also
140 follows the manifest switch ``html_docs: {"doxygen": false}``, because
141 the fan-out then neither builds nor aggregates that subproject's
142 Doxygen and a card would point at a withdrawn tree.
143 """
144 from oct.docs.doxyfile_init import has_doxyfile
145 from oct.docs.typedoc import has_typedoc
146 return {
147 "workspace_name": workspace.name,
148 "option_c_href": "option_c/sphinx/html/index.html",
149 "subprojects": [
150 {
151 "name": sp.name,
152 "sphinx_href": f"{sp.name}/sphinx/html/index.html",
153 "doxygen_href": (
154 f"{sp.name}/doxygen/html/index.html"
155 if sp.html_docs_doxygen and has_doxyfile(sp.path)
156 else None
157 ),
158 # TypeDoc follows the same rule with its own declaration,
159 # typedoc.json: TypeScript subprojects get this card in
160 # place of the Doxygen one, since Doxygen cannot parse them.
161 "typedoc_href": (
162 f"{sp.name}/typedoc/html/index.html"
163 if has_typedoc(sp.path) else None
164 ),
165 }
166 for sp in workspace.subprojects
167 if sp.html_docs and sp.html_docs_hub
168 ],
169 "generated_at": datetime.now().isoformat(timespec="seconds"),
170 }
171
172
173def copy_assets(ws_root: Path, site_root: Path) -> Path | None:
174 """Copy workspace assets (or bundled fallbacks) into ``site_root/_assets``.
175
176 Returns the resolved source directory used, or ``None`` when no
177 assets directory was found and nothing was copied.
178 """
179 func = "copy_assets"
180 workspace_assets = ws_root / _WORKSPACE_ASSETS_REL
181 target = site_root / "_assets"
182
183 if workspace_assets.is_dir():
184 _dbg("OCT-DOCS", func, f"copying workspace assets {workspace_assets} -> {target}", 3)
185 shutil.copytree(workspace_assets, target, dirs_exist_ok=True)
186 return workspace_assets
187
188 if _BUNDLED_ASSETS_DIR.is_dir():
189 _dbg("OCT-DOCS", func, f"copying bundled assets {_BUNDLED_ASSETS_DIR} -> {target}", 3)
190 shutil.copytree(_BUNDLED_ASSETS_DIR, target, dirs_exist_ok=True)
191 return _BUNDLED_ASSETS_DIR
192
193 _dbg("OCT-DOCS", func, "no asset source found; skipping copy", 3)
194 return None
195
196
197def render_front_page(ws_root: Path, site_root: Path, workspace: Workspace) -> Path:
198 """Render the front page and write it to ``site_root/index.html``.
199
200 Parameters
201 ----------
202 ws_root:
203 Workspace root path. Used to find the override template and
204 the workspace ``assets/`` directory.
205 site_root:
206 Aggregation directory (``<ws_root>/_site/`` in normal use).
207 Created if it does not exist.
208 workspace:
209 Loaded :class:`Workspace` providing the manifest data the
210 template loops over.
211
212 Returns
213 -------
214 Path
215 The absolute path to the written ``index.html``.
216 """
217 func = "render_front_page"
218 _dbg("OCT-DOCS", func, f"start ws_root={ws_root} site_root={site_root}", 2)
219
220 site_root.mkdir(parents=True, exist_ok=True)
221
222 template_path = resolve_template_path(ws_root)
223 context = build_render_context(workspace)
224
225 # Defer the Jinja2 import so test environments without it can still
226 # exercise the resolution logic above.
227 from jinja2 import Environment, FileSystemLoader, select_autoescape
228 env = Environment(
229 loader=FileSystemLoader(str(template_path.parent)),
230 autoescape=select_autoescape(["html", "j2"]),
231 keep_trailing_newline=True,
232 )
233 template = env.get_template(template_path.name)
234 html = template.render(**context)
235
236 output = site_root / "index.html"
237 output.write_text(html, encoding="utf-8")
238 _dbg("OCT-DOCS", func, f"wrote {output} ({len(html)} chars)", 3)
239
240 copy_assets(ws_root, site_root)
241
242 _dbg("OCT-DOCS", func, "end", 2)
243 return output
244
245
246__all__ = [
247 "FrontPageError",
248 "resolve_template_path",
249 "build_render_context",
250 "copy_assets",
251 "render_front_page",
252]
Path|None copy_assets(Path ws_root, Path site_root)
Path resolve_template_path(Path ws_root)
Definition front_page.py:83
Path render_front_page(Path ws_root, Path site_root, Workspace workspace)
dict[str, Any] build_render_context(Workspace workspace)