Skip to content

Commit

Permalink
Allow user-defined template engine configuration via conf.py.
Browse files Browse the repository at this point in the history
Also some improvements to make the template handling code
better and more readable; mostly type hints.
  • Loading branch information
aknrdureegaesr committed Dec 5, 2024
1 parent 172acaa commit 48ec10d
Show file tree
Hide file tree
Showing 13 changed files with 168 additions and 90 deletions.
3 changes: 3 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ Features

* Trace template usage when an environment variable ``NIKOLA_TEMPLATES_TRACE``
is set to any non-empty value.
* Give user control over the raw underlying template engine
(either `mako.lookup.TemplateLookup` or `jinja2.Environment`)
via an optional `conf.py` method `TEMPLATE_ENGINE_FACTORY`.

Bugfixes
--------
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,4 @@ Here are some guidelines about how you can contribute to Nikola:

.. [1] Very inspired by `fabric’s <https://github.com/fabric/fabric/blob/master/CONTRIBUTING.rst>`_ — thanks!
.. [2] For example, logging or always making sure directories are created using ``utils.makedirs()``.
.. [2] For example, logging, or always making sure directories are created using ``utils.makedirs()``.
36 changes: 33 additions & 3 deletions docs/theming.rst
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,17 @@ The following keys are currently supported:

The parent is so you don’t have to create a full theme each time: just
create an empty theme, set the parent, and add the bits you want modified.
It is strongly recommended you define a parent. If you don't, many features
won’t work due to missing templates, messages, and assets until your home-grown
template is complete.

While it is possible to create a theme without a parent, it is
**strongly discouraged** and not officially supported, in the sense:
We won't help with issues that are caused by a theme being parentless,
and we won't guarantee that it will always work with new Nikola versions.
The `base` and `base-jinja` themes provide assets, messages, and generic templates
that Nikola expects to be able to use in all sites. That said, if you are making
something very custom, Nikola will not prevent the creation of a theme
without `base`, but you will need to manually determine which templates and
messages are required in your theme. (Initially setting the ``NIKOLA_TEMPLATES_TRACE``
environment variable might be of some help, see below.)

The following settings are recommended:

Expand Down Expand Up @@ -484,6 +492,28 @@ at https://www.transifex.com/projects/p/nikola/
If you want to create a theme that has new strings, and you want those strings to be translatable,
then your theme will need a custom ``messages`` folder.

Configuration of the raw template engine
----------------------------------------

For usage not covered by the above, you can define a method
`TEMPLATE_ENGINE_FACTORY` in `conf.py` that constructs the raw
underlying templating engine. That `raw_engine` that your method
needs to return is either a `jinja2.Environment` or a
`mako.loopkup.TemplateLookup` object. Your factory method is
called with the same arguments as is the pertinent `__init__`.

E.g., to configure `jinja2` to bark and error out on missing values,
instead of silently continuing with empty content, you might do this:

.. code:: python
# Somewhere in conf.py:
def TEMPLATE_ENGINE_FACTORY(**args) -> jinja2.Environment:
augmented_args = dict(args)
augmented_args['undefined'] = jinja2.StrictUndefined
return jinja2.Environment(**augmented_args)
`LESS <http://lesscss.org/>`__ and `Sass <https://sass-lang.com/>`__
--------------------------------------------------------------------

Expand Down
3 changes: 2 additions & 1 deletion nikola/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@

# The current Nikola version:
__version__ = '8.3.1'
# A flag whether logging should emmit debug information:
# A flag whether logging should emit debug information:
DEBUG = bool(os.getenv('NIKOLA_DEBUG'))
# A flag whether special templates trace logging should be generated:
TEMPLATES_TRACE = bool(os.getenv('NIKOLA_TEMPLATES_TRACE'))
# When this flag is set, fewer exceptions are handled internally;
# instead they are left unhandled for the run time system to deal with them,
# which typically leads to the stack traces being exposed.
# This is a less noisy alternative to the NIKOLA_DEBUG flag.
SHOW_TRACEBACKS = bool(os.getenv('NIKOLA_SHOW_TRACEBACKS'))

if sys.version_info[0] == 2:
Expand Down
14 changes: 14 additions & 0 deletions nikola/conf.py.in
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,20 @@ WARN_ABOUT_TAG_METADATA = False
# those.
# TEMPLATE_FILTERS = {}

# If you want to, you can augment or change Nikola's configuration
# of the underlying template engine used
# in any way you please, by defining this function:
# def TEMPLATE_ENGINE_FACTORY(**args):
# pass
# This should return either a jinja2.Environment or a mako.lookup.TemplateLookup
# object that have been configured with the args received plus any additional configuration wanted.
#
# E.g., to configure Jinja2 to bark on non-existing values instead of silently omitting:
# def TEMPLATE_ENGINE_FACTORY(**args) -> jinja2.Environment:
# augmented_args = dict(args)
# augmented_args['undefined'] = jinja2.StrictUndefined
# return jinja2.Environment(**augmented_args)

# Put in global_context things you want available on all your templates.
# It can be anything, data, functions, modules, etc.
GLOBAL_CONTEXT = {}
Expand Down
9 changes: 2 additions & 7 deletions nikola/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,7 @@ def configure_logging(logging_mode: LoggingMode = LoggingMode.NORMAL) -> None:
return

handler = logging.StreamHandler()
handler.setFormatter(
ColorfulFormatter(
fmt=_LOGGING_FMT,
datefmt=_LOGGING_DATEFMT,
)
)
handler.setFormatter(ColorfulFormatter(fmt=_LOGGING_FMT, datefmt=_LOGGING_DATEFMT))

