For the complete documentation index, see llms.txt. This page is also available as Markdown.

Navigation & Recursive Templates

Build navigation components with rw.pages and recursive includes

Navigation components have a problem no other component family shares: their content is a tree of unknown depth. A user's project might be five flat pages, or a folder inside a folder inside a folder. Your hook receives that tree as rw.pages, and your templates have to turn it into markup without knowing in advance how deep it goes. The core answer is a technique this guide covers in full: a template that @includes itself, once per child level, until the tree runs out.

The Page Tree

rw.pages is an array of page objects mirroring the project's page list. Each object carries identity (title, url, description), type flags (isPage, isFolder, isLink), state (isActive, hasActiveChild, isDraft), menu metadata (displayInMenu, openInNewWindow), depth info (navLevel, pageDepth), and — crucially — its children: hasPages plus a nested pages array of the same shape. The full field reference lives in rw.pages; everything below uses only those documented fields.

Because children are the same shape as parents, every real traversal is recursive. Tree does its entire preparation in one recursive pass — filtering, ID generation, depth limits, and active-state rollup in a single function:

// com.realmacsoftware.navTree/hooks.source.js
// Efficient recursive traversal: include node only if it should be shown
const buildPages = (pages, parentPath = "", relativeLevel = 0) => {
    const result = [];
    if (!Array.isArray(pages) || pages.length === 0) return result;

    // Check if we've reached the maximum depth limit
    const hasReachedMaxDepth =
        maxLevelsDepth === "manual" && maxLevels > 0 && relativeLevel >= maxLevels;

    for (let i = 0; i < pages.length; i += 1) {
        const page = pages[i];
        if (!(page.displayInMenu && !page.isDraft)) continue;

        const slug = slugify(page.title);
        const id = parentPath ? parentPath + "-" + slug : slug;

        // Only build children if we haven't reached the maximum depth
        const children = hasReachedMaxDepth
            ? []
            : buildPages(page.pages || [], id, relativeLevel + 1);

        const hasPages = children.length > 0;
        const hasActiveChild =
            hasPages &&
            children.some((c) => c.isActive || c.hasActiveChild);

        const processedPage = {
            ...page,
            id,
            pages: children,
            hasPages,
            hasActiveChild,
            isTopLevel: relativeLevel === 0,
            isSecondLevel: relativeLevel === 1,
            isThirdLevel: relativeLevel >= 2,
            showPageIcon: pageIconDisplay && relativeLevel === 0,
        };

        if (processedPage.isActive || hasActiveChild) {
            openPages.push(id);
        }

        result.push(processedPage);
    }

    return result;
};

