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
199 changes: 199 additions & 0 deletions docs/_extensions/category_nav.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""Sphinx extension to inject topic-category prev/next nav under headings.

The "In this documentation" table on the home page groups pages into topic
categories that cut across the Diataxis pillars, so the toctree prev/next
cannot express their order. This extension reads that table and injects a nav
strip under each heading the table points at, keeping the table the single
source of the ordering.

Entries are matched per section rather than per page: a `:ref:` label resolves
to a section anchor, and several pages carry more than one table entry
(reference/cli/workshop holds five, spanning two categories).
"""

from docutils import nodes
from sphinx import addnodes
from sphinx.errors import NoUri
from sphinx.util import logging

logger = logging.getLogger(__name__)

# Docname of the page holding the category table, and the auto-generated
# anchor of its "In this documentation" section, which the category name in
# each strip links back to.
INDEX_DOCNAME = 'index'
INDEX_ANCHOR = 'in-this-documentation'


def _find_category_table(doctree):
"""Return the "In this documentation" table node, or None.

Identified by the `:class: borderless` set on the list-table directive,
which docutils preserves on the table node.
"""
for table in doctree.findall(nodes.table):
if 'borderless' in table.get('classes', ()):
return table
return None


def _extract_rows(table, labels):
"""Yield (category, items) per table row, items being ordered links.

Each item is a (text, docname, node_id) triple, where text is the link
text used in the table rather than the target's own title.

Args:
table: The category table node, from an unresolved doctree.
labels: The std domain's label map.
"""
# Only tbody, so that adding :header-rows: later cannot turn a header into
# a category.
for tbody in table.findall(nodes.tbody):
for row in tbody.findall(nodes.row):
entries = [child for child in row.children if isinstance(child, nodes.entry)]
if len(entries) < 2:
continue

category = entries[0].astext().strip()
items = []

for xref in entries[1].findall(addnodes.pending_xref):
if xref.get('refdomain') != 'std' or xref.get('reftype') != 'ref':
continue
target = labels.get(xref['reftarget'])
if target is None:
# The std domain already warns about undefined labels.
continue
docname, node_id, _ = target
items.append((xref.astext(), docname, node_id))

if category and items:
yield category, items


def _build_index(app, env):
"""Return (by_docname, signature) extracted from the category table.

by_docname maps a docname to the list of nav entries to inject into it.
The signature covers every resolved target, so it changes when a target
page moves even though the table itself did not.
"""
table = _find_category_table(env.get_doctree(INDEX_DOCNAME))
if table is None:
logger.info('category_nav: no category table found in %s', INDEX_DOCNAME)
return {}, ()

by_docname = {}
signature = []

for category, items in _extract_rows(table, env.domains['std'].labels):
for position, (text, docname, node_id) in enumerate(items):
by_docname.setdefault(docname, []).append({
'category': category,
'node_id': node_id,
'prev': items[position - 1] if position > 0 else None,
'next': items[position + 1] if position < len(items) - 1 else None,
})
signature.append((category, position, text, docname, node_id))

return by_docname, tuple(signature)


def collect_category_nav(app, env):
"""Extract the category table, and force affected pages to rewrite.

