# 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
<?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.

---