Five decisions in this function are worth stealing for any nav component:

  • Filter as you walk. page.displayInMenu && !page.isDraft drops drafts and pages excluded from the menu at every level, so the template never needs to know those pages exist.

  • Rebuild derived fields after filtering. hasPages and hasActiveChild are recomputed from the filtered children — if a folder's only child was a draft, the folder correctly renders as a leaf.

  • Give every node a stable ID. Slugified titles joined down the parent path (docs-getting-started) give the template something to hand to Alpine for expand/collapse state and localStorage persistence.

  • Enforce depth in the hook, not the template. When the user picks a manual depth limit (the inspector's maxLevelsDepth/maxLevels properties), children beyond the limit are simply never built — the template needs no depth logic at all.

  • Precompute level booleans. isTopLevel/isSecondLevel/isThirdLevel become single-condition @if tests in the template, which matters because template @if accepts exactly one condition.

Around this core, the hook also scopes which subtree to walk: a wantedPages inspector property selects the whole project (rw.pages), the active page's children (found with a recursive findActivePage), or a manually chosen folder (found with a recursive findPageByTitle). Three small recursive helpers, one shared traversal — that's the whole data layer.

Recursive Includes

Tree's markup layer is two files. The root template renders the top-level list and hands each page to an include:

The include is where the technique lives. include/item.html renders one node — and for that node's children, includes itself. This is the full file (only the decorative chevron SVG is elided):

Walk it top to bottom:

  • Line 1, @if(page.hasPages) — the whole file is one fork: branch nodes (pages with children) take the first half, leaves take the @else half. This test is the recursion's brain.

  • The branch header <div role="treeitem"> — carries the hook-built page.id into every Alpine call: isOpen('{{page.id}}'), toggle('{{page.id}}'), setCurrent('{{page.id}}'). The template interpolates data; the Alpine component (registered once via @portal(bodyEnd, includeOnce: true) in index.html) owns all state, including keyboard navigation and the roving tabindex.

  • The level-class chain@if(page.isTopLevel) … @elseif(page.isSecondLevel) … @elseif(page.isThirdLevel) … picks styling per depth. The same include file renders every level; only the precomputed booleans change. Note that levels three-and-deeper share one style — unbounded structure doesn't require unbounded styling.

  • :aria-current="({{page.isActive}} || {{page.hasActiveChild}}) ? 'page' : null" — template @if can't express ||, but interpolating two booleans into a client-side Alpine expression can. The rendered output is plain JavaScript like (true || false) ? 'page' : null.

  • @if(page.isFolder) — folders get a <span> (nothing to link to), real pages get an <a href="{{page.url}}">.

  • @if(showSubMenus) @if(page.hasPages) — two nested single-condition @ifs are the template-language spelling of AND. showSubMenus comes from the hook: always true when published, but on the canvas it's the user's Preview Sub Menus switch.

  • The recursive line@each(subPage in page.pages) @include("item", page: subPage) @endeach. Each child is passed back into the same file under the same name, page:. That renaming is what makes recursion work: item.html is written against a single contract — "I receive one page object called page" — and never knows or cares which level it's rendering.

  • The @else leaf branch — a plain link with no @include("item") anywhere in it. This is the base case.

Termination needs no counter and no special directive. Recursion only re-enters through the branch half, which is guarded by page.hasPages; every child either has children (recurse again) or doesn't (render the leaf and stop). Because the hook already truncated the tree — children is [] past the depth limit, which makes hasPages false — the template's recursion depth is bounded by the data, and the data is bounded by the hook. If you write your own recursive include, preserve both properties: a guarded self-include and a leaf branch that includes nothing.

Two mechanical rules from the @include reference apply doubly here: the file must live in templates/include/ (only that directory is resolvable by name), and a mistyped template name fails silently — in a recursive setup that means your entire tree below level one quietly vanishes, so check the include name first when a nav renders only its top level.

One Tree, Two Menus: The Desktop/Mobile Split

Menu (com.realmacsoftware.navbar) answers a different question: what if one page tree needs two completely different renderings? Its templates/include/ holds a dozen partials split by filename prefix — desktop_item, desktop_folder, desktop_submenu, desktop_submenu_menu, desktop_submenu_indicator, and mobile_* counterparts, plus logo and title (the prefix convention the @include best practices recommend). index.html walks the same pages prop twice, dispatching into each family:

The desktop tree renders inline in the header as hover-driven dropdowns; the mobile tree renders through @portal("bodyStart") as a click-driven slide-over that can overlay the whole page. One hook, one filtered tree, two markup worlds — all the data work is shared, all the presentation is per-device. Note the contrast with Tree: Menu's includes chain fixed levels (desktop_itemdesktop_submenudesktop_submenu_menu, three deep) instead of self-including, because a dropdown menu has a sensible maximum depth where a sidebar tree doesn't. Pick the pattern that matches your markup: self-include for unbounded trees, a chain of level-specific includes when each level genuinely looks different.

Active States and aria-current

Menu's hook computes ancestor-active state with a deliberate subtlety — it checks the raw tree, not the filtered one:

If the current page is hidden from the menu, its parent folder should still light up — so hasActiveChild is derived from page.pages (the unfiltered children) even though the rendered list uses filteredPages. Tree computes the same flag from filtered children instead; both are defensible, but Menu's version is the one to copy when hidden-but-active pages are possible.

In the templates, the flags become attributes. Menu emits the accessibility standard aria-current="page" on the active link and a styling hook on ancestors:

Every partial in both families repeats this pair, so the active trail is visible (and stylable via [aria-current="page"] and [data-active-child] selectors) at every depth. Tree renders the same information dynamically instead — the :aria-current Alpine binding you saw in item.html marks a node when it's active or on the active trail. Whichever variant you choose, emit aria-current="page" on exactly one link per menu; screen readers announce it, and it doubles as your styling hook, replacing invented active classes.

The Nav on the Canvas

Navigation components hit two edit-mode snags. First, canvas chrome: Elements injects drop areas into the editing canvas, and Menu ships a three-line templates/editor.css just to keep them from distorting the navbar layout:

How editor-only stylesheets work — and why wrapping them in @if(edit) is the better habit — is covered in Designing the Edit-Mode Experience. Second, sparse projects: users often build navs in a project that has one page so far, which makes the filtered tree a single lonely item and submenus impossible to see. The core components answer with preview switches rather than fake data — Tree's Preview Sub Menus switch (showSubMenus is forced on when published, user-controlled on the canvas) and Menu's mobile-menu preview switch. If your nav renders literally nothing in a one-page project, fall back to the empty-state placeholder pattern so users still see something stylable.

The deploy Flag

Tree's info.json is also the core pack's only component to set the deploy key explicitly:

Per the info.json reference, deploy controls whether the component is included when deploying a pack, and it defaults to true — so Tree's entry just states the default out loud. The flag earns its keep set to false: internal helper components, test harnesses, or half-finished experiments living in your dev pack can be excluded from the deployed pack without deleting them. If you keep private scaffolding alongside shippable components, mark it "deploy": false.

  • rw.pages — the full page-object field reference

  • @include — include resolution, parameters, and recursion

  • @each — iterating arrays in templates

  • Designing the Edit-Mode Experience — editor CSS, placeholders, and preview switches

  • info.json — the deploy flag and other component metadata

Last updated

Was this helpful?