agentleFS
danieletetidanieleteti/delphimvcframework/.agents/skills/dmvcframework-webapp/SKILL.md

dmvcframework-webapp

Skill1.4k starsChanged 16 days agoApache-2.0View on GitHub

Use when building a server-side web application with DelphiMVCFramework — HTML pages and HTMX fragments rendered by TemplatePro. Covers view engine setup, template inheritance and blocks, ViewData and its ownership rules, fragments, custom filters, cookie/JWT login, static files, and the HTMX request/response helpers on the Delphi side. Triggers on "TemplatePro", "RenderView", "ViewData", "baselayout", "template inheritance", "HTML fragment", "web controller", "HTMX", "hx-get", "server-side view", "web application", "cookie auth", "static files".

  • Reads credentials

1 other project uses a version of this, 1 of them edited.

Trending

Quiet since December.thin dataabout the whole repository

Dec 2025: 1 mentionJan 2026: 0 mentionsFeb 2026: 0 mentionsMar 2026: 0 mentionsApr 2026: 0 mentionsMay 2026: 0 mentionsJun 2026: 0 mentionsJul 2026: 0 mentionsAug 2026: 0 mentionsSep 2026: 0 mentionsOct 2026: 0 mentionsNov 2026: 0 mentions
Dec 2025Jun 2026Nov 2026
Mentions a month of the danieleteti/delphimvcframework repository that our weekly crawl found: only 12 in all, so this is a hint, not a trend. There is no adoption data for this file yet.

Most recently taken up by

Who's talking about danieleteti/delphimvcframework

The biggest 5 of 12 mentions linking to the repository that our crawl found. Each line is their own headline.

See all 12

Talked about on GitHub, Stack Overflow and Hacker News (12 times).

On GitHub · 6

1 more on GitHub

On Hacker News · 1

The file

We split this skill into the rules it gives an agent

7 rules. About 0 are common, 3 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Never invent an identifier or answer from memory.

    line 35
  2. Distinctive:

    If you still cannot verify it, say so.

    line 40
  3. Very specific:

    Read EngineConfigU.pas, the web controller and bin/templates/baselayout.html before writing anything.

    line 68
  4. Distinctive:

    Do not scaffold one. Tell the user:

    line 72
  5. Very specific:

    The host may be WebBroker (ISAPI, Apache) — that is fine: Keep it, do not migrate it, do not suggest migrating it.

    line 85
  6. Distinctive:

    All controller actions are functions, never procedures.

    line 113
  7. Very specific:

    Prefer injecting the service rather than resolving it by hand — [MVCInject] on the controller's constructor or on an action parameter.

    line 916

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

DMVCFramework — Server-Side Views with TemplatePro + HTMX

Reference project: webapplication_with_htmx wizard template. TemplatePro repo: https://github.com/danieleteti/templatepro HTMX docs: https://htmx.org/docs/

REQUIRED COMPANION SKILL: dmvcframework — covers the REST/ActiveRecord/middleware layer that underpins every web application. If the user asks about controllers, entities, JWT middleware configuration, or engine setup beyond what is in this skill, invoke dmvcframework.

COMPANION SKILL (on demand): htmx-skill — indexes every page of the official htmx.org documentation. For any attribute, trigger/swap modifier, event, header or extension beyond the few patterns in Section 7, invoke it and read the linked page. Do not write HTMX from memory.

COMPANION SKILL (on demand): dmvcframework-ui — the presentation layer the wizard generates: Bootstrap 5.3, baselayout.html blocks, style.css brand tokens, dark mode via data-bs-theme, toasts. Invoke it before writing markup or CSS.


REQUIRED REFERENCE — dmvcframework-security. Any endpoint that accepts input from a client (body, query string, header, cookie, upload, URL) must follow it: access control and IDOR, mass assignment, SQL injection, XSS, CSRF, path traversal, uploads, security headers, JWT, secrets. Invoke it whenever you write or review such an endpoint — not only when the user says "security".


When in doubt about an API — verify it, never guess

Never invent an identifier or answer from memory. If you need a signature this skill does not cover, ask the user for the path to their DelphiMVCFramework checkout and read sources/ (and the matching samples/ project); failing that, read the official repository — sources · samples. If you still cannot verify it, say so. Where a skill and a sample disagree, the sample wins.


1. STOP — this skill works inside a wizard-generated project

Never create the project from scratch. Never hand-write the .dpr or the base templates. The wizard generates a running web application — layout, static files, session, view engine, HTMX — and your job is to add pages to it.

Step 1 — Detect the project in the current folder

The user is expected to have run the wizard and started the agent from inside the project folder. A web wizard project has:

*.dpr                    Boot + RunServer  (Indy Direct by default; a WebModule here means WebBroker/ISAPI/Apache — also fine)
BootConfigU.pas          dotEnv, LoggerPro, TemplatePro init
EngineConfigU.pas        view engine, middleware/filters, UseExceptionHandler
Controllers.HomeU.pas    web controller — actions returning RenderView(...)
Controllers.APIU.pas     JSON sidecar (if present)
TemplateProHelpersU.pas  custom TemplatePro filters
ServicesU.pas            DI registrations
bin/.env                 port, secrets, view settings
bin/templates/           baselayout.html + pages
bin/www/                 static files (css/, favicon, manifest)

Read EngineConfigU.pas, the web controller and bin/templates/baselayout.html before writing anything.

