Skip to content

Layouts & chrome

By default the platform wraps your rendered sections in its own chrome. Declare supports.layout: true and ship a layout.liquid, and your template owns the body chrome, header, navigation, footer, and where the page content sits. This is what lets a template restructure a site, not just recolour it.

Yours (layout.liquid) The platform’s (always)
Header markup & brand lockup <head>, title, SEO, social cards, JSON-LD
Navigation (from nav data) Consent banner + consent-gated tags
Footer The Port60 ID identity brand (the member_menu island)
Where {% content %} goes Webfont loading (from your manifest’s fonts)

A template can never omit compliance chrome, the platform appends it after your layout’s output.

<header class="site-header">
<div class="container header-inner">
<a class="brand-lockup" href="/">
{% if brand.logoUrl and brand.logoType != 'icon' %}
<img class="brand-logo" src="{{ brand.logoUrl }}" alt="{{ brand.name }}">
{% else %}
<strong>{{ brand.name }}</strong>
{% endif %}
</a>
<nav class="site-nav" id="site-nav" aria-label="Primary">
{% for item in nav.items %}
{% if item.cta %}<a class="nav-cta" href="{{ item.href }}">{{ item.label }}</a>
{% else %}<a href="{{ item.href }}">{{ item.label }}</a>{% endif %}
{% endfor %}
{% island 'member_menu' %}
</nav>
</div>
</header>
<main>
{% content %}
</main>
<footer class="site-footer">
<p>{{ brand.name }}{% if brand.tagline %}, {{ brand.tagline }}{% endif %}</p>
</footer>

Three rules the validator enforces:

  1. Exactly one {% content %}, the slot the page’s rendered sections are injected into.
  2. {% content %} is layout-only, a section renderer using it fails conformance.
  3. Islands placed in the layout follow the same discipline as everywhere: declared in supports.islands, present in the registry.

Your layout receives nav.items, the tenant’s primary navigation. By default it’s derived by the platform from what actually exists (pages, enabled features, published content); when the tenant has curated a header menu in their admin, nav.items is that menu instead. Either way it’s the same shape, so you render it identically and never need feature logic. Each item has label, href, an optional cta flag (style it prominently, the Donate action), an optional external flag (add target="_blank" rel="noopener"), and optional children for dropdown groups. Render all of it.

Two more menus ride alongside:

  • nav.footer, the tenant’s curated FOOTER menu (same item shape). It’s empty unless they’ve built one, so branch on it and fall back to whatever footer links you’d normally show: {% if nav.footer.size > 0 %}…{% else %}…{% endif %}. Supporting it is optional, a template that ignores it just keeps its own footer.
  • nav.derived, the platform’s automatic list, always present even when the tenant has curated nav.items. Reach for it only if you want the auto, feature-aware nav somewhere regardless of their header curation; most templates just render nav.items.

See Render context for the fixture your layout must survive.

Navigation may contain two levels below the top item. A child can carry group, description and imageUrl, while a top item can carry megaMenu.columns and an optional megaMenu.promo card. These presentation fields are tenant-authored. They have different jobs:

  • group is a shared presentation heading, such as “Ways to help” or “Events”. Several siblings with the same value belong under one heading; it is not a badge to repeat on every card.
  • children is the actual navigation hierarchy. Keep each child’s descendants attached to that child. Grouping siblings must not flatten a category and its article links into unrelated cards.
  • Article categories organise content. A navigation group string does not assign an article to a category or create a category archive. Use the category links the platform supplies.

The example below reads the current site.nav tree. It renders named groups in their first-seen order, keeps links within each group in supplied order, then renders ungrouped links in a separate area without inventing an “Other” heading. Missing, null, empty and whitespace-only group values are ungrouped. Every supplied child and grandchild remains available.

map, compact, uniq and where are supported by the Liquid whitelist. group_by is not. Render the supplied structure and omit optional presentation when its data is absent:

