Reference

Template syntax

Route templates, reusable blocks, values and conditional composition.

Templates use shortcodes enclosed in [# and #].

[#shortcode#]
[#shortcode param#]
[#shortcode key=value key2=value2#]

Shortcodes can be nested - any parameter value or shortcode name can itself contain a shortcode:

[#get data.users.[#get selected-id#]#]

This also works inside tpl= parameters and shortcode names, which lets you compose template names dynamically:

[#if current_image=(not-empty) tpl=card-image[#if current_image=(empty) echo=-placeholder#]#]

Resolves to tpl=card-image when an image exists, tpl=card-image-placeholder when it doesn't.

A particularly powerful application is content-type dispatch - embed a type field directly in the shortcode name to route to a type-specific template without any branching:

[#card-[#get item.type#]#]

Resolves to [#card-news#], [#card-event#], [#card-download#], etc. based on the item's type. This pattern eliminates chains of [#if item.type=X tpl=Y#] conditions and scales cleanly as new types are added.

Use default= to guarantee a valid shortcode name when the value may be missing or unrecognised - an unresolved shortcode renders as literal text in the output:

[#card-[#get item.type default=unknown#]#]

Implement [#card-unknown#] as a generic fallback (or leave it empty to render nothing).

Templates are plain files in the target output format (HTML, JSON, CSS, etc.). They have a .tpl extension.

Route templates

Every URL maps to a file in ext/uri/:

ext/uri/index.tpl              → /
ext/uri/about/index.tpl        → /about/
ext/uri/blog/(slug)/index.tpl  → /blog/<anything>/

A route template renders the page:

[#html#]

The main.tpl in the same folder contains the page body, rendered inside the HTML shell. It is always scoped to that route - even ext/uri/main.tpl is only the body for the home page (/), not a global layout.

Reusable templates

Stored in ext/tpl/<name>/index.tpl. Called with their name as a shortcode:

[#hero-section#]

Template components do not receive parameters directly. To pass data to a template, use [#set ... overwrite#] before calling it:

[#set back_url=/agenda overwrite#]
[#back-button#]

Inside the template, read the variable with [#get back_url default=/#]. Lib shortcodes (ext/lib/<name>/) are different - they receive $params and can be called with inline parameters.

Template naming convention

Name every reusable template with the partial type first, followed by the route or subject it belongs to:

{type}-{route-or-subject}

Types and examples:

Type Used for Example
page- Full-page template (one per route) page-community-gamechangers, page-accelerator
section- A section or block rendered inside a page section-home-header, section-page-header
card- A repeatable card component card-alumni, card-network-partner
logo- A logo display component logo-partner, logo-partner-image
icon- An inline icon snippet icon-arrow-right, icon-chevron-right
form- A form component form-registration

Sub-templates inside a page directory follow the same rule - type first:

ext/tpl/page-community-networkpartners/
  card-cluster.tpl
  card-network-partner.tpl
  card-network-partner-logo.tpl

Never put the type at the end (alumni-card, coalition-page). Type-first keeps all cards, all sections, and all page templates sorted together in the filesystem.

URI-scoped templates

Templates can also live flat inside a URI folder (not in a subfolder). These are not routes - they are partial templates included from within that route's index.tpl or main.tpl.

ext/uri/new/page-settings-fields.tpl   ← partial, not a route
ext/uri/new/index.tpl                  ← route
ext/uri/new/main.tpl                   ← route body

Important: a subfolder inside ext/uri/ always creates a new route. A flat .tpl file in a URI folder is a partial template scoped to that route.

route.inc

route.inc is only used for dynamic routes - routes with (param) segments in the URL that need to be matched, validated, and accepted or rejected. Static routes (plain URL paths with no parameters) do not use route.inc; their index.tpl is loaded directly.

Dynamic routes are not auto-discovered. Every route with a (param) segment must also have a record in the .routes resource, otherwise the request will never reach its route.inc and will 404. For example, ext/uri/en/blog/(slug)/route.inc needs a .routes record with route: "en/blog/(slug)" and an appropriate order, usually 200. Run php core/cli/nimbly.php routes:sync to scan ext/uri/**/route.inc files and create missing .routes records.

A route.inc sits alongside index.tpl and decides whether this route owns the request:

<?php
$parts = router_match(__FILE__);
if ($parts === false) return;

$slug = $parts[0];
load_library('data');
load_library('util');

$records = data_read_index('articles', 'title_slug', md5_uuid($slug));
if (empty($records)) return;

set_variable_dot('record', reset($records));
router_accept();

Router functions

Function Description
router_match(__FILE__) Matches the current URL against this route's dynamic segments. Returns an array of captured values (one per (param) in the path), or false if the URL does not match. Always call first.
router_accept() Signals that this route.inc accepts the request. The template engine proceeds to render index.tpl.
router_deny() Signals rejection - the router continues looking for another matching route. Calling return without router_accept() has the same effect.

The standard pattern: call router_match(), validate the result, load and validate any data, then call router_accept() only when everything checks out. If anything fails, just return - the request falls through to the next route or a 404.

Validate all route input at the boundary

Treat every value exposed through a URL as untrusted input. This includes dynamic path segments, GET/query parameters, headers used by the route, and any request value obtained through the template variable system. Automated scanners routinely send SQL fragments, shell syntax, traversal sequences, oversized numbers, and other fuzzing payloads to every public parameter, even when Nimbly does not use SQL.

Validate and normalize these values in the route before passing them to a library or using them in arithmetic, resource lookups, filesystem paths, cache keys, redirects, or commands:

  • Prefer an allowlist for the exact accepted syntax; do not try to remove known-bad fragments.
  • Enforce type, length, range, and allowed values as applicable.
  • Convert validated numeric input to int or float before calculations.
  • Keep raw request text out of filesystem and cache paths unless its complete syntax has been allowlist-validated and deliberately forms part of the cache identity.
  • Reject invalid input immediately with the route's normal failure response. API-like asset routes may return an empty HTTP 400 when malformed input should be observable by rate limiting or bot detection.
  • Validate independently in PHP even when Apache, a proxy, or a CDN already filters the same parameter; edge filtering is an optimization, not the trust boundary.

For example, a thumbnail ratio query parameter must be validated as a bounded positive decimal and converted to a float before image calculations. A string such as ratio=1' OR 1=1 must never reach thumbnail arithmetic or cache-file creation.

Keep route.inc focused on routing logic only - match, validate, set a variable or two, accept or deny. Business logic belongs in a library loaded from index.tpl via a shortcode.

Never add route.inc to a static route. A static route (e.g. login/, about/) has no (param) segments and is always accepted - adding route.inc without calling router_accept() causes a 404. If you need to run logic on a static page, put it in a shortcode called from index.tpl, not in route.inc.

route is a reserved shortcode name. There is no [#route#] shortcode and one cannot be created. The route.inc filename belongs exclusively to the routing system.


View this page as Markdown