Step 2 — If there is no wizard project, stop and say so

Do not scaffold one. Tell the user:

This skill works on a project generated by the DMVCFramework IDE wizard, and I do not see one in this folder. Please create it first:

Delphi IDE → File → New → Other → Delphi Projects → DelphiMVCFramework → New DMVCFramework Application

Pick Web Application (or Minimal API WebApp for lambda handlers). Accept the defaults — the server backend is Indy Direct. Compile and run it once and check the page opens in the browser. Then cd into the project folder, start me there, and tell me what page to add.

Then wait. Do not generate project files in the meantime.

The host may be WebBroker (ISAPI, Apache) — that is fine. Keep it, do not migrate it, do not suggest migrating it. Everything above the host is identical on every backend. See dmvcframework, reference/servers.md.

Step 3 — Follow the scaffolding workflow for new pages

See Section 16 for the step-by-step workflow.


2. Architecture Overview

Browser
  │  HTML pages + HTMX attributes
  │
  ├─ GET /web/...         → TWebController   → RenderView('path/template')
  │                                            ViewData['key'] := value
  │
  ├─ GET /web/...         → TWebController   → same action, fragment template
  │    (HTMX request,                          IsHTMX and not HXIsBoosted → RenderView('x/table')
  │     not boosted)                           otherwise                  → RenderView('x/index')
  │
  └─ GET /api/...         → TAPIController   → OkResponse(StrDict(...))
       (JSON sidecar)

Key invariants:

  • All controller actions are functions, never procedures.
  • [MVCProduces(TMVCMediaType.TEXT_HTML)] is optional — the samples omit it and set the default content type once in the engine config (Config[TMVCConfigKey.DefaultContentType] := TMVCMediaType.TEXT_HTML) or in OnBeforeAction. Use it only when one action must differ from the app default.
  • Full-page actions call Result := RenderView('folder/template').
  • Fragment actions also call RenderView: the same action renders x/table or x/index (§7). Do not hand-build HTML strings.
  • procedure is allowed for redirects (Redirect('/people')), which produce no body.
  • The JSON API sidecar (/api) uses standard IMVCResponse factory methods.

3. Engine Configuration (EngineConfigU.pas)

procedure ConfigureEngine(AEngine: TMVCEngine);
var
  lJWT: TMVCJWTCookieAuthenticationOptions;
  lWwwPath: string;
begin
  lWwwPath := TPath.Combine(AppPath, 'www');

  // Controllers
  AEngine.AddController(TWebController);
  AEngine.AddController(TAPIController);

  // Server-Side View Engine
  AEngine.SetViewEngine(TMVCTemplateProViewEngine);

  // Filter chain (engine-wide). Order matters: first = outermost
  AEngine.Use(RedirectHTTPFilter(['/'], '/web'));
  AEngine.Use(FileServerBundle('/static', lWwwPath));                   // files + Range, before routing
  AEngine.Use(CompressionHTTPFilter(1024));
  AEngine.Use(FileSessionHTTPFilter(0, TPath.Combine(AppPath, 'sessions'),
    SessionCookieFlags(dotEnv.Env('SESSION_COOKIE_SECURE', True))));   // HttpOnly, + Secure

  // JWT in an HTTP-only cookie, one bundle: POST /login issues it and /logout expires it (before
  // routing), every routed request is checked (after routing)
  lJWT := TMVCJWTCookieAuthenticationOptions.Default;   // Cookie: HttpOnly, Secure, SameSite=Strict
  lJWT.Handler := TAuthentication.Create;
  lJWT.ClaimsSetup := procedure(const JWT: TJWT)         // what goes into every issued token
    begin
      JWT.Claims.Issuer := dotEnv.Env('JWT_ISSUER', 'MyApp');
      JWT.Claims.ExpirationTime := Now + OneHour;
      JWT.Claims.NotBefore := Now - OneMinute * 5;
      JWT.Claims.IssuedAt := Now;
    end;
  lJWT.Secret := dotEnv.Env('JWT_SECRET');  // no fallback: a missing key must fail, not sign with a public string
  lJWT.ClaimsToCheck := [TJWTCheckableClaim.ExpirationTime, TJWTCheckableClaim.NotBefore, TJWTCheckableClaim.IssuedAt];
  lJWT.Cookie.Secure := dotEnv.Env('JWT_COOKIE_SECURE', True);
  // only these origins may POST /login (403 otherwise)
  lJWT.LoginAllowedOrigins := dotEnv.Env('LOGIN_ALLOWED_ORIGINS', '').Split([',']);
  if Length(lJWT.LoginAllowedOrigins) = 0 then // the bundle would check no origin
    raise Exception.Create('LOGIN_ALLOWED_ORIGINS is empty: set it in .env, the origins a browser may POST /login from');
  AEngine.Use(JWTCookieAuthenticationBundle(lJWT));

  // Browser-friendly error pages: renders templates/error.html for clients that prefer HTML,
  // leaves the JSON error body untouched for API clients. One call — no handler to hand-write.
  AEngine.UseExceptionHandler('error', 'MyApp');
end;

A project from a 3.4 wizard has the same chain as classic middleware (TMVCRedirectMiddleware, UseJWTCookieAuthentication, UseFileSessionMiddleware, TMVCStaticFilesMiddleware, TMVCCompressionMiddleware): deprecated in 3.5, they still work, and each warning names its filter. Keep them unless the user asks to migrate; in new code write the filters.

