frappe-syntax-jinja — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited frappe-syntax-jinja (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Deterministic Jinja reference for Print Formats, Email Templates, Notification Templates, and Portal Pages in Frappe v14/v15/v16.
USE when:
www/*.html) with Python controllershooks.pyDO NOT USE for:
{%= %}), NOT Jinjafrappe-syntax-clientscriptsfrappe-syntax-serverscriptsNeed a printable document?
├─ YES → Is it for a Query/Script Report?
│ ├─ YES → Use JS Template ({%= %}), NOT Jinja
│ └─ NO → Use Jinja Print Format
└─ NO → Is it for email?
├─ YES → Is it triggered by workflow/notification?
│ ├─ YES → Notification Template (Jinja)
│ └─ NO → Email Template (Jinja)
└─ NO → Is it a web page?
├─ YES → Portal Page (www/*.html + .py controller)
└─ NO → frappe.render_template() for ad-hoc rendering| Syntax | Purpose | Example | ||
|---|---|---|---|---|
{{ }} | Output expression | {{ doc.name }} | ||
{% %} | Control statement | {% if doc.status == "Paid" %} | ||
{# #} | Comment | {# This is a comment #} | ||
{{ _("text") }} | Translation | {{ _("Invoice") }} | ||
| `{{ val \ | filter }}` | Filter | `{{ name \ | default("N/A") }}` |
| Aspect | Jinja (Print Formats) | JS Template (Report Print Formats) |
|---|---|---|
| Output | {{ expression }} | {%= expression %} |
| Code block | {% statement %} | {% js_code %} |
| Language | Python | JavaScript |
| Context | doc, frappe | data, filters |
NEVER use Jinja syntax in Report Print Formats. NEVER use `{%= %}` in standard Print Formats.
| Object | Description |
|---|---|
doc | The document being printed (full Document object) |
frappe | Frappe module (whitelisted methods only) |
frappe.utils | Utility functions |
_() | Translation function |
doc.items, doc.taxes | Child table accessors (by fieldname) |
| Object | Description |
|---|---|
doc | The linked document (when triggered from a DocType) |
frappe | Frappe module (limited) |
_() | Translation function |
| Object | Description |
|---|---|
doc | The document that triggered the notification |
frappe | Frappe module |
_() | Translation function |
| Object | Description |
|---|---|
frappe | Frappe module |
frappe.session.user | Current authenticated user |
frappe.form_dict | Query parameters from URL |
frappe.lang | Current language code |
| Custom context | Set via get_context(context) in .py controller |
Full details: references/context-objects.md{# ALWAYS use get_formatted() for fields in Print Formats #}
{{ doc.get_formatted("posting_date") }}
{{ doc.get_formatted("grand_total") }}
{# Child table rows — ALWAYS pass parent doc for currency context #}
{% for row in doc.items %}
{{ row.get_formatted("rate", doc) }}
{{ row.get_formatted("amount", doc) }}
{% endfor %}
{# General formatting with explicit fieldtype #}
{{ frappe.format(value, {'fieldtype': 'Currency'}) }}
{{ frappe.format_date(doc.posting_date) }}{# Full document — use only when multiple fields needed #}
{% set customer = frappe.get_doc("Customer", doc.customer) %}
{# Single field — ALWAYS prefer over get_doc for one field #}
{% set abbr = frappe.db.get_value("Company", doc.company, "abbr") %}
{# List of records (no permission check) #}
{% set tasks = frappe.get_all("Task",
filters={"status": "Open"},
fields=["title", "due_date"],
order_by="due_date asc",
page_length=10) %}
{# List with permission check (portal pages) #}
{% set orders = frappe.get_list("Sales Order",
filters={"customer": doc.customer},
fields=["name", "grand_total"]) %}<h1>{{ _("Invoice") }}</h1>
<p>{{ _("Total: {0}").format(doc.get_formatted("grand_total")) }}</p>{{ frappe.get_url() }}
{{ frappe.get_fullname() }}
{{ frappe.get_fullname(doc.owner) }}
{{ frappe.db.get_single_value("System Settings", "time_zone") }}
{% if frappe.session.user != "Guest" %}...{% endif %}Full method reference: references/methods-reference.md{% if doc.status == "Paid" %}
<span class="paid">{{ _("Paid") }}</span>
{% elif doc.status == "Overdue" %}
<span class="overdue">{{ _("Overdue") }}</span>
{% else %}
<span>{{ doc.status }}</span>
{% endif %}{% for item in doc.items %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ item.item_name }}</td>
<td>{{ item.get_formatted("amount", doc) }}</td>
</tr>
{% else %}
<tr><td colspan="3">{{ _("No items") }}</td></tr>
{% endfor %}| Variable | Description |
|---|---|
loop.index | 1-indexed position |
loop.index0 | 0-indexed position |
loop.first | True on first iteration |
loop.last | True on last iteration |
loop.length | Total number of items |
{% set total = 0 %}
{% set name = doc.customer_name | default("Unknown") %}| Filter | Example | Notes | |
|---|---|---|---|
default | `{{ val \ | default("N/A") }}` | ALWAYS use for optional fields |
length | `{{ items \ | length }}` | Count items |
join | `{{ names \ | join(", ") }}` | Join list to string |
truncate | `{{ text \ | truncate(100) }}` | Truncate with ellipsis |
escape | `{{ input \ | escape }}` | HTML-escape (default behavior) |
safe | `{{ html \ | safe }}` | Render raw HTML — NEVER for user input |
round | `{{ num \ | round(2) }}` | Round number |
lower / upper | `{{ text \ | upper }}` | Case conversion |
Full filter reference: references/filters-reference.md# hooks.py
jenv = {
"methods": [
"myapp.jinja.methods" # Module with callable functions
],
"filters": [
"myapp.jinja.filters" # Module with filter functions
]
}# myapp/jinja/methods.py
import frappe
def get_company_logo(company):
"""Returns company logo URL. Called as get_company_logo() in Jinja."""
return frappe.db.get_value("Company", company, "company_logo") or ""<img src="{{ get_company_logo(doc.company) }}" alt="Logo"># myapp/jinja/filters.py
def nl2br(text):
"""Convert newlines to <br> tags. Used as {{ text | nl2br }}."""
return (text or "").replace("\n", "<br>"){{ doc.notes | nl2br | safe }}Details: references/methods.md<style>
.print-header { background: #f5f5f5; padding: 15px; }
.item-table { width: 100%; border-collapse: collapse; }
.item-table th, .item-table td { border: 1px solid #ddd; padding: 8px; }
.text-right { text-align: right; }
</style>
<div class="print-header">
<h1>{{ doc.select_print_heading or _("Invoice") }}</h1>
<p>{{ doc.name }} — {{ doc.get_formatted("posting_date") }}</p>
</div>
<table class="item-table">
<thead>
<tr>
<th>#</th>
<th>{{ _("Item") }}</th>
<th class="text-right">{{ _("Qty") }}</th>
<th class="text-right">{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ row.item_name }}</td>
<td class="text-right">{{ row.qty }}</td>
<td class="text-right">{{ row.get_formatted("amount", doc) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><strong>{{ _("Grand Total") }}: {{ doc.get_formatted("grand_total") }}</strong></p>/* v14/v15 (wkhtmltopdf) */
.page-break { page-break-before: always; }
/* v16 (Chrome PDF) — ALWAYS prefer break-* in v16 */
.page-break { break-before: page; }Full examples:references/examples.md| Patterns:references/patterns.md
| Aspect | v14/v15 (wkhtmltopdf) | v16 (Chrome) |
|---|---|---|
| CSS Support | Limited CSS3 | Full modern CSS |
| Flexbox/Grid | Partial | Full support |
| Page breaks | page-break-* | break-* preferred |
| Fonts | System fonts only | Web fonts supported |
// site_config.json
{
"pdf_engine": "chrome",
"chrome_path": "/usr/bin/chromium"
}{% extends "templates/web.html" %}
{% block title %}{{ _("Projects") }}{% endblock %}
{% block page_content %}
<h1>{{ _("Projects") }}</h1>
{% for project in projects %}
<h3>{{ project.title }}</h3>
<p>{{ project.description | default("") | truncate(150) }}</p>
{% else %}
<p>{{ _("No projects found.") }}</p>
{% endfor %}
{% endblock %}import frappe
def get_context(context):
context.title = "Projects"
context.no_cache = True
context.projects = frappe.get_all("Project",
filters={"is_public": 1},
fields=["name", "title", "description"],
order_by="creation desc")
return contextFull structure:references/structure.md| Templates:references/templates.md
_() for ALL user-facing stringsget_formatted() for currency, date, and numeric fieldsdefault() filter for optional/nullable fieldsdoc to child row get_formatted("field", doc)frappe.db.get_value() when you need only one field| safe on user-supplied input (XSS vulnerability){%= %})frappe.get_doc() when frappe.db.get_value() suffices_() translation wrappersafe_render without security reviewAnti-patterns with fixes: references/anti-patterns.md| File | Contents |
|---|---|
references/syntax.md | Jinja syntax reference (tags, filters, tests, loops) |
references/methods.md | Custom Jinja methods/filters via hooks |
references/context-objects.md | Available objects per template type |
references/filters-reference.md | All standard and custom Frappe filters |
references/methods-reference.md | All frappe.* methods available in Jinja |
references/examples.md | Complete Print Format, Email, Portal examples |
references/anti-patterns.md | Common mistakes and correct alternatives |
references/templates.md | Template structure patterns |
references/patterns.md | Conditional rendering, loops, child tables |
references/structure.md | File structure for template types |
frappe-syntax-hooks — jenv configuration in hooks.pyfrappe-impl-printformat — Print Format implementation patternsfrappe-errors-serverscripts — Server-side error handling~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.