Sphinx cannot see that every target page depends on the table, so editing
the table would otherwise leave their nav strips stale. Docnames returned
from `env-updated` are added to the set of pages to write, which is all a
`doctree-resolved` injection needs; noting a dependency would instead
force a needless re-read of every target.
"""
if app.builder.format != 'html':
return None

by_docname, signature = _build_index(app, env)

previous_signature = getattr(env, 'category_nav_signature', None)
previous_docnames = getattr(env, 'category_nav_docnames', set())

env.category_nav_by_docname = by_docname
env.category_nav_signature = signature
env.category_nav_docnames = set(by_docname)

# `env-updated` fires on every build; rewriting only on a change keeps
# autobuild from touching every target page on each save.
if previous_signature is None or previous_signature == signature:
return None

# Pages dropped from the table are rewritten too, to shed their strips.
affected = sorted((previous_docnames | set(by_docname)) & env.found_docs)
logger.info('category_nav: table changed, rewriting %d page(s)', len(affected))
return affected


def _nav_node(app, fromdocname, entry):
"""Build the nav strip: category name, then prev/next links."""

def link(text, docname, node_id, classes):
uri = app.builder.get_relative_uri(fromdocname, docname) + '#' + node_id
reference = nodes.reference('', '', internal=True, refuri=uri, classes=classes)
reference += nodes.Text(text)
return reference

nav = nodes.paragraph('', '', classes=['category-nav'])
nav += link(entry['category'], INDEX_DOCNAME, INDEX_ANCHOR, ['category-nav-category'])

siblings = nodes.inline('', '', classes=['category-nav-siblings'])
if entry['prev']:
text, docname, node_id = entry['prev']
siblings += link('← ' + text, docname, node_id, ['category-nav-prev'])
if entry['prev'] and entry['next']:
siblings += nodes.inline('', '|', classes=['category-nav-sep'])
if entry['next']:
text, docname, node_id = entry['next']
siblings += link(text + ' →', docname, node_id, ['category-nav-next'])
nav += siblings

return nav


def inject_category_nav(app, doctree, fromdocname):
"""Insert a nav strip under each heading this page contributes to a category."""
if app.builder.format != 'html':
return

entries = getattr(app.env, 'category_nav_by_docname', {}).get(fromdocname)
if not entries:
return

for entry in entries:
section = doctree.ids.get(entry['node_id'])
if not isinstance(section, nodes.section) or not isinstance(section[0], nodes.title):
continue
if any('category-nav' in child.get('classes', ()) for child in section.children):
continue

try:
nav = _nav_node(app, fromdocname, entry)
except NoUri:
continue

section.insert(1, nav)


def setup(app):
"""
Register the category-nav extension with Sphinx.