UseExceptionHandler(AErrorViewName = 'error'; AAppName = ''; AOptions = []) renders <ViewPath>/error.<DefaultViewFileExtension> and exposes status, statustext, app_name, dmvc_version, current_year to the template. error is empty unless you pass ehShowDetails in AOptions — which you want in development and almost certainly not in production, since it leaks the exception message to the browser. Reach for SetExceptionHandler(proc) only when you need behaviour this cannot express.

Required units for EngineConfigU.pas:

uses
  TemplatePro,
  MVCFramework.View.Renderers.TemplatePro,
  MVCFramework.Filters,
  MVCFramework.Filters.Authentication,
  MVCFramework.JWT,
  MVCFramework.Commons,
  System.SysUtils,
  System.IOUtils,
  System.DateUtils;

4. Web Controller Pattern

unit Controllers.HomeU;

interface

uses MVCFramework, MVCFramework.Commons, MVCFramework.Serializer.Commons;

type
  [MVCPath('/web')]
  TWebController = class(TMVCController)
  protected
    procedure OnBeforeAction(Context: TWebContext;
      const AActionName: string; var Handled: Boolean); override;
  public
    [MVCPath]
    [MVCHTTPMethod([httpGET])]
    [MVCProduces(TMVCMediaType.TEXT_HTML)]
    function Index: String;

    [MVCPath('/about')]
    [MVCHTTPMethod([httpGET])]
    [MVCProduces(TMVCMediaType.TEXT_HTML)]
    function About: String;

    // Fragment endpoint — a partial TEMPLATE, rendered with RenderView (see section 9)
    [MVCPath('/fragment/clock')]
    [MVCHTTPMethod([httpGET])]
    function GetClockFragment: String;
  end;

implementation

uses System.SysUtils, System.DateUtils, MVCFramework.Logger;

procedure TWebController.OnBeforeAction(Context: TWebContext;
  const AActionName: string; var Handled: Boolean);
begin
  inherited;
  // Set ViewData available to ALL views via baselayout.html
  ViewData['app_name']     := 'MyApp';
  ViewData['dmvc_version'] := DMVCFRAMEWORK_VERSION;
  ViewData['current_year'] := YearOf(Now);
  ViewData['page_id']      := AActionName.ToLower; // for aria-current="page"
end;

function TWebController.Index: String;
begin
  ViewData['current_date'] := FormatDateTime('dddd, dd mmmm yyyy', Now);
  Result := RenderView('home/index');  // renders templates/home/index.html
end;

function TWebController.About: String;
begin
  Result := RenderView('about/index');
end;

function TWebController.GetClockFragment: String;
begin
  ViewData['time'] := FormatDateTime('hh:nn:ss', Now);
  ViewData['date'] := FormatDateTime('dddd, dd mmmm yyyy', Now);
  Result := RenderView('home/_clock');   // templates/home/_clock.html — no {{extends}}, so no chrome
end;

Passing objects and lists to templates

// Scalar values
ViewData['title']    := 'My Page';
ViewData['count']    := 42;

// Object — template accesses properties with dot notation
ViewData['person']   := lPerson;

// List — template iterates with {{for item in list}}
ViewData['people']   := lPeople;               // TObjectList<T>

ViewData does NOT own what you put in it. TMVCViewDataObject is a TObjectDictionary<string, TValue> constructed with no ownership flags (MVCFramework.Commons.pas) — nothing is freed for you. Free objects yourself, after the render:

function TWebController.Customers: String;
var
  lCustomers: TObjectList<TCustomer>;
begin
  lCustomers := GetCustomers;
  try
    ViewData['customers'] := lCustomers;
    Result := RenderView('customers');   // render happens here, inside the try
  finally
    lCustomers.Free;
  end;
end;

A TDataSet (e.g. a TFDMemTable) can be passed as ViewData the same way, and iterated with {{for}}.

Accessing session data and current user in a controller

// After JWTCookieAuthenticationBundle (or UseJWTCookieAuthentication), the session is validated automatically.
// Access claims from the JWT token:
var lUsername := Context.LoggedUser.UserName;
var lRoles    := Context.LoggedUser.Roles;  // TList<string>
var lCustom   := Context.LoggedUser.CustomData['key'];

Form posts — validate and re-render with errors

A posted form is not validated automatically (only [MVCFromBody] is). The framework sample samples/webapp_htmx_forms shows the pattern (ReadPerson in PeopleSampleU.pas):

  1. Copy Context.Request.ContentFields (keys are lowercase field names) into an object whose properties carry validator attributes (MVCFramework.Validators).
  2. TMVCValidationEngine.Validate(lModel, lErrors) (MVCFramework.ValidationEngine): lErrors is nil when valid, otherwise a TDictionary<string, string> you free, keyed by property name (Email).
  3. Copy the messages into your error map with LowerCase(Key) — the forms library (lib/forms_bootstrap5.tpro) looks them up by the control's name.
  4. Valid → save, then RedirectResponse(...) (Post/Redirect/Get). Invalid → ViewData['formModel'] := Context.Request.ContentFields (shows what was typed), ViewData['formErrors'] := lErrors, render the same view with status 422 (TMVCHTMLResponse with StatusCode := HTTP_STATUS.UnprocessableEntity).

