diff --git a/CHANGES.txt b/CHANGES.txt
index f310bbdb8a..5991a344d1 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -7,6 +7,7 @@ Features
* Support passing ``--poll`` to ``nikola auto`` to better deal with symlink farms.
* Trace template usage when an environment variable ``NIKOLA_TEMPLATES_TRACE``
is set to any non-empty value.
+* Allow configuration of Jinja2 extensions through the theme configuration.
Bugfixes
--------
diff --git a/docs/theming.rst b/docs/theming.rst
index 0c2f5f7583..5c550efd03 100644
--- a/docs/theming.rst
+++ b/docs/theming.rst
@@ -104,8 +104,8 @@ with the same name as your theme, and a ``.theme`` extension, eg.
.. code:: ini
[Theme]
- engine = mako
- parent = base
+ engine = jinja
+ parent = base-jinja
author = The Nikola Contributors
author_url = https://getnikola.com/
based_on = Bootstrap 3
@@ -120,6 +120,10 @@ with the same name as your theme, and a ``.theme`` extension, eg.
[Nikola]
bootswatch = True
+ [jinja]
+ # Good for investigation, but not recommended to leave active in production:
+ extensions = jinja2.ext.debug
+
The following keys are currently supported:
* ``Theme`` — contains information about the theme.
@@ -164,6 +168,13 @@ The following keys are currently supported:
* ``ignored_assets`` — comma-separated list of assets to ignore (relative to
the ``assets/`` directory, eg. ``css/theme.css``)
+* ``jinja`` - This section is ignored unless your theme's engine is ``jinja``.
+
+ * ``extensions`` - comma-separated list of
+ `jinja2-extensions `_
+ that you want to be available when rendering your templates.
+
+
Templates
---------
diff --git a/nikola/log.py b/nikola/log.py
index 91db4ae554..9848477b53 100644
--- a/nikola/log.py
+++ b/nikola/log.py
@@ -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:
@@ -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,
diff --git a/nikola/nikola.py b/nikola/nikola.py
index 5609b408df..83ca9df1aa 100644
--- a/nikola/nikola.py
+++ b/nikola/nikola.py
@@ -36,6 +36,7 @@
import pathlib
import sys
import typing
+from typing import Any, Dict, Iterable, List, Optional, Set
import mimetypes
from collections import defaultdict
from copy import copy
@@ -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 = {
@@ -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 = {}
@@ -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)
@@ -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
@@ -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)
@@ -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:
@@ -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 = []
@@ -1390,13 +1391,15 @@ def _get_global_context(self):
def _get_template_system(self):
if self._template_system is None:
# Load template plugin
- template_sys_name = utils.get_template_engine(self.THEMES)
+ template_sys_name, template_sys_user_config = utils.get_template_engine(self.THEMES)
pi = self.plugin_manager.get_plugin_by_name(template_sys_name, "TemplateSystem")
if pi is None:
sys.stderr.write("Error loading {0} template system "
"plugin\n".format(template_sys_name))
sys.exit(1)
self._template_system = typing.cast(TemplateSystem, pi.plugin_object)
+ if template_sys_user_config is not None:
+ self._template_system.user_configuration(template_sys_user_config)
lookup_dirs = ['templates'] + [os.path.join(utils.get_theme_path(name), "templates")
for name in self.THEMES]
self._template_system.set_directories(lookup_dirs,
@@ -1444,7 +1447,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
@@ -1463,7 +1466,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)
@@ -1699,7 +1702,7 @@ def _register_templated_shortcodes(self):
builtin_sc_dir = utils.pkg_resources_path(
'nikola',
- os.path.join('data', 'shortcodes', utils.get_template_engine(self.THEMES)))
+ os.path.join('data', 'shortcodes', utils.get_template_engine(self.THEMES)[0]))
for sc_dir in [builtin_sc_dir, 'shortcodes']:
if not os.path.isdir(sc_dir):
diff --git a/nikola/plugin_categories.py b/nikola/plugin_categories.py
index b9eee3af00..31b7c43ab4 100644
--- a/nikola/plugin_categories.py
+++ b/nikola/plugin_categories.py
@@ -29,16 +29,15 @@
import io
import logging
import os
-import typing
+
+from typing import Callable, Dict, Iterable, List, Mapping, Optional, Tuple
import doit
from doit.cmd_base import Command as DoitCommand
from .utils import LOGGER, first_line, get_logger, req_missing
-if typing.TYPE_CHECKING:
- import nikola
- import nikola.post
+from nikola.post import Post
__all__ = (
'Command',
@@ -88,11 +87,11 @@ def register_auto_watched_folder(self, folder: str) -> None:
class PostScanner(BasePlugin):
"""The scan method of these plugins is called by Nikola.scan_posts."""
- def scan(self) -> 'typing.List[nikola.post.Post]':
+ def scan(self) -> List[Post]:
"""Create a list of posts from some source. Returns a list of Post objects."""
raise NotImplementedError()
- def supported_extensions(self) -> 'typing.Optional[typing.List]':
+ def supported_extensions(self) -> Optional[List[str]]:
"""Return a list of supported file extensions, or None if such a list isn't known beforehand."""
return None
@@ -171,11 +170,11 @@ class BaseTask(BasePlugin):
# the others have to be specifie in the command line.
is_default = True
- def gen_tasks(self) -> 'typing.List[dict]':
+ def gen_tasks(self) -> List[dict]:
"""Generate tasks."""
raise NotImplementedError()
- def group_task(self) -> dict:
+ def group_task(self) -> Dict[str, Optional[str]]:
"""Return dict for group task."""
return {
'basename': self.name,
@@ -201,10 +200,14 @@ class TemplateSystem(BasePlugin):
name = "dummy_templates"
- def set_directories(self, directories: 'typing.List[str]', cache_folder: str):
+ def set_directories(self, directories: List[str], cache_folder: str) -> None:
"""Set the list of folders where templates are located and cache."""
raise NotImplementedError()
+ def user_configuration(self, user_config: Mapping[str, str]) -> None:
+ """Accept user configuration from the theme configuration file."""
+ raise NotImplementedError()
+
def template_deps(self, template_name: str, context=None):
"""Return filenames which are dependencies for a template."""
raise NotImplementedError()
@@ -217,7 +220,7 @@ def get_string_deps(self, text: str, context=None):
"""Find dependencies for a template string."""
raise NotImplementedError()
- def render_template(self, template_name: str, output_name: str, context: 'typing.Dict[str, str]'):
+ def render_template(self, template_name: str, output_name: str, context: Dict[str, str]) -> str:
"""Render template to a file using context.
This must save the data to output_name *and* return it
@@ -225,7 +228,7 @@ def render_template(self, template_name: str, output_name: str, context: 'typing
"""
raise NotImplementedError()
- def render_template_to_string(self, template: str, context: 'typing.Dict[str, str]') -> str:
+ def render_template_to_string(self, template: str, context: Dict[str, str]) -> str:
"""Render template to a string using context."""
raise NotImplementedError()
@@ -270,11 +273,11 @@ class PageCompiler(BasePlugin):
}
config_dependencies = []
- def get_dep_filename(self, post: 'nikola.post.Post', lang: str) -> str:
+ def get_dep_filename(self, post: Post, lang: str) -> str:
"""Return the .dep file's name for the given post and language."""
return post.translated_base_path(lang) + '.dep'
- def _read_extra_deps(self, post: 'nikola.post.Post', lang: str) -> 'typing.List[str]':
+ def _read_extra_deps(self, post: Post, lang: str) -> List[str]:
"""Read contents of .dep file and return them as a list."""
dep_path = self.get_dep_filename(post, lang)
if os.path.isfile(dep_path):
@@ -283,9 +286,9 @@ def _read_extra_deps(self, post: 'nikola.post.Post', lang: str) -> 'typing.List[
return deps
return []
- def register_extra_dependencies(self, post: 'nikola.post.Post'):
+ def register_extra_dependencies(self, post: Post):
"""Add dependency to post object to check .dep file."""
- def create_lambda(lang: str) -> 'typing.Callable':
+ def create_lambda(lang: str) -> Callable:
# We create a lambda like this so we can pass `lang` to it, because if we didn’t
# add that function, `lang` would always be the last language in TRANSLATIONS.
# (See https://docs.python-guide.org/writing/gotchas/#late-binding-closures)
@@ -294,7 +297,7 @@ def create_lambda(lang: str) -> 'typing.Callable':
for lang in self.site.config['TRANSLATIONS']:
post.add_dependency(create_lambda(lang), 'fragment', lang=lang)
- def get_extra_targets(self, post: 'nikola.post.Post', lang: str, dest: str) -> 'typing.List[str]':
+ def get_extra_targets(self, post: Post, lang: str, dest: str) -> List[str]:
"""Return a list of extra targets for the render_posts task when compiling the post for the specified language."""
if self.use_dep_file:
return [self.get_dep_filename(post, lang)]
@@ -321,11 +324,11 @@ def extension(self) -> str:
"""Return the preferred extension for the output of this compiler."""
return ".html"
- def read_metadata(self, post: 'nikola.post.Post', lang=None) -> 'typing.Dict[str, str]':
+ def read_metadata(self, post: Post, lang=None) -> Dict[str, str]:
"""Read the metadata from a post, and return a metadata dict."""
return {}
- def split_metadata(self, data: str, post=None, lang=None) -> (str, str):
+ def split_metadata(self, data: str, post=None, lang=None) -> Tuple[str, str]:
"""Split data from metadata in the raw post content."""
if lang and post:
extractor = post.used_extractor[lang]
@@ -396,14 +399,14 @@ class MetadataExtractor(BasePlugin):
# Whether or not the extractor supports writing metadata.
supports_write = False
- def _extract_metadata_from_text(self, source_text: str) -> 'typing.Dict[str, str]':
+ def _extract_metadata_from_text(self, source_text: str) -> Dict[str, str]:
"""Extract metadata from text."""
raise NotImplementedError()
- def split_metadata_from_text(self, source_text: str) -> (str, str):
+ def split_metadata_from_text(self, source_text: str) -> Tuple[str, str]:
"""Split text into metadata and content (both strings)."""
if self.split_metadata_re is None:
- return source_text
+ return "", source_text
else:
split_result = self.split_metadata_re.split(source_text.lstrip(), maxsplit=1)
if len(split_result) == 1:
@@ -412,19 +415,19 @@ def split_metadata_from_text(self, source_text: str) -> (str, str):
# Necessary?
return split_result[0], split_result[-1]
- def extract_text(self, source_text: str) -> 'typing.Dict[str, str]':
+ def extract_text(self, source_text: str) -> Dict[str, str]:
"""Split file, return metadata and the content."""
+ # TODO: The name and interface of this method is a mess and needs to be cleaned up.
split = self.split_metadata_from_text(source_text)
- if not split:
+ if len(split[0]) == 0:
return {}
- meta = self._extract_metadata_from_text(split[0])
- return meta
+ return self._extract_metadata_from_text(split[0])
- def extract_filename(self, filename: str, lang: str) -> 'typing.Dict[str, str]':
+ def extract_filename(self, filename: str, lang: str) -> Dict[str, str]:
"""Extract metadata from filename."""
return {}
- def write_metadata(self, metadata: 'typing.Dict[str, str]', comment_wrap=False) -> str:
+ def write_metadata(self, metadata: Dict[str, str], comment_wrap=False) -> str:
"""Write metadata in this extractor’s format.
``comment_wrap`` is either True, False, or a 2-tuple of comments to use for wrapping, if necessary.
@@ -715,18 +718,18 @@ def is_enabled(self, lang=None) -> bool:
"""
return True
- def get_implicit_classifications(self, lang: str) -> 'typing.List[str]':
+ def get_implicit_classifications(self, lang: str) -> List[str]:
"""Return a list of classification strings which should always appear in posts_per_classification."""
return []
- def classify(self, post: 'nikola.post.Post', lang: str) -> 'typing.Iterable[str]':
+ def classify(self, post: Post, lang: str) -> Iterable[str]:
"""Classify the given post for the given language.
Must return a list or tuple of strings.
"""
raise NotImplementedError()
- def sort_posts(self, posts: 'typing.List[nikola.post.Post]', classification: str, lang: str):
+ def sort_posts(self, posts: List[Post], classification: str, lang: str):
"""Sort the given list of posts.
Allows the plugin to order the posts per classification as it wants.
@@ -735,7 +738,7 @@ def sort_posts(self, posts: 'typing.List[nikola.post.Post]', classification: str
"""
pass
- def sort_classifications(self, classifications: 'typing.List[str]', lang: str, level=None):
+ def sort_classifications(self, classifications: List[str], lang: str, level=None):
"""Sort the given list of classification strings.
Allows the plugin to order the classifications as it wants. The
@@ -809,7 +812,7 @@ def get_path(self, classification: str, lang: str, dest_type='page') -> str:
"""
raise NotImplementedError()
- def extract_hierarchy(self, classification: str) -> 'typing.List[str]':
+ def extract_hierarchy(self, classification: str) -> List[str]:
"""Given a classification, return a list of parts in the hierarchy.
For non-hierarchical taxonomies, it usually suffices to return
@@ -817,7 +820,7 @@ def extract_hierarchy(self, classification: str) -> 'typing.List[str]':
"""
return [classification]
- def recombine_classification_from_hierarchy(self, hierarchy: 'typing.List[str]') -> str:
+ def recombine_classification_from_hierarchy(self, hierarchy: List[str]) -> str:
"""Given a list of parts in the hierarchy, return the classification string.
For non-hierarchical taxonomies, it usually suffices to return hierarchy[0].
@@ -834,7 +837,7 @@ def provide_overview_context_and_uptodate(self, lang: str) -> str:
"""
raise NotImplementedError()
- def provide_context_and_uptodate(self, classification: str, lang: str, node=None) -> 'typing.Tuple[typing.Dict]':
+ def provide_context_and_uptodate(self, classification: str, lang: str, node=None) -> Tuple[Dict, Dict]:
"""Provide data for the context and the uptodate list for the list of the given classification.
Must return a tuple of two dicts. The first is merged into the page's context,
@@ -847,19 +850,19 @@ def provide_context_and_uptodate(self, classification: str, lang: str, node=None
"""
raise NotImplementedError()
- def should_generate_classification_page(self, classification: str, post_list: 'typing.List[nikola.post.Post]', lang: str) -> bool:
+ def should_generate_classification_page(self, classification: str, post_list: List[Post], lang: str) -> bool:
"""Only generates list of posts for classification if this function returns True."""
return True
- def should_generate_atom_for_classification_page(self, classification: str, post_list: 'typing.List[nikola.post.Post]', lang: str) -> bool:
+ def should_generate_atom_for_classification_page(self, classification: str, post_list: List[Post], lang: str) -> bool:
"""Only generates Atom feed for list of posts for classification if this function returns True."""
return self.should_generate_classification_page(classification, post_list, lang)
- def should_generate_rss_for_classification_page(self, classification: str, post_list: 'typing.List[nikola.post.Post]', lang: str) -> bool:
+ def should_generate_rss_for_classification_page(self, classification: str, post_list: List[Post], lang: str) -> bool:
"""Only generates RSS feed for list of posts for classification if this function returns True."""
return self.should_generate_classification_page(classification, post_list, lang)
- def postprocess_posts_per_classification(self, posts_per_classification_per_language: 'typing.List[nikola.post.Post]', flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None) -> 'typing.List[nikola.post.Post]':
+ def postprocess_posts_per_classification(self, posts_per_classification_per_language: List[Post], flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None) -> None:
"""Rearrange, modify or otherwise use the list of posts per classification and per language.
For compatibility reasons, the list could be stored somewhere else as well.
@@ -871,7 +874,7 @@ def postprocess_posts_per_classification(self, posts_per_classification_per_lang
"""
pass
- def get_other_language_variants(self, classification: str, lang: str, classifications_per_language: 'typing.List[str]') -> 'typing.List[str]':
+ def get_other_language_variants(self, classification: str, lang: str, classifications_per_language: List[str]) -> List[str]:
"""Return a list of variants of the same classification in other languages.
Given a `classification` in a language `lang`, return a list of pairs
diff --git a/nikola/plugin_manager.py b/nikola/plugin_manager.py
index ae93205bcf..7dbf2140e7 100644
--- a/nikola/plugin_manager.py
+++ b/nikola/plugin_manager.py
@@ -35,13 +35,12 @@
from collections import deque
from dataclasses import dataclass
from pathlib import Path
-from typing import Dict, List, Optional, Type, TYPE_CHECKING, Set
+from typing import Dict, Iterable, List, Optional, Type, Set
from .plugin_categories import BasePlugin, CATEGORIES
from .utils import get_logger
-if TYPE_CHECKING:
- import logging
+import logging
LEGACY_PLUGIN_NAMES: Dict[str, str] = {
"Compiler": "PageCompiler",
@@ -157,7 +156,7 @@ def locate_plugins(self) -> List[PluginCandidate]:
)
return self.candidates
- def load_plugins(self, candidates: List[PluginCandidate]) -> None:
+ def load_plugins(self, candidates: Iterable[PluginCandidate]) -> None:
"""Load selected candidate plugins."""
plugins_root = Path(__file__).parent.parent
diff --git a/nikola/plugins/command/theme.py b/nikola/plugins/command/theme.py
index 388001fc20..5a9059fa25 100644
--- a/nikola/plugins/command/theme.py
+++ b/nikola/plugins/command/theme.py
@@ -334,7 +334,7 @@ def new_theme(self, name, engine, parent, create_legacy_meta=False):
LOGGER.info("Created directory {0}".format(base))
# Check if engine and parent match
- parent_engine = utils.get_template_engine(utils.get_theme_chain(parent, self.site.themes_dirs))
+ parent_engine = utils.get_template_engine(utils.get_theme_chain(parent, self.site.themes_dirs))[0]
if parent_engine != engine:
LOGGER.error("Cannot use engine {0} because parent theme '{1}' uses {2}".format(engine, parent, parent_engine))
diff --git a/nikola/plugins/task/archive.py b/nikola/plugins/task/archive.py
index 44849fa007..f4eaa5e2cb 100644
--- a/nikola/plugins/task/archive.py
+++ b/nikola/plugins/task/archive.py
@@ -209,7 +209,7 @@ def provide_context_and_uptodate(self, classification, lang, node=None):
kw.update(context)
return context, kw
- def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None):
+ def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None) -> None:
"""Rearrange, modify or otherwise use the list of posts per classification and per language."""
# Build a lookup table for archive navigation, if we’ll need one.
if self.site.config['CREATE_ARCHIVE_NAVIGATION']:
@@ -228,7 +228,7 @@ def postprocess_posts_per_classification(self, posts_per_classification_per_lang
for k, v in self.archive_navigation[lang].items():
self.archive_navigation[lang][k] = natsort.natsorted(v, alg=natsort.ns.F | natsort.ns.IC)
- return super().postprocess_posts_per_classification(posts_per_classification_per_language, flat_hierarchy_per_lang, hierarchy_lookup_per_lang)
+ super().postprocess_posts_per_classification(posts_per_classification_per_language, flat_hierarchy_per_lang, hierarchy_lookup_per_lang)
def should_generate_classification_page(self, classification, post_list, lang):
"""Only generates list of posts for classification if this function returns True."""
diff --git a/nikola/plugins/template/jinja.py b/nikola/plugins/template/jinja.py
index e0ddf4aae7..a08ae44b4a 100644
--- a/nikola/plugins/template/jinja.py
+++ b/nikola/plugins/template/jinja.py
@@ -29,6 +29,7 @@
import io
import json
import os
+from typing import List, Mapping, Optional
from nikola.plugin_categories import TemplateSystem
from nikola.utils import makedirs, req_missing, slugify, sort_posts, _smartjoin_filter
@@ -45,15 +46,34 @@ class JinjaTemplates(TemplateSystem):
"""Support for Jinja2 templates."""
name = "jinja"
- lookup = None
+ if jinja2 is None:
+ lookup = None
+ else:
+ lookup: Optional[jinja2.Environment] = None
dependency_cache = {}
per_file_cache = {}
+ _user_configured_jina_extensions: List[str] = []
def __init__(self):
"""Initialize Jinja2 environment with extended set of filters."""
if jinja2 is None:
return
+ def user_configuration(self, user_config: Mapping[str, str]) -> None:
+ supported_config_keys = set(["extensions",])
+ for key in user_config.keys():
+ if key not in supported_config_keys:
+ raise RuntimeError(f'Configuration key "{key}" found in theme init file is not supported for jinja template engine.')
+
+ if "extensions" in user_config:
+ self._user_configured_jina_extensions = [ext for ext in (e.strip() for e in user_config["extensions"].split(",")) if ext]
+ else:
+ self._user_configured_jina_extensions = []
+
+ if self.lookup:
+ for wanted_extension in self._user_configured_jina_extensions:
+ self.lookup.add_extension(wanted_extension)
+
def set_directories(self, directories, cache_folder):
"""Create a new template lookup with set directories."""
if jinja2 is None:
@@ -62,6 +82,8 @@ def set_directories(self, directories, cache_folder):
makedirs(cache_folder)
cache = jinja2.FileSystemBytecodeCache(cache_folder)
self.lookup = jinja2.Environment(bytecode_cache=cache)
+ for wanted_extension in self._user_configured_jina_extensions:
+ self.lookup.add_extension(wanted_extension)
self.lookup.trim_blocks = True
self.lookup.lstrip_blocks = True
self.lookup.filters['tojson'] = json.dumps
diff --git a/nikola/utils.py b/nikola/utils.py
index 6f6811b1bb..d67e136a85 100644
--- a/nikola/utils.py
+++ b/nikola/utils.py
@@ -40,7 +40,6 @@
import subprocess
import sys
import threading
-import typing
from collections import defaultdict, OrderedDict
from collections.abc import Callable, Iterable
from html import unescape as html_unescape
@@ -61,7 +60,7 @@
from doit import tools
from doit.cmdparse import CmdParse
from nikola.packages.pygments_better_html import BetterHtmlFormatter
-from typing import List
+from typing import Any, Dict, List, Mapping, Match, Optional, Tuple, Union
from unidecode import unidecode
# Renames
@@ -589,7 +588,7 @@ def pkg_resources_path(package, resource):
return str(resources.files(package).joinpath(resource))
-def get_theme_path_real(theme, themes_dirs):
+def get_theme_path_real(theme, themes_dirs) -> str:
"""Return the path where the given theme's files are located.
Looks in ./themes and in the place where themes go when installed.
@@ -609,7 +608,7 @@ def get_theme_path(theme):
return theme
-def parse_theme_meta(theme_dir):
+def parse_theme_meta(theme_dir) -> Optional[configparser.ConfigParser]:
"""Parse a .theme meta file."""
cp = configparser.ConfigParser()
# The `or` case is in case theme_dir ends with a trailing slash
@@ -619,25 +618,31 @@ def parse_theme_meta(theme_dir):
return cp if cp.has_section('Theme') else None
-def get_template_engine(themes):
- """Get template engine used by a given theme."""
+def get_template_engine(themes) -> Tuple[str, Optional[Mapping[str, str]]]:
+ """Get template engine used by a given theme, plus any config info for that engine if pertinent."""
for theme_name in themes:
meta = parse_theme_meta(theme_name)
if meta:
e = meta.get('Theme', 'engine', fallback=None)
if e:
- return e
+ # Maybe we have a configuration section like "Jinja" or "Mako"
+ # that can be used to configure the template engine further:
+ if e in meta:
+ config = meta[e]
+ return e, config
+ else:
+ return e, None
else:
# Theme still uses old-style parent/engine files
engine_path = os.path.join(theme_name, 'engine')
if os.path.isfile(engine_path):
with open(engine_path) as fd:
- return fd.readlines()[0].strip()
+ return fd.readlines()[0].strip(), None
# default
- return 'mako'
+ return 'mako', None
-def get_parent_theme_name(theme_name, themes_dirs=None):
+def get_parent_theme_name(theme_name, themes_dirs=None) -> Optional[str]:
"""Get name of parent theme."""
meta = parse_theme_meta(theme_name)
if meta:
@@ -657,7 +662,7 @@ def get_parent_theme_name(theme_name, themes_dirs=None):
return None
-def get_theme_chain(theme, themes_dirs):
+def get_theme_chain(theme, themes_dirs) -> List[str]:
"""Create the full theme inheritance chain including paths."""
themes = [get_theme_path_real(theme, themes_dirs)]
@@ -1195,7 +1200,7 @@ class LocaleBorg(object):
in_string_formatter = None
@classmethod
- def initialize(cls, locales: 'typing.Dict[str, str]', initial_lang: str):
+ def initialize(cls, locales: Dict[str, str], initial_lang: str):
"""Initialize LocaleBorg.
locales: dict with custom locale name overrides.
@@ -1248,8 +1253,8 @@ def set_locale(self, lang: str) -> str:
return ''
def formatted_date(self, date_format: 'str',
- date: 'typing.Union[datetime.date, datetime.datetime]',
- lang: 'typing.Optional[str]' = None) -> str:
+ date: Union[datetime.date, datetime.datetime],
+ lang: Optional[str] = None) -> str:
"""Return the formatted date/datetime as a string."""
if lang is None:
lang = self.current_lang
@@ -1268,7 +1273,7 @@ def formatted_date(self, date_format: 'str',
else:
return format_datetime(date, date_format, locale=locale)
- def format_date_in_string(self, message: str, date: datetime.date, lang: 'typing.Optional[str]' = None) -> str:
+ def format_date_in_string(self, message: str, date: datetime.date, lang: Optional[str] = None) -> str:
"""Format date inside a string (message).
Accepted modes: month, month_year, month_day_year.
@@ -1284,7 +1289,7 @@ def format_date_in_string(self, message: str, date: datetime.date, lang: 'typing
lang = self.current_lang
locale = self.locales.get(lang, lang)
- def date_formatter(match: typing.Match) -> str:
+ def date_formatter(match: Match) -> str:
"""Format a date as requested."""
mode, custom_format = match.groups()
if LocaleBorg.in_string_formatter is not None:
@@ -1938,8 +1943,8 @@ def sort_posts(posts, *keys):
return posts
-def smartjoin(join_char: str, string_or_iterable) -> str:
- """Join string_or_iterable with join_char if it is iterable; otherwise converts it to string.
+def smartjoin(join_char: str, string_or_iterable: Union[None, str, bytes, Iterable[Any]]) -> str:
+ """Join string_or_iterable with join_char if it is iterable; otherwise convert it to string.
>>> smartjoin('; ', 'foo, bar')
'foo, bar'
@@ -1947,10 +1952,10 @@ def smartjoin(join_char: str, string_or_iterable) -> str:
'foo; bar'
>>> smartjoin(' to ', ['count', 42])
'count to 42'
+
+ The treatment of bytes (calling str(string_or_iterable)) is somewhat dubious. Is this needed?
"""
- if isinstance(string_or_iterable, (str, bytes)):
- return string_or_iterable
- elif isinstance(string_or_iterable, Iterable):
+ if isinstance(string_or_iterable, Iterable):
return join_char.join([str(e) for e in string_or_iterable])
else:
return str(string_or_iterable)