Returns:
dict: Extension metadata with version and parallel_read_safe/parallel_write_safe flags.
"""
app.connect('env-updated', collect_category_nav)
app.connect('doctree-resolved', inject_category_nav)

return {
'version': '0.1',
'parallel_read_safe': True,
'parallel_write_safe': True,
}
47 changes: 47 additions & 0 deletions docs/_static/category-nav.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* Topic-category prev/next strips injected under headings by category_nav.py.
Colors come from Furo's variables, which the theme already flips for dark
mode. */

p.category-nav {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
justify-content: space-between;
align-items: baseline;
margin: 0.25rem 0 1.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--color-background-border);
font-size: 0.85em;
}

p.category-nav a {
color: var(--color-foreground-secondary);
text-decoration: none;
}

p.category-nav a:hover {
color: var(--color-brand-content);
text-decoration: underline;
}

p.category-nav a.category-nav-category {
font-weight: 600;
}

.category-nav-siblings {
display: flex;
gap: 0.6rem;
align-items: baseline;
}

.category-nav-sep {
color: var(--color-foreground-muted);
}

/* Below the content breakpoint, keep the strip left-aligned rather than
stretching category and links to opposite edges. */
@media (max-width: 46em) {
p.category-nav {
justify-content: flex-start;
}
}
2 changes: 2 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
html_css_files = [
"workshop.css",
"flat-toctree.css",
"category-nav.css",
"cookie-banner.css",
]

Expand Down Expand Up @@ -167,6 +168,7 @@
"sphinxcontrib.cairosvgconverter",
"sphinx_sitemap",
"flat_toctree",
"category_nav",
]

exclude_patterns = [
Expand Down
55 changes: 38 additions & 17 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,42 +79,62 @@ In this documentation
:ref:`Add actions <how_add_actions>` •
:ref:`Multi-workshop patterns <exp_multi_workshop_patterns>` •
:ref:`Use multiple workshops <how_use_multiple_workshops>` •
:ref:`Forward ports <how_forward_ports>` •
:ref:`Status diagrams <ref_workshop_status>` •
:ref:`Definition file <ref_workshop_definition>`

* - **SDKs**
- :ref:`Concepts <exp_sdk_concepts>` •
:ref:`Sketch SDKs in-place <tut_sketch_sdks>` •
:ref:`Craft full SDKs <tut_craft_sdks>` •
:ref:`Parts <exp_sdk_parts>` •
:ref:`Design best practices <exp_sdk_best_practices>` •
:ref:`SDKs vs Dockerfiles <exp_dockerfile_vs_sdk>` •
:ref:`Definition file <ref_sdk_definition>`
:ref:`Definition <ref_workshop_definition>`

* - **Interfaces**
- :ref:`Concepts <exp_interface_concepts>` •
:ref:`Plugs and slots <exp_plugs_slots>` •
:ref:`Camera <exp_camera_interface>` •
:ref:`Custom device <exp_custom_device_interface>` •
:ref:`Desktop <exp_desktop_interface>` •
:ref:`GPU <exp_gpu_interface>` •
:ref:`Mounts <exp_mount_interface>` •
:ref:`SSH agent <exp_ssh_interface>` •
:ref:`Networking <exp_tunnel_interface>`
:ref:`Networking <exp_tunnel_interface>` •
:ref:`Add mounts <how_add_mounts>` •
:ref:`Use host devices <how_use_host_devices>` •
:ref:`Forward ports <how_forward_ports>`

* - **SDKs**
- :ref:`Concepts <exp_sdk_concepts>` •
:ref:`Lifecycle <exp_sdk_lifecycle>` •
:ref:`Parts <exp_sdk_parts>` •
:ref:`Runtime hooks <exp_sdk_hooks>` •
:ref:`SDKs vs Dockerfiles <exp_dockerfile_vs_sdk>` •
:ref:`Definition <ref_sdk_definition>`

* - **Projects**
- :ref:`Concepts <exp_projects>` •
:ref:`Move projects <how_move_projects>` •
:ref:`Update projects <tut_project_updates>` •
:ref:`Changes and tasks <exp_changes_tasks>`

* - **Development**
* - **Use workshops**
- :ref:`Connect VS Code <how_vscode_connect_remote>` •
:ref:`JetBrains Gateway <how_jetbrains_gateway>` •
:ref:`JupyterLab in browser <how_jupyterlab_run_in_browser>` •
:ref:`Use with Git <how_git_workshops>` •
:ref:`Run GitHub Actions locally <how_run_github_actions_locally>` •
:ref:`AI agents <how_use_workshops_with_ai_agents>`
:ref:`Manage Python environments <how_manage_python_environments>` •
:ref:`Use with Git <how_git_workshops>`

* - **Create SDKs**
- :ref:`Build an SDK <how_build_sdk>` •
:ref:`Declare plugs and slots <how_declare_plugs_slots>` •
:ref:`Configure a mount <how_configure_mount>` •
:ref:`Write runtime hooks <how_write_runtime_hooks>` •
:ref:`Design best practices <exp_sdk_best_practices>` •
:ref:`Publish an SDK <how_publish_sdk>` •
:ref:`Project definition file <ref_sdkcraft_definition>`

* - **CI/CD**
- :ref:`Run GitHub Actions locally <how_run_github_actions_locally>` •
:ref:`Run workshops in GitHub Actions <how_run_workshops_in_github_actions>`

* - **AI agents**
- :ref:`Use with AI agents <how_use_workshops_with_ai_agents>` •
:ref:`LLM-readable docs <ref_ai_discovery>` •
:ref:`Workshop skill <ref_ai_use_workshop_skill>` •
:ref:`SDK designer skill <ref_ai_sdk_designer_skill>`

* - **Troubleshooting**
- :ref:`Debug workshops <how_debug_issues_workshops>` •
Expand All @@ -129,7 +149,8 @@ In this documentation
:ref:`SDK internals <ref_sdk_internals>`

* - **CLI**
- :ref:`Workshop CLI <ref_workshop__cli>` •
- :ref:`Concepts <exp_cli>` •
:ref:`Workshop CLI <ref_workshop__cli>` •
:ref:`SDK CLI <ref_sdk__cli>` •
:ref:`SDKcraft CLI <ref_sdkcraft__cli>` •
:ref:`workshopctl CLI <ref_workshopctl__cli>`
Expand Down
Loading