MVCRequired passes on Integer/TDate (0 is a value): parse those fields yourself and add a message when the text does not convert. Full code (controller and Minimal API): the dmvcframework skill, reference/validation.md, "HTML form post".


5. TemplatePro Template Syntax

Output a variable

{{:variable_name}}
{{:person.FirstName}}
{{:person.Address.City}}

Template inheritance

Base layout (baselayout.html) defines named blocks:

<!DOCTYPE html>
<html>
<head>
  <title>{{block "title"}}{{:app_name}}{{endblock}}</title>
</head>
<body>
  <main>{{block "body"}}{{endblock}}</main>
  {{block "scripts"}}{{endblock}}
</body>
</html>

Child template overrides blocks:

{{extends "../baselayout.html"}}

{{block "title"}}My Page{{endblock}}

{{block "body"}}
  <h1>Hello from {{:app_name}}</h1>
{{endblock}}
  • {{extends "path"}} — path is relative to the current template file.
  • error.html extends with {{extends "baselayout.html"}} (same folder).
  • Subfolder templates use {{extends "../baselayout.html"}}.

Conditionals

{{if page_id|eq,"index"}}aria-current="page"{{endif}}

{{if user_logged_in}}
  <p>Welcome, {{:username}}!</p>
{{else}}
  <p><a href="/login">Log in</a></p>
{{endif}}

Available comparison operators for |eq, |ne, |gt, |lt, |ge, |le:

{{if count|gt,0}}...{{endif}}
{{if role|eq,"admin"}}...{{endif}}

Loops

{{for person in people}}
  <li>{{:person.FirstName}} {{:person.LastName}}</li>
{{endfor}}