handlers = [handler]
if logging_mode == LoggingMode.STRICT:
Expand Down Expand Up @@ -152,7 +147,7 @@ def init_template_trace_logging(filename: str) -> None:
As there is lots of other stuff happening on the normal output stream,
this info is also written to a log file.
"""
"""
TEMPLATES_LOGGER.level = logging.DEBUG
formatter = logging.Formatter(
fmt=_LOGGING_FMT,
Expand Down
28 changes: 17 additions & 11 deletions nikola/nikola.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import pathlib
import sys
import typing
from typing import Any, Callable, Dict, Iterable, List, Optional, Set
import mimetypes
from collections import defaultdict
from copy import copy
Expand Down Expand Up @@ -373,7 +374,7 @@ class Nikola(object):
plugin_manager: PluginManager
_template_system: TemplateSystem

def __init__(self, **config):
def __init__(self, **config) -> None:
"""Initialize proper environment for running tasks."""
# Register our own path handlers
self.path_handlers = {
Expand All @@ -395,7 +396,7 @@ def __init__(self, **config):
self.timeline = []
self.pages = []
self._scanned = False
self._template_system: typing.Optional[TemplateSystem] = None
self._template_system: Optional[TemplateSystem] = None
self._THEMES = None
self._MESSAGES = None
self.filters = {}
Expand Down Expand Up @@ -996,13 +997,13 @@ def __init__(self, **config):
# WebP files have no official MIME type yet, but we need to recognize them (Issue #3671)
mimetypes.add_type('image/webp', '.webp')

def _filter_duplicate_plugins(self, plugin_list: typing.Iterable[PluginCandidate]):
def _filter_duplicate_plugins(self, plugin_list: Iterable[PluginCandidate]):
"""Find repeated plugins and discard the less local copy."""
def plugin_position_in_places(plugin: PluginInfo):
# plugin here is a tuple:
# (path to the .plugin file, path to plugin module w/o .py, plugin metadata)
place: pathlib.Path
for i, place in enumerate(self._plugin_places):
place: pathlib.Path
try:
# Path.is_relative_to backport
plugin.source_dir.relative_to(place)
Expand All @@ -1025,7 +1026,7 @@ def plugin_position_in_places(plugin: PluginInfo):
result.append(plugins[-1])
return result

def init_plugins(self, commands_only=False, load_all=False):
def init_plugins(self, commands_only=False, load_all=False) -> None:
"""Load plugins as needed."""
extra_plugins_dirs = self.config['EXTRA_PLUGINS_DIRS']
self._loading_commands_only = commands_only
Expand Down Expand Up @@ -1086,9 +1087,9 @@ def init_plugins(self, commands_only=False, load_all=False):
# Search for compiler plugins which we disabled but shouldn't have
self._activate_plugins_of_category("PostScanner")
if not load_all:
file_extensions = set()
file_extensions: Set[str] = set()
post_scanner: PostScanner
for post_scanner in [p.plugin_object for p in self.plugin_manager.get_plugins_of_category('PostScanner')]:
post_scanner: PostScanner
exts = post_scanner.supported_extensions()
if exts is not None:
file_extensions.update(exts)
Expand Down Expand Up @@ -1126,8 +1127,8 @@ def init_plugins(self, commands_only=False, load_all=False):

self._activate_plugins_of_category("Taxonomy")
self.taxonomy_plugins = {}
taxonomy: Taxonomy
for taxonomy in [p.plugin_object for p in self.plugin_manager.get_plugins_of_category('Taxonomy')]:
taxonomy: Taxonomy
if not taxonomy.is_enabled():
continue
if taxonomy.classification_name in self.taxonomy_plugins:
Expand Down Expand Up @@ -1322,7 +1323,7 @@ def _activate_plugin(self, plugin_info: PluginInfo) -> None:
if candidate.exists() and candidate.is_dir():
self.template_system.inject_directory(str(candidate))

def _activate_plugins_of_category(self, category) -> typing.List[PluginInfo]:
def _activate_plugins_of_category(self, category) -> List[PluginInfo]:
"""Activate all the plugins of a given category and return them."""
# this code duplicated in tests/base.py
plugins = []
Expand Down Expand Up @@ -1397,6 +1398,11 @@ def _get_template_system(self):
"plugin\n".format(template_sys_name))
sys.exit(1)
self._template_system = typing.cast(TemplateSystem, pi.plugin_object)

engine_factory: Optional[Callable[..., Any]] = self.config.get("TEMPLATE_ENGINE_FACTORY")
if engine_factory is not None:
self._template_system.user_engine_factory(engine_factory)

lookup_dirs = ['templates'] + [os.path.join(utils.get_theme_path(name), "templates")
for name in self.THEMES]
self._template_system.set_directories(lookup_dirs,
Expand Down Expand Up @@ -1444,7 +1450,7 @@ def get_compiler(self, source_name):

return compiler

def render_template(self, template_name, output_name, context, url_type=None, is_fragment=False):
def render_template(self, template_name: str, output_name: str, context, url_type=None, is_fragment=False):
"""Render a template with the global context.
If ``output_name`` is None, will return a string and all URL
Expand All @@ -1463,7 +1469,7 @@ def render_template(self, template_name, output_name, context, url_type=None, is
utils.TEMPLATES_LOGGER.debug("For %s, template %s builds %s", context["post"].source_path, template_name, output_name)
else:
utils.TEMPLATES_LOGGER.debug("Template %s builds %s", template_name, output_name)
local_context: typing.Dict[str, typing.Any] = {}
local_context: Dict[str, Any] = {}
local_context["template_name"] = template_name
local_context.update(self.GLOBAL_CONTEXT)
local_context.update(context)
Expand Down
Loading

0 comments on commit 48ec10d

Please sign in to comment.