seitime-frappe/frappe/website/page_renderers/template_page.py
Ankush Menat e768f679ff perf: Speedup rendering of simple templates
Also, slowdown rendering of complex ones. Nothing comes free.
2025-01-14 18:11:27 +05:30

316 lines
9.7 KiB
Python

import os
from importlib.machinery import all_suffixes
import click
import frappe
from frappe.website.page_renderers.base_template_page import BaseTemplatePage
from frappe.website.router import get_base_template, get_page_info
from frappe.website.utils import (
cache_html,
extract_comment_tag,
extract_title,
get_frontmatter,
get_next_link,
get_sidebar_items,
get_toc,
is_binary_file,
)
PY_LOADER_SUFFIXES = tuple(all_suffixes())
WEBPAGE_PY_MODULE_PROPERTIES = (
"base_template_path",
"template",
"no_cache",
"sitemap",
"condition_field",
)
COMMENT_PROPERTY_KEY_VALUE_MAP = {
"no-breadcrumbs": ("no_breadcrumbs", 1),
"show-sidebar": ("show_sidebar", 1),
"add-breadcrumbs": ("add_breadcrumbs", 1),
"no-header": ("no_header", 1),
"add-next-prev-links": ("add_next_prev_links", 1),
"no-cache": ("no_cache", 1),
"no-sitemap": ("sitemap", 0),
"sitemap": ("sitemap", 1),
}
class TemplatePage(BaseTemplatePage):
def __init__(self, path, http_status_code=None):
super().__init__(path=path, http_status_code=http_status_code)
self.set_template_path()
def set_template_path(self):
"""
Searches for file matching the path in the /www
and /templates/pages folders and sets path if match is found
"""
folders = get_start_folders()
for app in reversed(frappe.get_installed_apps()):
app_path = frappe.get_app_path(app)
for dirname in folders:
search_path = os.path.join(app_path, dirname, self.path)
for file_path in self.get_index_path_options(search_path):
if os.path.isfile(file_path) and not is_binary_file(file_path):
self.app = app
self.app_path = app_path
self.file_dir = dirname
self.basename = os.path.splitext(file_path)[0]
self.template_path = os.path.relpath(file_path, self.app_path)
self.basepath = os.path.dirname(file_path)
self.filename = os.path.basename(file_path)
self.name = os.path.splitext(self.filename)[0]
return
def can_render(self):
return (
hasattr(self, "template_path")
and self.template_path
and not self.template_path.endswith(PY_LOADER_SUFFIXES)
)
@staticmethod
def get_index_path_options(search_path):
return (
frappe.as_unicode(f"{search_path}{d}") for d in ("", ".html", ".md", "/index.html", "/index.md")
)
def render(self):
html = self.get_html()
html = self.add_csrf_token(html)
return self.build_response(html)
@cache_html
def get_html(self):
# context object should be separate from self for security
# because it will be accessed via the user defined template
self.init_context()
self.set_pymodule()
self.update_context()
self.setup_template_source()
self.load_colocated_files()
self.set_properties_from_source()
self.post_process_context()
html = self.render_template()
html = self.update_toc(html)
return html
def post_process_context(self):
self.set_user_info()
self.add_sidebar_and_breadcrumbs()
super().post_process_context()
def add_sidebar_and_breadcrumbs(self):
if not self.context.sidebar_items:
self.context.sidebar_items = get_sidebar_items(self.context.website_sidebar, self.basepath)
if self.context.add_breadcrumbs and not self.context.parents:
parent_path = os.path.dirname(self.path)
if self.path.endswith("index"):
# in case of index page move one directory up for parent path
parent_path = os.path.dirname(parent_path)
for parent_file_path in self.get_index_path_options(parent_path):
parent_file_path = os.path.join(self.app_path, self.file_dir, parent_file_path)
if os.path.isfile(parent_file_path):
parent_page_context = get_page_info(parent_file_path, self.app, self.file_dir)
if parent_page_context:
self.context.parents = [
dict(route=os.path.dirname(self.path), title=parent_page_context.title)
]
break
def set_pymodule(self):
"""
A template may have a python module with a `get_context` method along with it in the
same folder. Also the hyphens will be coverted to underscore for python module names.
This method sets the pymodule_name if it exists.
"""
template_basepath = os.path.splitext(self.template_path)[0]
self.pymodule_name = None
# replace - with _ in the internal modules names
self.pymodule_path = os.path.join(
os.path.dirname(template_basepath),
os.path.basename(template_basepath.replace("-", "_")) + ".py",
)
if os.path.exists(os.path.join(self.app_path, self.pymodule_path)):
self.pymodule_name = self.app + "." + self.pymodule_path.replace(os.path.sep, ".")[:-3]
def setup_template_source(self):
"""Setup template source, frontmatter and markdown conversion"""
self.original_source = self.source = self.get_raw_template()
self.extract_frontmatter()
self.convert_from_markdown()
def update_context(self):
self.set_page_properties()
self.context.build_version = frappe.utils.get_build_version()
if self.pymodule_name:
self.pymodule = frappe.get_module(self.pymodule_name)
self.set_pymodule_properties()
data = self.run_pymodule_method("get_context")
# some methods may return a "context" object
if data:
self.context.update(data)
# TODO: self.context.children = self.run_pymodule_method('get_children')
self.context.developer_mode = frappe.conf.developer_mode
if self.context.http_status_code:
self.http_status_code = self.context.http_status_code
def set_pymodule_properties(self):
for prop in WEBPAGE_PY_MODULE_PROPERTIES:
if hasattr(self.pymodule, prop):
self.context[prop] = getattr(self.pymodule, prop)
def set_page_properties(self):
self.context.base_template = self.context.base_template or get_base_template(self.path)
self.context.basepath = self.basepath
self.context.basename = self.basename
self.context.name = self.name
self.context.path = self.path
self.context.route = self.path
self.context.template = self.template_path
def set_properties_from_source(self):
if not self.source:
return
context = self.context
if not context.title:
context.title = extract_title(self.source, self.path)
base_template = extract_comment_tag(self.source, "base_template")
if base_template:
context.base_template = base_template
if (
context.base_template
and "{%- extends" not in self.source
and "{% extends" not in self.source
and "</body>" not in self.source
):
self.source = f"""{{% extends "{context.base_template}" %}}
{{% block page_content %}}{self.source}{{% endblock %}}"""
self.set_properties_via_comments()
def set_properties_via_comments(self):
for comment, (context_key, value) in COMMENT_PROPERTY_KEY_VALUE_MAP.items():
comment_tag = f"<!-- {comment} -->"
if comment_tag in self.source:
self.context[context_key] = value
click.echo(f"\n⚠️ DEPRECATION WARNING: {comment_tag} will be deprecated on 2021-12-31.")
click.echo(f"Please remove it from {self.template_path} in {self.app}")
def run_pymodule_method(self, method_name):
if hasattr(self.pymodule, method_name):
import inspect
method = getattr(self.pymodule, method_name)
if inspect.getfullargspec(method).args:
return method(self.context)
else:
return method()
def render_template(self):
if self.template_path.endswith("min.js"):
html = self.source # static
else:
if self.context.safe_render is not None:
safe_render = self.context.safe_render
else:
safe_render = True
src_modified = self.source is not self.original_source
html = frappe.render_template(
self.source if src_modified else self.context.template, self.context, safe_render=safe_render
)
return html
def extends_template(self):
return self.template_path.endswith((".html", ".md")) and (
"{%- extends" in self.source or "{% extends" in self.source
)
def get_raw_template(self):
return frappe.get_jloader().get_source(frappe.get_jenv(), self.context.template)[0]
def load_colocated_files(self):
"""load co-located css/js files with the same name"""
js_path = self.basename + ".js"
if os.path.exists(js_path) and "{% block script %}" not in self.source:
self.context.colocated_js = self.get_colocated_file(js_path)
css_path = self.basename + ".css"
if os.path.exists(css_path) and "{% block style %}" not in self.source:
self.context.colocated_css = self.get_colocated_file(css_path)
def get_colocated_file(self, path):
with open(path, encoding="utf-8") as f:
return f.read()
def extract_frontmatter(self):
if not self.template_path.endswith((".md", ".html")):
return
try:
# values will be used to update self
res = get_frontmatter(self.source)
if res["attributes"]:
self.context.update(res["attributes"])
self.source = res["body"]
except Exception:
pass
def convert_from_markdown(self):
if self.template_path.endswith(".md"):
self.source = frappe.utils.md_to_html(self.source)
self.context.page_toc_html = self.source.toc_html
if not self.context.show_sidebar:
self.source = '<div class="from-markdown">' + self.source + "</div>"
def update_toc(self, html):
if "{index}" in html:
html = html.replace("{index}", get_toc(self.path))
if "{next}" in html:
html = html.replace("{next}", get_next_link(self.path))
return html
def set_standard_path(self, path):
self.app = "frappe"
self.app_path = frappe.get_app_path("frappe")
self.path = path
self.template_path = f"www/{path}.html"
def set_missing_values(self):
super().set_missing_values()
# for backward compatibility
self.context.docs_base_url = "/docs"
def set_user_info(self):
from frappe.utils.user import get_fullname_and_avatar
info = get_fullname_and_avatar(frappe.session.user)
self.context["fullname"] = info.fullname
self.context["user_image"] = info.avatar
self.context["user"] = info.name
def get_start_folders():
return frappe.local.flags.web_pages_folders or ("www", "templates/pages")