{{# Loop index: it is a pseudo-property OF THE LOOP VARIABLE, and it is 1-based #}}
{{for item in items}}
  <tr>
    <td>{{:item.@@index}}</td>
    <td>{{:item.Name}}</td>
  </tr>
{{endfor}}

{{# ...so it composes with filters #}}
{{if item.@@index|lt,4}}<span class="badge">top</span>{{endif}}

Filters (built-in)

{{:name|uppercase}}
{{:name|lowercase}}
{{:name|capitalize}}
{{:name|rpad,10}}   {{:code|lpad,3,"0"}}
{{:date|datetostr}}   {{:ts|datetimetostr}}   {{:ts|formatdatetime,"dd/mm/yyyy"}}

{{# Filters DMVCFramework adds on top of TemplatePro (MVCFramework.View.Renderers.TemplatePro) #}}
{{:obj|json}}   {{:s|urlencode}}   {{:list|count}}   {{:name|fromquery}}

{{# Comparison filters work on dates too #}}
{{if cust.dob|ge,"2000/01/01"}}...{{endif}}

{{# Output is HTML-escaped by default. Suffix $ to emit raw HTML #}}
{{:myobj.rawhtml$}}

{{# Negation #}}
{{if !person}}<p>Not found</p>{{endif}}
{{:value|default,"N/A"}}

Custom filters (registered in TemplateProHelpersU.pas)

{{:value|MyHelper1}}
{{:value|MyHelper2}}

Comments

{{# This is a comment — not rendered in HTML output #}}

Include (partial template)

{{include "partials/card.html"}}

6. Template File Structure

bin/templates/
  baselayout.html           ← base layout; all pages extend this
  error.html                ← browser error page (extends baselayout.html)
  home/
    index.html              ← extends "../baselayout.html"
  about/
    index.html              ← extends "../baselayout.html"
  myfeature/
    index.html              ← extends "../baselayout.html"
    _card.html              ← reusable partial (prefix _ by convention)

The view path is configured in .env:

dmvc.view_path=templates
dmvc.default.view_file_extension=html
dmvc.view_cache=false   # set to true in production for performance

RenderView('home/index') resolves to templates/home/index.html.


7. HTMX — the patterns this stack actually uses

HTMX is loaded in baselayout.html; child templates need no setup.

For any attribute, trigger modifier, swap mode, event or extension: invoke the htmx-skill and read the linked htmx.org page. It indexes every page of the official docs. Do not write HTMX attributes from memory — inheritance rules and swap/trigger modifiers are easy to get subtly wrong. What follows is only the handful of patterns that carry a Delphi-side implication.

The one pattern that shapes your controller: page or fragment, one action

The same action serves the full page and the HTMX fragment — the shape of the People table in samples/webapp_htmx_forms (Controllers.PeoplePagesU, people/index.html + people/table.html). Below with customers. Two templates: customers/index.html is the page (it {{extends "../baselayout.html"}} and {{include "table.html"}}), customers/table.html is the part that changes and has no {{extends}}. You do not write a separate fragment endpoint, and you do not build HTML strings.

function TWebController.Customers: String;
var
  lCustomers: TObjectList<TCustomer>;
begin
  lCustomers := FCustomerService.GetAll;
  try
    ViewData['customers'] := lCustomers;
    // HTMX asks for the table only; a normal request gets the whole page
    if Context.Request.IsHTMX and not Context.Request.HXIsBoosted
       and not Context.Request.HXIsHistoryRestoreRequest then   // uses MVCFramework.HTMX
      Result := RenderView('customers/table')
    else
      Result := RenderView('customers/index');
  finally
    lCustomers.Free;                 // ViewData owns nothing
  end;
end;

A plain browser navigation gets the whole document; an hx-get gets just the table. Test HXIsBoosted too: a link under hx-boost is an HTMX request that expects the whole page — checking IsHTMX alone serves it a fragment without the layout. And HXIsHistoryRestoreRequest: on a history-cache miss (Back after the cache is full) htmx re-requests the URL with HX-Request: true and swaps the answer into body, so it needs the whole page too. Because the same URL answers with a page or a fragment, also send Vary: HX-Request.

Triggering the request

<!-- click -->
<button hx-get="/web/customers" hx-target="#list" hx-swap="innerHTML">Load</button>

<!-- on load / polling -->
<div hx-get="/web/stats" hx-trigger="load"></div>
<div hx-get="/web/stats" hx-trigger="every 5s"></div>

<!-- form post: response replaces the form -->
<form hx-post="/web/customers" hx-target="#result" hx-swap="outerHTML">…</form>

<!-- destructive action -->
<button hx-delete="/web/customers/1" hx-confirm="Delete this customer?" hx-target="closest tr"
        hx-swap="outerHTML">Delete</button>

hx-put/hx-patch/hx-delete reach the matching [MVCHTTPMethod] action directly — no method-override hidden field, unlike a plain HTML form.

URL in the address bar

<a hx-get="/web/customers" hx-target="#main" hx-push-url="true">Customers</a>

Without hx-push-url, the fragment loads but the URL does not change and the page is not bookmarkable.

Loading indicator

htmx-request is added to the element for the duration of the request; style it in style.css:

.htmx-request { opacity: 0.5; transition: opacity 0.3s; }

Live updates: SSE, not polling

For server-pushed updates use Server-Sent Events. Do not hand-roll the stream in the action — inherit TMVCSSEController (see the dmvcframework skill, reference/sse.md) and consume it with the htmx SSE extension (https://htmx.org/extensions/sse/).

8. HTMX response helpers (from the Delphi side)

uses MVCFramework.HTMX; — it adds typed helpers to Context.Request / Context.Response. Use them; do not hand-write HX-* header strings (a typo in a header name fails silently).

// full page reload, like window.location — e.g. after login
Context.Response.HXSetRedirect('/web/dashboard');

// HTMX-aware navigation: no full reload, content swapped, history updated
Context.Response.HXSetLocation('/web/products');

// URL bar only
Context.Response.HXSetPushUrl('/web/items/' + ItemID);
Context.Response.HXSetReplaceUrl('/web/items/' + ItemID);

// client-side events (see the dmvcframework-ui skill for showToast — it takes HTML)
Context.Response.HXTriggerClientEvent('refreshCart');
Context.Response.HXTriggerClientEvents(['refreshCart', 'updateBadge']);

// With a payload. It is serialized as a JSON *value*: a string arrives as evt.detail.value.
// Pass an object when you want named properties (evt.detail.FirstName, ...).
Context.Response.HXTriggerClientEvent('showMessage', 'Customer saved');
Context.Response.HXTriggerClientEvent('customerSaved', lCustomer);
// when it fires: etReceived (default), or after swap / after settle — pass the TClientEventType
Context.Response.HXTriggerClientEvent('initDatePicker', etSettled);

// override the swap / target the server chose on the client
Context.Response.HXSetReswap(TSwapOption.soOuterHTML);
Context.Response.HXSetRetarget('#other-element');

// tell the client to refresh the page
Context.Response.HXSetPageRefresh;

On the request side: Context.Request.IsHTMX, HXIsBoosted, HXGetTarget, HXGetPrompt, HXGetTriggeringEventAsJSON.

To do nothing (leave the DOM as it is), answer 204:

Context.Response.StatusCode := HTTP_STATUS.NoContent;
Result := '';

9. Fragments — a template, never a string

A fragment is a template, rendered by the same view engine as a full page. Never concatenate HTML in Delphi, and never call TTProCompiler.CompileAndRender yourself — that bypasses the engine, its cache and its filters, and it forces you to hand-escape every value.

Two ways to serve one, both RenderView:

a) Same action, page or fragment. Preferred: one URL, bookmarkable, works without JS.

function TWebController.Items: String;
var
  lItems: TObjectList<TItem>;
begin
  lItems := FItemService.GetAll;
  try
    ViewData['items'] := lItems;
    if Context.Request.IsHTMX and not Context.Request.HXIsBoosted then
      Result := RenderView('items/table')
    else
      Result := RenderView('items/index');
  finally
    lItems.Free;                     // ViewData owns nothing
  end;
end;

b) A dedicated fragment action, when the fragment has no page of its own (a row after an edit, a search-results list). Point it at a partial template and skip the layout:

[MVCPath('/fragment/item-list')]
[MVCHTTPMethod([httpGET])]
function TWebController.GetItemList: String;
var
  lItems: TObjectList<TItem>;
begin
  lItems := FItemService.GetAll;
  try
    ViewData['items'] := lItems;
    Result := RenderView('items/_list');   // a template with no {{extends}} → no chrome
  finally
    lItems.Free;
  end;
end;
{{# templates/items/_list.html — the fragment, and reusable via {{include}} from the page #}}
<ul id="item-list">
{{for item in items}}
  <li id="item-{{:item.ID}}">
    {{:item.Name}}
    <a hx-delete="/web/items/{{:item.ID}}" hx-target="#item-{{:item.ID}}"
       hx-swap="outerHTML" hx-confirm="Delete?">[x]</a>
  </li>
{{endfor}}
</ul>

TemplatePro escapes {{:item.Name}} for you — there is no HTMLEscape call to forget. The same partial can be {{include}}d by the full page, so page and fragment never drift apart.


10. Cookie JWT Authentication

AuthenticationU.pas — the three handler methods

type
  TAuthentication = class(TInterfacedObject, IMVCAuthenticationHandler)
  protected
    procedure OnRequest(
      const AContext: TWebContext;
      const ControllerQualifiedClassName, ActionName: string;
      var AuthenticationRequired: Boolean);

    procedure OnAuthentication(
      const AContext: TWebContext;
      const UserName, Password: string;
      UserRoles: TList<string>;
      var IsValid: Boolean;
      const SessionData: TSessionData);

    procedure OnAuthorization(
      const AContext: TWebContext;
      UserRoles: TList<string>;
      const ControllerQualifiedClassName, ActionName: string;
      var IsAuthorized: Boolean);
  end;

OnRequest — which routes need auth

procedure TAuthentication.OnRequest(
  const AContext: TWebContext;
  const ControllerQualifiedClassName, ActionName: string;
  var AuthenticationRequired: Boolean);
begin
  // Protect everything under TAdminController
  AuthenticationRequired :=
    ControllerQualifiedClassName.Contains('TAdminController');
end;

OnAuthentication — validate credentials

procedure TAuthentication.OnAuthentication(
  const AContext: TWebContext;
  const UserName, Password: string;
  UserRoles: TList<string>;
  var IsValid: Boolean;
  const SessionData: TSessionData);
begin
  // validate the credentials against your user store
  IsValid := ValidateFromDB(UserName, Password);
  if IsValid then
  begin
    UserRoles.Add('user');
    if IsAdmin(UserName) then
      UserRoles.Add('admin');
    SessionData.AddOrSetValue('username', UserName);
  end;
end;

OnAuthorization — check roles per action

procedure TAuthentication.OnAuthorization(
  const AContext: TWebContext;
  UserRoles: TList<string>;
  const ControllerQualifiedClassName, ActionName: string;
  var IsAuthorized: Boolean);
begin
  if ActionName.StartsWith('Admin') then
    IsAuthorized := UserRoles.Contains('admin')
  else
    IsAuthorized := True;
end;

Login form pattern (browser-facing)

<form hx-post="/login" hx-swap="none"
      hx-on::after-request="if (event.detail.successful) window.location.href = '/web'">
  <input name="jwtusername" type="text" required>
  <input name="jwtpassword" type="password" required>
  <button type="submit">Login</button>
</form>

The /login route is handled by the login of JWTCookieAuthenticationBundle (by UseJWTCookieAuthentication in a 3.4 project), which a plain HTML form does not fit:

  • Field names. Form data is read only as jwtusername / jwtpassword (TMVCJWTDefaults.USERNAME_HEADER / PASSWORD_HEADER); username / password are accepted only in a JSON body. A form posting username gets 401.
  • No redirect. On success it sets the HTTP-only cookie and answers 200 with JSON {"token": "..."}. The page must navigate by itself — hence hx-swap="none" and the after-request handler above. A plain <form method="POST"> would leave the user looking at the JSON. A failed login answers 401.

Reading the current user in a controller

function TWebController.Profile: String;
begin
  ViewData['username'] := Context.LoggedUser.UserName;
  ViewData['is_admin'] := Context.LoggedUser.Roles.Contains('admin');
  Result := RenderView('profile/index');
end;

11. Custom TemplatePro Filters (TemplateProHelpersU.pas)

Registering filters

The wizard already generates TemplateProContextConfigure in TemplateProHelpersU.pas and calls it once in Boot (BootConfigU.pas). Add your AddFilter calls inside its existing handler — never assign TTProConfiguration.OnContextConfiguration a second time. The generated handler also sets CompiledTemplate.IncludeRootPath, which keeps a dynamic {{include}} built from request data inside the views folder; a replacement handler silently drops that protection.

  TTProConfiguration.OnContextConfiguration := procedure(const CompiledTemplate: ITProCompiledTemplate)
  begin
    CompiledTemplate.IncludeRootPath := lViewPath;   // generated - keep it
    CompiledTemplate.AddFilter('currency', CurrencyFilter);
    CompiledTemplate.AddFilter('timeago', TimeAgoFilter);
    // ... rest of the generated handler
  end;

Writing a filter

uses System.Rtti, TemplatePro, TemplatePro.Types;

function CurrencyFilter(const Value: TValue;
  const Parameters: TArray<TFilterParameter>): TValue;
begin
  Result := FormatCurr('€#,##0.00', Value.AsExtended);
end;

function TimeAgoFilter(const Value: TValue;
  const Parameters: TArray<TFilterParameter>): TValue;
var
  lDelta: TTimeSpan;
begin
  lDelta := TTimeSpan.Subtract(Now, Value.AsExtended);
  if lDelta.TotalMinutes < 1 then
    Result := 'just now'
  else if lDelta.TotalHours < 1 then
    Result := Format('%d minutes ago', [Round(lDelta.TotalMinutes)])
  else
    Result := Format('%d hours ago', [Round(lDelta.TotalHours)]);
end;

Template usage:

{{:price|currency}}
{{:created_at|timeago}}

Dynamic external data source (OnGetValue)

CompiledTemplate.OnGetValue :=
  procedure(const DataSource, Members: string;
    var Value: TValue; var Handled: Boolean)
  begin
    if SameText(DataSource, 'config') then
    begin
      Value := GetConfigValue(Members);  // e.g. 'config.siteName'
      Handled := True;
    end;
  end;

Template: {{:config.siteName}}


12. DI / Services Pattern (ServicesU.pas)

Registering services

procedure RegisterServices(Container: IMVCServiceContainer);
begin
  Container.RegisterType(
    TPeopleService, IPeopleService, TRegistrationType.SingletonPerRequest);
  Container.RegisterType(
    TProductService, IProductService, TRegistrationType.Singleton);
end;

Registration types:

  • SingletonPerRequest — one instance per HTTP request (most common for web)
  • Singleton — one instance for the lifetime of the process
  • Transient — new instance on every Resolve call

Resolving in a controller

function TWebController.Products: String;
var
  lService: IProductService;
  lProducts: TObjectList<TProduct>;
begin
  lService := Context.ServiceContainerResolver.Resolve(TypeInfo(IProductService)) as IProductService;
  lProducts := lService.GetAll;
  try
    ViewData['products'] := lProducts;
    Result := RenderView('products/index');
  finally
    lProducts.Free;                  // ViewData owns nothing
  end;
end;

Registering — once, in the .dpr

TWebContext has no ServiceContainer property (only the read-only ServiceContainerResolver). Registration happens once, before the server starts, and Build is mandatory:

RegisterServices(DefaultMVCServiceContainer);
DefaultMVCServiceContainer.Build;

Prefer injecting the service rather than resolving it by hand — [MVCInject] on the controller's constructor or on an action parameter. See the dmvcframework skill, reference/di-and-repository.md.


13. API JSON Sidecar (Controllers.APIU.pas)

The web application includes a JSON API controller alongside the web controller.

[MVCPath('/api')]
TAPIController = class(TMVCController)
public
  [MVCPath('/server/info')]
  [MVCHTTPMethod([httpGET])]
  function GetServerInfo: IMVCResponse;
end;

function TAPIController.GetServerInfo: IMVCResponse;
begin
  Result := OkResponse(
    StrDict(
      ['application', 'serverTime', 'dmvcVersion'],
      ['MyApp', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', Now), DMVCFRAMEWORK_VERSION]
    )
  );
end;

StrDict(keys, values) returns a TMVCStringDictionary (string→string), serialized as a flat JSON object. For a dictionary of objects, use ObjectDict() — that is the one returning IMVCObjectDictionary.


14. Static Files

Static files are served from bin/www/ at the /static URL prefix.

bin/www/
  css/style.css          → /static/css/style.css
  favicon.ico            → /static/favicon.ico
  favicon-32x32.png      → /static/favicon-32x32.png
  site.webmanifest       → /static/site.webmanifest

Declaration (MVCFramework.Filters; a 3.4 project has the deprecated TMVCStaticFilesMiddleware):

AEngine.Use(FileServerBundle('/static', TPath.Combine(AppPath, 'www')));

FileServerBundle sends the MIME type, Last-Modified (304 on a conditional GET), the default document of a folder, refuses path traversal, and answers 206 to a Range request (audio, video, resumable downloads). StaticFilesHTTPFilter('/static', ...) is the same without Range. FileServerBundle('/', lWwwPath, fsmSPA) serves a single-page app: a GET/HEAD with no route gets index.html, the API routes stay. More switches in TMVCFileServerOptions (Flags, Rules, MediaTypesCustomizer).

In templates: <link rel="stylesheet" href="/static/css/style.css">


15. Environment Configuration (bin/.env)

Full reference — profiles, precedence, ${}/$[] syntax, RequireKeys, the Boot order: dmvcframework skill, reference/dotenv.md.

Key settings for the webapp template:

# Server
dmvc.server.port=8080

# View engine
dmvc.view_path=templates
dmvc.default.view_file_extension=html
dmvc.view_cache=false         # true in production (caches compiled templates)

# JWT Cookie Auth - the wizard writes a random 384-bit key here; give every environment its own
# (e.g. openssl rand -hex 48) and keep .env out of version control
JWT_SECRET=<generated>
JWT_ISSUER=MyApp
# Set to false only for local development over plain HTTP.
JWT_COOKIE_SECURE=true
# Origins (comma separated) a browser may POST /login from; any other origin is
# refused (login CSRF). Add the public address of the site, e.g. https://www.example.com
LOGIN_ALLOWED_ORIGINS=http://localhost:8080,https://localhost:8080
SESSION_COOKIE_SECURE=true

# Logging
logger.config.file=loggerpro.json

Profile-specific overrides: .env.test, .env.prod (layered by UseProfile).


16. Scaffolding Workflow — Adding a New Page

Step 1 — Add actions to the web controller

In Controllers.HomeU.pas (or a new controller unit):

[MVCPath('/products')]
[MVCHTTPMethod([httpGET])]
[MVCProduces(TMVCMediaType.TEXT_HTML)]
function Products: String;

[MVCPath('/products/fragment/list')]
[MVCHTTPMethod([httpGET])]
[MVCProduces(TMVCMediaType.TEXT_HTML)]
function GetProductListFragment: String;
function TWebController.Products: String;
begin
  ViewData['page_id'] := 'products';
  Result := RenderView('products/index');
end;

function TWebController.GetProductListFragment: String;
var
  lProducts: TObjectList<TProduct>;
begin
  lProducts := fProductService.GetAll;
  try
    ViewData['products'] := lProducts;         // ViewData owns nothing — free it below
    Result := RenderView('products/_list');    // partial template, no {{extends}}
  finally
    lProducts.Free;
  end;
end;

Step 2 — Create the template

Create bin/templates/products/index.html:

{{extends "../baselayout.html"}}

{{block "title"}}Products{{endblock}}

{{block "body"}}
  <div class="hero">
    <h1>Products</h1>
  </div>

  <div hx-get="/web/products/fragment/list"
       hx-trigger="load"
       hx-swap="innerHTML">
    <p aria-busy="true">Loading products...</p>
  </div>
{{endblock}}

Step 3 — Add nav link in baselayout.html

<li>
  <a href="/web/products"
     {{if page_id|eq,"products"}}aria-current="page"{{endif}}>
    Products
  </a>
</li>

Step 4 — Register new controller (if separate unit)

In EngineConfigU.pas:

AEngine.AddController(TProductsController);

17. Common Pitfalls

PitfallFix
Action is procedure instead of functionAll web actions must be function: return String for HTML, IMVCResponse for JSON.
Freeing nothing after ViewData['x'] := lObjViewData owns nothing. Free the object yourself in a finally after RenderView.
Hand-building HTML for HTMX fragmentsSame action, two templates: IsHTMX and not HXIsBoosted and not HXIsHistoryRestoreRequest → RenderView('x/table'), otherwise RenderView('x/index').
Writing HX-* headers by handuses MVCFramework.HTMX — Response.HXSetRedirect/HXSetPushUrl/HXSetReswap/HXSetRetarget/HXTriggerClientEvent, Request.IsHTMX/HXGetTarget.
ViewData not set before RenderViewOnBeforeAction sets shared data; page-specific data must be set in the action function body before Result := RenderView(...).
Template extends wrong relative pathhome/index.html → {{extends "../baselayout.html"}}. error.html (same folder as baselayout) → {{extends "baselayout.html"}}.
JWT_COOKIE_SECURE=true with HTTPCookie won't be sent by the browser over plain HTTP. Use false during local dev, true in production with HTTPS.
View cache enabled during developmentdmvc.view_cache=true caches compiled templates — template changes won't appear until restart. Use false in dev.
SetExceptionHandler handles all requestsCheck WebContext.Request.ClientPreferHTML first and Exit if false — otherwise API clients get an HTML error page.
Static file not foundFile must be under bin/www/. The URL prefix is /static, not /www. FileServerBundle answers before routing, so the check of the authentication bundle never sees it; declare it after RedirectHTTPFilter.
Template variable silently emptyTemplatePro doesn't error on missing keys by default. Add a breakpoint after ViewData[...] assignments to verify they are set.
{{for item in list}} doesn't iterateThe list must be a TObjectList<T> or array registered in ViewData. Ensure the service returns a non-nil, non-empty list.
HTMX response headers ignoredHTMX checks for HX-* headers only on responses to HTMX requests (requests with HX-Request: true header). Verify the button/div has a hx-* trigger attribute.
hx-swap-oob elements not swappedEach oob element must have an id that matches an existing DOM element. HTMX swaps by ID.

18. Key Units Reference

UnitPurpose
MVCFramework.View.Renderers.TemplateProTMVCTemplateProViewEngine
TemplateProTTProCompiler.CompileAndRender, TTProConfiguration, ITProCompiledTemplate
TemplatePro.TypesTFilterParameter
MVCFramework.FiltersRedirectHTTPFilter, FileServerBundle, StaticFilesHTTPFilter, CompressionHTTPFilter, FileSessionHTTPFilter, SessionCookieFlags, TMVCAuthCookieOptions
MVCFramework.Filters.AuthenticationJWTCookieAuthenticationBundle, TMVCJWTCookieAuthenticationOptions
MVCFramework.Middleware.*The classic middleware of 3.4 projects (UseJWTCookieAuthentication, UseFileSessionMiddleware, TMVCStaticFilesMiddleware, …) — deprecated in 3.5
MVCFramework.JWTTJWT, TJWTClaimsSetup, TJWTCheckableClaim
MVCFramework.CommonsTMVCMediaType, AppPath, DMVCFRAMEWORK_VERSION, dotEnv, dotEnvConfigure
MVCFramework.ContainerIMVCServiceContainer, TRegistrationType
MVCFramework.DotEnvNewDotEnv (the builder)
LoggerPro.ConfigTLoggerProConfig.BuilderFromJSONFile

19. Sample Reference Project

In the DelphiMVCFramework repository (https://github.com/danieleteti/delphimvcframework/tree/master/samples):

SampleWhat it shows
samples/htmx_website_with_templatepro/TemplatePro + HTMX website: baselayout with blocks, partials, custom filters. Its ispage flag predates the wizard's page/table pattern and ignores hx-boost — follow the wizard
samples/serversideviews_templatepro/The view engine in depth: inheritance, filters, RenderViews, CSV/non-HTML views, dataset in ViewData
samples/wizard_showcase/web/Minimal-API flavour of a web app: .AsWeb, sessions, RenderView from lambda handlers

Or generate one with the IDE wizard (preset Web Application) — that is the layout this skill assumes.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.