Sign in Join

Live chat for Free Pascal, Lazarus, Delphi and every Pascal programmer.

Catalog · How the catalog works

Random project

ginger

Modern Jinja2 template engine for FPC. Seamless integration with mORMot 2 & Brook frameworks for high-speed HTML rendering.

View on GitHub

Anyone may press Interesting, signed in or not. It helps others find this project.

Member rating

No ratings yet.

Sign in to rate this project.

README

Ginger

Ginger is a template engine for Free Pascal / Lazarus that implements Jinja2's template syntax — the same {{ }}, {% if %}, {% for %}, filters, macros, and template inheritance that Python/Flask/Django developers already know — as a native FPC library, with no Python runtime involved.

It exists so an FPC web application can render HTML (or any other text format) the same way a modern Python or Node app does: designers and frontend developers write .html templates in a syntax that is widely documented and tool-supported, and the backend renders them through a fast, native, memory-checked library.

Status

Pre-1.0, unpublished, no external users yet. The engine itself is solid: 648 automated tests, 0 unfreed memory blocks on every run, and every feature is implemented against a ground-truthed reading of real Jinja2's own source (not guessed from documentation) — see docs/adr/ for the full, numbered design history, including every accepted gap versus upstream Jinja2. Licensed BSD-3-Clause (see LICENSE).

Feature parity target: Jinja2 3.1.6 specifically, not "whatever Jinja2 does" in the abstract. Every ADR's ground-truthing was done against a real, installed jinja2==3.1.6, and every behavior this README or an ADR calls "matching upstream"/"ground-truthed" means matching that exact release — not a later or earlier Jinja2 version, and not the Jinja2 docs, which lag and occasionally disagree with the installed package's actual behavior. A future Jinja2 release changing some edge case does not retroactively make Ginger's 3.1.6-ground- truthed behavior wrong; revisiting the target version is a deliberate decision for a future ADR, not something that happens silently.

Why Jinja2 syntax, specifically

Jinja2 is the most widely used template language in the Python web world (Flask's default, and Django's own syntax is a close cousin). Implementing that syntax in Pascal — rather than inventing a new template language — means:

  • Existing .html/.txt/.xml templates, editor syntax highlighting, and documentation written for Jinja2 mostly apply as-is.
  • A team with any Python/Flask/Django background can write Ginger templates on day one.
  • Design decisions (operator precedence, autoescaping rules, whitespace control, how {% for %} handles a missing variable, and so on) have a known-correct reference implementation to check against, instead of being invented from scratch.

Ginger is not a Python binding and does not embed Python. It is a from-scratch Pascal implementation of the lexer, parser, and evaluator for that syntax.

101: getting started with no Python or Jinja background required

