Concepts

Modules

Package reusable application behavior, templates and resources.

A module is a self-contained feature that bundles its own routes, templates, libraries, and install logic.

Use a module when:

  • A feature has both a library (shortcode) and a dedicated route that belong together - e.g. a Stripe integration with a webhook endpoint and a stripe-webhook shortcode.
  • A feature ships as a reusable unit - e.g. an event system, a shop, a blog - rather than a loose set of pages.
  • A feature needs an install script to create resources or register routes.

Use ext/lib/ and ext/uri/ directly (without a module) for things that don't naturally group together - a standalone utility shortcode, or a one-off page with no associated library.

Module directory structure

ext/modules/<name>/
  .install.inc     # Install script - runs once when the module is installed
  lib/             # Shortcode libraries scoped to this module
  tpl/             # Templates scoped to this module
  uri/             # Routes scoped to this module (merged into the URL space)

Module templates follow the same index.tpl convention as ext/tpl/. A template at ext/modules/member/tpl/member-status/index.tpl is callable as [#member-status#]. Flat .tpl files inside the folder are partials - only index.tpl is the public shortcode entry point.

Routes inside modules/<name>/uri/ are served at the same paths as if they were in ext/uri/. A route at ext/modules/event/uri/event/(slug)/index.tpl is accessible at /event/<slug>/.

Module auto-discovery

All modules in ext/modules/ and core/modules/ are discovered automatically on the first template lookup of any request. There is no need to declare or load a module explicitly - its templates, libraries, and routes are always available.

The [#module name#] shortcode is a no-op and should not be used. Use [#nop module event#] or a comment if you want to document a dependency inline.

.install.inc

The install script runs when ./nimbly module:install <name> (or the admin install button) is executed. Use it to create the resource(s) and register any dynamic routes the module needs.

<?php

load_library("data");

// Create the resource if it doesn't exist yet
$result = data_create_resource("events", [
    "fields" => [
        "title"     => ["name" => "Title", "type" => "text", "required" => true],
        "title_slug" => ["name" => "URL slug", "type" => "slug", "source" => "title"],
        "published" => ["name" => "Published", "type" => "boolean"],
        "date"      => ["name" => "Date", "type" => "date"],
        "body"      => ["name" => "Body", "type" => "html",
                        "buttons" => "h2,h3,bold,italic,anchor",
                        "media" => true, "media_sizes" => "sm-90,md-70,lg-60",
                        "admin_col" => false],
        "sort_order" => ["name" => "Sort order", "type" => "text", "admin_col" => false]
    ],
    "index" => ["title_slug"],
    "sort" => ["field" => "sort_order", "flags" => "numeric", "order" => "asc"]
]);

// Register any dynamic routes this module needs
$route = 'events/(slug)';
$result &= data_exists(".routes", md5($route))
    || data_create(".routes", md5($route), ["route" => $route, "order" => 200]);

return $result;

PHP data API (available in .install.inc and route.inc)

Function Description
data_create_resource($name, $meta) Creates a new resource with the given .meta definition
data_create($resource, $uuid, $data) Creates a new record
data_read($resource, $uuid) Reads a single record by UUID
data_exists($resource, $uuid) Returns true if a record exists
load_library($name) Loads a shortcode library so its PHP functions are available

Slug-to-UUID routing

Use the index system to resolve a slug from the URL to a record. Add title_slug to the index array in .meta, then look it up in route.inc:

<?php

$parts = router_match(__FILE__);
if ($parts === false || count($parts) !== 1) return;

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

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

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

router_accept();

The record UUID is stable and random - slugs are stored as regular fields and indexed for fast lookup. If the slug field changes, the index updates automatically on next save.


View this page as Markdown