Frappe_Claude_Skill_Package frappe-syntax-jinja
install
source · Clone the upstream repo
git clone https://github.com/OpenAEC-Foundation/Frappe_Claude_Skill_Package
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/OpenAEC-Foundation/Frappe_Claude_Skill_Package "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/source/syntax/frappe-syntax-jinja" ~/.claude/skills/openaec-foundation-frappe-claude-skill-package-frappe-syntax-jinja && rm -rf "$T"
manifest:
skills/source/syntax/frappe-syntax-jinja/SKILL.mdsource content
Frappe Jinja Templates Syntax
Deterministic Jinja reference for Print Formats, Email Templates, Notification Templates, and Portal Pages in Frappe v14/v15/v16.
When to Use This Skill
USE when:
- Creating or modifying Print Formats (Jinja-based)
- Writing Email Templates with dynamic fields
- Building Portal Pages (
) with Python controllerswww/*.html - Writing Notification Templates (system/email/SMS)
- Registering custom Jinja methods or filters via
hooks.py
DO NOT USE for:
- Report Print Formats — they use JavaScript templating (
), NOT Jinja{%= %} - Client Scripts — see
frappe-syntax-clientscripts - Server Scripts — see
frappe-syntax-serverscripts
Decision Tree: Which Template Type?
Need 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
Quick Reference: Jinja Syntax
| Syntax | Purpose | Example |
|---|---|---|
| Output expression | |
| Control statement | |
| Comment | |
| Translation | |
| Filter | |
CRITICAL: Jinja vs JS Template Syntax
| Aspect | Jinja (Print Formats) | JS Template (Report Print Formats) |
|---|---|---|
| Output | | |
| Code block | | |
| Language | Python | JavaScript |
| Context | , | , |
NEVER use Jinja syntax in Report Print Formats. NEVER use
in standard Print Formats.{%= %}
Context Objects by Template Type
Print Formats
| Object | Description |
|---|---|
| The document being printed (full Document object) |
| Frappe module (whitelisted methods only) |
| Utility functions |
| Translation function |
, | Child table accessors (by fieldname) |
Email Templates
| Object | Description |
|---|---|
| The linked document (when triggered from a DocType) |
| Frappe module (limited) |
| Translation function |
Notification Templates
| Object | Description |
|---|---|
| The document that triggered the notification |
| Frappe module |
| Translation function |
Portal Pages (www/*.html)
| Object | Description |
|---|---|
| Frappe module |
| Current authenticated user |
| Query parameters from URL |
| Current language code |
| Custom context | Set via in controller |
Full details:
references/context-objects.md
Essential Methods (Whitelisted in Jinja)
Formatting: ALWAYS Use for Display
{# 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) }}
Document Retrieval
{# 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"]) %}
Translation: REQUIRED for All User-Facing Strings
<h1>{{ _("Invoice") }}</h1> <p>{{ _("Total: {0}").format(doc.get_formatted("grand_total")) }}</p>
System & Session
{{ 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
Control Structures
Conditionals
{% if doc.status == "Paid" %} <span class="paid">{{ _("Paid") }}</span> {% elif doc.status == "Overdue" %} <span class="overdue">{{ _("Overdue") }}</span> {% else %} <span>{{ doc.status }}</span> {% endif %}
Loops with Child Tables
{% 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 %}
Loop Variables
| Variable | Description |
|---|---|
| 1-indexed position |
| 0-indexed position |
| on first iteration |
| on last iteration |
| Total number of items |
Variables
{% set total = 0 %} {% set name = doc.customer_name | default("Unknown") %}
Filters
| Filter | Example | Notes |
|---|---|---|
| | ALWAYS use for optional fields |
| | Count items |
| | Join list to string |
| | Truncate with ellipsis |
| | HTML-escape (default behavior) |
| | Render raw HTML — NEVER for user input |
| | Round number |
/ | | Case conversion |
Full filter reference:
references/filters-reference.md
Custom Jinja Methods & Filters via hooks.py
hooks.py Registration
# hooks.py jenv = { "methods": [ "myapp.jinja.methods" # Module with callable functions ], "filters": [ "myapp.jinja.filters" # Module with filter functions ] }
Custom Method
# 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">
Custom Filter
# 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
Print Format Patterns
Minimal Print Format Template
<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>
Page Breaks
/* v14/v15 (wkhtmltopdf) */ .page-break { page-break-before: always; } /* v16 (Chrome PDF) — ALWAYS prefer break-* in v16 */ .page-break { break-before: page; }
Full examples:
| Patterns:references/examples.mdreferences/patterns.md
V16: Chrome PDF Rendering
| Aspect | v14/v15 (wkhtmltopdf) | v16 (Chrome) |
|---|---|---|
| CSS Support | Limited CSS3 | Full modern CSS |
| Flexbox/Grid | Partial | Full support |
| Page breaks | | preferred |
| Fonts | System fonts only | Web fonts supported |
V16 Configuration
// site_config.json { "pdf_engine": "chrome", "chrome_path": "/usr/bin/chromium" }
Portal Page Pattern
www/projects/index.html
{% 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 %}
www/projects/index.py
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 context
Full structure:
| Templates:references/structure.mdreferences/templates.md
Critical Rules
ALWAYS
- Use
for ALL user-facing strings_() - Use
for currency, date, and numeric fieldsget_formatted() - Use
filter for optional/nullable fieldsdefault() - Pass parent
to child rowdocget_formatted("field", doc) - Use
when you need only one fieldfrappe.db.get_value() - Keep calculations in Python controllers, not Jinja templates
NEVER
- Execute database queries inside loops (N+1 problem)
- Use
on user-supplied input (XSS vulnerability)| safe - Use Jinja syntax in Report Print Formats (they require JS
){%= %} - Use
whenfrappe.get_doc()
sufficesfrappe.db.get_value() - Hardcode strings without
translation wrapper_() - Disable
without security reviewsafe_render
Anti-patterns with fixes:
references/anti-patterns.md
Reference Files
| File | Contents |
|---|---|
| Jinja syntax reference (tags, filters, tests, loops) |
| Custom Jinja methods/filters via hooks |
| Available objects per template type |
| All standard and custom Frappe filters |
| All frappe.* methods available in Jinja |
| Complete Print Format, Email, Portal examples |
| Common mistakes and correct alternatives |
| Template structure patterns |
| Conditional rendering, loops, child tables |
| File structure for template types |
See Also
— jenv configuration in hooks.pyfrappe-syntax-hooks
— Print Format implementation patternsfrappe-impl-printformat
— Server-side error handlingfrappe-errors-serverscripts