# Nimbly
> Nimbly is a lean, open source, full-stack web framework. Code, content and data live as plain files in Git, and a request runs its route and nothing else.
---
Source: https://atomic.nimblyweb.com/en/docs/learn/introduction/
# Introduction
What Nimbly is, what a project looks like and where to read next.
Nimbly is a full-stack atomic design system. Routing, templates, data, an admin and a REST API are parts of one system, so a custom product is assembled from small pieces that fit together instead of being wired up from separate tools.
## What you build with
- **Routes are folders.** A folder under `ext/uri/` is an address. Its `index.tpl` is the page.
- **Templates are small.** A template is HTML with shortcodes such as `[#get record.title#]`. A reusable template in `ext/tpl/` becomes a shortcode of its own.
- **Data is a resource.** A resource is a folder with a `.meta` file that describes its fields, and one JSON file per record. There is no database to set up.
- **The admin follows the data.** Every resource gets list, edit and create screens, permissions and an API endpoint from its `.meta` file.
- **Logic lives in libraries.** PHP in `ext/lib/` prepares data and talks to other systems. Markup stays in templates.
## A page in three blocks
A page that lists articles is a resource, a route and a component:
```text
ext/data/articles/.meta the resource: the fields of an article
ext/uri/articles/index.tpl the route: the page settings
ext/uri/articles/main.tpl the route: the content of the page
ext/tpl/card-article/index.tpl the component: one article in the list
```
```html
[#set page-title="Articles"#]
[#html#]
```
```html
[#data articles filter=published:1#]
[#repeat data.articles tpl=card-article#]
```
```html
[#get item.title#]
```
The page is on `/articles/`. The same resource is editable in the admin and readable over the API without further code.
## Two repositories
A project is two Git repositories in one folder. `core/` is the framework and is the same for every project. `ext/` is your application: its routes, templates, data, libraries and theme. You update core without touching your application, and your application never patches core.
## Where to go next
- [Installation](/en/docs/learn/installation/) gets a site running on your machine.
- [Core and ext](/en/docs/concepts/core-and-ext/) explains the two repositories and the folder layout.
- [Templates and shortcodes](/en/docs/concepts/templates/) explains how pages are written.
- [Resources](/en/docs/concepts/resources/) explains the data layer.
- [Routing](/en/docs/concepts/routing/) explains how addresses map to folders.
## For AI agents
These docs are written for agents as much as for people. [For AI agents](/en/docs/learn/for-ai-agents/) explains how to work in a Nimbly project and where the plain-text versions of these pages are.
---
Source: https://atomic.nimblyweb.com/en/docs/learn/installation/
# Installation
Clone core, add your application and run two commands to have a working site.
```bash
# 1. Clone core
git clone git@github.com:Nimbly-Web-Systems/nimbly-core.git my-project
cd my-project
# 2. Clone your application into ext/ (or let setup create an empty ext/)
git clone git@github.com:your-org/your-app.git ext
# 3. Install dependencies, create local config/data/admin user, and build assets
./nimbly init
```
After this, the project is fully operational. Core and ext evolve independently.
Start the local site with `./nimbly up` and open `http://localhost`; the admin
is at `http://localhost/nb-admin/`. A site without pages of its own opens on
the starter home page that `system:setup` wrote to `ext/uri/`.
`./nimbly` uses npm internally for frontend dependencies and assets. It uses host PHP when PHP 8+ is available. If PHP is not available, it automatically runs PHP-backed commands through the Nimbly Docker Compose service. To force Docker explicitly:
```bash
./nimbly --docker init
```
---
Source: https://atomic.nimblyweb.com/en/docs/learn/for-ai-agents/
# For AI agents
How an agent works in a Nimbly project: where to read, where each block lives and which rules always hold.
This page is for an AI agent that writes code in a Nimbly project, and for the people who work with one. A Nimbly app is made of small blocks in predictable places, so a task usually touches a few short files.
## Read first
Every project has the full reference in `NIMBLY.md` at its root. Do not read it in one go. Look up what the task needs:
```bash
./nimbly docs:list
./nimbly docs:search "router_accept"
./nimbly docs:section "Template Syntax > route.inc"
```
- `docs:list` prints every heading, to find the right term.
- `docs:search` finds one term in the text.
- `docs:section` prints one section by its heading path.
The same content is on this site as plain text. [`/llms.txt`](/llms.txt) lists every page, [`/llms-full.txt`](/llms-full.txt) holds all pages in one file, and every page has a Markdown version at its own address with `.md` added, for example `/en/docs/concepts/resources.md`. [`/nimbly.json`](/nimbly.json) lists the stack, the included features and the CLI commands.
## Where each block lives
| Block | Place | What it holds |
|---|---|---|
| Route | `ext/uri//index.tpl` | One address. The folder path is the URL. |
| Template | `ext/tpl//index.tpl` | A reusable block of markup, called as `[#name#]`. |
| Resource | `ext/data//.meta` | The fields, validation, indexes and permissions of one kind of record. |
| Library | `ext/lib/.php` | PHP logic, called as `[#name#]`. No markup. |
| Module | `ext/modules//` | A feature with its own routes and data. |
The framework is in `core/` and is the same for every project. Application work goes in `ext/`.
## Rules that always hold
- Inspect the existing code before adding something new. Reuse a block that is already there.
- Markup belongs in templates. PHP libraries prepare data and do not print HTML.
- `[#if#]` is one tag. It has no block form. Conditional markup goes in its own template, named with `tpl=`.
- `[#set#]` does not replace a value that is already set, unless `overwrite` is given.
- A static route has no `route.inc`. A dynamic route checks its input and answers with `router_accept()` or `router_deny()`.
- PHP uses snake_case for functions, variables and file names.
- Use the existing roles and permissions. Do not build a second permission system.
## Check your work
```bash
./nimbly build
./nimbly test:architecture --strict
./nimbly test:run
```
Run the smallest check that covers the change. Content and data are files in the repository, so every change can be reviewed as a diff before it is committed.
---
Source: https://atomic.nimblyweb.com/en/docs/concepts/atomic-design/
# Atomic design in Nimbly
A small set of atoms and one way to combine them: every project composes exactly the blocks it needs.
Nimbly is a design system for the whole stack. The visual language is DaisyUI and Tailwind CSS, themed with your own tokens. On top of that you get a small set of atoms and one way to combine them, and every project composes exactly the blocks it needs. No prefabricated page blocks have to be restyled, overridden or carried along.
## One mechanism
Everything in a template is a call between `[#` and `#]`:
```html
[#card-article#]
```
A call is resolved by name. The name can be:
- **a template**: a folder with an `index.tpl`, in the route's own folder or in `ext/tpl/`;
- **a variable**: a value set earlier with `[#set#]` or loaded with `[#data#]`;
- **a library**: a PHP function in `ext/lib/` that prepares data.
That is the whole model. A page, a section, a card and an icon are all the same kind of thing: a named block that can call other named blocks.
## The atoms
| Atom | Where it lives | What it is |
|---|---|---|
| Template | `ext/tpl//index.tpl` | Markup, styling and calls to other blocks, under one name. |
| Route | `ext/uri//index.tpl` | A template with an address. |
| Resource | `ext/data//.meta` | The shape of one kind of record. |
| Library | `ext/lib/.php` | Logic behind a name. No markup. |
| Theme | `ext/theme.css`, `ext/tailwind.theme.js` | The tokens of your brand, used by every block. |
## Composing
A block is made by writing it. This card did not exist until its file did:
```html
```
`card`, `card-body` and `card-title` are DaisyUI classes, and `bg-base-200` is a theme token. Change the token in the theme and every block that uses it follows.
A page uses it by name:
```html
[#data articles filter=published:1#]
[#repeat data.articles tpl=card-article#]
```
Blocks call blocks, to any depth. A project contains only the blocks it composed, and a page runs only the blocks it calls.
## Your application comes first
A name is looked up in `ext/` before `core/`. A block in your application with the same name as a block of the framework takes its place, without changing the framework.
## Your design, your names
The theme holds the tokens of your brand. Which blocks exist, what they are called and how they are grouped is a choice of the project.
---
Source: https://atomic.nimblyweb.com/en/docs/concepts/core-and-ext/
# Core and ext
Two repositories and one runtime: what belongs in core, what belongs in ext and how the folders are laid out.
## Two repositories, one runtime
Nimbly splits every project into two separate git repositories that live nested on disk:
| Repository | What it is | Who owns it |
|---|---|---|
| **core** | The Nimbly framework - routing, template engine, shortcodes, admin, API | Nimbly (never modified per project) |
| **ext** | Your application - routes, templates, data, custom shortcodes | You (every project has its own ext repo) |
On disk they combine into one directory tree:
```
my-project/ ← core repo root
core/ # Framework internals
ext/ ← ext repo root (nested inside core)
css/ # CSS source (shared build)
js/ # JS source (shared build)
```
This means `git status` at the project root reflects core changes; `git status` inside `ext/` reflects application changes. They are independent repos with independent histories, branches, and remotes.
They are also licensed separately: core is open source under the ISC license, while every ext repo is proprietary - copyright Nimbly Web Systems, all rights reserved - and carries its own `ext/LICENSE.txt`.
## Deciding core vs. ext
The "never modify core" rule is about not bending core's *existing* framework code to fit one app. It is not a rule about where *new* framework-level features belong. Ask: would every Nimbly app need this, not just this project?
- **Yes → core.** Built-in admin screens, generic resource CRUD, auth/roles/permissions, shortcodes any project could use. If getting basic functionality working in a new project would mean copy-pasting the same files into every ext repo, that's a sign the feature is stranded in the wrong place.
- **No → ext.** This project's own resources, routes, business logic, branding, and config - anything that only makes sense for this specific application.
Concrete example of getting it wrong: the role editor (`/nb-admin/roles/(id)` and `/nb-admin/roles/add`) was originally built entirely in `ext/uri/nb-admin/roles/`, even though every other admin resource page (users, media, jobs, ...) is served generically from `core/modules/admin/uri/nb-admin/(resource)/...` with no per-project override at all. Roles/permissions are core to Nimbly's own permission system, not app data - so that route belonged beside `debug/`, `media/`, `profile/` etc. under `core/modules/admin/uri/nb-admin/`, not in ext. Leaving it in ext meant every new project would have to hand-copy those files just to manage roles.
## Directory layout
```
core/ # Framework - never modify
lib/ # Core libraries and shortcode implementations
modules/ # Core modules (admin, forms, api, install, user)
tpl/ # Core templates (html wrapper, etc.)
uri/ # Core routes
ext/ # Your application - all custom work goes here
data/ # Resources (.meta + JSON records)
lib/ # Custom libraries and shortcode implementations
modules/ # Custom modules
tpl/ # Reusable templates
uri/ # Route templates
static/ # Built assets (output of npm build)
css/ # CSS source
js/ # JS source (JSX/Alpine)
```
**Rule:** Never modify `core/`. All customization lives in `ext/`.
**Rule:** All text in `core/` must be in **English**. Use `[#text Key#]` for every user-facing string so projects can supply translations via `ext/data/.i18n/text..po`. Never hard-code Dutch or any other language in core templates.
---
Source: https://atomic.nimblyweb.com/en/docs/concepts/templates/
# Templates and shortcodes
How a page is assembled from small templates and shortcodes, and how templates are found.
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//
```
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//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//`) are different - they receive `$params` and can be called with inline parameters.
## 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.
---
Source: https://atomic.nimblyweb.com/en/docs/concepts/resources/
# Resources: data as files
The data layer: file-backed records, the .meta file that describes them, and the conventions every resource follows.
## Structure
```
ext/data//
.meta → field definitions and configuration (JSON)
→ one record per file (JSON, no extension)
```
Example:
```
ext/data/articles/
├── .meta
├── a1b2c3d4e5f6g7h8
└── z9y8x7w6v5u4t3s2
```
## .meta file
Defines the structure and behavior of a resource. All fields must be explicitly configured.
```json
{
"fields": {
"title": {
"name": "Title",
"type": "text",
"required": true
},
"title_slug": {
"name": "URL slug",
"type": "slug",
"source": "title"
},
"published": {
"name": "Published",
"type": "boolean"
},
"body": {
"name": "Body",
"type": "html",
"buttons": "h2,h3,h4,bold,italic,orderedlist,unorderedlist,quote,anchor",
"media": true,
"media_sizes": "sm-90,md-70,lg-60,xl-50,xxl-40",
"admin_col": false
},
"sort_order": {
"name": "Sort order",
"type": "text",
"admin_col": false
}
},
"sort": {
"field": "sort_order",
"flags": "numeric",
"order": "asc"
}
}
```
## System fields (auto-managed)
Every record automatically has: `uuid`, `_created`, `_modified`, `_created_by`, `_modified_by`. Never define these in `.meta`.
## Resource conventions (required)
These rules are mandatory for all resources unless explicitly stated otherwise.
**Always include:**
- `"required": true` on all name/title fields
- `published` boolean field on content resources
- `sort_order` field + `sort` config on list resources (partners, logos, cards, team members, etc.)
- `"admin_col": false` on images, html, urls, large text, and technical fields like sort_order
**Always consider:**
- Does this resource need a `published` field?
- Does ordering matter? Add `sort_order` + `sort`
- Is this linked from a URL? Add a slug field and list it in `index`
- Does any field need to be unique? Add it to `unique`; a unique field is indexed by itself
- Does the admin overview need a specific column order? Use `admin_columns`, including `_modified` and `_created` when useful
- Does content need to be translated? Add `languages` and `i18n` per field
- Can this be smaller while still fully solving the current requirement?
- Can existing system metadata or a single field index solve this without extra custom fields?
**Admin visibility - fields to hide by default:**
- images
- html content
- urls
- large text fields
- sort_order and other technical fields
---
Source: https://atomic.nimblyweb.com/en/docs/concepts/routing/
# 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
**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:
```text
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:
```json
{
"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//
ext/uri/user/(id)/index.tpl → /user//
```
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
```
---
---
Source: https://atomic.nimblyweb.com/en/docs/concepts/i18n/
# Field-level i18n
Languages as part of the system: language routes, translated fields, static texts and templates per language.
Nimbly supports multi-language systems out of the box. Languages are handled at three levels: site configuration, routing, and data.
## Step 1: Define site languages
`ext/data/.config/site`:
```json
{
"languages": ["en", "nl"]
}
```
Rules:
- Required for all multi-language setups
- Only 2-letter language codes
- First language is the fallback
## Step 2: Set up language routing
Each language gets its own URL prefix: `/en/`, `/nl/`
Redirect root to the detected language in `ext/uri/index.tpl`:
```
[#redirect [#detect-language#]#]
```
Language detection order:
1. URL prefix (`/en/`, `/nl/`)
2. User preference (`?lang=en`)
3. Domain TLD (`.en`, `.nl`)
4. Browser language header
5. Fallback - first language in site config
Once a user is inside a language scope, it persists automatically across navigation.
## Step 3: Configure resources for translation
Core uses field-level i18n. Add `languages` to every resource `.meta`
that stores translated values in the same record:
```json
"languages": ["en", "nl"]
```
Mark translatable fields with `i18n: true`:
```json
"title": {
"type": "text",
"required": true,
"i18n": true
},
"intro": {
"type": "html",
"buttons": "bold,italic",
"admin_col": false,
"i18n": true
}
```
Fields without `i18n: true` are shared across all languages. Most technical
fields should stay shared, but `i18n: true` is valid for any field whose value
intentionally differs per language, such as a language-specific image,
visibility flag, sort order, or date.
## Old record-level i18n vs new field-level i18n
Older Nimbly projects may still use record-level i18n:
- Resource `.meta` has `translations`, not `languages`
- Each record has a scalar `lang` field
- Each language is a separate record
- Records are linked through a `translations` object in the record data
Core's field-level i18n is different:
- Resource `.meta` has `languages`
- Individual fields opt in with `"i18n": true`
- One record stores all language values for that field
- Translated field values are objects such as `{ "nl": "Titel", "en": "Title" }`
Both models can use `/nl/` and `/en/` routes. The difference is the data shape
and admin behavior, not the public URL shape.
Do not add `.meta.languages` to an old record-level resource just because the
site has multiple languages. That opts the admin into the 1.1 field-level data
shape and can corrupt editing workflows unless the existing records are being
intentionally migrated to `{nl,en,...}` field objects.
## Step 4: Add static text translations
Use `[#text Key#]` for UI labels in templates. Keys use underscores for spaces.
Create translation files per language:
`ext/data/.i18n/text.en.po`:
```po
msgid "Search"
msgstr "Search"
msgid "Read_more"
msgstr "Read more"
```
`ext/data/.i18n/text.nl.po`:
```po
msgid "Search"
msgstr "Zoeken"
msgid "Read_more"
msgstr "Lees meer"
```
Build the merged base file after changes:
```bash
./nimbly build
```
## Step 5: Templates per language
Languages are not just translations of the same site - each language can have its own templates, routing, and structure. They are effectively separate sites under `/en/`, `/nl/`, etc.
```
ext/uri/en/index.tpl → /en/
ext/uri/en/about/index.tpl → /en/about/
ext/uri/nl/index.tpl → /nl/
ext/uri/nl/about/index.tpl → /nl/about/
```
When structure is shared across languages, extract it into a reusable template:
`ext/uri/nl/about/main.tpl`:
```
[#about-main#]
```
`ext/tpl/about-main/index.tpl`:
```html
[#text About#]
[#text About_intro#]
```
## AI-assisted translation
Fields can define translation instructions using `ai_prompts`. The admin uses these to guide AI translation.
Structure:
```json
"ai_prompts": {
"_all": [...], → shared context for all languages
"en": [...], → English-specific instructions
"nl": [...] → Dutch-specific instructions
}
```
**For text fields:**
```json
"title": {
"type": "text",
"i18n": true,
"ai_prompts": {
"_all": ["Your style is accurate and professional."],
"en": ["You translate to English."],
"nl": ["You translate to Dutch."]
}
}
```
**For HTML fields - strict rules apply:**
HTML fields require explicit instructions to preserve structure. Always include all of the following in `_all`:
```json
"main_text": {
"type": "html",
"i18n": true,
"ai_prompts": {
"_all": [
"The input and output are in HTML, properly escaped for safe use in an HTML editor.",
"You preserve existing HTML structures including images, lists, links and other tags as much as possible.",
"You do not introduce raw < or > characters in your output, but use < and >.",
"Your style is accurate and professional."
],
"en": ["You translate to English."],
"nl": ["You translate to Dutch."]
}
}
```
Rules for HTML translation:
- Always include the HTML safety instructions in `_all`
- Always preserve images, links, lists, and formatting tags
- Never allow the model to simplify or restructure the HTML
## i18n rules for AI agents
- Always define `languages` in both site config and resource `.meta`
- Always use 2-letter language codes, consistently everywhere
- Add `"i18n": true` only when the field value should intentionally differ per language
- Never hand-translate slug fields or UUIDs. I18n slug fields are valid when
they are generated from i18n source fields.
- Keep resource structure identical across languages
- Use `[#text#]` for UI labels; use `i18n` fields for structured content
- Always include HTML safety instructions in `ai_prompts._all` for html fields
---
---
Source: https://atomic.nimblyweb.com/en/docs/concepts/rendering-boundary/
# Choosing the rendering boundary
What is rendered on the server, what runs in the browser and how to choose the boundary between them.
Do not default to frontend or backend rendering. Choose the smallest, clearest solution by weighing maintainability, lines of code, duplicated representations, performance, accessibility, SEO, and where the state actually changes. Alpine.js is an interaction layer, not a reason to move existing content and markup into JavaScript.
Keep content server-rendered when the browser only needs to show, hide, sort, or filter existing elements. Expose only the small values Alpine needs through `data-*` attributes:
```html
[#get-html item.body#]
```
Use `[#fmt var=data.records json#]` when the client genuinely needs structured records for reactive creation, transformation, editing, or state changes:
```html
```
For hybrid interfaces, server-render reusable markup inside a native `` and let Alpine clone or populate it. This keeps the HTML structure in templates instead of duplicating it in JavaScript while still allowing client-side creation:
```html
```
For large, paginated, permission-sensitive, expensive, or frequently changing datasets, do not preload every record into the page. Let Alpine request the required page or slice through the API and keep pagination, loading, empty, and error states explicit. This reduces initial page weight and server work, and avoids sending records the user has not requested. Do not introduce API loading for a small static list when server rendering is simpler and faster.
Choose among the four approaches deliberately:
| Approach | Prefer it when |
|---|---|
| Server-rendered elements plus `data-*` | Existing content only needs filtering, sorting, toggling, or selection |
| Server-rendered `` plus Alpine | The client creates elements dynamically but their markup should remain template-owned |
| Alpine plus `[#fmt ... json#]` | The client genuinely needs structured data for reactive rendering or transformation |
| Alpine plus paginated API requests | The dataset is too large, expensive, sensitive, or dynamic to preload responsibly |
Prefer the option with the least duplicated data, markup, and logic. Passing already-loaded data to JSON is computationally cheap, but it can still add architectural complexity, duplicate server-rendered content, increase page weight, and move markup ownership into JavaScript. Count indirection and multiple representations as costs even when they require few lines of code.
---
Source: https://atomic.nimblyweb.com/en/docs/concepts/lean-by-design/
# Lean by design
KISS, YAGNI and DRY as working rules: simple parts, no speculative code, one place for each thing.
Keep implementations as small as the current requirement allows.
- Prefer the simplest solution that fully solves the actual task.
- Do not add speculative fields, statuses, workflow steps, or audit data "for later".
- Reuse built-in Nimbly metadata like `_created` and `_modified` before introducing parallel custom fields.
- Do not derive UUIDs from business fields unless the project explicitly requires it.
- Don't repeat logic - extract shared behaviour into a helper rather than duplicating it.
- Don't repeat substantial Tailwind class lists across links, buttons, or related UI elements. When the same long visual contract repeats, extract its canonical class list into a small `.tpl` class partial (for example `cls-action-link.tpl`) and reuse its rendered value. Place it at the narrowest useful scope: beside the route in `core/modules//uri//` or `ext/uri//` for page-scoped styles, in `core/tpl/` for framework-wide styles, or in `ext/tpl/` for reusable application-specific styles. Apply this only when the abstraction provides a real consistency or maintenance benefit. Keep one-off classes, single utilities, and short class lists inline; extracting those adds indirection without enough value.
- When duplicate prevention is needed, prefer an index on the business field before inventing a more complex identity scheme.
Production-ready does **not** mean speculative. It means clean, coherent, and sufficient for the current use case.
- If a project-specific solution exposes a reusable framework capability, explicitly mention the core/framework option before defaulting to a local workaround.
---
Source: https://atomic.nimblyweb.com/en/docs/reference/cli/
# CLI
Every command of ./nimbly: setup, builds, tests, upkeep, scheduler and statistics.
Nimbly ships a CLI at `core/cli/nimbly.php`. The root `./nimbly` launcher is the preferred way to invoke it:
```bash
./nimbly init
./nimbly up
./nimbly deps
./nimbly build
./nimbly watch
./nimbly test:run
./nimbly user:create
./nimbly module:install
```
`init`, `deps`, `build`, and `watch` use npm internally. PHP-backed commands use host PHP when available and fall back to Docker automatically. Explicit variants are also available:
```bash
php core/cli/nimbly.php system:setup # require host PHP
./nimbly --docker system:setup # force Docker Compose
```
Equivalent direct invocations:
```bash
php core/cli/nimbly.php system:setup
php core/cli/nimbly.php user:create
php core/cli/nimbly.php module:install
php core/cli/nimbly.php routes:sync
php core/cli/nimbly.php index:rebuild [resource]
php core/cli/nimbly.php data:cache:clear
php core/cli/nimbly.php user:email-index:rebuild
php core/cli/nimbly.php system:repair
php core/cli/nimbly.php help
```
## Documentation lookup
The documentation commands are read-only and read only the local `NIMBLY.md`.
Use them to load the smallest useful reference section while working:
```bash
./nimbly docs:list
./nimbly docs:section "Template Syntax"
./nimbly docs:section "Template Syntax > Route templates"
./nimbly docs:search "router_accept"
```
Matching is case-insensitive. A section path may identify a nested heading; a
heading name without a path must resolve uniquely. Missing and ambiguous
sections return an error.
## Commands
`./nimbly help` lists the commands. `./nimbly --help` (or `-h`) prints what one command does and takes, without running it.
### `system:setup`
Sets up the local system. Safe to re-run - existing files and records are never overwritten.
What it does:
- Creates `.env` with `APP_ENV`, a generated `PEPPER`, and `BASE_PATH` only for subdirectory installs
- Generates `.htaccess` from template
- Generates `.user.ini` from template for PHP-FPM runtime settings
- Creates the `ext/` directory scaffold (`data/`, `static/`, `lib/`, `modules/`, `tpl/`, `uri/`, temp dirs)
- Gives a site without any page a first home page (`ext/uri/index.tpl` and `main.tpl`): a short, editable page that shows where pages, content and data live, with one example resource (`ext/data/messages/.meta`) and one form that saves to it (`ext/uri/message.json`), closed to visitors. It is yours to change or remove; a site that already has a route is left alone
- Creates `.config/site`, the `.content` resource, core `.routes` records, and default roles (`admin`, `editor`)
- Creates the `users` resource and an initial admin user
Prompts: **Site name**, **Admin email**, **Admin password**. Steps that are already complete are skipped silently.
**Non-interactive (CI/CD):** Set environment variables to skip all prompts:
| Variable | Description |
|---|---|
| `APP_ENV` | Environment label (`prod`, `stage`, `dev`; default `dev`) |
| `BASE_PATH` | Optional URL base path for subdirectory installs, e.g. `/mysite/`; omit for root installs |
| `PEPPER` | Encryption pepper - set to reuse an existing value; omit to auto-generate |
| `EXT_REPO` | Git remote URL for the ext repo (written to `ext/readme.md`) |
| `SITE_NAME` | Site name written to `.config/site` |
| `SITE_LANGUAGE` | Validated lowercase two-letter primary language for a new site (default `en`) |
| `ADMIN_EMAIL` | Initial admin user email |
| `ADMIN_PASSWORD` | Initial admin user password (min 8 chars) |
```bash
SITE_NAME="My Site" ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secret123 ./nimbly system:setup
```
Without a terminal (a container start, a scheduler) setup never waits for an answer. A missing `SITE_NAME` becomes `My Nimbly Site`. A site without users needs `ADMIN_EMAIL` and `ADMIN_PASSWORD`: when one is missing, setup stops with one line that names it and exit code 1, so a production container on an empty volume does not start until both are set.
### `user:create`
Creates an additional user account. Prompts for email, role, and password interactively. Available roles are read from `ext/data/roles/`. The user is always also assigned the `user` role. Requires setup to have been run first.
### `module:install`
Runs a module's `.install.inc` script. Looks in `ext/modules/` first, then `core/modules/` as fallback. Requires setup to have been run first.
```bash
./nimbly module:install event
```
### `routes:sync`
Scans `ext/uri/**/route.inc` and creates missing records in `.routes` for dynamic routes. Use this after adding a new route folder with `(param)` segments.
```bash
./nimbly routes:sync
```
### `index:rebuild`
Rebuilds index entries for an indexed resource. Use this after adding `index` to an existing resource's `.meta`, or after importing records outside of the normal API flow.
```bash
php core/cli/nimbly.php index:rebuild # interactive: lists indexed resources, prompts for choice
php core/cli/nimbly.php index:rebuild articles # direct: reindex the 'articles' resource
```
The command scans all records in the resource and creates any missing index files. It is idempotent - existing entries are left untouched.
### `data:cache:clear`
Throws away the cached query results of every resource; the next read builds them again. Use this after editing a record file in place by hand (see Data caching). Saving through the site, the API, Git or `ext:sync` needs no clearing.
```bash
php core/cli/nimbly.php data:cache:clear
```
### `user:email-index:rebuild`
Adds email lookup metadata to the `users` resource and rebuilds its email index.
```bash
php core/cli/nimbly.php user:email-index:rebuild
php core/cli/nimbly.php user:email-index:rebuild --yes
```
This migration supports the user identity model, where the user
record UUID is stable and email is an indexed lookup field. It adds `email` to
`users/.meta.index`, adds `email` to `users/.meta.unique` when no duplicate
emails exist, and creates index entries for all existing users, including
legacy users whose UUID was originally `md5(email)`.
If duplicate emails are found, the command reports them and skips adding
`unique=email` until the duplicates are resolved. The email index is still
rebuilt so login lookups remain fast where possible.
### `test:run`
Runs the E2E suite. The command creates temporary test data with `test:setup`, installs the Chromium Playwright browser if needed, runs Playwright using `core/tests/playwright.config.js`, then removes the temporary data with `test:teardown`.
```bash
./nimbly test:run
./nimbly --docker test:run
```
Use `--docker` in CI or whenever PHP data files must be written through the same Docker user model as the web runtime.
Core tests must not call metered external providers. Mock the provider boundary
and test Nimbly's request, response handling, validation, and UI behavior
deterministically. Provider availability is an operational integration check,
not part of the Core test suite.
### `test:setup` / `test:teardown`
Low-level commands used by `./nimbly test:run`. `test:setup` creates the `test` role, `test@nimbly.dev` user, `test-records` resource, and seed records. `test:teardown` removes those records and the temporary `.test` route config entries.
### `system:repair`
Brings generated files and system records level with the current core. A core update never rewrites them, so an older site can miss what a new install gets from `system:setup`. Each check reports first; nothing changes without `--yes`.
| Check | Repaired when |
|-------|---------------|
| Root `.htaccess` | It differs from `core/cli/setup/htaccess.tpl`. The pepper and the rewrite base are taken from the existing file; the previous file is kept as `.htaccess.before-repair`. |
| Directory guards | An `.htaccess` under `ext/` or `core/` is missing or still in the old `` form. A guard with its own content is reported and left alone. |
| `ext/.gitignore` | It does not ignore the thumbnail cache, the job queue, the scheduler state or the agent chats and runs. |
| `.config/.meta` | It has no `upsert`, so page settings cannot be saved for a page that has none yet. |
| Core routes | A core route added after the site was set up is not registered. |
| Users email index | `users/.meta` does not index `email`. `unique` is added only when no two users share an address. |
```bash
./nimbly system:repair # report only
./nimbly system:repair --yes # apply
```
The ignored folders change on every server by themselves (the scheduler state every minute), so tracking one makes `ext:sync` conflict and can leave the server's `ext/` stuck in a rebase. The ignore rule does not untrack a folder that is already tracked:
```bash
git -C ext rm -r --cached data/.state
```
### `schedule:run`
Runs due scheduled commands for one project. On manual VPS servers with multiple
Nimbly projects, do not create one cron entry per project. Use the server-level
scheduler orchestrator instead and let it call each registered project's
`schedule:run` sequentially.
Projects may define schedules in `ext/cli/`. The scheduler selects files in this order:
1. `SCHEDULE_FILE` from `.env` or the process environment, as an explicit file path.
2. `ext/cli/schedule..inc`, where `` comes from `SCHEDULE_ENV`, `APP_ENV`, or `NIMBLY_ENV`.
3. `ext/cli/schedule.inc`.
4. `core/cli/schedule.inc`.
Environment aliases are normalized: `production` → `prod`, `staging` → `stage`, `development` and `local` → `dev`.
Use environment-specific schedule files when staging or development must run background jobs but must not run production-only tasks such as member reminders.
Schedule tasks may use `every_minutes` for intervals longer than one minute:
```php
[
'id' => 'uptime-poll',
'command' => 'uptime:poll',
'every_minutes' => 2,
]
```
The value must be a positive integer. A new task is due immediately; after it
runs, it becomes due when the clock enters the next interval bucket. Existing
`every`, `daily_at`, and optional `timezone` schedules remain supported.
Scheduler last-run state is stored in `ext/data/.state/schedule`. Existing installs with older `ext/data/.config/schedule` state are read as a migration fallback until the scheduler writes the new state file.
### Mandatory maintenance and session expiry
Every `schedule:run` invocation includes the Core task definitions before it
loads the application's schedule. Application schedules add tasks; they cannot
remove or reschedule these mandatory invocations:
| Task ID | Command | Interval |
|---|---|---|
| `sessions-prune` | `sessions:prune` | 30 minutes |
| `jobs-run` | `jobs:run 10` | 1 minute |
| `jobs-prune` | `jobs:prune --days=30` | 24 hours |
| `history-prune` | `history:prune --days=90` | 24 hours |
| `agent-prune` | `agent:prune --days=30` | 24 hours |
| `stats-rollup` | `stats:rollup` | 1 hour |
Existing application entries with these IDs or commands are deduplicated.
Override an **implementation**, not its schedule: define the same command in
`ext/cli/commands.php`, using the normal command registry contract. Core trusts
the implementation's exit status. No inspection of its internal work occurs.
The host registry lists projects, not task choices; legacy `enabled` flags no
longer suppress registered projects. Remove retired deployments from the
registry. Before registering an older unscheduled project, review its queued
jobs and application schedule: enabling processing may deliver pending email
or execute other queued work.
`./nimbly schedule:status` reports successful mandatory task completion and
exits nonzero when a task failed, never succeeded, or is overdue by more than
two intervals. A successful run with an empty queue is healthy. The admin
**Needs attention** band and host audit independently read this state, so they
also detect a stopped scheduler. Check this command after every deployment;
installing cron alone is not proof of working maintenance. The installed host
audit ships a root-owned `nimbly-maintenance.php` helper beside its collector.
For VPS installations, use the existing host orchestrator and register **all**
active projects. Container images already invoke the project scheduler every
minute. Other cloud platforms must invoke the same CLI every minute using a
single scheduler with access to the application's persistent `ext/data` and
runtime configuration. Monitor `schedule:status` independently of that runner.
No extra session-cleanup daemon or per-project cleanup registration is needed.
Session functions, including file inspection and cleanup, live in
`core/lib/session.php`. Defaults are rolling inactivity limits: anonymous
4 hours, authenticated users 3 days, admins 7 days. There is **no absolute
login-age limit**: regular activity keeps an authenticated session alive.
Session ID rotation does not reset its identity or sign the user out.
Anonymous reads and permission checks do not create sessions; forms and other
stateful features may do so. Authenticated cookies persist across browser
restarts; anonymous cookies remain browser-session cookies.
Every cookie core sets has the same options (`session_cookie_options()`): the
site's base path, `HttpOnly`, `SameSite=Lax`, and `Secure` on https. That goes
for the session cookie, the form key of a visitor without a session (`key`) and
a variable kept with `[#set session ...#]` when there is no session; the last
two last 30 days (`persistent_cookie_options()`). Scripts in the page cannot
read them.
Projects can configure durations in seconds under `session` in `.config/site`:
```json
{
"session": {
"anonymous": 14400,
"authenticated": 259200,
"roles": { "admin": 604800, "editor": 259200 }
}
}
```
The longest matching assigned-role override wins; otherwise the authenticated
default applies. Temporary role switching does not change the assigned-role
snapshot. Legacy sessions retain their original 24-hour validity until their
first valid live request upgrades them; an already-expired session is never
revived. Login, cookies, and cleanup use the same policy. Core disables PHP's
request-triggered garbage collection for its private session directory.
`./nimbly sessions:prune --dry-run` reports aggregate counts without changing
sessions. Normal execution streams files, skips locked files and symlinks, and
removes expired sessions only. Malformed, unsupported serialized objects or
references, and files larger than 1 MiB are reported as failures rather than
deleted. The reader accepts scalar/array `php` and `php_serialize` encodings.
The separate **Sign out everyone** action intentionally removes every session.
Remember-me UI and its longer timeout are deferred.
### Request statistics (`stats:*`)
Core records every request that reaches `index.php` without cookies or
JavaScript. Recording is on for `APP_ENV=prod` and `stage`; set
`STATS_ENABLED=1` or `0` to override. Files served directly by `.htaccess`
(`ext/static`, `core/static`, `/img/` thumbnails) and blocked `.php` probes
never reach PHP and are not recorded.
A shutdown function appends one JSON line per request to
`ext/data/.tmp/stats/running-YYYY-MM-DD.log`: UTC time, method, host, path with
query (relative to the base url), status, content type, duration, IP, user agent, referrer, first
`Accept-Language` tag and a logged-in flag. Past `STATS_MAX_DAY_BYTES`
(default 200 MB) a day only counts further requests as overflow.
`stats:rollup` (mandatory maintenance, hourly)
archives finished days under `ext/data/.stats//`, which `ext:sync`
commits:
| Path | Content |
|---|---|
| `raw/YYYY/YYYY-MM-DD.log.gz.enc` | the complete day, gzipped and AES-256-GCM encrypted |
| `raw/YYYY/YYYY-MM-DD.apache.log.gz.enc` | optional Apache enrichment from `apache-YYYY-MM-DD.log` |
| `months/YYYY-MM.json` | complete counts per day by status, route, visitor class, device, browser, language, bot, referrer and path, plus `hours` (requests, pageviews, visitors, bots, scanners per UTC hour) |
| `years/YYYY.json` | the same counts summed per month, plus a year total |
All dates, file names and day boundaries are UTC. Interfaces convert to the
viewer's timezone by regrouping `hours`; a visitor counts in the hour of their
first pageview of the UTC day. Zoom from year to month to day to the raw requests; weeks and all-time views
are derived from these files. Above day level `visitors` becomes `visits`, the
sum of daily unique visitors. The JSON files hold counts only, never rankings
or IP addresses; interfaces derive top lists from them. Classification (human, editor, bot, scanner,
suspect, monitor, tool) happens during rollup, so `stats:rollup --rebuild`
recomputes every month from `raw/` after rules improve.
`stats:import-apache` reads Apache access log lines (`combined` or
`vhost_combined`) from stdin, so only the log read needs root:
```bash
zcat -f /var/log/apache2/site-access.log.* | sudo -u www-data ./nimbly stats:import-apache --mode=full --before=2026-09-25T02:02:00Z
zcat -f /var/log/apache2/site-access.log.1 | sudo -u www-data ./nimbly stats:import-apache
```
`--mode=full` backfills every line before `--before` (days before recording
started). The default `--mode=enrich` only adds what never reaches PHP: files
served from `ext/static` or `core/static`, `/img/` thumbnails and blocked
`.php` probes. Lines are written to `apache-YYYY-MM-DD.log` (UTC dates) and
archived by the next rollup; a watermark in `.tmp/stats/apache.watermark` skips
lines already imported. Use `--base=/subdir/` for subdirectory installs and
`--host=` when `SITE_URL` is not set. Apache lines have no `Accept-Language`,
content type or login state: content types are guessed from the path and
editors count as visitors.
`STATS_KEY` (64 hex characters, generated by `system:setup`) encrypts the raw
archive. Back it up: without it the raw history cannot be read. While it is
missing, rollup fails and keeps the running logs. `stats:raw YYYY-MM-DD
[--apache]` prints a decrypted day.
### `scheduler:*`
Runs multiple project schedulers from one server-level cron. The project schedule
files remain project-local in `ext/cli/`; the server registry is the only source
of truth for which projects are orchestrated.
The orchestrator uses these server-managed files by default:
| Path | Purpose |
|---|---|
| `/usr/local/bin/nimbly-scheduler-orchestrator` | Wrapper run by cron |
| `/etc/nimbly/scheduler-projects.json` | Registered projects and defaults |
| `/etc/cron.d/nimbly-scheduler` | System cron entry |
| `/var/log/nimbly-scheduler.log` | Scheduler output log |
| `/etc/logrotate.d/nimbly-scheduler` | Weekly rotation of the log, eight weeks kept |
Install it once per server from any current Nimbly core checkout:
```bash
sudo php core/cli/nimbly.php scheduler:install
sudo php core/cli/nimbly.php scheduler:project:add site-name /var/www/site-name
sudo php core/cli/nimbly.php scheduler:cron:install --user=www-data
```
Useful management commands:
```bash
php core/cli/nimbly.php scheduler:project:list
sudo -u www-data php core/cli/nimbly.php scheduler:run --dry-run
sudo -u www-data /usr/local/bin/nimbly-scheduler-orchestrator
php core/cli/nimbly.php scheduler:cron:status
```
`scheduler:run` takes a short global lock and dispatches two workers per
registered project: one runs `jobs:run 10` for prompt queue delivery, and the
other runs the project's `schedule:run`. Workers log their completion and exit
code to the host scheduler log. A long application task therefore does not
delay another project's minute tasks or its own queued alerts. Job runners use
a per-project lock so the direct queue worker and mandatory `jobs-run` task do
not dispatch the same job concurrently. The project scheduler retains its own
lock, so later cron ticks do not duplicate a long schedule run. Legacy
`default_delay_after_seconds` values in the registry are ignored.
`scheduler:run` itself always dispatches every registered project's workers
immediately (it never waits before spawning), so a slow project cannot delay
the next cron tick's dispatch. Concurrency is instead capped inside each
`scheduler:worker`: before running the project's real `jobs:run`/`schedule:run`,
a worker blocks on a shared slot semaphore, and releases it when done. This
keeps a large fleet from pushing every project's worker onto a single-CPU host
at the same instant. The cap defaults to 5 simultaneous workers across the
whole host and is configurable with `NIMBLY_SCHEDULER_MAX_CONCURRENCY`; the
slot lock files themselves live under `NIMBLY_SCHEDULER_SEMAPHORE_DIR`
(default: the system temp directory).