Concepts

Routing

Folders are routes: static and dynamic routes, input checks at the boundary, subdomain folders and localized addresses.

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.


Subdomain folders

A subdomain is part of the site, not a separate installation. Its routes live in a folder named after it:

ext/uri/(subdomain-docs)/index.tpl             → docs.example.com/
ext/uri/(subdomain-docs)/guide/index.tpl       → docs.example.com/guide
ext/uri/(subdomain-docs)/nl/index.tpl          → docs.example.com/nl
ext/uri/(subdomain-docs)/article/(slug)/       → docs.example.com/article/my-post

The name is the first label of the request host, so docs.localhost reaches the same folder during development. There is nothing to configure; point the subdomain at the same document root.

  • The folder is looked up first. What it does not have (admin, API, login, media) comes from the site's normal routes.
  • The request URI stays what the visitor asked for. Links, language detection and canonical URLs need no translation: write [#base-url#]/guide.
  • A dynamic route is written like any other; router_match(__FILE__) leaves the subdomain folder out, and the route only answers on its own subdomain.
  • The folder is not reachable as a path on any host, and its pages are left out of the main sitemap. The subdomain has its own sitemap.xml with its static pages.
  • Templates in ext/tpl/, resources, users and the admin are shared with the main site.
  • The login is shared too: on a site with subdomain folders the session cookie is set for the main domain, so it also reaches its subdomains. A single-label host such as localhost keeps one login per host.
  • Link between the site and its subdomains with [#subdomain-url docs#]/guide (the docs subdomain) and [#subdomain-url#]/contact (the main site); both work from either side and on a local host name.

Localized route URLs

Add i18n-url.json beside a route's index.tpl or route.inc to declare the equivalent URL for each configured language. Values are installation-relative and may use normal Nimbly shortcodes:

{
  "nl": "nl/artikel/[#get record.title_slug lang=nl#]/",
  "en": "en/article/[#get record.title_slug lang=en#]/"
}

[#i18n-url en#] resolves the English URL. (home) resolves to the language homepage and (hide) makes that language unavailable. A route may override a mapping for the current request with [#set i18n-url.en="en/special-page/"#]. When no mapping exists, .config.site.lang_switch_fallback accepts home (the default), hide, or disabled.

[#language-switch#] renders all languages in .config.site.languages. Apps may override that template to provide a menu or dropdown while retaining the core URL resolution behavior.

Translated routes that render the same author-managed page can set a shared page and content key before [#html#], for example [#set page-content-key=about#]. Both .config page settings and .content fields then resolve through that key instead of creating language-route copies.

Routes are .tpl files in ext/uri/. The folder structure maps directly to the URL.

Dynamic URL segments use parentheses:

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

Dynamic routes are not registered from the folder structure alone. If the route contains a (param) segment, add a matching .routes record or run php core/cli/nimbly.php routes:sync after creating the route files. Static routes do not need .routes records.

Route-scoped JavaScript

If a route folder contains an index.js file, it is automatically loaded by the HTML template for that route only. Use this for JavaScript that is specific to a single page and should not be part of the global bundle.

ext/uri/dashboard/index.tpl
ext/uri/dashboard/index.js     ← auto-loaded on /dashboard/ only

View this page as Markdown