frappe-impl-website — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited frappe-impl-website (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.
Step-by-step workflows for building websites, portals, and public-facing pages. For hooks syntax see frappe-impl-hooks. For Jinja templating see frappe-impl-jinja.
Version: v14/v15/v16 | Note: v15+ uses Bootstrap 5; v14 uses Bootstrap 4.
WHAT do you need?
├── Static content page (About, Terms) → Web Page DocType or www/ HTML
├── Data entry by external users → Web Form
├── List of records visible on website → has_web_view on DocType
├── Blog / news articles → Blog Post + Blog Category
├── Custom app with sidebar/toolbar → Custom Portal Page (www/)
└── Dynamic route with parameters → website_route_rules in hooks.pySee references/decision-tree.md for the complete decision tree.
Portal pages live in your app's www/ directory. The file name becomes the URL route.
myapp/www/custom_page.html:{% extends "templates/web.html" %}
{% block page_content %}
<h1>{{ title }}</h1>
<div>{{ content }}</div>
{% endblock %}myapp/www/custom_page.py:import frappe
def get_context(context):
context.title = "My Custom Page"
context.content = "Hello World"
context.no_cache = 1 # ALWAYS set for dynamic content/custom_pageFile types auto-loaded: .html (template), .py (controller), .css (styles), .js (scripts).
Subdirectory pattern — for nested routes:
myapp/www/
├── services/
│ ├── index.html → /services
│ ├── index.py
│ ├── consulting.html → /services/consulting
│ └── consulting.py| Key | Type | Effect |
|---|---|---|
title | str | Page title and browser tab |
no_cache | bool | Disable page caching |
no_header | bool | Hide the page header |
no_breadcrumbs | bool | Remove breadcrumbs |
add_breadcrumbs | bool | Auto-generate from folder structure |
show_sidebar | bool | Display web sidebar |
sitemap | int | 0 = exclude from sitemap, 1 = include |
metatags | dict | SEO meta tags (see Workflow 7) |
Rule: ALWAYS set no_cache = 1 for pages with user-specific or frequently changing content.
Web Forms let external users submit data that creates Frappe documents.
fieldname to the target DocType field namesALLOWING guest submissions?
├── YES → Uncheck "Login Required"
│ → Set "Guest Title" for the submission form
│ → ALWAYS add rate limiting in site_config:
│ "rate_limit": {"web_form": "5/hour"}
│ → ALWAYS validate server-side (guests can bypass JS)
└── NO → Keep "Login Required" checked (default)frappe.web_form.on("after_load", function() {
// Runs after form loads in browser
});
frappe.web_form.on("before_submit", function() {
// Validate before submission — return false to cancel
let val = frappe.web_form.get_value("email");
if (!val) {
frappe.throw("Email is required");
return false;
}
});
frappe.web_form.on("after_submit", function() {
// Redirect or show message after success
window.location.href = "/thank-you";
});In the Web Form document, add a Python script:
def get_context(context):
# Add custom context variables for the template
context.categories = frappe.get_all("Category", fields=["name", "title"])Rule: NEVER trust client-side validation alone for Web Forms. ALWAYS validate in the target DocType's controller or server script.
This makes individual documents accessible as web pages (e.g., /articles/my-article).
articles)route (Data, hidden) — auto-generated URL slugpublished (Check) — controls visibility{doctype_name}.html — single record template{doctype_name}_row.html — list item templatehooks.py, register as website generator:website_generators = ["Article"]get_context:class Article(WebsiteGenerator):
website = frappe._dict(
template="templates/generators/article.html",
condition_field="published",
page_title_field="title",
)
def get_context(self, context):
context.related = frappe.get_all(
"Article",
filters={"published": 1, "name": ("!=", self.name)},
fields=["title", "route"],
limit=5,
)Rule: ALWAYS include a published check field. NEVER expose unpublished documents to guests.
Route rules map URL patterns to controllers or pages.
# hooks.py
website_route_rules = [
# Map parameterized URL to a page
{"from_route": "/projects/<name>", "to_route": "projects/project"},
# Map URL prefix to DocType
{"from_route": "/kb/<path:name>", "to_route": "knowledge-base"},
]
# Redirects (301/304)
website_redirects = [
{"source": "/old-page", "target": "/new-page"},
{"source": r"/docs(/.*)?", "target": r"https://docs.example.com\1"},
]
# Homepage for logged-in users (role-based)
role_home_page = {
"Customer": "orders",
"Supplier": "rfqs",
}
# Dynamic homepage
get_website_user_home_page = "myapp.utils.get_home_page"Priority order for homepage: get_website_user_home_page > role_home_page > Portal Settings > Website Settings.
/blog/{slug}Rule: ALWAYS set Published On date — posts without a date NEVER appear in RSS feeds.
# Inject CSS/JS on all web pages
website_context = {
"favicon": "/assets/myapp/images/favicon.png",
}
update_website_context = "myapp.overrides.website_context"
# Override base template
base_template = "myapp/templates/custom_base.html"def get_context(context):
context.metatags = {
"title": "My Page Title",
"description": "Page description for search engines",
"image": "/assets/myapp/images/og-image.png",
"og:type": "website",
"twitter:card": "summary_large_image",
}Set meta fields directly: Meta Title, Meta Description, Meta Image.
/sitemap.xml from published Web Pages and has_web_view documentssitemap = 0 in context or frontmatterrobots_txt path in site_config.jsonRule: ALWAYS set meta description on public pages. NEVER leave it empty — search engines penalize pages without descriptions.
# site_config.json — rate limiting
{
"rate_limit": {
"web_form": "5/hour",
"api": "100/hour"
},
"allowed_referrers": ["https://mysite.com"],
"allow_cors": "https://mysite.com"
}Security rules:
ignore_csrf in production| Anti-Pattern | Correct Approach |
|---|---|
Hard-coding HTML in get_context | Use Jinja templates with context variables |
Skipping no_cache on dynamic pages | ALWAYS set no_cache = 1 for user-specific content |
| Guest Web Form without rate limiting | ALWAYS configure rate limits for guest forms |
Missing published field on has_web_view | ALWAYS add published check to prevent data leaks |
Using website_route_rules for simple redirects | Use website_redirects instead |
| Putting business logic in www/ controllers | Keep in DocType controllers; www/ is for presentation |
See references/anti-patterns.md for expanded anti-patterns with examples.
frappe-impl-hooks — Website hooks in detailfrappe-impl-jinja — Jinja templating patternsfrappe-impl-controllers — DocType controllers (WebsiteGenerator)frappe-syntax-clientscripts — Client-side API for Web Formsreferences/generators.md — Portal generators, blog system, custom routing patternsreferences/workflows.md — Extended workflow walkthroughsreferences/examples.md — Complete code examplesreferences/decision-tree.md — Full decision tree for page types~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.