<nav id="site-nav" aria-label="Primary" data-p60-nav>
{% for item in site.nav.items %}
{% if item.children.size > 0 %}
<div class="nav-group" data-p60-nav-item>
<a
class="nav-group-toggle"
href="{{ item.href }}"
data-p60-nav-toggle
aria-haspopup="true"
aria-expanded="false"
aria-controls="mega-menu-{{ forloop.index }}"
{% if item.external %}target="_blank" rel="noopener noreferrer"{% endif %}
>
{{ item.label }}
</a>
<div class="mega-menu" id="mega-menu-{{ forloop.index }}" data-p60-nav-menu>
<div class="mega-menu-heading">
<strong>{{ item.label }}</strong>
<a href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener noreferrer"{% endif %}>View all {{ item.label }}</a>
</div>
<div class="mega-menu-links"{% if item.megaMenu.columns %} style="--mega-columns: {{ item.megaMenu.columns }}"{% endif %}>
{% assign groupNames = item.children | map: 'group' | compact | uniq %}
{% for groupName in groupNames %}
{% assign trimmedGroup = groupName | strip %}
{% if trimmedGroup == '' %}{% continue %}{% endif %}
{% assign groupedChildren = item.children | where: 'group', groupName %}
<section class="mega-menu-group">
<h3 class="mega-menu-group-label">{{ groupName }}</h3>
<div class="mega-menu-group-items">
{% for child in groupedChildren %}
<div class="mega-menu-card">
<a href="{{ child.href }}"{% if child.external %} target="_blank" rel="noopener noreferrer"{% endif %}>
{% if child.imageUrl != nil and child.imageUrl != '' %}<img src="{{ child.imageUrl }}" alt="">{% endif %}
<strong>{{ child.label }}</strong>
{% if child.description %}<span>{{ child.description }}</span>{% endif %}
</a>
{% if child.children.size > 0 %}
<ul class="mega-menu-children">
{% for leaf in child.children %}
<li><a href="{{ leaf.href }}"{% if leaf.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ leaf.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endfor %}
{% assign hasUngrouped = false %}
{% for child in item.children %}
{% assign childGroup = child.group | default: '' | strip %}
{% if childGroup == '' %}{% assign hasUngrouped = true %}{% break %}{% endif %}
{% endfor %}
{% if hasUngrouped %}
<div class="mega-menu-group-items mega-menu-ungrouped">
{% for child in item.children %}
{% assign childGroup = child.group | default: '' | strip %}
{% unless childGroup == '' %}{% continue %}{% endunless %}
<div class="mega-menu-card">
<a href="{{ child.href }}"{% if child.external %} target="_blank" rel="noopener noreferrer"{% endif %}>
{% if child.imageUrl != nil and child.imageUrl != '' %}<img src="{{ child.imageUrl }}" alt="">{% endif %}
<strong>{{ child.label }}</strong>
{% if child.description %}<span>{{ child.description }}</span>{% endif %}
</a>
{% if child.children.size > 0 %}
<ul class="mega-menu-children">
{% for leaf in child.children %}
<li><a href="{{ leaf.href }}"{% if leaf.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ leaf.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if item.megaMenu.promo %}
<a class="mega-menu-promo" href="{{ item.megaMenu.promo.href | default: item.href }}">
{% if item.megaMenu.promo.imageUrl %}<img src="{{ item.megaMenu.promo.imageUrl }}" alt="">{% endif %}
<strong>{{ item.megaMenu.promo.title }}</strong>
{% if item.megaMenu.promo.text %}<span>{{ item.megaMenu.promo.text }}</span>{% endif %}
{% if item.megaMenu.promo.label %}<span>{{ item.megaMenu.promo.label }}</span>{% endif %}
</a>
{% endif %}
</div>
</div>
{% elsif item.cta %}
<a class="nav-cta" href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.label }}</a>
{% else %}
<a href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.label }}</a>
{% endif %}
{% endfor %}
</nav>

You design the menu; the platform’s nav behaviour makes it work. Under .p60-js show a panel only when its group carries is-open, which the engine manages with hover intent (the pointer can cross the gap between the toggle and the panel), keyboard and touch toggling, Escape, outside-click and focus-away dismissal, and honest aria-expanded. Keep your :hover and :focus-within rules scoped to html:not(.p60-js) so the no-JS render still opens on hover. Inside the open mobile panel the same is-open lands on the group when its toggle is tapped. Declare nav in supports.behaviors; the grammar is in Motion and behaviour. The older data-nav-drop hooks keep working, but only cover taps inside the mobile panel. Do not add fixed category descriptions or links that are not present in the navigation data.

The editor limits megaMenu.columns to 2 through 4 and the context limits navigation depth, so a template can make responsive decisions without handling an unbounded tree. The template decides whether a named group is one column or spans a row containing several cards. Do not repeat its heading merely to fill each column.

