diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml index f3124a86..be9f5d55 100644 --- a/.github/workflows/check-docs.yml +++ b/.github/workflows/check-docs.yml @@ -7,11 +7,13 @@ on: paths: - docs/** - scenedetect/** + - scenedetect.cfg - website/** push: paths: - docs/** - scenedetect/** + - scenedetect.cfg - website/** branches: - main @@ -46,9 +48,11 @@ jobs: - name: Check CLI Documentation shell: bash run: | - if [[ `git status --porcelain=1 | wc -l` -ne 0 ]]; then - echo "CLI documentation is of date: docs/cli.rst does not match output after running docs/generate_cli_docs.py!" - echo "Re-run `python docs/generate_cli_docs.py` to update and commit the result." + python docs/generate_cli_docs.py + if [[ $(git status --porcelain=1 -- docs | wc -l) -ne 0 ]]; then + git --no-pager diff -- docs + echo 'CLI documentation is out of date: the generated docs do not match the output of docs/generate_cli_docs.py!' + echo 'Re-run "python docs/generate_cli_docs.py" and commit the result.' exit 1 fi diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 63cd9fdc..225da7d3 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -8,6 +8,7 @@ on: - 'releases/**' # docs/** paths: - 'docs/**' + - 'scenedetect.cfg' workflow_dispatch: jobs: @@ -59,9 +60,11 @@ jobs: - name: Check CLI Documentation shell: bash run: | - if [[ `git status --porcelain=1 | wc -l` -ne 0 ]]; then - echo "CLI documentation is of date: docs/cli.rst does not match output after running docs/generate_cli_docs.py!" - echo "Re-run `python docs/generate_cli_docs.py` to update and commit the result." + python docs/generate_cli_docs.py + if [[ $(git status --porcelain=1 -- docs | wc -l) -ne 0 ]]; then + git --no-pager diff -- docs + echo 'CLI documentation is out of date: the generated docs do not match the output of docs/generate_cli_docs.py!' + echo 'Re-run "python docs/generate_cli_docs.py" and commit the result.' exit 1 fi diff --git a/docs/_ext/section_sidebar.py b/docs/_ext/section_sidebar.py new file mode 100644 index 00000000..32aebba3 --- /dev/null +++ b/docs/_ext/section_sidebar.py @@ -0,0 +1,265 @@ +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +"""Sphinx extension: show only the current section (e.g. CLI or API) in the sidebar. + +The root document has one named toctree per section. For every page inside a section, the +sidebar template gets ``section_toctree()``, which renders that section's toctree fully +expanded (other pages to the section's ``maxdepth``, the current page to ``current_maxdepth``), +plus ``sidebar_section`` and ``sidebar_sections`` for a switch between sections. The root +document itself renders every labelled section's tree, each pruned to +``section_sidebar_root_depth`` (by default the section's ``maxdepth``). Pages in no section +(genindex, search, py-modindex) and the singlehtml builder fall back to the regular global +toctree. + +Configure with ``section_sidebar_sections`` in conf.py:: + + section_sidebar_sections = { + "clitoc": ("CLI", "cli", 2, 2), # toctree name: (label, landing doc, depth, cur depth) + "reftoc": (None, None, 2, 2), # no label: every page in the section is a small link + } + section_sidebar_root_depth = {"apitoc": 1} # on the root document: page titles only + +A section whose label is ``None`` is not part of the switch; the template gets its pages as +``sidebar_section_docs[name]`` (a list of ``(title, docname)``) and draws them as a row of +smaller links. Its tree is only drawn on its own pages, and then holds just the current page's +headings, since the row already lists the pages. + +``section_sidebar_summary`` trims what a page contributes to the tree when it is NOT the +current page: only entries (at any depth) whose text matches one of the glob patterns are +kept, together with the headings above them; an empty list keeps just the page title. +``section_sidebar_page_depth`` overrides the section's current-page depth for one page:: + + section_sidebar_summary = { + "api/backends": ["VideoStream*"], # glob on the entry text + "api/platform": [], # title only + } + section_sidebar_page_depth = {"api/backends": 3} # classes, but not their members +""" + +import fnmatch + +from docutils import nodes +from sphinx import addnodes +from sphinx.application import Sphinx +from sphinx.environment.adapters.toctree import TocTree +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +_LEVEL_PREFIX = "toctree-l" +# Depth the section tree is resolved to before pruning; deeper than any configured depth. +_RESOLVE_DEPTH = 6 + + +def _section_toctree_node(app: Sphinx, pagename: str) -> addnodes.toctree | None: + """Root-doc toctree node whose entries contain `pagename` or one of its ancestors.""" + env = app.env + if pagename not in env.all_docs: + return None + lineage = {pagename, *TocTree(env).get_toctree_ancestors(pagename)} + for node in env.get_doctree(env.config.root_doc).findall(addnodes.toctree): + if lineage.intersection(node["includefiles"]): + return node + return None + + +def _entry_target(item: nodes.list_item) -> str: + """URI a toctree entry links to (page-relative, as rendered), without the anchor.""" + ref = next(item.findall(nodes.reference), None) + return ref.get("refuri", "").split("#", 1)[0] if ref is not None else "" + + +def _drop_sublists(item: nodes.list_item) -> None: + item.children = [c for c in item.children if not isinstance(c, nodes.bullet_list)] + + +def _entry_text(item: nodes.list_item) -> str: + """Text of the entry itself (its first paragraph), not of the entries nested under it.""" + return item[0].astext() if len(item) else "" + + +def _apply_summary(top: nodes.list_item, patterns: list[str]) -> bool: + """Keep only the entries under `top` that match `patterns`, plus the headings above them. + + Returns False if a non-empty pattern list matched nothing. + """ + matches = [ + item + for item in top.findall(nodes.list_item) + if item is not top and any(fnmatch.fnmatchcase(_entry_text(item), p) for p in patterns) + ] + keep: set[int] = set() + for item in matches: + _drop_sublists(item) + node = item + while node is not top: + keep.add(id(node)) + node = node.parent + for item in list(top.findall(nodes.list_item)): + if item is not top and id(item) not in keep and item.parent is not None: + item.parent.remove(item) + for empty in [bl for bl in top.findall(nodes.bullet_list) if not bl.children]: + empty.parent.remove(empty) + return bool(matches) or not patterns + + +def _entry_level(item: nodes.list_item) -> int | None: + return next( + (int(c[len(_LEVEL_PREFIX) :]) for c in item["classes"] if c.startswith(_LEVEL_PREFIX)), + None, + ) + + +def _prune_depth(top: nodes.list_item, maxdepth: int) -> None: + """Drop the sub-lists of every entry under `top` at `maxdepth` or deeper.""" + for item in list(top.findall(nodes.list_item)): + level = _entry_level(item) + if level is not None and level >= maxdepth: + _drop_sublists(item) + + +def _keep_current_headings(tree: nodes.Element) -> None: + """Replace the section's page list with the headings of the current page, one level up. + + For sections without a switch label: the link row under the switch already lists their + pages, so the tree repeats neither the current page's title nor its siblings. + """ + for top in list(tree.findall(nodes.list_item)): + if "toctree-l1" not in top["classes"] or not top.get("iscurrent"): + continue + pages = top.parent + headings = next((c for c in top.children if isinstance(c, nodes.bullet_list)), None) + if headings is None: + pages.parent.remove(pages) + return + for item in headings.findall(nodes.list_item): + level = _entry_level(item) + if level is not None: + item["classes"].remove(f"{_LEVEL_PREFIX}{level}") + item["classes"].append(f"{_LEVEL_PREFIX}{level - 1}") + pages.replace_self(headings) + return + + +def _prune_tree( + tree: nodes.Element, maxdepth: int, current_maxdepth: int, summary: dict[str, list[str]] +) -> None: + """Trim the resolved section tree to the configured depths. + + The current page is cut at `current_maxdepth`. Every other page is cut at `maxdepth`, or, + if `summary` (keyed by rendered URI) has patterns for it, reduced to the matching entries + (none for an empty list) plus the headings above them. + """ + for top in list(tree.findall(nodes.list_item)): + if "toctree-l1" not in top["classes"]: + continue + if top.get("iscurrent"): + _prune_depth(top, current_maxdepth) + continue + patterns = summary.get(_entry_target(top)) + if patterns is None: + _prune_depth(top, maxdepth) + elif not _apply_summary(top, patterns): + logger.warning( + "section_sidebar_summary: no entry of %s matches %r", + _entry_target(top), + patterns, + once=True, + ) + + +def _add_section_toctree(app: Sphinx, pagename: str, templatename, context, doctree) -> None: + sections = app.config.section_sidebar_sections + if app.builder.name == "singlehtml": + # Everything is one page: the switch would link to itself, so hide it. + sections = {} + node = _section_toctree_node(app, pagename) if sections else None + names = node.parent.get("names", []) if node is not None else [] + section = next((n for n in names if n in sections), None) + root_nodes: list[tuple[str, addnodes.toctree]] = [] + if sections and node is None and pagename == app.env.config.root_doc: + for root_node in app.env.get_doctree(pagename).findall(addnodes.toctree): + name = next((n for n in root_node.parent.get("names", []) if n in sections), None) + if name is not None: + root_nodes.append((name, root_node)) + context["sidebar_section"] = section + context["sidebar_sections"] = sections + context["sidebar_section_docs"] = _section_docs(app, sections) if sections else {} + + def render(name: str, toctree_node: addnodes.toctree) -> str: + _, _, maxdepth, current_maxdepth = sections[name] + current_maxdepth = app.config.section_sidebar_page_depth.get(pagename, current_maxdepth) + tree = TocTree(app.env).resolve( + pagename, + app.builder, + toctree_node, + prune=True, + maxdepth=_RESOLVE_DEPTH, + collapse=False, + includehidden=True, + ) + if tree is None: + return "" + # The switch and link row above the tree already name the sections; the toctree + # caption would only repeat them (the captions still head the global toctree used by + # the fallback pages and the singlehtml build). + for child in [c for c in tree.children if isinstance(c, nodes.title)]: + tree.remove(child) + root_depth = app.config.section_sidebar_root_depth + if pagename == app.env.config.root_doc and name in root_depth: + # The root page has no current entry; one depth for every page, no summaries. + _prune_tree(tree, root_depth[name], root_depth[name], {}) + return app.builder.render_partial(tree)["fragment"] + # The resolved tree links by page-relative URI, so key the summary patterns the same way. + summary = { + app.builder.get_relative_uri(pagename, docname): patterns + for docname, patterns in app.config.section_sidebar_summary.items() + } + _prune_tree(tree, maxdepth, current_maxdepth, summary) + if sections[name][0] is None: + _keep_current_headings(tree) + return app.builder.render_partial(tree)["fragment"] + + def section_toctree() -> str: + if section is not None: + return render(section, node) + return "".join( + render(name, root_node) for name, root_node in root_nodes if sections[name][0] + ) + + context["section_toctree"] = section_toctree + + +def _section_docs(app: Sphinx, sections: dict) -> dict[str, list[tuple[str, str]]]: + """(title, docname) of every page listed by each section's toctree, in order.""" + env = app.env + docs: dict[str, list[tuple[str, str]]] = {} + for node in env.get_doctree(env.config.root_doc).findall(addnodes.toctree): + name = next((n for n in node.parent.get("names", []) if n in sections), None) + if name is not None: + docs[name] = [ + (env.titles[d].astext(), d) for d in node["includefiles"] if d in env.titles + ] + return docs + + +def _check_config(app: Sphinx, env) -> None: + """Warn about summary / depth entries that name pages or sections which do not exist.""" + configured = set(app.config.section_sidebar_summary) + configured |= set(app.config.section_sidebar_page_depth) + for docname in sorted(configured - set(env.all_docs)): + logger.warning("section_sidebar: %r is not a document in this project", docname) + sections = set(app.config.section_sidebar_sections) + for name in sorted(set(app.config.section_sidebar_root_depth) - sections): + logger.warning("section_sidebar_root_depth: %r is not a configured section", name) + + +def setup(app: Sphinx) -> dict: + app.add_config_value("section_sidebar_sections", {}, "html", dict) + app.add_config_value("section_sidebar_summary", {}, "html", dict) + app.add_config_value("section_sidebar_page_depth", {}, "html", dict) + app.add_config_value("section_sidebar_root_depth", {}, "html", dict) + app.connect("env-check-consistency", _check_config) + app.connect("html-page-context", _add_section_toctree) + return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/docs/_static/pyscenedetect.css b/docs/_static/pyscenedetect.css index bafe76a4..cf1d0147 100644 --- a/docs/_static/pyscenedetect.css +++ b/docs/_static/pyscenedetect.css @@ -32,3 +32,163 @@ padding-bottom: 12px; padding-top: 12px; } + +/* Per-section sidebar: CLI | Python API switch and the expanded section tree. */ +div.sphinxsidebar nav.section-switch { + font-size: 1.2em; + margin: 0.5em 0 1em 0; +} +/* No captions over the trees (the switch names them); on the index page, where every + section's tree is listed, space separates one tree from the next. */ +div.sphinxsidebarwrapper > ul + ul { + margin-top: 1.2em; +} +div.sphinxsidebar nav.section-switch a.current { + font-weight: bold; + border-bottom: 2px solid #004B6B; +} +div.sphinxsidebar nav.section-switch .sep { + color: #AAA; + margin: 0 0.4em; +} +div.sphinxsidebar ul li.toctree-l3 > a, +div.sphinxsidebar ul li.toctree-l4 > a { + font-size: 0.9em; +} +/* Long constant names (MAX_CONSECUTIVE_DECODE_FAILURES) must wrap inside the sidebar. */ +div.sphinxsidebar code span.pre { + white-space: normal; + overflow-wrap: anywhere; +} + +/* Pages that span both halves (Examples): a row of smaller links under the switch. */ +div.sphinxsidebar nav.section-switch span.section-links { + display: block; + font-size: 0.75em; + margin-top: 0.4em; +} +div.sphinxsidebar nav.section-switch span.section-links::before { + content: "\2192\00a0"; +} +div.sphinxsidebar nav.section-switch span.section-links a { + white-space: nowrap; /* "Migration Guide (v0.7)" wraps as a whole, not at "(v0.7)" */ +} +div.sphinxsidebar nav.section-switch span.section-links a.current { + font-weight: bold; + border-bottom: none; +} + +/* CLI <-> Python API cross-links: compact "See also" boxes with the title inline. */ +div.body div.admonition.seealso { + padding: 8px 14px; + margin: 12px 0; +} +div.body div.admonition.seealso p.admonition-title { + display: inline; + font-size: 1em; + font-weight: bold; + line-height: inherit; + margin: 0 0.4em 0 0; +} +div.body div.admonition.seealso p.admonition-title + p { + display: inline; +} +div.body div.admonition.seealso ul { + margin-top: 0.4em; + margin-bottom: 0; +} + +/* Index page: the Command Line and Python API lists side by side (stacked below alabaster's + breakpoint). */ +div.surfaces { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 2em; + margin: 1em 0; +} +div.surfaces div.surface { + min-width: 0; +} +div.surfaces div.surface p.rubric { + font-size: 1.2em; + margin: 0 0 0.3em 0; +} +div.surfaces div.surface > ul { + margin-left: 1.2em; +} +div.surfaces div.surface li > ul { + margin: 0.1em 0 0.3em 1.4em; +} +div.surfaces li li a { + white-space: nowrap; +} + +/* Generated tables. Per-detector option tables: automatic layout; the option and parameter + names (first two columns) never break, other code may wrap so the Default column takes the + slack; the container scrolls sideways when the table cannot fit (narrow screens, see below). + Config key tables: fixed layout, and code is allowed to wrap because the [split-video] args + default is a long single token. basic.css makes `span.pre` nowrap, so every override has + to name it. */ +table.detector-options, +table.config-keys { + width: 100%; + font-size: 0.9em; +} +div.table-scroll { + overflow-x: auto; +} +div.body table.detector-options code span.pre, +table.config-keys code span.pre { + white-space: normal; + overflow-wrap: anywhere; +} +div.body table.detector-options td:nth-child(-n+2) code span.pre { + white-space: nowrap; + overflow-wrap: normal; +} +table.config-keys { + table-layout: fixed; +} +/* Cross-link boxes: keep each link (e.g. [detect-content]) on one line. */ +div.body div.admonition.seealso a { + white-space: nowrap; +} +/* Long inline code (config file paths) and URLs wrap instead of widening the page. The 980px + bound matches the fluid-width query below. */ +@media screen and (max-width: 980px) { + div.body code.literal span.pre, + div.body a.reference.external { + white-space: normal; + overflow-wrap: anywhere; + } +} + +/* alabaster fixes the page at 940px and only goes fluid below 876px; fill the gap. */ +@media screen and (min-width: 876px) and (max-width: 980px) { + div.document, + div.footer { + width: auto; + margin-left: 20px; + margin-right: 20px; + } +} +@media screen and (max-width: 875px) { + div.surfaces { + grid-template-columns: 1fr; + } + /* The sidebar is drawn as a dark block at the bottom of the page. */ + div.sphinxsidebar code, + div.sphinxsidebar code span.pre { + color: #DDD; + background: transparent; + } + div.sphinxsidebar nav.section-switch a.current { + border-bottom-color: #DDD; + } +} +@media screen and (max-width: 700px) { + /* Too narrow for four columns: keep the table readable and let its container scroll. */ + div.table-scroll table.detector-options { + min-width: 560px; + } +} diff --git a/docs/_templates/navigation.html b/docs/_templates/navigation.html index 4493c591..7da16877 100644 --- a/docs/_templates/navigation.html +++ b/docs/_templates/navigation.html @@ -1,5 +1,28 @@ +{#- Per-section sidebar: `sidebar_section`, `sidebar_sections`, `sidebar_section_docs` and + `section_toctree()` are set by docs/_ext/section_sidebar.py. -#} +{%- if sidebar_sections %} + +{%- endif %} +{%- set section_tree = section_toctree() if sidebar_sections else "" %} +{%- if section_tree %} +{{ section_tree }} +{%- else %}

{{ _('Navigation') }}

{{ toctree(maxdepth=2, includehidden=theme_sidebar_includehidden, collapse=theme_sidebar_collapse) }} +{%- endif %} {% if theme_extra_nav_links %}