r"""
:mod:`taika.ext.layouts` -- Jinja layouts
=========================================
This extension renders documents content trought a the Jinja2 templating engine. It also
renders the content of documents itself if any Jinja block/comment/var is detected.
Event
-----
This extension is subscribed to the :data:`site-post-read` event.
Payload
-------
When the content and the templates are rendered, certain payload is passed and becomes
accessible by both content and templates. This payload has two main keys: ``site`` and
``document``.
Using :code:`document` you can access the document attributes being processed, such as the
:code:`path`, :code:`content`, etc. Check :doc:`/reference/documents` for details.
Inside ``site``, the :class:`taika.Taika` is accessible.
.. note::
Note that ``site.config`` returns a dictionary with all the sections included, so to access
which extensions are listed you should use ``site.config.taika.extensions``. This is a long
"import-like" statement, and probably we will shrink it in the future.
Frontmatter
-----------
.. data:: layout (str)
The layout that should render the document. Should exist under the :data:`layouts_path`. If None
the documents is not passed throught the template, but its body is still rendered.
Configuration
-------------
.. note::
All configuration hangs from a key in the YML configuration named 'layouts'.
Inside it, you can add the following options:
.. data:: path (list)
Default: **[./templates/]**
A list of paths from where the layouts will be loaded.
.. data:: options (dict)
Default: **{}** (empty-dict)
A dictionary (key-value) of options to pass to the Jinja environment when created.
.. data:: default (str)
Default: **index.html**
The default layout if the document has no :code:`layout` defined.
.. data:: patterns (str)
Default: **["*"]**
A list of patterns to match Which files should be renderered. Default to all the files.
Default filters
---------------
.. data:: link
Link against other documents inside your site using
:code:`{{ '/posts/2019/my-other-post.md' | link }}`. Relative links not supported, only
absolute paths will be accepted.
Process
-------
#. (pre-registering) The :class:`JinjaRenderer` is initialized with the configuration. The Jinja
environment is created and the templates loaded.
#. Checks if the path of the document matches :data:`layouts_pattern`, if not, skips it.
#. Composes the layout using the document itself, so the document metadata is available directly.
#. If the content has any Jinja flag, it is renderered, so you can include Jinja syntax into the
document text.
#. Then the content (rendered or not) is rendered throught the template :data:`layout`.
#. The document's content is modified.
#. Done!
Classes and Functions
---------------------
"""
import copy
import fnmatch
import logging
import pathlib
import sys
import urllib
from .. import utils
try:
import jinja2
except ImportError:
print("This extension needs Jinja2, please install the jinja2 module.")
sys.exit(1)
LAYOUTS = {"path": ["./templates/"], "default": "index.html", "options": {}, "patterns": ["*"]}
LOGGER = logging.getLogger(__name__)
[docs]class DocumentNotFound(Exception):
pass
[docs]class RelativeLinkNotSupported(Exception):
pass
[docs]class JinjaRenderer(object):
"""This class holds the Jinja2 environment, removing the need to create it each time.
Attributes
----------
env : :class:`jinja2.Environment`
The configured Jinja environment.
layouts_patterns : str
The list of patterns which will be used to decide if the document should be processed.
layouts_default : str
The option so the :meth:`JinjaRenderer.render_content` can access it.
"""
def __init__(self, config):
user_config = config.get("layouts", {})
self.config = utils.merge(user_config, copy.deepcopy(LAYOUTS))
layouts_path = self.config["path"]
layouts_options = self.config["options"]
loader = jinja2.FileSystemLoader(layouts_path)
self.env = jinja2.Environment(loader=loader, **layouts_options)
self.default = self.config["default"]
self.patterns = self.config["patterns"]
[docs] def render_content(self, site):
payload = {}
payload["site"] = site
for document in site.documents:
if not any(fnmatch.fnmatch(document["path"], patt) for patt in self.patterns):
continue
payload["document"] = document
content = document["content"]
if self._has_block(content) or self._has_comment(content) or self._has_var(content):
try:
content = self.env.from_string(content).render(**payload)
except Exception as err:
LOGGER.error(f"Error processing the document '{document['path']}': {err}.")
sys.exit(1)
document["pre_render_content"] = payload["document"]["content"] = content
template_name = document.get("layout", self.default)
if template_name is not None:
template = self.env.get_template(template_name)
content = template.render(**payload)
document["content"] = content
def _has_block(self, s):
return self.env.block_start_string in s and self.env.block_end_string in s
def _has_var(self, s):
return self.env.variable_start_string in s and self.env.variable_end_string in s
def _has_comment(self, s):
return self.env.comment_start_string in s and self.env.comment_end_string in s
[docs]@jinja2.contextfilter
def link(context, path):
path = str(pathlib.PurePosixPath(path))
if path.startswith("../") or "/../" in path or not path.startswith("/"):
raise RelativeLinkNotSupported(f"taika does not support relative links such as '{path}'.")
if path.startswith("/"):
path = path[1:]
for file in context["site"].documents:
LOGGER.debug(f"Checking '{path}' against '{str(pathlib.PurePosixPath(file['path']))}'")
if str(pathlib.PurePosixPath(file["path"])) == path:
return urllib.parse.urljoin("/", str(pathlib.PurePosixPath(file["url"])))
raise NameError(f"Link error: From '{context['document']['path']}', '{path}' not found.")
[docs]def setup(site):
renderer = JinjaRenderer(site.config)
renderer.env.filters.update({"link": link})
site.events.register("site-post-read", renderer.render_content)