Articles and blog posts use the same article content model. “News”, “Blog” and “Latest” can be tenant-chosen navigation labels; they do not identify separate collections. Categories organise those articles, while the navigation tree determines which category and article links a visitor sees.

In automatic navigation, published articles produce an entry using the tenant’s articles label. When at least two categories exist, its children are category archive links followed by “All articles”. With fewer categories it is a plain link. A tenant-curated header menu replaces this automatic menu rather than merging with it. Curated article-topic links therefore stay where the tenant placed them; templates must not append a second set of categories from article content.

The menu builder also offers a top-level “Latest articles in a topic (auto)” item. The platform resolves its article links at render time; this automatic item is not currently available nested inside another menu. A supplied tree can otherwise use both child levels, for example a News parent, category children and article grandchildren. Render those relationships as supplied, using their existing URLs. A matching group label is only a shared visual heading, not an instruction to fetch articles, create a category or invent another hierarchy.

A template that declares supports.worship: true receives the optional worship object in its layout. It contains tenant supplied labels, dates, a note and up to eight prayer or service times. Always branch on the object so sites without a configured schedule do not receive empty chrome:

{% if worship %}
<aside class="worship-rail" aria-label="Worship times">
<p>
{{ worship.today }}
{% if worship.todayAlt %}<span>{{ worship.todayAlt }}</span>{% endif %}
</p>
<div class="worship-scroll">
<table>
<thead>
<tr>
<td></td>
{% for time in worship.times %}<th scope="col">{{ time.name }}</th>{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<th scope="row">{{ worship.beginsLabel }}</th>
{% for time in worship.times %}<td>{{ time.begins }}</td>{% endfor %}
</tr>
<tr>
<th scope="row">{{ worship.congregationLabel }}</th>
{% for time in worship.times %}<td>{{ time.congregation }}</td>{% endfor %}
</tr>
</tbody>
</table>
</div>
{% if worship.note %}
{% if worship.href %}<a href="{{ worship.href }}">{{ worship.note }}</a>
{% else %}<p>{{ worship.note }}</p>{% endif %}
{% endif %}
</aside>
{% endif %}

Use horizontal scrolling for the timetable on narrow screens. Do not calculate prayer times in the template. The platform and tenant own those values.

The same object can also contain worship.next, structured worship.jumah, worship.hijri and worship.observances. Branch on each value. Place {% island 'next_prayer' %} when you want the platform-owned live countdown instead of a static next time.

Every layout receives locale.code, locale.direction and locale.languages. The platform sets the document lang and dir attributes before your layout renders. Use logical CSS properties such as margin-inline-start, and place {% island 'language_switch' %} when the design needs the platform-owned language selector. Contract v1 currently supplies English, Welsh and Arabic.

Put {% island 'member_menu' %} in your header’s nav. It renders the branded Port60 ID sign-in button for guests and the account pill for signed-in members, and renders nothing when the tenant doesn’t allow member sign-ups. The button’s look and popup behaviour are platform-owned and identical across every template (it’s a trust anchor, like a “Sign in with Google” button, a hand-rolled copy would erode it). Omitting it is a validator warning: sign-in becomes unreachable on member-enabled tenants.

The platform tail script binds behaviours to documented attributes, emit them and the behaviour arrives free, platform-maintained:

Attribute Behaviour
data-p60-nav, data-p60-nav-item, data-p60-nav-toggle, data-p60-nav-menu, data-p60-nav-burger The nav behaviour: menus and the burger with hover intent, keyboard, touch, Escape and dismissal, state class is-open (declare nav in supports.behaviors)
data-nav-burger on a button + id="site-nav" on the nav Legacy mobile burger open/close (an .open class toggle; links close the panel)
data-consent-open on any element Opens the cookie preference centre

The platform’s base stylesheet also ships the chrome mechanics for the standard class names (.site-header, .site-nav, .nav-dropdown, .nav-burger breakpoints…). Reusing them is optional but saves you the responsive plumbing; your own class names are equally valid, then the responsive behaviour is yours to style.

Declare webfont stylesheets in the manifest, the platform loads them in <head> with proper preconnects (never a render-blocking CSS @import):

{ "fonts": ["https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap"] }

Google Fonts css2 URLs only (the schema enforces the pattern).

The truthfulness rule covers layouts: a footer derives from brand and nav, it must not ship fabricated mission statements or invented claims. The starter’s footer is the worked example: brand name, tagline (only when present), navigation links, legal links. Derive or omit.