Browse Source

Show the documentation hierarchy in the sidebar (#4732)

This PR shows the documentation hierarchy in the sidebar. 

- Completes
https://github.com/orgs/zauberzeug/projects/6/views/1?pane=issue&itemId=82941340
- Mentioned in https://github.com/zauberzeug/nicegui/discussions/2976

<img width="1280" alt="{A8984628-61CD-40C3-BEEF-7B7834085616}"
src="https://github.com/user-attachments/assets/f9389386-5517-4b3a-a23a-301d830ff45e"
/>

<img width="1280" alt="{40313142-2528-40CD-92E5-75E6773CBDE0}"
src="https://github.com/user-attachments/assets/45a44731-728b-4ad4-8363-7fa062dc2eeb"
/>

---

_If, in the unlucky case that you have found some deficiencies in the
tree generation code, here is an old copy of the code with a lot of
debugging prints:_

<details>
<summary> Ugly code ahead! </summary>


```py
@ui.page('/debug_documentation')
def _debug_documentation_page() -> None:
    def display_dict_pretty(d: dict) -> None:
        for k, v in d.items():
            ui.label(f'{k}: {v}').classes('text-xs font-mono')
    ui.label('Debug documentation')

    all_registry_keys = [k for k in documentation.registry.keys()]

    single_key_empty = {k: v for k, v in documentation.registry.items() if k == ''}
    no_back_link = {k: v for k, v in documentation.registry.items() if k != '' and v.back_link is None}
    rest = {k: v for k, v in documentation.registry.items() if k != '' and v.back_link is not None}

    """ui.label('Single key empty:').classes('font-bold')
    display_dict_pretty(single_key_empty)

    for k, v in documentation.content.overview.tiles:
        ui.label(k.__name__.rpartition(".")[2]).classes('text-xs font-mono')

    ui.label('No back link:').classes('font-bold')
    display_dict_pretty(no_back_link)

    ui.label('Rest:').classes('font-bold')
    display_dict_pretty(rest)"""

    all_registry_keys.remove('')

    # First build the adjacency list
    adjacency_list: List[tuple[str, str, str]] = []
    for k, v in documentation.content.overview.tiles:
        adjacency_list.append(('', k.__name__.rpartition(
            ".")[2], documentation.registry[k.__name__.rpartition(".")[2]].title))
        all_registry_keys.remove(k.__name__.rpartition(".")[2])

    """for k, v in rest.items():
        adjacency_list.append((v.back_link, k))"""

    i = 0
    while i < len(adjacency_list):
        if i > 1000000:
            break  # no way
        _, v, _ = adjacency_list[i]
        if '#' in v:
            i += 1
            continue
        registry_entry = documentation.registry.get(v)
        if registry_entry and registry_entry.parts:
            for part in registry_entry.parts:
                if part.link:
                    adjacency_list.append((v, part.link, part.title))
                    all_registry_keys.remove(part.link)
                elif part.link_target:
                    adjacency_list.append((v, f'{v}#{part.link_target}', part.title))
        i += 1

    """ui.label('Adjacency list:').classes('font-bold')
    for k, v, t in adjacency_list:
        ui.label(f'{k} -> {v} ({t})').classes('text-xs font-mono')

    ui.label('Length of adjacency list:').classes('font-bold')
    ui.label(str(len(adjacency_list))).classes('text-xs font-mono')

    ui.label('Number of remaining entries:').classes('font-bold')
    ui.label(str(len(all_registry_keys))).classes('text-xs font-mono')

    ui.label('Remaining entries:').classes('font-bold')
    for k in all_registry_keys:
        ui.label(k).classes('text-xs font-mono')"""

    def add_to_tree(tree, parent_id, child_id, title):
        for node in tree:
            if node['id'] == parent_id:
                node['children'].append({'id': child_id, 'children': [], 'title': title})
                return True
            # Recursively search in the children
            if add_to_tree(node['children'], parent_id, child_id, title):
                return True
        return False

    adjacency_list = [(k, v, t.replace("*", "")) for k, v, t in adjacency_list]

    # Build the tree from adjacency list
    tree_format_list = []
    for k, v, t in adjacency_list:
        if k == '':
            tree_format_list.append({'id': v, 'children': [], 'title': t})
        else:
            # Try to add the child to the correct parent in the tree
            if not add_to_tree(tree_format_list, k, v, t):
                # If the parent is not found, create a new top-level node
                tree_format_list.append({'id': k, 'children': [{'id': v, 'children': [], 'title': t}]})

    ui.tree(tree_format_list, label_key='title',).props('accordion=true').classes('w-full').add_slot('default-header', '''
        <span :props="props">
            <a :href="'/documentation/' + props.node.id" target="_blank" onclick="event.stopPropagation()">{{ props.node.title }}</a>
        </span>
    ''')
```


</details>

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
Evan Chan 4 days ago
parent
commit
3172fe8cb1
5 changed files with 58 additions and 15 deletions
  1. 1 0
      main.py
  2. 2 0
      website/documentation/__init__.py
  3. 18 5
      website/documentation/rendering.py
  4. 37 0
      website/documentation/tree.py
  5. 0 10
      website/style.py

+ 1 - 0
main.py

@@ -26,6 +26,7 @@ app.add_static_file(local_file=svg.PATH / 'logo.png', url_path='/logo.png')
 app.add_static_file(local_file=svg.PATH / 'logo_square.png', url_path='/logo_square.png')
 
 documentation.build_search_index()
+documentation.build_tree()
 
 
 @app.post('/dark_mode')

+ 2 - 0
website/documentation/__init__.py

@@ -3,6 +3,7 @@ from .custom_restructured_text import CustomRestructuredText
 from .intro import create_intro
 from .rendering import render_page
 from .search import build_search_index
+from .tree import build_tree
 from .windows import bash_window, browser_window, python_window
 
 __all__ = [
@@ -10,6 +11,7 @@ __all__ = [
     'bash_window',
     'browser_window',
     'build_search_index',
+    'build_tree',
     'create_intro',
     'overview',  # ensure documentation tree is built
     'python_window',

+ 18 - 5
website/documentation/rendering.py

@@ -1,3 +1,5 @@
+from typing import List
+
 from nicegui import ui
 
 from ..header import add_head_html, add_header
@@ -6,6 +8,7 @@ from .content import DocumentationPage
 from .custom_restructured_text import CustomRestructuredText as custom_restructured_text
 from .demo import demo
 from .reference import generate_class_doc
+from .tree import nodes
 
 
 def render_page(documentation: DocumentationPage, *, with_menu: bool = True) -> None:
@@ -16,11 +19,16 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
         with ui.left_drawer() \
                 .classes('column no-wrap gap-1 bg-[#eee] dark:bg-[#1b1b1b] mt-[-20px] px-8 py-20') \
                 .style('height: calc(100% + 20px) !important') as menu:
-            if documentation.back_link:
-                ui.markdown(f'[← back]({documentation.back_link or "."})').classes('bold-links')
-            else:
-                ui.markdown('[← Overview](/documentation)').classes('bold-links')
-            ui.markdown(f'**{documentation.heading.replace("*", "")}**').classes('mt-4')
+            tree = ui.tree(nodes, label_key='title').classes('w-full').props('accordion no-connectors')
+            tree.add_slot('default-header', '''
+                <a :href="'/documentation/' + props.node.id" onclick="event.stopPropagation()">{{ props.node.title }}</a>
+            ''')
+            tree.expand(_ancestor_nodes(documentation.name))
+            ui.run_javascript(f'''
+                Array.from(getHtmlElement({tree.id}).getElementsByTagName("a"))
+                    .find(el => el.innerText.trim() === "{(documentation.parts[0].title or '').replace('*', '')}")
+                    .scrollIntoView({{block: "center"}});
+            ''')
     else:
         menu = None
 
@@ -64,3 +72,8 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
                     documentation.extra_column()
         else:
             render_content()
+
+
+def _ancestor_nodes(node_id: str) -> List[str]:
+    parent = next((node for node in nodes if any(child['id'] == node_id for child in node.get('children', []))), None)
+    return [node_id] + (_ancestor_nodes(parent['id']) if parent else [])

+ 37 - 0
website/documentation/tree.py

@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+from .content import DocumentationPage, registry
+from .content.overview import tiles
+
+nodes: List[Dict[str, Any]] = []
+
+
+def build_tree() -> None:
+    """Build tree by recursively collecting documentation pages and parts."""
+    nodes.clear()
+    for module, _ in tiles:
+        page = registry[module.__name__.rsplit('.', 1)[-1]]
+        nodes.append({
+            'id': page.name,
+            'title': _plain(page.title),
+            'children': _children(page),
+        })
+
+
+def _children(page: DocumentationPage) -> List[Dict[str, Any]]:
+    return [
+        {
+            'id': part.link if part.link else f'{page.name}#{part.link_target}',
+            'title': _plain(part.title),
+            'children': _children(registry[part.link]) if part.link else [],
+        }
+        for part in page.parts
+        if part.link or part.link_target
+    ]
+
+
+def _plain(string: Optional[str]) -> str:
+    assert string is not None
+    return string.replace('*', '')

+ 0 - 10
website/style.py

@@ -73,16 +73,6 @@ def subheading(text: str, *, link: Optional[str] = None, major: bool = False, an
             ui.label(text).classes(classes)
         with ui.link(target=f'#{name}').classes('absolute').style('transform: translateX(-150%)'):
             ui.icon('link', size='sm').classes('opacity-10 hover:opacity-80')
-    drawers = [element for element in ui.context.client.elements.values() if isinstance(element, ui.left_drawer)]
-    if drawers:
-        menu = drawers[0]
-        with menu:
-            async def click():
-                if await ui.run_javascript('!!document.querySelector("div.q-drawer__backdrop")', timeout=5.0):
-                    menu.hide()
-                    ui.navigate.to(f'#{name}')
-            ui.link(text, target=f'#{name}').props('data-close-overlay').on('click', click, []) \
-                .classes('font-bold mt-4' if major else '')
 
 
 def create_anchor_name(text: str) -> str: