Current resolution is confusing mess: 1. Evaluation is done in order of installed app, first install wins 2. Except frappe, frappe is treated as lowest priority. Following same principle of "last write wins" everywhere similar to previous PRs: - https://github.com/frappe/frappe/pull/17869 - https://github.com/frappe/frappe/pull/20648 - https://github.com/frappe/frappe/pull/19653 Closes https://github.com/frappe/frappe/issues/20377
315 lines
9.5 KiB
Python
315 lines
9.5 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 self.basepath:
|
|
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.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 = """{{% extends "{0}" %}}
|
|
{{% block page_content %}}{1}{{% endblock %}}""".format(
|
|
context.base_template, self.source
|
|
)
|
|
|
|
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
|
|
|
|
html = frappe.render_template(self.source, 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")
|