A Leed menu is built in the CMS, and it can point at a page that is still a draft. Nothing stops you from adding the item before the page exists — that is the point, because the nav then lights up on its own the moment the page publishes. But if your template renders the item anyway, a visitor gets a link to nothing, or a folder that opens onto nothing. These four helpers are how the shipped menu partials avoid that, and how yours should too.
All four return a boolean, so all four are used in subexpression position — inside an {{#if}}, never on their own. If you have not read How Helpers Work, the subexpression form is explained there; the Helper Index (A–Z) is the way back out if you arrived here by name.
Where the answer comes from
The build keeps a map of every page it actually rendered. It is populated as a side effect of the allPages collection: each item carrying a non-empty page id is recorded with the path it was written to. All four helpers come down to the same question of that map — is this page id in it, and does its entry have a path? The two that take a whole menu item ask it of the entries under the item as well as of the item’s own link.
The test is rendered, not “exists” and not “published”. That distinction is what makes the check work at all: an unpublished page is simply not part of the build, so there is nothing to find. It also means the answer is only as complete as the build that produced it. A page excluded from a preview build for any other reason answers the same way as a draft.
Because the map is filled while collections are computed, these helpers are safe to call from any template — by the time a template renders, the collection has run.
menuItemShouldRender — the one to reach for
Takes a whole menu item object and answers “should this <li> exist at all?”.
{{#if (menuItemShouldRender item)}}
<li role="menuitem" data-id="{{ item.menuItemId }}">
{{> leed/menu/link item=item menuName=menuName }}
</li>
{{/if}}It returns true in four cases and false in two:
flowchart TD
A["menuItemShouldRender item"] --> B{"Does any entry under it render?<br/>Disabled entries never do"}
B -- yes --> R1["render — something<br/>under it is navigable"]
B -- no --> C{"Does it have an href?"}
C -- no --> F{"Does it have<br/>submenu entries?"}
F -- no --> R2["render — a label-only item"]
F -- yes --> R6["omit — a folder<br/>with nothing to show"]
C -- yes --> D{"Does the href start<br/>with pageid: ?"}
D -- no --> R3["render — external URL,<br/>document path, anchor"]
D -- yes --> E{"Did that page render<br/>in this build?"}
E -- yes --> R4["render — a real link"]
E -- no --> R5["omit the item entirely"]
Visibility is recursive, and that is the part worth internalizing. An item renders when anything under it renders, at any depth, so a folder whose only published page sits two folders down still appears. A folder none of whose entries renders is omitted, rather than showing as an expandable heading with nothing under it, and it appears on its own once its first page is published. A disabled entry never keeps its folder visible. A link whose own target rendered survives whatever its children do; a link whose target did not render survives only while something under it does. That is why an unpublished page disappears from the nav without taking a folder that still has other content with it.
menuItemHasVisibleChildren — the submenu check
Takes a whole menu item object and answers “does anything under this item render?”. It returns true when at least one entry in the item’s submenu is not disabled and would itself pass menuItemShouldRender — so it looks through nested folders at any depth. It returns false when the item has no submenu, or when every entry is disabled or omitted. The item’s own href plays no part.
<li role="menuitem"
{{#if (menuItemHasVisibleChildren item)}}
aria-haspopup="menu"
aria-controls="submenu-{{ item.menuItemId }}"
{{/if}}
data-id="{{ item.menuItemId }}">
{{> leed/menu/link item=item menuName=menuName }}
{{#if (menuItemHasVisibleChildren item)}}
<ul role="menu" class="leed-submenu" id="submenu-{{ item.menuItemId }}">
{{#each item.submenu}}
{{#isFalsey this.disabled}}
{{> leed/menu/item item=this menuName=../menuName isSubmenuItem=true }}
{{/isFalsey}}
{{/each}}
</ul>
{{/if}}
</li>That is the shipped item partial’s shape, cut down. Gate everything that only makes sense when there are children on this helper: the submenu <ul>, aria-haspopup and aria-controls on the <li>, and the expand toggle, which the shipped link partial draws only when (menuItemHasVisibleChildren item) is true. Testing item.submenu instead gives a published link whose children are all unpublished an empty list and a toggle that opens nothing. Keep the disabled check inside the loop as well: the helper skips disabled entries when it decides, but {{#each}} still walks them.
hrefIsRenderable — the href-level check
Takes an href string and answers “can this be a working link?”.
{{#if (hrefIsRenderable item.href)}}
<a href="{{ item.href }}" class="leed-menu__item--wrapper leed-menu__item--link">
{{else}}
<div class="leed-menu__item--wrapper leed-menu__item--no-link">
{{/if}}
<span class="leed-menu__item--text">{{{ item.name }}}</span>
{{#if (hrefIsRenderable item.href)}}
</a>
{{else}}
</div>
{{/if}}That is the shipped link partial’s shape, and it is worth copying exactly: the helper is called twice, once to open the tag and once to close it, because the two decisions must agree. An item that opens an <a> and closes a </div> produces markup no browser will lay out the way you meant.
The rules are narrower than menuItemShouldRender’s, because there is no item to look at:
- An empty or missing href returns
false. The partial treats that as a folder marker and wraps the label in a<div>instead of a link. - A
pageid:href returnstrueonly when that page rendered. - Anything else — an external URL, a path to a document, a bare path — returns
truewithout a lookup.
The page id is pulled out of the href with the URL parser rather than string surgery, so a pageid: link carrying a query string still resolves: pageid:a02d6d8f?from=nav looks up a02d6d8f. The pageid: prefix is matched case-insensitively.
pageIdExists — the raw check
Takes a bare page id — no pageid: prefix, no query string — and returns whether that page rendered and has a path.
{{#if (pageIdExists relatedPageId)}}
<a href="pageid:{{ relatedPageId }}">Read the companion piece</a>
{{/if}}No shipped Leed template uses it; the menu partials reach for the other three. It is here for the case where you hold a page id from your own front matter, a data file or a computed value, rather than from a menu item’s href — which is exactly the situation the example shows, because a pageid: link in body content that has no target degrades to unlinked text rather than disappearing.
What a reader actually sees
| Item shape | Result in the published nav |
|---|---|
| A folder — entries, empty href — with at least one entry that renders | Rendered. The label is wrapped in a non-link <div>, and only the entries that render appear under it. |
| A folder whose entries are all unpublished, disabled, or folders that are themselves omitted | The whole <li> is omitted — no heading, no empty list. |
| A leaf pointing at a published page | Rendered as a real <a>, with the click-tracking attribute the menu partial adds. |
| A link to a published page whose children all fail to render | Rendered as a real <a>, with no submenu and no expand toggle. |
| A leaf pointing at an unpublished or deleted page | The whole <li> is omitted. Nothing marks the gap. |
Any item with disabled: true | Omitted, whatever its href, and never counted toward keeping its folder visible. |
Omitting a disabled item itself is not these helpers’ doing — the shipped item partial wraps everything in a disabled check before it ever asks menuItemShouldRender; the helpers only make sure a disabled entry never keeps its parent visible. Both filters are silent, so a nav that is missing an entry you know you added is almost always one of those two, and the fix is to publish the target rather than to touch the template. Where items are created, ordered and disabled is covered at Building and Editing a Menu, and a documentation left nav has extra rules of its own at Left Navigation Menu.
If you are rendering menus with {{> leed/menu/builder }} rather than writing your own, all of this is already in place — Menu Partials shows exactly which partial makes which call.
The one place this does not apply
Documentation previous/next links are not filtered at template time. previousPageNav and nextPageNav read the left menu, take the adjacent item and emit its href verbatim — which for a docs page is a pageid: href — with no renderability check of any kind.
What resolves it is the same DOM pass that rewrites every other pageid: link once the HTML is finished. When the target rendered, the href becomes a real path. When it did not, the anchor is replaced by a <span class="page-removed"> carrying the same inner markup, so the reader sees the previous/next label as plain unlinked text in the position where a link belongs.
The same <span class="page-removed"> is what a pageid: link in ordinary body content becomes when its target is missing. What that looks like to a reader, and how to avoid producing one, is at Linking Between Docs Pages.
Reference
| Helper | Form | Parameter | Returns true when | Returns false when | Used by Leed’s own templates? |
|---|---|---|---|---|---|
menuItemShouldRender | subexpression | item — a menu item object, required | an entry under it renders, at any depth; or it has no href and no submenu entries; or its href is not a pageid:; or its pageid: target rendered | nothing under it renders, and either it is a folder with entries or its pageid: target did not render | yes — leed/menu/item guards every <li> with it |
menuItemHasVisibleChildren | subexpression | item — a menu item object, required | at least one submenu entry is not disabled and renders, at any depth | it has no submenu; or every entry is disabled or omitted | yes — leed/menu/item gates the submenu, aria-haspopup and aria-controls with it, and leed/menu/link the expand toggle |
hrefIsRenderable | subexpression | href — string or undefined, required | the href is not a pageid:; or its pageid: target rendered | the href is empty or missing; or its pageid: target did not render | yes — leed/menu/link calls it twice, to open and to close the tag |
pageIdExists | subexpression | pageId — a bare page id string, required | that page rendered in this build and has a path | it did not | no — provided for ids you hold yourself |