# 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#] ``` ```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
  • [#get item.title#]

  • ``` `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 `