A template is a text file with ordinary content plus a small number of special markers:

  • {{ expression }} — prints a value.
  • {% statement %} — control flow: conditionals, loops, variable assignment, reusable blocks.
  • {# comment #} — removed from the output entirely.

That's the whole syntax surface for everyday use. A minimal template:

pascal
Hello, {{ name }}!

{% if items %}
<ul>
{% for item in items %}
  <li>{{ item }}</li>
{% endfor %}
</ul>
{% else %}
<p>No items.</p>
{% endif %}

Rendering it from Pascal

Ginger's own package unit is GingerLib (src/GingerLib.lpk); add it as a dependency of your project (or add its individual units, listed below, to your project/package manually). The units you'll touch directly for basic rendering are GingerLoader, GingerContext, and GingerValue.

pascal
program HelloGinger;

{$mode objfpc}{$H+}

uses
  SysUtils, GingerLoader, GingerContext, GingerValue;

var
  Loader: TGingerDictLoader;
  Env: TGingerEnvironment;
  Ctx: TGingerContext;
  Items: TGingerValueArray;
begin
  // A TGingerDictLoader keeps templates in memory by name -- handy
  // for embedding templates in your binary or for quick experiments.
  // For templates that live as files on disk, use
  // TGingerFileSystemLoader(BaseDir) instead; both expose the same
  // Load method shape, so either can be assigned to Env.Loader.
  Loader := TGingerDictLoader.Create;
  Loader.SetTemplate('hello.html',
    'Hello, {{ name }}!' + LineEnding +
    '{% for item in items %}- {{ item }}' + LineEnding + '{% endfor %}');

  Env := TGingerEnvironment.Create;
  Env.Loader := @Loader.Load;
  Env.DefaultAutoescape := True; // escape HTML-unsafe characters by default

  Ctx := TGingerContext.Create;
  try
    Ctx.SetVar('name', GingerStr('World'));

    Items := nil;
    SetLength(Items, 2);
    Items[0] := GingerStr('first');
    Items[1] := GingerStr('second');
    Ctx.SetVar('items', GingerList(Items));

    WriteLn(Env.RenderTemplate('hello.html', Ctx));
  finally
    Ctx.Free;
    Env.Free;
    Loader.Free;
  end;
end.

That's the entire integration surface for a basic use case: a loader to find template source by name, an environment to parse/cache templates and drive the render, a context to hold the variables a template sees, and GingerValue's Ginger* constructors (GingerStr, GingerInt, GingerFloat, GingerBool, GingerList, GingerEmptyDict/GingerDictSet, GingerNone) to build those variables' values.

What's supported

Expressions (arithmetic, comparisons, and/or/not, string concatenation with ~, attribute/index access, ternary a if b else c), the standard control-flow tags (if/elif/else, for/else, set, both inline and block forms, with, autoescape), template composition (extends/block/super(), include, import/from import), macros (including the catch-all varargs/kwargs parameters and *args/**kwargs unpacking at any call site — a plain call, a |filter(...), or an is test(...)), tuple-target {% set a, b = ... %}/{% for a, b in ... %} (a string or dict RHS unpacks per-character/per-key, same as a list), plain {% print %}, a library of built-in filters and tests, globals including range/dict/cycler/joiner/ namespace/lipsum, {% raw %}, and whitespace control (-/+ markers, trim_blocks/lstrip_blocks). String literals support \n/\t/\r/\a/\b/\f/\v, \'/\"/\\, 1-3-digit \ooo octal escapes, and \xHH/\uXXXX/\UXXXXXXXX Unicode escapes; any other \ is kept literally (\q stays \q, matching upstream).

Every accepted gap versus real Jinja2 — what's deliberately not implemented yet, and why — is listed in the "Consequences" section of the relevant ADR under docs/adr/; nothing is silently missing without a documented reason.

Deliberate divergences (opt-in)

A handful of behaviors depart from upstream Jinja2 on purpose, gated behind an explicit opt-in so a template that doesn't ask for them behaves exactly like real Jinja:

  • Ctx.TitlecaseWords — the title filter uses true Unicode titlecasing instead of a plain uppercase-first rule, only when set. This also covers the Latin digraphs used in Serbian/Croatian orthography (dž/lj/nj) and Czech/Slovak dz, each mapped to its own distinct titlecase codepoint rather than plain uppercase — no separate locale needed, since these are specific precomposed codepoints, not a language-wide rule.
  • Ctx.Locale = 'tr' / 'az' — Turkish/Azeri "dotted/dotless I" casing exceptions (plain ASCII I/i map differently than Unicode's locale-independent default).
  • Ctx.Locale = 'lt' — Lithuanian casing exceptions (dot-above preservation/removal around accented letters).
  • Ctx.Locale = 'nl' — Dutch "ij" digraph capitalization rules for title/capitalize.

See the relevant ADRs under docs/adr/ (ADR-0027 through ADR-0031) for the ground-truthed details of each.

Custom filters and tests

GingerFilters exports the same registration calls the built-ins themselves are wired up through, so a user-defined filter or is test is a first-class citizen, not a workaround:

pascal
uses GingerValue, GingerFilters;

function FilterShout(const Value: TGingerValue;
  const Args: TGingerValueArray; const KwNames: array of string;
  const KwValues: TGingerValueArray): TGingerValue;
begin
  Result := GingerStr(UpperCase(GingerToDisplayString(Value)) + '!!!');
end;

function TestIsTheAnswer(const Value: TGingerValue;
  const Args: TGingerValueArray): Boolean;
begin
  Result := GingerToDisplayString(Value) = '42';
end;

GingerRegisterFilter('shout', @FilterShout);
GingerRegisterTest('theanswer', @TestIsTheAnswer);

Once registered, {{ "hello" | shout }} and {% if x is theanswer %} work exactly like a built-in. A filter that needs to know whether autoescape is on (or the active locale/titlecasing setting) registers instead as a TGingerEvalCtxFilterFunc via GingerRegisterEvalCtxFilter — see upper/lower/join in GingerFilters.pas for worked examples of that signature.

Registration through GingerRegisterFilter/GingerRegisterEvalCtxFilter/ GingerRegisterTest is global and process-wide, not scoped to a single Environment — call one of these once at startup, and it applies to every Environment in that process, including ones created later. For two Environments with two different filter/test sets, see Env.RegisterFilter/RegisterEvalCtxFilter/RegisterTest below.

Per-Environment filters and tests

Real Jinja2's env.filters/env.tests are plain dicts scoped to one Environment — two Environments in the same process can register different filters, even though GingerRegisterFilter above cannot. TGingerEnvironment has the matching per-instance calls:

pascal
Env.RegisterFilter('shout', @FilterShout);
Env.RegisterEvalCtxFilter('loud', @EvalCtxFilterLoud);
Env.RegisterTest('theanswer', @TestIsTheAnswer);

These use the same TGingerFilterFunc/TGingerEvalCtxFilterFunc/ TGingerTestFunc types as the process-wide registration above. A filter or test registered on Env shadows a process-wide one of the same name (of either kind) for every render through that Env, including one reached via {% include %} or {% import %} — but two different Environments never see each other's registrations. Registering the same name twice on the same Env overwrites the earlier value, same as RegisterGlobal.

|map('name')/|select(...)/|reject(...)/|selectattr(...)/ |rejectattr(...)'s by-name filter/test dispatch also sees Environment-registered filters/tests, same precedence as above (see ADR-0053).

Custom globals

This is Ginger's equivalent of real Jinja2's Environment.globals — a value (or callable) available in every template rendered through a given TGingerEnvironment, without passing it as render data each time:

pascal
Env.RegisterGlobal('appName', GingerStr('Ginger'));
Env.RegisterGlobal('now', GingerCallable(@CurrentTimestamp));

{{ appName }} and {{ now() }} then work in any template rendered through Env, including one reached via {% include %} or {% import %}. Unlike GingerRegisterFilter/GingerRegisterTest above, this is scoped to the one Environment — two Environments in the same process can register different globals — because TGingerEnvironment already owns exactly this kind of per-instance configuration (TrimBlocks, DefaultAutoescape, …).

Two precedence rules, both matching real Jinja:

  • A plain context variable of the same name always wins over a registered global (Ctx.SetVar('appName', ...), or data passed into render, beats Env.RegisterGlobal('appName', ...)).
  • A registered global can shadow one of the six hardcoded names (range/dict/cycler/joiner/namespace/lipsum) — real Jinja's env.globals is a plain dict those six live in too, and reassigning one of its keys overrides it the same way.

Registering the same name twice overwrites the earlier value, matching a dict's own semantics — there is no separate "unregister" call.

Integrating with a web framework: mORMot2 / Brook

Ginger renders a template to a string. It has no dependency on any particular web framework, HTTP stack, or I/O model — which means wiring it into mORMot2 or the Brook Framework is the same small amount of glue either way: build a TGingerContext from the request/route data, call Env.RenderTemplate, and write the result to whatever response object that framework gives you.

No ready-made adapter package exists for either framework yet — this is a conceptual sketch of the integration shape using Ginger's real, existing public API, not a claim that GingerMormot/GingerBrook units ship today.

mORMot2 sketch

A mORMot2 HTTP server method typically ends by setting a response body string. A Ginger-backed handler would look like:

pascal
function TMyServer.RenderHello(Ctx: THttpServerRequest): Cardinal;
var
  TplCtx: TGingerContext;
begin
  TplCtx := TGingerContext.Create;
  try
    TplCtx.SetVar('name', GingerStr(Ctx.InURI)); // whatever your route data is
    Ctx.OutContent := GingerEnv.RenderTemplate('hello.html', TplCtx);
    Ctx.OutContentType := 'text/html; charset=utf-8';
    Result := 200;
  finally
    TplCtx.Free;
  end;
end;

GingerEnv here is a single, long-lived TGingerEnvironment created once at server startup (its template cache is why: creating it per request would reparse every template on every hit).

Brook Framework sketch

Brook's request handlers typically write directly to a response stream/writer. The same pattern applies — build a context, render, write the string:

pascal
procedure TMyHelloAction.Get;
var
  TplCtx: TGingerContext;
begin
  TplCtx := TGingerContext.Create;
  try
    TplCtx.SetVar('name', GingerStr(Request.Params.Values['name']));
    Response.ContentType := 'text/html; charset=utf-8';
    Response.Write(GingerEnv.RenderTemplate('hello.html', TplCtx));
  finally
    TplCtx.Free;
  end;
end;

In both cases, the integration point is exactly one call — Env.RenderTemplate(Name, Ctx) — so adapting Ginger to a framework you already use is mostly a question of where that framework wants you to read inbound request data from, and where it wants the rendered string written to. If there's real appetite for a ready-made adapter unit for either framework, that's a natural follow-up contribution — see docs/adr/ for the project's design process if you're considering one.

Authorship / provenance

Most of this codebase — lexer, parser, AST, evaluator, renderer, filters, the test suite, and every ADR under docs/adr/ — was written by Claude (Anthropic) as my coding agent. I set scope, made the design decisions it surfaced, reviewed and ground-truthed behavior against real Jinja2, and decided what shipped. Not hand-written with AI polish; substantially AI-generated under continuous human direction and review. docs/adr/ is the dated record of that process.

Project layout

  • src/ — the engine itself, one Pascal unit per pipeline stage (lexer → parser → AST → evaluator → renderer), plus GingerLoader (template sources) and GingerLib.lpk (the installable package).
  • tests/ — the full automated test suite.
  • docs/adr/ — Architecture Decision Records: every non-trivial design choice, ground-truthed against real Jinja2 where applicable, numbered and dated.