aboutsummaryrefslogtreecommitdiffstats
path: root/nikola/nikola.py
diff options
context:
space:
mode:
authorLibravatarUnit 193 <unit193@unit193.net>2021-02-03 19:17:00 -0500
committerLibravatarUnit 193 <unit193@unit193.net>2021-02-03 19:17:00 -0500
commit3a0d66f07b112b6d2bdc2b57bbf717a89a351ce6 (patch)
treea7cf56282e54f05785243bc1e903d6594f2c06ba /nikola/nikola.py
parent787b97a4cb24330b36f11297c6d3a7a473a907d0 (diff)
New upstream version 8.1.2.upstream/8.1.2
Diffstat (limited to 'nikola/nikola.py')
-rw-r--r--nikola/nikola.py2108
1 files changed, 1351 insertions, 757 deletions
diff --git a/nikola/nikola.py b/nikola/nikola.py
index e0af7ad..86d81e6 100644
--- a/nikola/nikola.py
+++ b/nikola/nikola.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2015 Roberto Alsina and others.
+# Copyright © 2012-2020 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -26,38 +26,32 @@
"""The main Nikola site object."""
-from __future__ import print_function, unicode_literals
-import io
-from collections import defaultdict
-from copy import copy
-from pkg_resources import resource_filename
import datetime
-import locale
-import os
+import io
import json
+import functools
+import logging
+import operator
+import os
import sys
-import natsort
import mimetypes
-try:
- from urlparse import urlparse, urlsplit, urlunsplit, urljoin, unquote
-except ImportError:
- from urllib.parse import urlparse, urlsplit, urlunsplit, urljoin, unquote # NOQA
-
-try:
- import pyphen
-except ImportError:
- pyphen = None
+from collections import defaultdict
+from copy import copy
+from urllib.parse import urlparse, urlsplit, urlunsplit, urljoin, unquote, parse_qs
import dateutil.tz
-import logging
-import PyRSS2Gen as rss
import lxml.etree
import lxml.html
-from yapsy.PluginManager import PluginManager
+import natsort
+import PyRSS2Gen as rss
+from pkg_resources import resource_filename
from blinker import signal
+from yapsy.PluginManager import PluginManager
+from . import DEBUG, SHOW_TRACEBACKS, filters, utils, hierarchy_utils, shortcodes
+from . import metadata_extractors
+from .metadata_extractors import default_metadata_extractors_by
from .post import Post # NOQA
-from . import DEBUG, utils
from .plugin_categories import (
Command,
LateTask,
@@ -65,13 +59,22 @@ from .plugin_categories import (
CompilerExtension,
MarkdownExtension,
RestExtension,
+ MetadataExtractor,
+ ShortcodePlugin,
Task,
TaskMultiplier,
TemplateSystem,
SignalHandler,
ConfigPlugin,
PostScanner,
+ Taxonomy,
)
+from .state import Persistor
+
+try:
+ import pyphen
+except ImportError:
+ pyphen = None
if DEBUG:
logging.basicConfig(level=logging.DEBUG)
@@ -80,31 +83,31 @@ else:
# Default "Read more..." link
DEFAULT_INDEX_READ_MORE_LINK = '<p class="more"><a href="{link}">{read_more}…</a></p>'
-DEFAULT_RSS_READ_MORE_LINK = '<p><a href="{link}">{read_more}…</a> ({min_remaining_read})</p>'
-
-# Default pattern for translation files' names
-DEFAULT_TRANSLATIONS_PATTERN = '{path}.{lang}.{ext}'
+DEFAULT_FEED_READ_MORE_LINK = '<p><a href="{link}">{read_more}…</a> ({min_remaining_read})</p>'
config_changed = utils.config_changed
__all__ = ('Nikola',)
-# We store legal values for some setting here. For internal use.
+# We store legal values for some settings here. For internal use.
LEGAL_VALUES = {
+ 'DEFAULT_THEME': 'bootblog4',
'COMMENT_SYSTEM': [
'disqus',
'facebook',
- 'googleplus',
'intensedebate',
'isso',
- 'livefyre',
'muut',
+ 'commento',
+ 'utterances',
],
'TRANSLATIONS': {
+ 'af': 'Afrikaans',
'ar': 'Arabic',
'az': 'Azerbaijani',
'bg': 'Bulgarian',
+ 'bs': 'Bosnian',
'ca': 'Catalan',
('cs', 'cz'): 'Czech',
'da': 'Danish',
@@ -118,127 +121,120 @@ LEGAL_VALUES = {
'fa': 'Persian',
'fi': 'Finnish',
'fr': 'French',
+ 'fur': 'Friulian',
+ 'gl': 'Galician',
+ 'he': 'Hebrew',
'hi': 'Hindi',
'hr': 'Croatian',
+ 'hu': 'Hungarian',
+ 'ia': 'Interlingua',
'id': 'Indonesian',
'it': 'Italian',
('ja', '!jp'): 'Japanese',
'ko': 'Korean',
- 'nb': 'Norwegian Bokmål',
+ 'lt': 'Lithuanian',
+ 'ml': 'Malayalam',
+ 'mr': 'Marathi',
+ 'nb': 'Norwegian (Bokmål)',
'nl': 'Dutch',
'pa': 'Punjabi',
'pl': 'Polish',
- 'pt_br': 'Portuguese (Brasil)',
+ 'pt': 'Portuguese',
+ 'pt_br': 'Portuguese (Brazil)',
'ru': 'Russian',
'sk': 'Slovak',
'sl': 'Slovene',
+ 'sq': 'Albanian',
'sr': 'Serbian (Cyrillic)',
+ 'sr_latin': 'Serbian (Latin)',
'sv': 'Swedish',
+ 'te': 'Telugu',
+ 'th': 'Thai',
('tr', '!tr_TR'): 'Turkish',
- 'ur': 'Urdu',
'uk': 'Ukrainian',
+ 'ur': 'Urdu',
+ 'vi': 'Vietnamese',
'zh_cn': 'Chinese (Simplified)',
- },
- '_WINDOWS_LOCALE_GUESSES': {
- # TODO incomplete
- # some languages may need that the appropiate Microsoft Language Pack be instaled.
- "bg": "Bulgarian",
- "ca": "Catalan",
- "de": "German",
- "el": "Greek",
- "en": "English",
- "eo": "Esperanto",
- "es": "Spanish",
- "fa": "Farsi", # Persian
- "fr": "French",
- "hr": "Croatian",
- "it": "Italian",
- "jp": "Japanese",
- "nl": "Dutch",
- "pl": "Polish",
- "pt_br": "Portuguese_Brazil",
- "ru": "Russian",
- "sl_si": "Slovenian",
- "tr_tr": "Turkish",
- "zh_cn": "Chinese_China", # Chinese (Simplified)
+ 'zh_tw': 'Chinese (Traditional)'
},
'_TRANSLATIONS_WITH_COUNTRY_SPECIFIERS': {
# This dict is used in `init` in case of locales that exist with a
# country specifier. If there is no other locale that has the same
# language with a different country, ``nikola init`` (but nobody else!)
# will accept it, warning the user about it.
- 'pt': 'pt_br',
- 'zh': 'zh_cn',
+
+ # This dict is currently empty.
},
- 'RTL_LANGUAGES': ('ar', 'fa', 'ur'),
- 'COLORBOX_LOCALES': defaultdict(
- str,
- ar='ar',
- bg='bg',
- ca='ca',
- cs='cs',
- cz='cs',
- da='da',
- de='de',
- en='',
- es='es',
- et='et',
- fa='fa',
- fi='fi',
- fr='fr',
- hr='hr',
- id='id',
- it='it',
- ja='ja',
- ko='kr', # kr is South Korea, ko is the Korean language
- nb='no',
- nl='nl',
- pl='pl',
- pt_br='pt-BR',
- ru='ru',
- sk='sk',
- sl='si', # country code is si, language code is sl, colorbox is wrong
- sr='sr', # warning: this is serbian in Latin alphabet
- sv='sv',
- tr='tr',
- uk='uk',
- zh_cn='zh-CN'
- ),
- 'MOMENTJS_LOCALES': defaultdict(
- str,
- ar='ar',
- bg='bg',
- bn='bn',
- ca='ca',
- cs='cs',
- cz='cs',
- da='da',
- de='de',
- en='',
- es='es',
- et='et',
- fa='fa',
- fi='fi',
- fr='fr',
- hr='hr',
- id='id',
- it='it',
- ja='ja',
- ko='ko',
- nb='nb',
- nl='nl',
- pl='pl',
- pt_br='pt-br',
- ru='ru',
- sk='sk',
- sl='sl',
- sr='sr-cyrl',
- sv='sv',
- tr='tr',
- zh_cn='zh-cn'
- ),
- 'PYPHEN_LOCALES': {
+ 'LOCALES_BASE': {
+ # A list of locale mappings to apply for every site. Can be overridden in the config.
+ 'sr_latin': 'sr_Latn',
+ },
+ 'RTL_LANGUAGES': ('ar', 'fa', 'he', 'ur'),
+ 'LUXON_LOCALES': defaultdict(lambda: 'en', **{
+ 'af': 'af',
+ 'ar': 'ar',
+ 'az': 'az',
+ 'bg': 'bg',
+ 'bn': 'bn',
+ 'bs': 'bs',
+ 'ca': 'ca',
+ 'cs': 'cs',
+ 'cz': 'cs',
+ 'da': 'da',
+ 'de': 'de',
+ 'el': 'el',
+ 'en': 'en',
+ 'eo': 'eo',
+ 'es': 'es',
+ 'et': 'et',
+ 'eu': 'eu',
+ 'fa': 'fa',
+ 'fi': 'fi',
+ 'fr': 'fr',
+ 'fur': 'fur',
+ 'gl': 'gl',
+ 'hi': 'hi',
+ 'he': 'he',
+ 'hr': 'hr',
+ 'hu': 'hu',
+ 'ia': 'ia',
+ 'id': 'id',
+ 'it': 'it',
+ 'ja': 'ja',
+ 'ko': 'ko',
+ 'lt': 'lt',
+ 'ml': 'ml',
+ 'mr': 'mr',
+ 'nb': 'nb',
+ 'nl': 'nl',
+ 'pa': 'pa',
+ 'pl': 'pl',
+ 'pt': 'pt',
+ 'pt_br': 'pt-BR',
+ 'ru': 'ru',
+ 'sk': 'sk',
+ 'sl': 'sl',
+ 'sq': 'sq',
+ 'sr': 'sr-Cyrl',
+ 'sr_latin': 'sr-Latn',
+ 'sv': 'sv',
+ 'te': 'te',
+ 'tr': 'tr',
+ 'th': 'th',
+ 'uk': 'uk',
+ 'ur': 'ur',
+ 'vi': 'vi',
+ 'zh_cn': 'zh-CN',
+ 'zh_tw': 'zh-TW'
+ }),
+ # TODO: remove in v9
+ 'MOMENTJS_LOCALES': defaultdict(lambda: 'en', **{
+ 'af': 'af',
+ 'ar': 'ar',
+ 'az': 'az',
'bg': 'bg',
+ 'bn': 'bn',
+ 'bs': 'bs',
'ca': 'ca',
'cs': 'cs',
'cz': 'cs',
@@ -246,43 +242,142 @@ LEGAL_VALUES = {
'de': 'de',
'el': 'el',
'en': 'en',
+ 'eo': 'eo',
+ 'es': 'es',
+ 'et': 'et',
+ 'eu': 'eu',
+ 'fa': 'fa',
+ 'fi': 'fi',
+ 'fr': 'fr',
+ 'gl': 'gl',
+ 'hi': 'hi',
+ 'he': 'he',
+ 'hr': 'hr',
+ 'hu': 'hu',
+ 'id': 'id',
+ 'it': 'it',
+ 'ja': 'ja',
+ 'ko': 'ko',
+ 'lt': 'lt',
+ 'ml': 'ml',
+ 'mr': 'mr',
+ 'nb': 'nb',
+ 'nl': 'nl',
+ 'pa': 'pa-in',
+ 'pl': 'pl',
+ 'pt': 'pt',
+ 'pt_br': 'pt-br',
+ 'ru': 'ru',
+ 'sk': 'sk',
+ 'sl': 'sl',
+ 'sq': 'sq',
+ 'sr': 'sr-cyrl',
+ 'sr_latin': 'sr',
+ 'sv': 'sv',
+ 'te': 'te',
+ 'tr': 'tr',
+ 'th': 'th',
+ 'uk': 'uk',
+ 'ur': 'ur',
+ 'vi': 'vi',
+ 'zh_cn': 'zh-cn',
+ 'zh_tw': 'zh-tw'
+ }),
+ 'PYPHEN_LOCALES': {
+ 'af': 'af',
+ 'bg': 'bg',
+ 'ca': 'ca',
+ 'cs': 'cs',
+ 'cz': 'cs',
+ 'da': 'da',
+ 'de': 'de',
+ 'el': 'el',
+ 'en': 'en_US',
'es': 'es',
'et': 'et',
'fr': 'fr',
'hr': 'hr',
+ 'hu': 'hu',
'it': 'it',
+ 'lt': 'lt',
'nb': 'nb',
'nl': 'nl',
'pl': 'pl',
+ 'pt': 'pt',
'pt_br': 'pt_BR',
'ru': 'ru',
'sk': 'sk',
'sl': 'sl',
'sr': 'sr',
'sv': 'sv',
+ 'te': 'te',
+ 'uk': 'uk',
},
+ 'DOCUTILS_LOCALES': {
+ 'af': 'af',
+ 'ca': 'ca',
+ 'da': 'da',
+ 'de': 'de',
+ 'en': 'en',
+ 'eo': 'eo',
+ 'es': 'es',
+ 'fa': 'fa',
+ 'fi': 'fi',
+ 'fr': 'fr',
+ 'gl': 'gl',
+ 'he': 'he',
+ 'it': 'it',
+ 'ja': 'ja',
+ 'lt': 'lt',
+ 'nl': 'nl',
+ 'pl': 'pl',
+ 'pt': 'pt_br', # hope nobody will mind
+ 'pt_br': 'pt_br',
+ 'ru': 'ru',
+ 'sk': 'sk',
+ 'sv': 'sv',
+ 'zh_cn': 'zh_cn',
+ 'zh_tw': 'zh_tw'
+ },
+ "METADATA_MAPPING": ["yaml", "toml", "rest_docinfo", "markdown_metadata"],
+}
+
+# Mapping old pre-taxonomy plugin names to new post-taxonomy plugin names
+TAXONOMY_COMPATIBILITY_PLUGIN_NAME_MAP = {
+ "render_archive": ["classify_archive"],
+ "render_authors": ["classify_authors"],
+ "render_indexes": ["classify_page_index", "classify_sections"], # "classify_indexes" removed from list (see #2591 and special-case logic below)
+ "render_tags": ["classify_categories", "classify_tags"],
}
+# Default value for the pattern used to name translated files
+DEFAULT_TRANSLATIONS_PATTERN = '{path}.{lang}.{ext}'
+
def _enclosure(post, lang):
"""Add an enclosure to RSS."""
enclosure = post.meta('enclosure', lang)
if enclosure:
- length = 0
+ try:
+ length = int(post.meta('enclosure_length', lang) or 0)
+ except KeyError:
+ length = 0
+ except ValueError:
+ utils.LOGGER.warning("Invalid enclosure length for post {0}".format(post.source_path))
+ length = 0
url = enclosure
mime = mimetypes.guess_type(url)[0]
return url, length, mime
class Nikola(object):
-
"""Class that handles site generation.
Takes a site config as argument on creation.
"""
def __init__(self, **config):
- """Setup proper environment for running tasks."""
+ """Initialize proper environment for running tasks."""
# Register our own path handlers
self.path_handlers = {
'slug': self.slug_path,
@@ -305,8 +400,10 @@ class Nikola(object):
self._scanned = False
self._template_system = None
self._THEMES = None
+ self._MESSAGES = None
+ self.filters = {}
self.debug = DEBUG
- self.loghandlers = utils.STDERR_HANDLER # TODO remove on v8
+ self.show_tracebacks = SHOW_TRACEBACKS
self.colorful = config.pop('__colorful__', False)
self.invariant = config.pop('__invariant__', False)
self.quiet = config.pop('__quiet__', False)
@@ -315,6 +412,8 @@ class Nikola(object):
self.configuration_filename = config.pop('__configuration_filename__', False)
self.configured = bool(config)
self.injected_deps = defaultdict(list)
+ self.shortcode_registry = {}
+ self.metadata_extractors_by = default_metadata_extractors_by()
self.rst_transforms = []
self.template_hooks = {
@@ -331,25 +430,38 @@ class Nikola(object):
# This is the default config
self.config = {
- 'ANNOTATIONS': False,
'ARCHIVE_PATH': "",
'ARCHIVE_FILENAME': "archive.html",
'ARCHIVES_ARE_INDEXES': False,
+ 'AUTHOR_PATH': 'authors',
+ 'AUTHOR_PAGES_ARE_INDEXES': False,
+ 'AUTHOR_PAGES_DESCRIPTIONS': {},
+ 'AUTHORLIST_MINIMUM_POSTS': 1,
'BLOG_AUTHOR': 'Default Author',
'BLOG_TITLE': 'Default Title',
+ 'BLOG_EMAIL': '',
'BLOG_DESCRIPTION': 'Default Description',
'BODY_END': "",
'CACHE_FOLDER': 'cache',
+ 'CATEGORIES_INDEX_PATH': '',
'CATEGORY_PATH': None, # None means: same as TAG_PATH
'CATEGORY_PAGES_ARE_INDEXES': None, # None means: same as TAG_PAGES_ARE_INDEXES
- 'CATEGORY_PAGES_DESCRIPTIONS': {},
+ 'CATEGORY_DESCRIPTIONS': {},
+ 'CATEGORY_TITLES': {},
'CATEGORY_PREFIX': 'cat_',
'CATEGORY_ALLOW_HIERARCHIES': False,
'CATEGORY_OUTPUT_FLAT_HIERARCHY': False,
+ 'CATEGORY_DESTPATH_AS_DEFAULT': False,
+ 'CATEGORY_DESTPATH_TRIM_PREFIX': False,
+ 'CATEGORY_DESTPATH_FIRST_DIRECTORY_ONLY': True,
+ 'CATEGORY_DESTPATH_NAMES': {},
+ 'CATEGORY_PAGES_FOLLOW_DESTPATH': False,
+ 'CATEGORY_TRANSLATIONS': [],
+ 'CATEGORY_TRANSLATIONS_ADD_DEFAULTS': False,
'CODE_COLOR_SCHEME': 'default',
'COMMENT_SYSTEM': 'disqus',
'COMMENTS_IN_GALLERIES': False,
- 'COMMENTS_IN_STORIES': False,
+ 'COMMENTS_IN_PAGES': False,
'COMPILERS': {
"rest": ('.txt', '.rst'),
"markdown": ('.md', '.mdown', '.markdown'),
@@ -362,34 +474,51 @@ class Nikola(object):
},
'CONTENT_FOOTER': '',
'CONTENT_FOOTER_FORMATS': {},
+ 'RSS_COPYRIGHT': '',
+ 'RSS_COPYRIGHT_PLAIN': '',
+ 'RSS_COPYRIGHT_FORMATS': {},
'COPY_SOURCES': True,
+ 'CREATE_ARCHIVE_NAVIGATION': False,
'CREATE_MONTHLY_ARCHIVE': False,
'CREATE_SINGLE_ARCHIVE': False,
'CREATE_FULL_ARCHIVES': False,
'CREATE_DAILY_ARCHIVE': False,
- 'DATE_FORMAT': '%Y-%m-%d %H:%M',
- 'JS_DATE_FORMAT': 'YYYY-MM-DD HH:mm',
+ 'DATE_FORMAT': 'yyyy-MM-dd HH:mm',
+ 'DISABLE_INDEXES': False,
+ 'DISABLE_MAIN_ATOM_FEED': False,
+ 'DISABLE_MAIN_RSS_FEED': False,
+ 'MOMENTJS_DATE_FORMAT': 'YYYY-MM-DD HH:mm',
+ 'LUXON_DATE_FORMAT': {},
'DATE_FANCINESS': 0,
'DEFAULT_LANG': "en",
'DEPLOY_COMMANDS': {'default': []},
'DISABLED_PLUGINS': [],
'EXTRA_PLUGINS_DIRS': [],
+ 'EXTRA_THEMES_DIRS': [],
'COMMENT_SYSTEM_ID': 'nikolademo',
+ 'ENABLE_AUTHOR_PAGES': True,
+ 'EXIF_WHITELIST': {},
'EXTRA_HEAD_DATA': '',
'FAVICONS': (),
'FEED_LENGTH': 10,
'FILE_METADATA_REGEXP': None,
+ 'FILE_METADATA_UNSLUGIFY_TITLES': True,
'ADDITIONAL_METADATA': {},
'FILES_FOLDERS': {'files': ''},
'FILTERS': {},
'FORCE_ISO8601': False,
+ 'FRONT_INDEX_HEADER': '',
'GALLERY_FOLDERS': {'galleries': 'galleries'},
'GALLERY_SORT_BY_DATE': True,
+ 'GALLERIES_USE_THUMBNAIL': False,
+ 'GALLERIES_DEFAULT_THUMBNAIL': None,
'GLOBAL_CONTEXT_FILLER': [],
'GZIP_COMMAND': None,
'GZIP_FILES': False,
'GZIP_EXTENSIONS': ('.txt', '.htm', '.html', '.css', '.js', '.json', '.xml'),
+ 'HIDDEN_AUTHORS': [],
'HIDDEN_TAGS': [],
+ 'HIDE_REST_DOCINFO': False,
'HIDDEN_CATEGORIES': [],
'HYPHENATE': False,
'IMAGE_FOLDERS': {'images': ''},
@@ -397,6 +526,7 @@ class Nikola(object):
'INDEX_FILE': 'index.html',
'INDEX_TEASERS': False,
'IMAGE_THUMBNAIL_SIZE': 400,
+ 'IMAGE_THUMBNAIL_FORMAT': '{name}.thumbnail{ext}',
'INDEXES_TITLE': "",
'INDEXES_PAGES': "",
'INDEXES_PAGES_MAIN': False,
@@ -404,79 +534,105 @@ class Nikola(object):
'INDEXES_STATIC': True,
'INDEX_PATH': '',
'IPYNB_CONFIG': {},
- 'LESS_COMPILER': 'lessc',
- 'LESS_OPTIONS': [],
+ 'KATEX_AUTO_RENDER': '',
'LICENSE': '',
'LINK_CHECK_WHITELIST': [],
'LISTINGS_FOLDERS': {'listings': 'listings'},
'LOGO_URL': '',
+ 'DEFAULT_PREVIEW_IMAGE': None,
'NAVIGATION_LINKS': {},
- 'MARKDOWN_EXTENSIONS': ['fenced_code', 'codehilite'], # FIXME: Add 'extras' in v8
+ 'NAVIGATION_ALT_LINKS': {},
+ 'MARKDOWN_EXTENSIONS': ['fenced_code', 'codehilite', 'extra'],
+ 'MARKDOWN_EXTENSION_CONFIGS': {},
'MAX_IMAGE_SIZE': 1280,
'MATHJAX_CONFIG': '',
+ 'METADATA_FORMAT': 'nikola',
+ 'METADATA_MAPPING': {},
+ 'MULTIPLE_AUTHORS_PER_POST': False,
+ 'NEW_POST_DATE_PATH': False,
+ 'NEW_POST_DATE_PATH_FORMAT': '%Y/%m/%d',
'OLD_THEME_SUPPORT': True,
'OUTPUT_FOLDER': 'output',
'POSTS': (("posts/*.txt", "posts", "post.tmpl"),),
- 'PAGES': (("stories/*.txt", "stories", "story.tmpl"),),
+ 'PRESERVE_EXIF_DATA': False,
+ 'PRESERVE_ICC_PROFILES': False,
+ 'PAGES': (("pages/*.txt", "pages", "page.tmpl"),),
'PANDOC_OPTIONS': [],
- 'PRETTY_URLS': False,
+ 'PRETTY_URLS': True,
'FUTURE_IS_NOW': False,
'INDEX_READ_MORE_LINK': DEFAULT_INDEX_READ_MORE_LINK,
- 'RSS_READ_MORE_LINK': DEFAULT_RSS_READ_MORE_LINK,
- 'RSS_LINKS_APPEND_QUERY': False,
'REDIRECTIONS': [],
'ROBOTS_EXCLUSIONS': [],
'GENERATE_ATOM': False,
+ 'ATOM_EXTENSION': '.atom',
+ 'ATOM_PATH': '',
+ 'ATOM_FILENAME_BASE': 'index',
+ 'FEED_TEASERS': True,
+ 'FEED_PLAIN': False,
+ 'FEED_READ_MORE_LINK': DEFAULT_FEED_READ_MORE_LINK,
+ 'FEED_LINKS_APPEND_QUERY': False,
'GENERATE_RSS': True,
+ 'RSS_EXTENSION': '.xml',
'RSS_LINK': None,
'RSS_PATH': '',
- 'RSS_PLAIN': False,
- 'RSS_TEASERS': True,
- 'SASS_COMPILER': 'sass',
- 'SASS_OPTIONS': [],
+ 'RSS_FILENAME_BASE': 'rss',
'SEARCH_FORM': '',
'SHOW_BLOG_TITLE': True,
+ 'SHOW_INDEX_PAGE_NAVIGATION': False,
'SHOW_SOURCELINK': True,
'SHOW_UNTRANSLATED_POSTS': True,
+ 'SLUG_AUTHOR_PATH': True,
'SLUG_TAG_PATH': True,
'SOCIAL_BUTTONS_CODE': '',
'SITE_URL': 'https://example.com/',
- 'STORY_INDEX': False,
- 'STRIP_INDEXES': False,
- 'SITEMAP_INCLUDE_FILELESS_DIRS': True,
+ 'PAGE_INDEX': False,
+ 'SECTION_PATH': '',
+ 'STRIP_INDEXES': True,
'TAG_PATH': 'categories',
'TAG_PAGES_ARE_INDEXES': False,
- 'TAG_PAGES_DESCRIPTIONS': {},
+ 'TAG_DESCRIPTIONS': {},
+ 'TAG_TITLES': {},
+ 'TAG_TRANSLATIONS': [],
+ 'TAG_TRANSLATIONS_ADD_DEFAULTS': False,
+ 'TAGS_INDEX_PATH': '',
'TAGLIST_MINIMUM_POSTS': 1,
'TEMPLATE_FILTERS': {},
- 'THEME': 'bootstrap3',
- 'THEME_REVEAL_CONFIG_SUBTHEME': 'sky',
- 'THEME_REVEAL_CONFIG_TRANSITION': 'cube',
+ 'THEME': LEGAL_VALUES['DEFAULT_THEME'],
+ 'THEME_COLOR': '#5670d4', # light "corporate blue"
+ 'THEME_CONFIG': {},
'THUMBNAIL_SIZE': 180,
- 'UNSLUGIFY_TITLES': False, # WARNING: conf.py.in overrides this with True for backwards compatibility
+ 'TRANSLATIONS_PATTERN': DEFAULT_TRANSLATIONS_PATTERN,
'URL_TYPE': 'rel_path',
'USE_BUNDLES': True,
'USE_CDN': False,
'USE_CDN_WARNING': True,
+ 'USE_REST_DOCINFO_METADATA': False,
'USE_FILENAME_AS_TITLE': True,
- 'USE_OPEN_GRAPH': True,
+ 'USE_KATEX': False,
'USE_SLUGIFY': True,
+ 'USE_TAG_METADATA': True,
'TIMEZONE': 'UTC',
- 'WRITE_TAG_CLOUD': True,
+ 'WARN_ABOUT_TAG_METADATA': True,
'DEPLOY_DRAFTS': True,
'DEPLOY_FUTURE': False,
'SCHEDULE_ALL': False,
'SCHEDULE_RULE': '',
- 'LOGGING_HANDLERS': {'stderr': {'loglevel': 'WARNING', 'bubble': True}},
'DEMOTE_HEADERS': 1,
'GITHUB_SOURCE_BRANCH': 'master',
'GITHUB_DEPLOY_BRANCH': 'gh-pages',
'GITHUB_REMOTE_NAME': 'origin',
+ 'GITHUB_COMMIT_SOURCE': False, # WARNING: conf.py.in overrides this with True for backwards compatibility
+ 'META_GENERATOR_TAG': True,
+ 'REST_FILE_INSERTION_ENABLED': True,
+ 'TYPES_TO_HIDE_TITLE': [],
}
# set global_context for template rendering
self._GLOBAL_CONTEXT = {}
+ # dependencies for all pages, not included in global context
+ self.ALL_PAGE_DEPS = {}
+
self.config.update(config)
# __builtins__ contains useless cruft
@@ -490,13 +646,22 @@ class Nikola(object):
self.config['__invariant__'] = self.invariant
self.config['__quiet__'] = self.quiet
- # Make sure we have sane NAVIGATION_LINKS.
+ # Use ATOM_PATH when set
+ self.config['ATOM_PATH'] = self.config['ATOM_PATH'] or self.config['INDEX_PATH']
+
+ # Make sure we have sane NAVIGATION_LINKS and NAVIGATION_ALT_LINKS.
if not self.config['NAVIGATION_LINKS']:
self.config['NAVIGATION_LINKS'] = {self.config['DEFAULT_LANG']: ()}
+ if not self.config['NAVIGATION_ALT_LINKS']:
+ self.config['NAVIGATION_ALT_LINKS'] = {self.config['DEFAULT_LANG']: ()}
# Translatability configuration.
self.config['TRANSLATIONS'] = self.config.get('TRANSLATIONS',
{self.config['DEFAULT_LANG']: ''})
+ for k, v in self.config['TRANSLATIONS'].items():
+ if os.path.isabs(v):
+ self.config['TRANSLATIONS'][k] = os.path.relpath(v, '/')
+
utils.TranslatableSetting.default_lang = self.config['DEFAULT_LANG']
self.TRANSLATABLE_SETTINGS = ('BLOG_AUTHOR',
@@ -509,43 +674,190 @@ class Nikola(object):
'BODY_END',
'EXTRA_HEAD_DATA',
'NAVIGATION_LINKS',
+ 'NAVIGATION_ALT_LINKS',
+ 'FRONT_INDEX_HEADER',
'INDEX_READ_MORE_LINK',
- 'RSS_READ_MORE_LINK',
+ 'FEED_READ_MORE_LINK',
'INDEXES_TITLE',
+ 'CATEGORY_DESTPATH_NAMES',
'INDEXES_PAGES',
- 'INDEXES_PRETTY_PAGE_URL',)
+ 'INDEXES_PRETTY_PAGE_URL',
+ 'THEME_CONFIG',
+ # PATH options (Issue #1914)
+ 'ARCHIVE_PATH',
+ 'ARCHIVE_FILENAME',
+ 'TAG_PATH',
+ 'TAGS_INDEX_PATH',
+ 'CATEGORY_PATH',
+ 'CATEGORIES_INDEX_PATH',
+ 'SECTION_PATH',
+ 'INDEX_PATH',
+ 'ATOM_PATH',
+ 'RSS_PATH',
+ 'RSS_FILENAME_BASE',
+ 'ATOM_FILENAME_BASE',
+ 'AUTHOR_PATH',
+ 'DATE_FORMAT',
+ 'LUXON_DATE_FORMAT',
+ 'MOMENTJS_DATE_FORMAT', # TODO: remove in v9
+ 'RSS_COPYRIGHT',
+ 'RSS_COPYRIGHT_PLAIN',
+ # Issue #2970
+ 'MARKDOWN_EXTENSION_CONFIGS',
+ )
self._GLOBAL_CONTEXT_TRANSLATABLE = ('blog_author',
'blog_title',
- 'blog_desc', # TODO: remove in v8
'blog_description',
'license',
'content_footer',
'social_buttons_code',
'search_form',
'body_end',
- 'extra_head_data',)
- # WARNING: navigation_links SHOULD NOT be added to the list above.
+ 'extra_head_data',
+ 'date_format',
+ 'js_date_format',
+ 'luxon_date_format',
+ 'front_index_header',
+ 'theme_config',
+ )
+
+ self._ALL_PAGE_DEPS_TRANSLATABLE = ('atom_path',
+ 'rss_path',
+ 'rss_filename_base',
+ 'atom_filename_base',
+ )
+ # WARNING: navigation_(alt_)links SHOULD NOT be added to the list above.
# Themes ask for [lang] there and we should provide it.
+ # Luxon setup is a dict of dicts, so we need to set up the default here.
+ if not self.config['LUXON_DATE_FORMAT']:
+ self.config['LUXON_DATE_FORMAT'] = {self.config['DEFAULT_LANG']: {'preset': False, 'format': 'yyyy-MM-dd HH:mm'}}
+ # TODO: remove Moment.js stuff in v9
+ if 'JS_DATE_FORMAT' in self.config:
+ utils.LOGGER.warning("Moment.js was replaced by Luxon in the default themes, which uses different date formats.")
+ utils.LOGGER.warning("If you’re using a built-in theme, set LUXON_DATE_FORMAT. If your theme uses Moment.js, you can silence this warning by renaming JS_DATE_FORMAT to MOMENTJS_DATE_FORMAT.")
+ utils.LOGGER.warning("Sample Luxon config: LUXON_DATE_FORMAT = " + str(self.config['LUXON_DATE_FORMAT']))
+ self.config['MOMENTJS_DATE_FORMAT'] = self.config['LUXON_DATE_FORMAT']
+
+ # We first have to massage MOMENTJS_DATE_FORMAT and LUXON_DATE_FORMAT, otherwise we run into trouble
+ if 'MOMENTJS_DATE_FORMAT' in self.config:
+ if isinstance(self.config['MOMENTJS_DATE_FORMAT'], dict):
+ for k in self.config['MOMENTJS_DATE_FORMAT']:
+ self.config['MOMENTJS_DATE_FORMAT'][k] = json.dumps(self.config['MOMENTJS_DATE_FORMAT'][k])
+ else:
+ self.config['MOMENTJS_DATE_FORMAT'] = json.dumps(self.config['MOMENTJS_DATE_FORMAT'])
+
+ if 'LUXON_DATE_FORMAT' in self.config:
+ for k in self.config['LUXON_DATE_FORMAT']:
+ self.config['LUXON_DATE_FORMAT'][k] = json.dumps(self.config['LUXON_DATE_FORMAT'][k])
+
for i in self.TRANSLATABLE_SETTINGS:
try:
self.config[i] = utils.TranslatableSetting(i, self.config[i], self.config['TRANSLATIONS'])
except KeyError:
pass
- # Handle CONTENT_FOOTER properly.
- # We provide the arguments to format in CONTENT_FOOTER_FORMATS.
+ # A EXIF_WHITELIST implies you want to keep EXIF data
+ if self.config['EXIF_WHITELIST'] and not self.config['PRESERVE_EXIF_DATA']:
+ utils.LOGGER.warning('Setting EXIF_WHITELIST implies PRESERVE_EXIF_DATA is set to True')
+ self.config['PRESERVE_EXIF_DATA'] = True
+
+ # Setting PRESERVE_EXIF_DATA with an empty EXIF_WHITELIST implies 'keep everything'
+ if self.config['PRESERVE_EXIF_DATA'] and not self.config['EXIF_WHITELIST']:
+ utils.LOGGER.warning('You are setting PRESERVE_EXIF_DATA and not EXIF_WHITELIST so EXIF data is not really kept.')
+
+ if 'UNSLUGIFY_TITLES' in self.config:
+ utils.LOGGER.warning('The UNSLUGIFY_TITLES setting was renamed to FILE_METADATA_UNSLUGIFY_TITLES.')
+ self.config['FILE_METADATA_UNSLUGIFY_TITLES'] = self.config['UNSLUGIFY_TITLES']
+
+ if 'TAG_PAGES_TITLES' in self.config:
+ utils.LOGGER.warning('The TAG_PAGES_TITLES setting was renamed to TAG_TITLES.')
+ self.config['TAG_TITLES'] = self.config['TAG_PAGES_TITLES']
+
+ if 'TAG_PAGES_DESCRIPTIONS' in self.config:
+ utils.LOGGER.warning('The TAG_PAGES_DESCRIPTIONS setting was renamed to TAG_DESCRIPTIONS.')
+ self.config['TAG_DESCRIPTIONS'] = self.config['TAG_PAGES_DESCRIPTIONS']
+
+ if 'CATEGORY_PAGES_TITLES' in self.config:
+ utils.LOGGER.warning('The CATEGORY_PAGES_TITLES setting was renamed to CATEGORY_TITLES.')
+ self.config['CATEGORY_TITLES'] = self.config['CATEGORY_PAGES_TITLES']
+
+ if 'CATEGORY_PAGES_DESCRIPTIONS' in self.config:
+ utils.LOGGER.warning('The CATEGORY_PAGES_DESCRIPTIONS setting was renamed to CATEGORY_DESCRIPTIONS.')
+ self.config['CATEGORY_DESCRIPTIONS'] = self.config['CATEGORY_PAGES_DESCRIPTIONS']
+
+ if 'DISABLE_INDEXES_PLUGIN_INDEX_AND_ATOM_FEED' in self.config:
+ utils.LOGGER.warning('The DISABLE_INDEXES_PLUGIN_INDEX_AND_ATOM_FEED setting was renamed and split to DISABLE_INDEXES and DISABLE_MAIN_ATOM_FEED.')
+ self.config['DISABLE_INDEXES'] = self.config['DISABLE_INDEXES_PLUGIN_INDEX_AND_ATOM_FEED']
+ self.config['DISABLE_MAIN_ATOM_FEED'] = self.config['DISABLE_INDEXES_PLUGIN_INDEX_AND_ATOM_FEED']
+
+ if 'DISABLE_INDEXES_PLUGIN_RSS_FEED' in self.config:
+ utils.LOGGER.warning('The DISABLE_INDEXES_PLUGIN_RSS_FEED setting was renamed to DISABLE_MAIN_RSS_FEED.')
+ self.config['DISABLE_MAIN_RSS_FEED'] = self.config['DISABLE_INDEXES_PLUGIN_RSS_FEED']
+
+ for val in self.config['DATE_FORMAT'].values.values():
+ if '%' in val:
+ utils.LOGGER.error('The DATE_FORMAT setting needs to be upgraded.')
+ utils.LOGGER.warning("Nikola now uses CLDR-style date strings. http://cldr.unicode.org/translation/date-time")
+ utils.LOGGER.warning("Example: %Y-%m-%d %H:%M ==> yyyy-MM-dd HH:mm")
+ utils.LOGGER.warning("(note it’s different to what moment.js uses!)")
+ sys.exit(1)
+
+ # Silently upgrade LOCALES (remove encoding)
+ locales = LEGAL_VALUES['LOCALES_BASE']
+ if 'LOCALES' in self.config:
+ for k, v in self.config['LOCALES'].items():
+ self.config['LOCALES'][k] = v.split('.')[0]
+ locales.update(self.config['LOCALES'])
+ self.config['LOCALES'] = locales
+
+ if self.config.get('POSTS_SECTIONS'):
+ utils.LOGGER.warning("The sections feature has been removed and its functionality has been merged into categories.")
+ utils.LOGGER.warning("For more information on how to migrate, please read: https://getnikola.com/blog/upgrading-to-nikola-v8.html#sections-were-replaced-by-categories")
+
+ for section_config_suffix, cat_config_suffix in (
+ ('DESCRIPTIONS', 'DESCRIPTIONS'),
+ ('TITLE', 'TITLES'),
+ ('TRANSLATIONS', 'TRANSLATIONS')
+ ):
+ section_config = 'POSTS_SECTION_' + section_config_suffix
+ cat_config = 'CATEGORY_' + cat_config_suffix
+ if section_config in self.config:
+ self.config[section_config].update(self.config[cat_config])
+ self.config[cat_config] = self.config[section_config]
+
+ self.config['CATEGORY_DESTPATH_NAMES'] = self.config.get('POSTS_SECTION_NAME', {})
+ # Need to mark this translatable manually.
+ self.config['CATEGORY_DESTPATH_NAMES'] = utils.TranslatableSetting('CATEGORY_DESTPATH_NAMES', self.config['CATEGORY_DESTPATH_NAMES'], self.config['TRANSLATIONS'])
+
+ self.config['CATEGORY_DESTPATH_AS_DEFAULT'] = not self.config.get('POSTS_SECTION_FROM_META')
+ utils.LOGGER.info("Setting CATEGORY_DESTPATH_AS_DEFAULT = " + str(self.config['CATEGORY_DESTPATH_AS_DEFAULT']))
+
+ if self.config.get('CATEGORY_PAGES_FOLLOW_DESTPATH') and (not self.config.get('CATEGORY_ALLOW_HIERARCHIES') or self.config.get('CATEGORY_OUTPUT_FLAT_HIERARCHY')):
+ utils.LOGGER.error('CATEGORY_PAGES_FOLLOW_DESTPATH requires CATEGORY_ALLOW_HIERARCHIES = True, CATEGORY_OUTPUT_FLAT_HIERARCHY = False.')
+ sys.exit(1)
+
+ # The Utterances comment system has a required configuration value
+ if self.config.get('COMMENT_SYSTEM') == 'utterances':
+ utterances_config = self.config.get('GLOBAL_CONTEXT', {}).get('utterances_config', {})
+ if not ('issue-term' in utterances_config or 'issue-number' in utterances_config):
+ utils.LOGGER.error("COMMENT_SYSTEM = 'utterances' must have either GLOBAL_CONTEXT['utterances_config']['issue-term'] or GLOBAL_CONTEXT['utterances_config']['issue-term'] defined.")
+
+ # Handle CONTENT_FOOTER and RSS_COPYRIGHT* properly.
+ # We provide the arguments to format in CONTENT_FOOTER_FORMATS and RSS_COPYRIGHT_FORMATS.
self.config['CONTENT_FOOTER'].langformat(self.config['CONTENT_FOOTER_FORMATS'])
+ self.config['RSS_COPYRIGHT'].langformat(self.config['RSS_COPYRIGHT_FORMATS'])
+ self.config['RSS_COPYRIGHT_PLAIN'].langformat(self.config['RSS_COPYRIGHT_FORMATS'])
# propagate USE_SLUGIFY
utils.USE_SLUGIFY = self.config['USE_SLUGIFY']
# Make sure we have pyphen installed if we are using it
if self.config.get('HYPHENATE') and pyphen is None:
- utils.LOGGER.warn('To use the hyphenation, you have to install '
- 'the "pyphen" package.')
- utils.LOGGER.warn('Setting HYPHENATE to False.')
+ utils.LOGGER.warning('To use the hyphenation, you have to install '
+ 'the "pyphen" package.')
+ utils.LOGGER.warning('Setting HYPHENATE to False.')
self.config['HYPHENATE'] = False
# FIXME: Internally, we still use post_pages because it's a pain to change it
@@ -555,86 +867,44 @@ class Nikola(object):
for i1, i2, i3 in self.config['PAGES']:
self.config['post_pages'].append([i1, i2, i3, False])
- # DEFAULT_TRANSLATIONS_PATTERN was changed from "p.e.l" to "p.l.e"
- # TODO: remove on v8
- if 'TRANSLATIONS_PATTERN' not in self.config:
- if len(self.config.get('TRANSLATIONS', {})) > 1:
- utils.LOGGER.warn('You do not have a TRANSLATIONS_PATTERN set in your config, yet you have multiple languages.')
- utils.LOGGER.warn('Setting TRANSLATIONS_PATTERN to the pre-v6 default ("{path}.{ext}.{lang}").')
- utils.LOGGER.warn('Please add the proper pattern to your conf.py. (The new default in v7 is "{0}".)'.format(DEFAULT_TRANSLATIONS_PATTERN))
- self.config['TRANSLATIONS_PATTERN'] = "{path}.{ext}.{lang}"
- else:
- # use v7 default there
- self.config['TRANSLATIONS_PATTERN'] = DEFAULT_TRANSLATIONS_PATTERN
-
- # HIDE_SOURCELINK has been replaced with the inverted SHOW_SOURCELINK
- # TODO: remove on v8
- if 'HIDE_SOURCELINK' in config:
- utils.LOGGER.warn('The HIDE_SOURCELINK option is deprecated, use SHOW_SOURCELINK instead.')
- if 'SHOW_SOURCELINK' in config:
- utils.LOGGER.warn('HIDE_SOURCELINK conflicts with SHOW_SOURCELINK, ignoring HIDE_SOURCELINK.')
- self.config['SHOW_SOURCELINK'] = not config['HIDE_SOURCELINK']
-
- # HIDE_UNTRANSLATED_POSTS has been replaced with the inverted SHOW_UNTRANSLATED_POSTS
- # TODO: remove on v8
- if 'HIDE_UNTRANSLATED_POSTS' in config:
- utils.LOGGER.warn('The HIDE_UNTRANSLATED_POSTS option is deprecated, use SHOW_UNTRANSLATED_POSTS instead.')
- if 'SHOW_UNTRANSLATED_POSTS' in config:
- utils.LOGGER.warn('HIDE_UNTRANSLATED_POSTS conflicts with SHOW_UNTRANSLATED_POSTS, ignoring HIDE_UNTRANSLATED_POSTS.')
- self.config['SHOW_UNTRANSLATED_POSTS'] = not config['HIDE_UNTRANSLATED_POSTS']
-
- # READ_MORE_LINK has been split into INDEX_READ_MORE_LINK and RSS_READ_MORE_LINK
- # TODO: remove on v8
- if 'READ_MORE_LINK' in config:
- utils.LOGGER.warn('The READ_MORE_LINK option is deprecated, use INDEX_READ_MORE_LINK and RSS_READ_MORE_LINK instead.')
- if 'INDEX_READ_MORE_LINK' in config:
- utils.LOGGER.warn('READ_MORE_LINK conflicts with INDEX_READ_MORE_LINK, ignoring READ_MORE_LINK.')
+ # Handle old plugin names (from before merging the taxonomy PR #2535)
+ for old_plugin_name, new_plugin_names in TAXONOMY_COMPATIBILITY_PLUGIN_NAME_MAP.items():
+ if old_plugin_name in self.config['DISABLED_PLUGINS']:
+ missing_plugins = []
+ for plugin_name in new_plugin_names:
+ if plugin_name not in self.config['DISABLED_PLUGINS']:
+ missing_plugins.append(plugin_name)
+ if missing_plugins:
+ utils.LOGGER.warning('The "{}" plugin was replaced by several taxonomy plugins (see PR #2535): {}'.format(old_plugin_name, ', '.join(new_plugin_names)))
+ utils.LOGGER.warning('You are currently disabling "{}", but not the following new taxonomy plugins: {}'.format(old_plugin_name, ', '.join(missing_plugins)))
+ utils.LOGGER.warning('Please also disable these new plugins or remove "{}" from the DISABLED_PLUGINS list.'.format(old_plugin_name))
+ self.config['DISABLED_PLUGINS'].extend(missing_plugins)
+ # Special-case logic for "render_indexes" to fix #2591
+ if 'render_indexes' in self.config['DISABLED_PLUGINS']:
+ if 'generate_rss' in self.config['DISABLED_PLUGINS'] or self.config['GENERATE_RSS'] is False:
+ if 'classify_indexes' not in self.config['DISABLED_PLUGINS']:
+ utils.LOGGER.warning('You are disabling the "render_indexes" plugin, as well as disabling the "generate_rss" plugin or setting GENERATE_RSS to False. To achieve the same effect, please disable the "classify_indexes" plugin in the future.')
+ self.config['DISABLED_PLUGINS'].append('classify_indexes')
else:
- self.config['INDEX_READ_MORE_LINK'] = utils.TranslatableSetting('INDEX_READ_MORE_LINK', config['READ_MORE_LINK'], self.config['TRANSLATIONS'])
-
- if 'RSS_READ_MORE_LINK' in config:
- utils.LOGGER.warn('READ_MORE_LINK conflicts with RSS_READ_MORE_LINK, ignoring READ_MORE_LINK.')
- else:
- self.config['RSS_READ_MORE_LINK'] = utils.TranslatableSetting('RSS_READ_MORE_LINK', config['READ_MORE_LINK'], self.config['TRANSLATIONS'])
-
- # Moot.it renamed themselves to muut.io
- # TODO: remove on v8?
- if self.config.get('COMMENT_SYSTEM') == 'moot':
- utils.LOGGER.warn('The moot comment system has been renamed to muut by the upstream. Setting COMMENT_SYSTEM to "muut".')
- self.config['COMMENT_SYSTEM'] = 'muut'
+ if not self.config['DISABLE_INDEXES']:
+ utils.LOGGER.warning('You are disabling the "render_indexes" plugin, but not the generation of RSS feeds. Please put "DISABLE_INDEXES = True" into your configuration instead.')
+ self.config['DISABLE_INDEXES'] = True
# Disable RSS. For a successful disable, we must have both the option
# false and the plugin disabled through the official means.
if 'generate_rss' in self.config['DISABLED_PLUGINS'] and self.config['GENERATE_RSS'] is True:
+ utils.LOGGER.warning('Please use GENERATE_RSS to disable RSS feed generation, instead of mentioning generate_rss in DISABLED_PLUGINS.')
self.config['GENERATE_RSS'] = False
-
- if not self.config['GENERATE_RSS'] and 'generate_rss' not in self.config['DISABLED_PLUGINS']:
- self.config['DISABLED_PLUGINS'].append('generate_rss')
+ self.config['DISABLE_MAIN_RSS_FEED'] = True
# PRETTY_URLS defaults to enabling STRIP_INDEXES unless explicitly disabled
if self.config.get('PRETTY_URLS') and 'STRIP_INDEXES' not in config:
self.config['STRIP_INDEXES'] = True
- if 'LISTINGS_FOLDER' in config:
- if 'LISTINGS_FOLDERS' not in config:
- utils.LOGGER.warn("The LISTINGS_FOLDER option is deprecated, use LISTINGS_FOLDERS instead.")
- self.config['LISTINGS_FOLDERS'] = {self.config['LISTINGS_FOLDER']: self.config['LISTINGS_FOLDER']}
- utils.LOGGER.warn("LISTINGS_FOLDERS = {0}".format(self.config['LISTINGS_FOLDERS']))
- else:
- utils.LOGGER.warn("Both LISTINGS_FOLDER and LISTINGS_FOLDERS are specified, ignoring LISTINGS_FOLDER.")
-
- if 'GALLERY_PATH' in config:
- if 'GALLERY_FOLDERS' not in config:
- utils.LOGGER.warn("The GALLERY_PATH option is deprecated, use GALLERY_FOLDERS instead.")
- self.config['GALLERY_FOLDERS'] = {self.config['GALLERY_PATH']: self.config['GALLERY_PATH']}
- utils.LOGGER.warn("GALLERY_FOLDERS = {0}".format(self.config['GALLERY_FOLDERS']))
- else:
- utils.LOGGER.warn("Both GALLERY_PATH and GALLERY_FOLDERS are specified, ignoring GALLERY_PATH.")
-
if not self.config.get('COPY_SOURCES'):
self.config['SHOW_SOURCELINK'] = False
- if self.config['CATEGORY_PATH'] is None:
+ if self.config['CATEGORY_PATH']._inp is None:
self.config['CATEGORY_PATH'] = self.config['TAG_PATH']
if self.config['CATEGORY_PAGES_ARE_INDEXES'] is None:
self.config['CATEGORY_PAGES_ARE_INDEXES'] = self.config['TAG_PAGES_ARE_INDEXES']
@@ -642,18 +912,14 @@ class Nikola(object):
self.default_lang = self.config['DEFAULT_LANG']
self.translations = self.config['TRANSLATIONS']
- locale_fallback, locale_default, locales = sanitized_locales(
- self.config.get('LOCALE_FALLBACK', None),
- self.config.get('LOCALE_DEFAULT', None),
- self.config.get('LOCALES', {}), self.translations)
- utils.LocaleBorg.initialize(locales, self.default_lang)
+ utils.LocaleBorg.initialize(self.config.get('LOCALES', {}), self.default_lang)
# BASE_URL defaults to SITE_URL
if 'BASE_URL' not in self.config:
self.config['BASE_URL'] = self.config.get('SITE_URL')
# BASE_URL should *always* end in /
if self.config['BASE_URL'] and self.config['BASE_URL'][-1] != '/':
- utils.LOGGER.warn("Your BASE_URL doesn't end in / -- adding it, but please fix it in your config file!")
+ utils.LOGGER.warning("Your BASE_URL doesn't end in / -- adding it, but please fix it in your config file!")
self.config['BASE_URL'] += '/'
try:
@@ -665,26 +931,26 @@ class Nikola(object):
utils.LOGGER.error("Punycode of {}: {}".format(_bnl, _bnl.encode('idna')))
sys.exit(1)
- # TODO: remove in v8
- if not isinstance(self.config['DEPLOY_COMMANDS'], dict):
- utils.LOGGER.warn("A single list as DEPLOY_COMMANDS is deprecated. DEPLOY_COMMANDS should be a dict, with deploy preset names as keys and lists of commands as values.")
- utils.LOGGER.warn("The key `default` is used by `nikola deploy`:")
- self.config['DEPLOY_COMMANDS'] = {'default': self.config['DEPLOY_COMMANDS']}
- utils.LOGGER.warn("DEPLOY_COMMANDS = {0}".format(self.config['DEPLOY_COMMANDS']))
- utils.LOGGER.info("(The above can be used with `nikola deploy` or `nikola deploy default`. Multiple presets are accepted.)")
-
- # TODO: remove and change default in v8
- if 'BLOG_TITLE' in config and 'WRITE_TAG_CLOUD' not in config:
- # BLOG_TITLE is a hack, otherwise the warning would be displayed
- # when conf.py does not exist
- utils.LOGGER.warn("WRITE_TAG_CLOUD is not set in your config. Defaulting to True (== writing tag_cloud_data.json).")
- utils.LOGGER.warn("Please explicitly add the setting to your conf.py with the desired value, as the setting will default to False in the future.")
+ # Load built-in metadata extractors
+ metadata_extractors.load_defaults(self, self.metadata_extractors_by)
+ if metadata_extractors.DEFAULT_EXTRACTOR is None:
+ utils.LOGGER.error("Could not find default meta extractor ({})".format(
+ metadata_extractors.DEFAULT_EXTRACTOR_NAME))
+ sys.exit(1)
+
+ # The Pelican metadata format requires a markdown extension
+ if config.get('METADATA_FORMAT', 'nikola').lower() == 'pelican':
+ if 'markdown.extensions.meta' not in config.get('MARKDOWN_EXTENSIONS', []) \
+ and 'markdown' in self.config['COMPILERS']:
+ utils.LOGGER.warning(
+ 'To use the Pelican metadata format, you need to add '
+ '"markdown.extensions.meta" to your MARKDOWN_EXTENSIONS setting.')
# We use one global tzinfo object all over Nikola.
try:
self.tzinfo = dateutil.tz.gettz(self.config['TIMEZONE'])
except Exception as exc:
- utils.LOGGER.warn("Error getting TZ: {}", exc)
+ utils.LOGGER.warning("Error getting TZ: {}", exc)
self.tzinfo = dateutil.tz.gettz()
self.config['__tzinfo__'] = self.tzinfo
@@ -693,31 +959,60 @@ class Nikola(object):
for k, v in self.config['COMPILERS'].items():
self.config['_COMPILERS_RAW'][k] = list(v)
- compilers = defaultdict(set)
- # Also add aliases for combinations with TRANSLATIONS_PATTERN
- for compiler, exts in self.config['COMPILERS'].items():
- for ext in exts:
- compilers[compiler].add(ext)
- for lang in self.config['TRANSLATIONS'].keys():
- candidate = utils.get_translation_candidate(self.config, "f" + ext, lang)
- compilers[compiler].add(candidate)
-
- # Avoid redundant compilers
- # Remove compilers that match nothing in POSTS/PAGES
- # And put them in "bad compilers"
- pp_exts = set([os.path.splitext(x[0])[1] for x in self.config['post_pages']])
- self.config['COMPILERS'] = {}
- self.disabled_compilers = {}
- self.bad_compilers = set([])
- for k, v in compilers.items():
- if pp_exts.intersection(v):
- self.config['COMPILERS'][k] = sorted(list(v))
- else:
- self.bad_compilers.add(k)
-
- self._set_global_context()
+ # Get search path for themes
+ self.themes_dirs = ['themes'] + self.config['EXTRA_THEMES_DIRS']
+
+ # Register default filters
+ filter_name_format = 'filters.{0}'
+ for filter_name, filter_definition in filters.__dict__.items():
+ # Ignore objects whose name starts with an underscore, or which are not callable
+ if filter_name.startswith('_') or not callable(filter_definition):
+ continue
+ # Register all other objects as filters
+ self.register_filter(filter_name_format.format(filter_name), filter_definition)
+
+ self._set_global_context_from_config()
+ self._set_all_page_deps_from_config()
+ # Read data files only if a site exists (Issue #2708)
+ if self.configured:
+ self._set_global_context_from_data()
+
+ # Set persistent state facility
+ self.state = Persistor('state_data.json')
+
+ # Set cache facility
+ self.cache = Persistor(os.path.join(self.config['CACHE_FOLDER'], 'cache_data.json'))
+
+ # Create directories for persistors only if a site exists (Issue #2334)
+ if self.configured:
+ self.state._set_site(self)
+ self.cache._set_site(self)
+
+ def _filter_duplicate_plugins(self, plugin_list):
+ """Find repeated plugins and discard the less local copy."""
+ def plugin_position_in_places(plugin):
+ # plugin here is a tuple:
+ # (path to the .plugin file, path to plugin module w/o .py, plugin metadata)
+ for i, place in enumerate(self._plugin_places):
+ if plugin[0].startswith(place):
+ return i
+ utils.LOGGER.warn("Duplicate plugin found in unexpected location: {}".format(plugin[0]))
+ return len(self._plugin_places)
+
+ plugin_dict = defaultdict(list)
+ for data in plugin_list:
+ plugin_dict[data[2].name].append(data)
+ result = []
+ for _, plugins in plugin_dict.items():
+ if len(plugins) > 1:
+ # Sort by locality
+ plugins.sort(key=plugin_position_in_places)
+ utils.LOGGER.debug("Plugin {} exists in multiple places, using {}".format(
+ plugins[-1][2].name, plugins[-1][0]))
+ result.append(plugins[-1])
+ return result
- def init_plugins(self, commands_only=False):
+ def init_plugins(self, commands_only=False, load_all=False):
"""Load plugins as needed."""
self.plugin_manager = PluginManager(categories_filter={
"Command": Command,
@@ -729,56 +1024,118 @@ class Nikola(object):
"CompilerExtension": CompilerExtension,
"MarkdownExtension": MarkdownExtension,
"RestExtension": RestExtension,
+ "MetadataExtractor": MetadataExtractor,
+ "ShortcodePlugin": ShortcodePlugin,
"SignalHandler": SignalHandler,
"ConfigPlugin": ConfigPlugin,
"PostScanner": PostScanner,
+ "Taxonomy": Taxonomy,
})
self.plugin_manager.getPluginLocator().setPluginInfoExtension('plugin')
extra_plugins_dirs = self.config['EXTRA_PLUGINS_DIRS']
- if sys.version_info[0] == 3:
- places = [
- resource_filename('nikola', 'plugins'),
- os.path.join(os.getcwd(), 'plugins'),
- os.path.expanduser('~/.nikola/plugins'),
- ] + [path for path in extra_plugins_dirs if path]
- else:
- places = [
- resource_filename('nikola', utils.sys_encode('plugins')),
- os.path.join(os.getcwd(), utils.sys_encode('plugins')),
- os.path.expanduser('~/.nikola/plugins'),
- ] + [utils.sys_encode(path) for path in extra_plugins_dirs if path]
+ self._plugin_places = [
+ resource_filename('nikola', 'plugins'),
+ os.path.expanduser(os.path.join('~', '.nikola', 'plugins')),
+ os.path.join(os.getcwd(), 'plugins'),
+ ] + [path for path in extra_plugins_dirs if path]
+
+ compilers = defaultdict(set)
+ # Also add aliases for combinations with TRANSLATIONS_PATTERN
+ for compiler, exts in self.config['COMPILERS'].items():
+ for ext in exts:
+ compilers[compiler].add(ext)
+ for lang in self.config['TRANSLATIONS'].keys():
+ candidate = utils.get_translation_candidate(self.config, "f" + ext, lang)
+ compilers[compiler].add(candidate)
- self.plugin_manager.getPluginLocator().setPluginPlaces(places)
+ # Avoid redundant compilers (if load_all is False):
+ # Remove compilers (and corresponding compiler extensions) that are not marked as
+ # needed by any PostScanner plugin and put them into self.disabled_compilers
+ # (respectively self.disabled_compiler_extensions).
+ self.config['COMPILERS'] = {}
+ self.disabled_compilers = {}
+ self.disabled_compiler_extensions = defaultdict(list)
+
+ self.plugin_manager.getPluginLocator().setPluginPlaces(self._plugin_places)
self.plugin_manager.locatePlugins()
bad_candidates = set([])
- for p in self.plugin_manager._candidates:
- if commands_only:
- if p[-1].details.has_option('Nikola', 'plugincategory'):
- # FIXME TemplateSystem should not be needed
- if p[-1].details.get('Nikola', 'PluginCategory') not in {'Command', 'Template'}:
+ if not load_all:
+ for p in self.plugin_manager._candidates:
+ if commands_only:
+ if p[-1].details.has_option('Nikola', 'PluginCategory'):
+ # FIXME TemplateSystem should not be needed
+ if p[-1].details.get('Nikola', 'PluginCategory') not in {'Command', 'Template'}:
+ bad_candidates.add(p)
+ else:
+ bad_candidates.add(p)
+ elif self.configured: # Not commands-only, and configured
+ # Remove blacklisted plugins
+ if p[-1].name in self.config['DISABLED_PLUGINS']:
+ bad_candidates.add(p)
+ utils.LOGGER.debug('Not loading disabled plugin {}', p[-1].name)
+ # Remove compilers we don't use
+ if p[-1].details.has_option('Nikola', 'PluginCategory') and p[-1].details.get('Nikola', 'PluginCategory') in ('Compiler', 'PageCompiler'):
bad_candidates.add(p)
- else: # Not commands-only
- # Remove compilers we don't use
- if p[-1].name in self.bad_compilers:
- bad_candidates.add(p)
- self.disabled_compilers[p[-1].name] = p
- utils.LOGGER.debug('Not loading unneeded compiler {}', p[-1].name)
- if p[-1].name not in self.config['COMPILERS'] and \
- p[-1].details.has_option('Nikola', 'plugincategory') and p[-1].details.get('Nikola', 'PluginCategory') == 'Compiler':
- bad_candidates.add(p)
- self.disabled_compilers[p[-1].name] = p
- utils.LOGGER.debug('Not loading unneeded compiler {}', p[-1].name)
- # Remove blacklisted plugins
- if p[-1].name in self.config['DISABLED_PLUGINS']:
- bad_candidates.add(p)
- utils.LOGGER.debug('Not loading disabled plugin {}', p[-1].name)
- # Remove compiler extensions we don't need
- if p[-1].details.has_option('Nikola', 'compiler') and p[-1].details.get('Nikola', 'compiler') in self.disabled_compilers:
- bad_candidates.add(p)
- utils.LOGGER.debug('Not loading comopiler extension {}', p[-1].name)
- self.plugin_manager._candidates = list(set(self.plugin_manager._candidates) - bad_candidates)
+ self.disabled_compilers[p[-1].name] = p
+ # Remove compiler extensions we don't need
+ if p[-1].details.has_option('Nikola', 'compiler') and p[-1].details.get('Nikola', 'compiler') in self.disabled_compilers:
+ bad_candidates.add(p)
+ self.disabled_compiler_extensions[p[-1].details.get('Nikola', 'compiler')].append(p)
+ self.plugin_manager._candidates = list(set(self.plugin_manager._candidates) - bad_candidates)
+
+ self.plugin_manager._candidates = self._filter_duplicate_plugins(self.plugin_manager._candidates)
self.plugin_manager.loadPlugins()
+ # Search for compiler plugins which we disabled but shouldn't have
+ self._activate_plugins_of_category("PostScanner")
+ if not load_all:
+ file_extensions = set()
+ for post_scanner in [p.plugin_object for p in self.plugin_manager.getPluginsOfCategory('PostScanner')]:
+ exts = post_scanner.supported_extensions()
+ if exts is not None:
+ file_extensions.update(exts)
+ else:
+ # Stop scanning for more: once we get None, we have to load all compilers anyway
+ utils.LOGGER.debug("Post scanner {0!r} does not implement `supported_extensions`, loading all compilers".format(post_scanner))
+ file_extensions = None
+ break
+ to_add = []
+ for k, v in compilers.items():
+ if file_extensions is None or file_extensions.intersection(v):
+ self.config['COMPILERS'][k] = sorted(list(v))
+ p = self.disabled_compilers.pop(k, None)
+ if p:
+ to_add.append(p)
+ for p in self.disabled_compiler_extensions.pop(k, []):
+ to_add.append(p)
+ for _, p in self.disabled_compilers.items():
+ utils.LOGGER.debug('Not loading unneeded compiler {}', p[-1].name)
+ for _, plugins in self.disabled_compiler_extensions.items():
+ for p in plugins:
+ utils.LOGGER.debug('Not loading compiler extension {}', p[-1].name)
+ if to_add:
+ self.plugin_manager._candidates = self._filter_duplicate_plugins(to_add)
+ self.plugin_manager.loadPlugins()
+
+ # Jupyter theme configuration. If a website has ipynb enabled in post_pages
+ # we should enable the Jupyter CSS (leaving that up to the theme itself).
+ if 'needs_ipython_css' not in self._GLOBAL_CONTEXT:
+ self._GLOBAL_CONTEXT['needs_ipython_css'] = 'ipynb' in self.config['COMPILERS']
+
+ # Activate metadata extractors and prepare them for use
+ for p in self._activate_plugins_of_category("MetadataExtractor"):
+ metadata_extractors.classify_extractor(p.plugin_object, self.metadata_extractors_by)
+
+ self._activate_plugins_of_category("Taxonomy")
+ self.taxonomy_plugins = {}
+ for taxonomy in [p.plugin_object for p in self.plugin_manager.getPluginsOfCategory('Taxonomy')]:
+ if not taxonomy.is_enabled():
+ continue
+ if taxonomy.classification_name in self.taxonomy_plugins:
+ utils.LOGGER.error("Found more than one taxonomy with classification name '{}'!".format(taxonomy.classification_name))
+ sys.exit(1)
+ self.taxonomy_plugins[taxonomy.classification_name] = taxonomy
+
self._activate_plugins_of_category("SignalHandler")
# Emit signal for SignalHandlers which need to start running immediately.
@@ -791,7 +1148,6 @@ class Nikola(object):
plugin_info.plugin_object.short_help = plugin_info.description
self._commands[plugin_info.name] = plugin_info.plugin_object
- self._activate_plugins_of_category("PostScanner")
self._activate_plugins_of_category("Task")
self._activate_plugins_of_category("LateTask")
self._activate_plugins_of_category("TaskMultiplier")
@@ -803,6 +1159,9 @@ class Nikola(object):
self.plugin_manager.activatePluginByName(plugin_info.name)
plugin_info.plugin_object.set_site(self)
+ # Activate shortcode plugins
+ self._activate_plugins_of_category("ShortcodePlugin")
+
# Load compiler plugins
self.compilers = {}
self.inverse_compilers = {}
@@ -812,12 +1171,36 @@ class Nikola(object):
self.compilers[plugin_info.name] = \
plugin_info.plugin_object
+ # Load config plugins and register templated shortcodes
self._activate_plugins_of_category("ConfigPlugin")
-
+ self._register_templated_shortcodes()
+
+ # Check with registered filters and configure filters
+ for actions in self.config['FILTERS'].values():
+ for i, f in enumerate(actions):
+ if isinstance(f, str):
+ # Check whether this denotes a registered filter
+ _f = self.filters.get(f)
+ if _f is not None:
+ f = _f
+ actions[i] = f
+ if hasattr(f, 'configuration_variables'):
+ args = {}
+ for arg, config in f.configuration_variables.items():
+ if config in self.config:
+ args[arg] = self.config[config]
+ if args:
+ actions[i] = functools.partial(f, **args)
+
+ # Signal that we are configured
signal('configured').send(self)
- def _set_global_context(self):
- """Create global context from configuration."""
+ def _set_global_context_from_config(self):
+ """Create global context from configuration.
+
+ These are options that are used by templates, so they always need to be
+ available.
+ """
self._GLOBAL_CONTEXT['url_type'] = self.config['URL_TYPE']
self._GLOBAL_CONTEXT['timezone'] = self.tzinfo
self._GLOBAL_CONTEXT['_link'] = self.link
@@ -828,24 +1211,24 @@ class Nikola(object):
self._GLOBAL_CONTEXT['rel_link'] = self.rel_link
self._GLOBAL_CONTEXT['abs_link'] = self.abs_link
self._GLOBAL_CONTEXT['exists'] = self.file_exists
- self._GLOBAL_CONTEXT['SLUG_TAG_PATH'] = self.config['SLUG_TAG_PATH']
- self._GLOBAL_CONTEXT['annotations'] = self.config['ANNOTATIONS']
self._GLOBAL_CONTEXT['index_display_post_count'] = self.config[
'INDEX_DISPLAY_POST_COUNT']
self._GLOBAL_CONTEXT['index_file'] = self.config['INDEX_FILE']
self._GLOBAL_CONTEXT['use_bundles'] = self.config['USE_BUNDLES']
self._GLOBAL_CONTEXT['use_cdn'] = self.config.get("USE_CDN")
+ self._GLOBAL_CONTEXT['theme_color'] = self.config.get("THEME_COLOR")
+ self._GLOBAL_CONTEXT['theme_config'] = self.config.get("THEME_CONFIG")
self._GLOBAL_CONTEXT['favicons'] = self.config['FAVICONS']
self._GLOBAL_CONTEXT['date_format'] = self.config.get('DATE_FORMAT')
self._GLOBAL_CONTEXT['blog_author'] = self.config.get('BLOG_AUTHOR')
self._GLOBAL_CONTEXT['blog_title'] = self.config.get('BLOG_TITLE')
+ self._GLOBAL_CONTEXT['blog_email'] = self.config.get('BLOG_EMAIL')
self._GLOBAL_CONTEXT['show_blog_title'] = self.config.get('SHOW_BLOG_TITLE')
self._GLOBAL_CONTEXT['logo_url'] = self.config.get('LOGO_URL')
self._GLOBAL_CONTEXT['blog_description'] = self.config.get('BLOG_DESCRIPTION')
-
- # TODO: remove in v8
- self._GLOBAL_CONTEXT['blog_desc'] = self.config.get('BLOG_DESCRIPTION')
-
+ self._GLOBAL_CONTEXT['front_index_header'] = self.config.get('FRONT_INDEX_HEADER')
+ self._GLOBAL_CONTEXT['color_hsl_adjust_hex'] = utils.color_hsl_adjust_hex
+ self._GLOBAL_CONTEXT['colorize_str_from_base_color'] = utils.colorize_str_from_base_color
self._GLOBAL_CONTEXT['blog_url'] = self.config.get('SITE_URL')
self._GLOBAL_CONTEXT['template_hooks'] = self.template_hooks
self._GLOBAL_CONTEXT['body_end'] = self.config.get('BODY_END')
@@ -858,19 +1241,17 @@ class Nikola(object):
self._GLOBAL_CONTEXT['site_has_comments'] = bool(self.config.get('COMMENT_SYSTEM'))
self._GLOBAL_CONTEXT['mathjax_config'] = self.config.get(
'MATHJAX_CONFIG')
- self._GLOBAL_CONTEXT['subtheme'] = self.config.get('THEME_REVEAL_CONFIG_SUBTHEME')
- self._GLOBAL_CONTEXT['transition'] = self.config.get('THEME_REVEAL_CONFIG_TRANSITION')
+ self._GLOBAL_CONTEXT['use_katex'] = self.config.get('USE_KATEX')
+ self._GLOBAL_CONTEXT['katex_auto_render'] = self.config.get('KATEX_AUTO_RENDER')
self._GLOBAL_CONTEXT['content_footer'] = self.config.get(
'CONTENT_FOOTER')
self._GLOBAL_CONTEXT['generate_atom'] = self.config.get('GENERATE_ATOM')
self._GLOBAL_CONTEXT['generate_rss'] = self.config.get('GENERATE_RSS')
- self._GLOBAL_CONTEXT['rss_path'] = self.config.get('RSS_PATH')
self._GLOBAL_CONTEXT['rss_link'] = self.config.get('RSS_LINK')
self._GLOBAL_CONTEXT['navigation_links'] = self.config.get('NAVIGATION_LINKS')
+ self._GLOBAL_CONTEXT['navigation_alt_links'] = self.config.get('NAVIGATION_ALT_LINKS')
- self._GLOBAL_CONTEXT['use_open_graph'] = self.config.get(
- 'USE_OPEN_GRAPH', True)
self._GLOBAL_CONTEXT['twitter_card'] = self.config.get(
'TWITTER_CARD', {})
self._GLOBAL_CONTEXT['hide_sourcelink'] = not self.config.get(
@@ -879,20 +1260,55 @@ class Nikola(object):
'SHOW_SOURCELINK')
self._GLOBAL_CONTEXT['extra_head_data'] = self.config.get('EXTRA_HEAD_DATA')
self._GLOBAL_CONTEXT['date_fanciness'] = self.config.get('DATE_FANCINESS')
- self._GLOBAL_CONTEXT['js_date_format'] = json.dumps(self.config.get('JS_DATE_FORMAT'))
- self._GLOBAL_CONTEXT['colorbox_locales'] = LEGAL_VALUES['COLORBOX_LOCALES']
+ self._GLOBAL_CONTEXT['luxon_locales'] = LEGAL_VALUES['LUXON_LOCALES']
+ self._GLOBAL_CONTEXT['luxon_date_format'] = self.config.get('LUXON_DATE_FORMAT')
+ # TODO: remove in v9
+ self._GLOBAL_CONTEXT['js_date_format'] = self.config.get('MOMENTJS_DATE_FORMAT')
self._GLOBAL_CONTEXT['momentjs_locales'] = LEGAL_VALUES['MOMENTJS_LOCALES']
+ # Patch missing locales into momentjs defaulting to English (Issue #3216)
+ for l in self._GLOBAL_CONTEXT['translations']:
+ if l not in self._GLOBAL_CONTEXT['momentjs_locales']:
+ self._GLOBAL_CONTEXT['momentjs_locales'][l] = ""
self._GLOBAL_CONTEXT['hidden_tags'] = self.config.get('HIDDEN_TAGS')
self._GLOBAL_CONTEXT['hidden_categories'] = self.config.get('HIDDEN_CATEGORIES')
+ self._GLOBAL_CONTEXT['hidden_authors'] = self.config.get('HIDDEN_AUTHORS')
self._GLOBAL_CONTEXT['url_replacer'] = self.url_replacer
-
- # IPython theme configuration. If a website has ipynb enabled in post_pages
- # we should enable the IPython CSS (leaving that up to the theme itself).
-
- self._GLOBAL_CONTEXT['needs_ipython_css'] = 'ipynb' in self.config['COMPILERS']
+ self._GLOBAL_CONTEXT['sort_posts'] = utils.sort_posts
+ self._GLOBAL_CONTEXT['smartjoin'] = utils.smartjoin
+ self._GLOBAL_CONTEXT['colorize_str'] = utils.colorize_str
+ self._GLOBAL_CONTEXT['meta_generator_tag'] = self.config.get('META_GENERATOR_TAG')
+ self._GLOBAL_CONTEXT['multiple_authors_per_post'] = self.config.get('MULTIPLE_AUTHORS_PER_POST')
self._GLOBAL_CONTEXT.update(self.config.get('GLOBAL_CONTEXT', {}))
+ def _set_global_context_from_data(self):
+ """Load files from data/ and put them in the global context."""
+ self._GLOBAL_CONTEXT['data'] = {}
+ for root, dirs, files in os.walk('data', followlinks=True):
+ for fname in files:
+ fname = os.path.join(root, fname)
+ data = utils.load_data(fname)
+ key = os.path.splitext(fname.split(os.sep, 1)[1])[0]
+ self._GLOBAL_CONTEXT['data'][key] = data
+ # Offer global_data as an alias for data (Issue #2488)
+ self._GLOBAL_CONTEXT['global_data'] = self._GLOBAL_CONTEXT['data']
+
+ def _set_all_page_deps_from_config(self):
+ """Save dependencies for all pages from configuration.
+
+ Changes of values in this dict will force a rebuild of all pages.
+ Unlike global context, contents are NOT available to templates.
+ """
+ self.ALL_PAGE_DEPS['atom_extension'] = self.config.get('ATOM_EXTENSION')
+ self.ALL_PAGE_DEPS['atom_path'] = self.config.get('ATOM_PATH')
+ self.ALL_PAGE_DEPS['rss_extension'] = self.config.get('RSS_EXTENSION')
+ self.ALL_PAGE_DEPS['rss_path'] = self.config.get('RSS_PATH')
+ self.ALL_PAGE_DEPS['rss_filename_base'] = self.config.get('RSS_FILENAME_BASE')
+ self.ALL_PAGE_DEPS['atom_filename_base'] = self.config.get('ATOM_FILENAME_BASE')
+ self.ALL_PAGE_DEPS['slug_author_path'] = self.config.get('SLUG_AUTHOR_PATH')
+ self.ALL_PAGE_DEPS['slug_tag_path'] = self.config.get('SLUG_TAG_PATH')
+ self.ALL_PAGE_DEPS['locale'] = self.config.get('LOCALE')
+
def _activate_plugins_of_category(self, category):
"""Activate all the plugins of a given category and return them."""
# this code duplicated in tests/base.py
@@ -906,17 +1322,20 @@ class Nikola(object):
def _get_themes(self):
if self._THEMES is None:
try:
- self._THEMES = utils.get_theme_chain(self.config['THEME'])
+ self._THEMES = utils.get_theme_chain(self.config['THEME'], self.themes_dirs)
except Exception:
- utils.LOGGER.warn('''Cannot load theme "{0}", using 'bootstrap3' instead.'''.format(self.config['THEME']))
- self.config['THEME'] = 'bootstrap3'
- return self._get_themes()
+ if self.config['THEME'] != LEGAL_VALUES['DEFAULT_THEME']:
+ utils.LOGGER.warning('''Cannot load theme "{0}", using '{1}' instead.'''.format(
+ self.config['THEME'], LEGAL_VALUES['DEFAULT_THEME']))
+ self.config['THEME'] = LEGAL_VALUES['DEFAULT_THEME']
+ return self._get_themes()
+ raise
# Check consistency of USE_CDN and the current THEME (Issue #386)
if self.config['USE_CDN'] and self.config['USE_CDN_WARNING']:
bootstrap_path = utils.get_asset_path(os.path.join(
'assets', 'css', 'bootstrap.min.css'), self._THEMES)
- if bootstrap_path and bootstrap_path.split(os.sep)[-4] not in ['bootstrap', 'bootstrap3']:
- utils.LOGGER.warn('The USE_CDN option may be incompatible with your theme, because it uses a hosted version of bootstrap.')
+ if bootstrap_path and bootstrap_path.split(os.sep)[-4] not in ['bootstrap', 'bootstrap3', 'bootstrap4']:
+ utils.LOGGER.warning('The USE_CDN option may be incompatible with your theme, because it uses a hosted version of bootstrap.')
return self._THEMES
@@ -924,9 +1343,12 @@ class Nikola(object):
def _get_messages(self):
try:
- return utils.load_messages(self.THEMES,
- self.translations,
- self.default_lang)
+ if self._MESSAGES is None:
+ self._MESSAGES = utils.load_messages(self.THEMES,
+ self.translations,
+ self.default_lang,
+ themes_dirs=self.themes_dirs)
+ return self._MESSAGES
except utils.LanguageNotFoundError as e:
utils.LOGGER.error('''Cannot load language "{0}". Please make sure it is supported by Nikola itself, or that you have the appropriate messages files in your themes.'''.format(e.lang))
sys.exit(1)
@@ -982,7 +1404,7 @@ class Nikola(object):
"""
ext = os.path.splitext(source_name)[1]
try:
- compile_html = self.inverse_compilers[ext]
+ compiler = self.inverse_compilers[ext]
except KeyError:
# Find the correct compiler for this files extension
lang_exts_tab = list(self.config['COMPILERS'].items())
@@ -990,26 +1412,28 @@ class Nikola(object):
len([ext_ for ext_ in exts if source_name.endswith(ext_)]) > 0]
if len(langs) != 1:
if len(set(langs)) > 1:
- exit("Your file extension->compiler definition is "
- "ambiguous.\nPlease remove one of the file extensions "
- "from 'COMPILERS' in conf.py\n(The error is in "
- "one of {0})".format(', '.join(langs)))
+ sys.exit("Your file extension->compiler definition is "
+ "ambiguous.\nPlease remove one of the file "
+ "extensions from 'COMPILERS' in conf.py\n(The "
+ "error is in one of {0})".format(', '.join(langs)))
elif len(langs) > 1:
langs = langs[:1]
else:
- exit("COMPILERS in conf.py does not tell me how to "
- "handle '{0}' extensions.".format(ext))
+ sys.exit("COMPILERS in conf.py does not tell me how to "
+ "handle '{0}' extensions.".format(ext))
lang = langs[0]
try:
- compile_html = self.compilers[lang]
+ compiler = self.compilers[lang]
except KeyError:
- exit("Cannot find '{0}' compiler; it might require an extra plugin -- do you have it installed?".format(lang))
- self.inverse_compilers[ext] = compile_html
+ sys.exit("Cannot find '{0}' compiler; "
+ "it might require an extra plugin -- "
+ "do you have it installed?".format(lang))
+ self.inverse_compilers[ext] = compiler
- return compile_html
+ return compiler
- def render_template(self, template_name, output_name, context):
+ def render_template(self, template_name, output_name, 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
@@ -1017,6 +1441,12 @@ class Nikola(object):
If ``output_name`` is a string, URLs will be normalized and
the resultant HTML will be saved to the named file (path must
start with OUTPUT_FOLDER).
+
+ The argument ``url_type`` allows to override the ``URL_TYPE``
+ configuration.
+
+ If ``is_fragment`` is set to ``True``, a HTML fragment will
+ be rendered and not a whole HTML document.
"""
local_context = {}
local_context["template_name"] = template_name
@@ -1025,6 +1455,11 @@ class Nikola(object):
for k in self._GLOBAL_CONTEXT_TRANSLATABLE:
local_context[k] = local_context[k](local_context['lang'])
local_context['is_rtl'] = local_context['lang'] in LEGAL_VALUES['RTL_LANGUAGES']
+ local_context['url_type'] = self.config['URL_TYPE'] if url_type is None else url_type
+ local_context["translations_feedorder"] = sorted(
+ local_context["translations"],
+ key=lambda x: (int(x != local_context['lang']), x)
+ )
# string, arguments
local_context["formatmsg"] = lambda s, *a: s % a
for h in local_context['template_hooks'].values():
@@ -1039,8 +1474,8 @@ class Nikola(object):
if output_name is None:
return data
- assert output_name.startswith(
- self.config["OUTPUT_FOLDER"])
+ if not output_name.startswith(self.config["OUTPUT_FOLDER"]):
+ raise ValueError("Output path for templates must start with OUTPUT_FOLDER")
url_part = output_name[len(self.config["OUTPUT_FOLDER"]) + 1:]
# Treat our site as if output/ is "/" and then make all URLs relative,
@@ -1052,23 +1487,32 @@ class Nikola(object):
utils.makedirs(os.path.dirname(output_name))
parser = lxml.html.HTMLParser(remove_blank_text=True)
- doc = lxml.html.document_fromstring(data, parser)
- self.rewrite_links(doc, src, context['lang'])
- data = b'<!DOCTYPE html>\n' + lxml.html.tostring(doc, encoding='utf8', method='html', pretty_print=True)
+ if is_fragment:
+ doc = lxml.html.fragment_fromstring(data.strip(), parser)
+ else:
+ doc = lxml.html.document_fromstring(data.strip(), parser)
+ self.rewrite_links(doc, src, context['lang'], url_type)
+ if is_fragment:
+ # doc.text contains text before the first HTML, or None if there was no text
+ # The text after HTML elements is added by tostring() (because its implicit
+ # argument with_tail has default value True).
+ data = (doc.text or '').encode('utf-8') + b''.join([lxml.html.tostring(child, encoding='utf-8', method='html') for child in doc.iterchildren()])
+ else:
+ data = lxml.html.tostring(doc, encoding='utf8', method='html', pretty_print=True, doctype='<!DOCTYPE html>')
with open(output_name, "wb+") as post_file:
post_file.write(data)
- def rewrite_links(self, doc, src, lang):
+ def rewrite_links(self, doc, src, lang, url_type=None):
"""Replace links in document to point to the right places."""
# First let lxml replace most of them
- doc.rewrite_links(lambda dst: self.url_replacer(src, dst, lang), resolve_base_href=False)
+ doc.rewrite_links(lambda dst: self.url_replacer(src, dst, lang, url_type), resolve_base_href=False)
# lxml ignores srcset in img and source elements, so do that by hand
- objs = list(doc.findall('*//img')) + list(doc.findall('*//source'))
+ objs = list(doc.xpath('(//img|//source)'))
for obj in objs:
if 'srcset' in obj.attrib:
urls = [u.strip() for u in obj.attrib['srcset'].split(',')]
- urls = [self.url_replacer(src, dst, lang) for dst in urls]
+ urls = [self.url_replacer(src, dst, lang, url_type) for dst in urls]
obj.set('srcset', ', '.join(urls))
def url_replacer(self, src, dst, lang=None, url_type=None):
@@ -1085,6 +1529,10 @@ class Nikola(object):
lang is used for language-sensitive URLs in link://
url_type is used to determine final link appearance, defaulting to URL_TYPE from config
"""
+ # Avoid mangling links within the page
+ if dst.startswith('#'):
+ return dst
+
parsed_src = urlsplit(src)
src_elems = parsed_src.path.split('/')[1:]
dst_url = urlparse(dst)
@@ -1099,7 +1547,17 @@ class Nikola(object):
# Refuse to replace links that are full URLs.
if dst_url.netloc:
if dst_url.scheme == 'link': # Magic link
- dst = self.link(dst_url.netloc, dst_url.path.lstrip('/'), lang)
+ if dst_url.query:
+ # If query strings are used in magic link, they will be
+ # passed to the path handler as keyword arguments (strings)
+ link_kwargs = {unquote(k): unquote(v[-1]) for k, v in parse_qs(dst_url.query).items()}
+ else:
+ link_kwargs = {}
+
+ # unquote from issue #2934
+ dst = self.link(dst_url.netloc, unquote(dst_url.path.lstrip('/')), lang, **link_kwargs)
+ if dst_url.fragment:
+ dst += '#' + dst_url.fragment
# Assuming the site is served over one of these, and
# since those are the only URLs we want to rewrite...
else:
@@ -1112,7 +1570,7 @@ class Nikola(object):
# python 3: already unicode
pass
nl = nl.encode('idna')
- if isinstance(nl, utils.bytes_str):
+ if isinstance(nl, bytes):
nl = nl.decode('latin-1') # so idna stays unchanged
dst = urlunsplit((dst_url.scheme,
nl,
@@ -1140,7 +1598,7 @@ class Nikola(object):
return dst
elif url_type == 'full_path':
dst = urljoin(self.config['BASE_URL'], dst.lstrip('/'))
- return urlparse(dst).path
+ return utils.full_path_from_urlparse(urlparse(dst))
else:
return "#"
@@ -1155,10 +1613,7 @@ class Nikola(object):
dst = urljoin(self.config['BASE_URL'], dst.lstrip('/'))
if url_type == 'full_path':
parsed = urlparse(urljoin(self.config['BASE_URL'], dst.lstrip('/')))
- if parsed.fragment:
- dst = '{0}#{1}'.format(parsed.path, parsed.fragment)
- else:
- dst = parsed.path
+ dst = utils.full_path_from_urlparse(parsed)
return dst
# Now both paths are on the same site and absolute
@@ -1171,7 +1626,7 @@ class Nikola(object):
# Now i is the longest common prefix
result = '/'.join(['..'] * (len(src_elems) - i - 1) + dst_elems[i:])
- if not result:
+ if not result and not parsed_dst.fragment:
result = "."
# Don't forget the query part of the link
@@ -1182,23 +1637,135 @@ class Nikola(object):
if parsed_dst.fragment:
result += "#" + parsed_dst.fragment
- assert result, (src, dst, i, src_elems, dst_elems)
+ if not result:
+ raise ValueError("Failed to parse link: {0}".format((src, dst, i, src_elems, dst_elems)))
return result
- def generic_rss_renderer(self, lang, title, link, description, timeline, output_path,
- rss_teasers, rss_plain, feed_length=10, feed_url=None,
- enclosure=_enclosure, rss_links_append_query=None):
- """Take all necessary data, and render a RSS feed in output_path."""
+ def _make_renderfunc(self, t_data, fname=None):
+ """Return a function that can be registered as a template shortcode.
+
+ The returned function has access to the passed template data and
+ accepts any number of positional and keyword arguments. Positional
+ arguments values are added as a tuple under the key ``_args`` to the
+ keyword argument dict and then the latter provides the template
+ context.
+
+ Global context keys are made available as part of the context,
+ respecting locale.
+
+ As a special quirk, the "data" key from global_context is
+ available only as "global_data" because of name clobbering.
+
+ """
+ def render_shortcode(*args, **kw):
+ context = self.GLOBAL_CONTEXT.copy()
+ context.update(kw)
+ context['_args'] = args
+ context['lang'] = utils.LocaleBorg().current_lang
+ for k in self._GLOBAL_CONTEXT_TRANSLATABLE:
+ context[k] = context[k](context['lang'])
+ output = self.template_system.render_template_to_string(t_data, context)
+ if fname is not None:
+ dependencies = [fname] + self.template_system.get_deps(fname)
+ else:
+ dependencies = []
+ return output, dependencies
+ return render_shortcode
+
+ def _register_templated_shortcodes(self):
+ """Register shortcodes based on templates.
+
+ This will register a shortcode for any template found in shortcodes/
+ folders and a generic "template" shortcode which will consider the
+ content in the shortcode as a template in itself.
+ """
+ self.register_shortcode('template', self._template_shortcode_handler)
+
+ builtin_sc_dir = resource_filename(
+ 'nikola',
+ os.path.join('data', 'shortcodes', utils.get_template_engine(self.THEMES)))
+
+ for sc_dir in [builtin_sc_dir, 'shortcodes']:
+ if not os.path.isdir(sc_dir):
+ continue
+
+ for fname in os.listdir(sc_dir):
+ name, ext = os.path.splitext(fname)
+
+ if ext != '.tmpl':
+ continue
+ with open(os.path.join(sc_dir, fname)) as fd:
+ self.register_shortcode(name, self._make_renderfunc(
+ fd.read(), os.path.join(sc_dir, fname)))
+
+ def _template_shortcode_handler(self, *args, **kw):
+ t_data = kw.pop('data', '')
+ context = self.GLOBAL_CONTEXT.copy()
+ context.update(kw)
+ context['_args'] = args
+ context['lang'] = utils.LocaleBorg().current_lang
+ for k in self._GLOBAL_CONTEXT_TRANSLATABLE:
+ context[k] = context[k](context['lang'])
+ output = self.template_system.render_template_to_string(t_data, context)
+ dependencies = self.template_system.get_string_deps(t_data)
+ return output, dependencies
+
+ def register_shortcode(self, name, f):
+ """Register function f to handle shortcode "name"."""
+ if name in self.shortcode_registry:
+ utils.LOGGER.warning('Shortcode name conflict: {}', name)
+ return
+ self.shortcode_registry[name] = f
+
+ def apply_shortcodes(self, data, filename=None, lang=None, extra_context=None):
+ """Apply shortcodes from the registry on data."""
+ if extra_context is None:
+ extra_context = {}
+ if lang is None:
+ lang = utils.LocaleBorg().current_lang
+ return shortcodes.apply_shortcodes(data, self.shortcode_registry, self, filename, lang=lang, extra_context=extra_context)
+
+ def apply_shortcodes_uuid(self, data, _shortcodes, filename=None, lang=None, extra_context=None):
+ """Apply shortcodes from the registry on data."""
+ if lang is None:
+ lang = utils.LocaleBorg().current_lang
+ if extra_context is None:
+ extra_context = {}
+ deps = []
+ for k, v in _shortcodes.items():
+ replacement, _deps = shortcodes.apply_shortcodes(v, self.shortcode_registry, self, filename, lang=lang, extra_context=extra_context)
+ data = data.replace(k, replacement)
+ deps.extend(_deps)
+ return data, deps
+
+ def _get_rss_copyright(self, lang, rss_plain):
+ if rss_plain:
+ return (
+ self.config['RSS_COPYRIGHT_PLAIN'](lang) or
+ lxml.html.fromstring(self.config['RSS_COPYRIGHT'](lang)).text_content().strip())
+ else:
+ return self.config['RSS_COPYRIGHT'](lang)
+
+ def generic_rss_feed(self, lang, title, link, description, timeline,
+ rss_teasers, rss_plain, feed_length=10, feed_url=None,
+ enclosure=_enclosure, rss_links_append_query=None, copyright_=None):
+ """Generate an ExtendedRSS2 feed object for later use."""
rss_obj = utils.ExtendedRSS2(
title=title,
- link=link,
+ link=utils.encodelink(link),
description=description,
lastBuildDate=datetime.datetime.utcnow(),
- generator='https://getnikola.com/',
+ generator='Nikola (getnikola.com)',
language=lang
)
+ if copyright_ is None:
+ copyright_ = self._get_rss_copyright(lang, rss_plain)
+ # Use the configured or specified copyright string if present.
+ if copyright_:
+ rss_obj.copyright = copyright_
+
if feed_url:
absurl = '/' + feed_url[len(self.config['BASE_URL']):]
rss_obj.xsl_stylesheet_href = self.url_replacer(absurl, "/assets/xml/rss.xsl")
@@ -1207,16 +1774,20 @@ class Nikola(object):
feed_append_query = None
if rss_links_append_query:
+ if rss_links_append_query is True:
+ raise ValueError("RSS_LINKS_APPEND_QUERY (or FEED_LINKS_APPEND_QUERY) cannot be True. Valid values are False or a formattable string.")
feed_append_query = rss_links_append_query.format(
feedRelUri='/' + feed_url[len(self.config['BASE_URL']):],
feedFormat="rss")
for post in timeline[:feed_length]:
data = post.text(lang, teaser_only=rss_teasers, strip_html=rss_plain,
- rss_read_more_link=True, rss_links_append_query=feed_append_query)
+ feed_read_more_link=True, feed_links_append_query=feed_append_query)
if feed_url is not None and data:
# Massage the post's HTML (unless plain)
if not rss_plain:
+ if 'previewimage' in post.meta[lang] and post.meta[lang]['previewimage'] not in data:
+ data = "<figure><img src=\"{}\"></figure> {}".format(post.meta[lang]['previewimage'], data)
# FIXME: this is duplicated with code in Post.text()
try:
doc = lxml.html.document_fromstring(data)
@@ -1232,9 +1803,9 @@ class Nikola(object):
if str(e) == "Document is empty":
data = ""
else: # let other errors raise
- raise(e)
+ raise
args = {
- 'title': post.title(lang),
+ 'title': post.title(lang) if post.should_show_title() else None,
'link': post.permalink(lang, absolute=True, query=feed_append_query),
'description': data,
# PyRSS2Gen's pubDate is GMT time.
@@ -1242,7 +1813,7 @@ class Nikola(object):
post.date.astimezone(dateutil.tz.tzutc())),
'categories': post._tags.get(lang, []),
'creator': post.author(lang),
- 'guid': post.permalink(lang, absolute=True),
+ 'guid': post.guid(lang),
}
if post.author(lang):
@@ -1260,16 +1831,18 @@ class Nikola(object):
rss_obj.items = items
rss_obj.self_url = feed_url
rss_obj.rss_attrs["xmlns:atom"] = "http://www.w3.org/2005/Atom"
+ return rss_obj
- dst_dir = os.path.dirname(output_path)
- utils.makedirs(dst_dir)
- with io.open(output_path, "w+", encoding="utf-8") as rss_file:
- data = rss_obj.to_xml(encoding='utf-8')
- if isinstance(data, utils.bytes_str):
- data = data.decode('utf-8')
- rss_file.write(data)
+ def generic_rss_renderer(self, lang, title, link, description, timeline, output_path,
+ rss_teasers, rss_plain, feed_length=10, feed_url=None,
+ enclosure=_enclosure, rss_links_append_query=None, copyright_=None):
+ """Take all necessary data, and render a RSS feed in output_path."""
+ rss_obj = self.generic_rss_feed(lang, title, link, description, timeline,
+ rss_teasers, rss_plain, feed_length=feed_length, feed_url=feed_url,
+ enclosure=enclosure, rss_links_append_query=rss_links_append_query, copyright_=copyright_)
+ utils.rss_writer(rss_obj, output_path)
- def path(self, kind, name, lang=None, is_link=False):
+ def path(self, kind, name, lang=None, is_link=False, **kwargs):
r"""Build the path to a certain kind of page.
These are mostly defined by plugins by registering via the
@@ -1290,47 +1863,70 @@ class Nikola(object):
* gallery (name is the gallery name)
* listing (name is the source code file name)
* post_path (name is 1st element in a POSTS/PAGES tuple)
- * slug (name is the slug of a post or story)
- * filename (name is the source filename of a post/story, in DEFAULT_LANG, relative to conf.py)
+ * slug (name is the slug of a post or page)
+ * filename (name is the source filename of a post/page, in DEFAULT_LANG, relative to conf.py)
- The returned value is always a path relative to output, like
- "categories/whatever.html"
+ The returned value is either a path relative to output, like "categories/whatever.html", or
+ an absolute URL ("https://getnikola.com/"), if path handler returns a string.
If is_link is True, the path is absolute and uses "/" as separator
(ex: "/archive/index.html").
If is_link is False, the path is relative to output and uses the
platform's separator.
(ex: "archive\index.html")
+ If the registered path handler returns a string instead of path component list - it's
+ considered to be an absolute URL and returned as is.
+
"""
if lang is None:
lang = utils.LocaleBorg().current_lang
try:
- path = self.path_handlers[kind](name, lang)
- path = [os.path.normpath(p) for p in path if p != '.'] # Fix Issue #1028
-
- if is_link:
- link = '/' + ('/'.join(path))
- index_len = len(self.config['INDEX_FILE'])
- if self.config['STRIP_INDEXES'] and \
- link[-(1 + index_len):] == '/' + self.config['INDEX_FILE']:
- return link[:-index_len]
- else:
- return link
- else:
- return os.path.join(*path)
+ path = self.path_handlers[kind](name, lang, **kwargs)
except KeyError:
- utils.LOGGER.warn("Unknown path request of kind: {0}".format(kind))
+ utils.LOGGER.warning("Unknown path request of kind: {0}".format(kind))
return ""
+ # If path handler returns a string we consider it to be an absolute URL not requiring any
+ # further processing, i.e 'https://getnikola.com/'. See Issue #2876.
+ if isinstance(path, str):
+ return path
+
+ if path is None:
+ path = "#"
+ else:
+ path = [os.path.normpath(p) for p in path if p != '.'] # Fix Issue #1028
+ if is_link:
+ link = '/' + ('/'.join(path))
+ index_len = len(self.config['INDEX_FILE'])
+ if self.config['STRIP_INDEXES'] and \
+ link[-(1 + index_len):] == '/' + self.config['INDEX_FILE']:
+ return link[:-index_len]
+ else:
+ return link
+ else:
+ return os.path.join(*path)
+
def post_path(self, name, lang):
- """Handle post_path paths."""
+ """Link to the destination of an element in the POSTS/PAGES settings.
+
+ Example:
+
+ link://post_path/posts => /blog
+ """
return [_f for _f in [self.config['TRANSLATIONS'][lang],
os.path.dirname(name),
self.config['INDEX_FILE']] if _f]
def root_path(self, name, lang):
- """Handle root_path paths."""
+ """Link to the current language's root.
+
+ Example:
+
+ link://root_path => /
+
+ link://root_path => /translations/spanish/
+ """
d = self.config['TRANSLATIONS'][lang]
if d:
return [d, '']
@@ -1338,17 +1934,27 @@ class Nikola(object):
return []
def slug_path(self, name, lang):
- """Handle slug paths."""
+ """Return a link to a post with given slug, if not ambiguous.
+
+ Example:
+
+ link://slug/yellow-camaro => /posts/cars/awful/yellow-camaro/index.html
+ """
results = [p for p in self.timeline if p.meta('slug') == name]
if not results:
utils.LOGGER.warning("Cannot resolve path request for slug: {0}".format(name))
else:
if len(results) > 1:
utils.LOGGER.warning('Ambiguous path request for slug: {0}'.format(name))
- return [_f for _f in results[0].permalink(lang).split('/') if _f]
+ return [_f for _f in results[0].permalink(lang).split('/')]
def filename_path(self, name, lang):
- """Handle filename paths."""
+ """Link to post or page by source filename.
+
+ Example:
+
+ link://filename/manual.txt => /docs/handbook.html
+ """
results = [p for p in self.timeline if p.source_path == name]
if not results:
utils.LOGGER.warning("Cannot resolve path request for filename: {0}".format(name))
@@ -1364,9 +1970,11 @@ class Nikola(object):
else:
self.path_handlers[kind] = f
- def link(self, *args):
+ def link(self, *args, **kwargs):
"""Create a link."""
- return self.path(*args, is_link=True)
+ url = self.path(*args, is_link=True, **kwargs)
+ url = utils.encodelink(url)
+ return url
def abs_link(self, dst, protocol_relative=False):
"""Get an absolute link."""
@@ -1378,6 +1986,7 @@ class Nikola(object):
url = urlparse(dst).geturl()
if protocol_relative:
url = url.split(":", 1)[1]
+ url = utils.encodelink(url)
return url
def rel_link(self, src, dst):
@@ -1392,7 +2001,7 @@ class Nikola(object):
parsed_src = urlsplit(src)
parsed_dst = urlsplit(dst)
if parsed_src[:2] != parsed_dst[:2]:
- return dst
+ return utils.encodelink(dst)
# Now both paths are on the same site and absolute
src_elems = parsed_src.path.split('/')[1:]
dst_elems = parsed_dst.path.split('/')[1:]
@@ -1403,7 +2012,20 @@ class Nikola(object):
else:
i += 1
# Now i is the longest common prefix
- return '/'.join(['..'] * (len(src_elems) - i - 1) + dst_elems[i:])
+ url = '/'.join(['..'] * (len(src_elems) - i - 1) + dst_elems[i:])
+ url = utils.encodelink(url)
+ return url
+
+ def register_filter(self, filter_name, filter_definition):
+ """Register a filter.
+
+ filter_name should be a name not confusable with an actual
+ executable. filter_definition should be a callable accepting
+ one argument (the filename).
+ """
+ if filter_name in self.filters:
+ utils.LOGGER.warning('''The filter "{0}" is defined more than once.'''.format(filter_name))
+ self.filters[filter_name] = filter_definition
def file_exists(self, path, not_empty=False):
"""Check if the file exists. If not_empty is True, it also must not be empty."""
@@ -1433,7 +2055,8 @@ class Nikola(object):
task_dep = []
for pluginInfo in self.plugin_manager.getPluginsOfCategory(plugin_category):
for task in flatten(pluginInfo.plugin_object.gen_tasks()):
- assert 'basename' in task
+ if 'basename' not in task:
+ raise ValueError("Task {0} does not have a basename".format(task))
task = self.clean_task_paths(task)
if 'task_dep' not in task:
task['task_dep'] = []
@@ -1460,7 +2083,7 @@ class Nikola(object):
"""Parse a category name into a hierarchy."""
if self.config['CATEGORY_ALLOW_HIERARCHIES']:
try:
- return utils.parse_escaped_hierarchical_category_name(category_name)
+ return hierarchy_utils.parse_escaped_hierarchical_category_name(category_name)
except Exception as e:
utils.LOGGER.error(str(e))
sys.exit(1)
@@ -1470,7 +2093,7 @@ class Nikola(object):
def category_path_to_category_name(self, category_path):
"""Translate a category path to a category name."""
if self.config['CATEGORY_ALLOW_HIERARCHIES']:
- return utils.join_hierarchical_category_path(category_path)
+ return hierarchy_utils.join_hierarchical_category_path(category_path)
else:
return ''.join(category_path)
@@ -1495,7 +2118,7 @@ class Nikola(object):
"""Create category hierarchy."""
result = []
for name, children in cat_hierarchy.items():
- node = utils.TreeNode(name, parent)
+ node = hierarchy_utils.TreeNode(name, parent)
node.children = create_hierarchy(children, node)
node.category_path = [pn.name for pn in node.get_path()]
node.category_name = self.category_path_to_category_name(node.category_path)
@@ -1506,7 +2129,25 @@ class Nikola(object):
root_list = create_hierarchy(self.category_hierarchy)
# Next, flatten the hierarchy
- self.category_hierarchy = utils.flatten_tree_structure(root_list)
+ self.category_hierarchy = hierarchy_utils.flatten_tree_structure(root_list)
+
+ @staticmethod
+ def sort_posts_chronologically(posts, lang=None):
+ """Sort a list of posts chronologically.
+
+ This function also takes priority, title and source path into account.
+ """
+ # Last tie breaker: sort by source path (A-Z)
+ posts = sorted(posts, key=lambda p: p.source_path)
+ # Next tie breaker: sort by title if language is given (A-Z)
+ if lang is not None:
+ posts = natsort.natsorted(posts, key=lambda p: p.title(lang), alg=natsort.ns.F | natsort.ns.IC)
+ # Next tie breaker: sort by date (reverse chronological order)
+ posts = sorted(posts, key=lambda p: p.date, reverse=True)
+ # Finally, sort by priority meta value (descending)
+ posts = sorted(posts, key=lambda p: int(p.meta('priority')) if p.meta('priority') else 0, reverse=True)
+ # Return result
+ return posts
def scan_posts(self, really=False, ignore_quit=False, quiet=False):
"""Scan all the posts.
@@ -1526,37 +2167,34 @@ class Nikola(object):
self.tags_per_language = defaultdict(list)
self.category_hierarchy = {}
self.post_per_file = {}
+ self.post_per_input_file = {}
self.timeline = []
self.pages = []
- for p in self.plugin_manager.getPluginsOfCategory('PostScanner'):
- timeline = p.plugin_object.scan()
+ for p in sorted(self.plugin_manager.getPluginsOfCategory('PostScanner'), key=operator.attrgetter('name')):
+ try:
+ timeline = p.plugin_object.scan()
+ except Exception:
+ utils.LOGGER.error('Error reading timeline')
+ raise
# FIXME: can there be conflicts here?
self.timeline.extend(timeline)
quit = False
# Classify posts per year/tag/month/whatever
- slugged_tags = set([])
+ slugged_tags = defaultdict(set)
for post in self.timeline:
if post.use_in_feeds:
self.posts.append(post)
self.posts_per_year[str(post.date.year)].append(post)
self.posts_per_month[
'{0}/{1:02d}'.format(post.date.year, post.date.month)].append(post)
- for tag in post.alltags:
- _tag_slugified = utils.slugify(tag)
- if _tag_slugified in slugged_tags:
- if tag not in self.posts_per_tag:
- # Tags that differ only in case
- other_tag = [existing for existing in self.posts_per_tag.keys() if utils.slugify(existing) == _tag_slugified][0]
- utils.LOGGER.error('You have tags that are too similar: {0} and {1}'.format(tag, other_tag))
- utils.LOGGER.error('Tag {0} is used in: {1}'.format(tag, post.source_path))
- utils.LOGGER.error('Tag {0} is used in: {1}'.format(other_tag, ', '.join([p.source_path for p in self.posts_per_tag[other_tag]])))
- quit = True
- else:
- slugged_tags.add(utils.slugify(tag, force=True))
- self.posts_per_tag[tag].append(post)
for lang in self.config['TRANSLATIONS'].keys():
+ for tag in post.tags_for_language(lang):
+ _tag_slugified = utils.slugify(tag, lang)
+ slugged_tags[lang].add(_tag_slugified)
+ if post not in self.posts_per_tag[tag]:
+ self.posts_per_tag[tag].append(post)
self.tags_per_language[lang].extend(post.tags_for_language(lang))
self._add_post_to_category(post, post.meta('category'))
@@ -1569,6 +2207,7 @@ class Nikola(object):
for lang in self.config['TRANSLATIONS'].keys():
dest = post.destination_path(lang=lang)
src_dest = post.destination_path(lang=lang, extension=post.source_ext())
+ src_file = post.translated_source_path(lang=lang)
if dest in self.post_per_file:
utils.LOGGER.error('Two posts are trying to generate {0}: {1} and {2}'.format(
dest,
@@ -1583,14 +2222,17 @@ class Nikola(object):
quit = True
self.post_per_file[dest] = post
self.post_per_file[src_dest] = post
+ if src_file is not None:
+ self.post_per_input_file[src_file] = post
# deduplicate tags_per_language
self.tags_per_language[lang] = list(set(self.tags_per_language[lang]))
# Sort everything.
- for thing in self.timeline, self.posts, self.all_posts, self.pages:
- thing.sort(key=lambda p: (p.date, p.source_path))
- thing.reverse()
+ self.timeline = self.sort_posts_chronologically(self.timeline)
+ self.posts = self.sort_posts_chronologically(self.posts)
+ self.all_posts = self.sort_posts_chronologically(self.all_posts)
+ self.pages = self.sort_posts_chronologically(self.pages)
self._sort_category_hierarchy()
for i, p in enumerate(self.posts[1:]):
@@ -1604,102 +2246,149 @@ class Nikola(object):
sys.exit(1)
signal('scanned').send(self)
+ def generic_renderer(self, lang, output_name, template_name, filters, file_deps=None, uptodate_deps=None, context=None, context_deps_remove=None, post_deps_dict=None, url_type=None, is_fragment=False):
+ """Create tasks for rendering pages and post lists and other related pages.
+
+ lang is the current language.
+ output_name is the destination file name.
+ template_name is the template to be used.
+ filters is the list of filters (usually site.config['FILTERS']) which will be used to post-process the result.
+ file_deps (optional) is a list of additional file dependencies (next to template and its dependencies).
+ uptodate_deps (optional) is a list of additional entries added to the task's uptodate list.
+ context (optional) a dict used as a basis for the template context. The lang parameter will always be added.
+ context_deps_remove (optional) is a list of keys to remove from the context after using it as an uptodate dependency. This should name all keys containing non-trivial Python objects; they can be replaced by adding JSON-style dicts in post_deps_dict.
+ post_deps_dict (optional) is a dict merged into the copy of context which is used as an uptodate dependency.
+ url_type (optional) allows to override the ``URL_TYPE`` configuration.
+ is_fragment (optional) allows to write a HTML fragment instead of a HTML document.
+ """
+ utils.LocaleBorg().set_locale(lang)
+
+ file_deps = copy(file_deps) if file_deps else []
+ file_deps += self.template_system.template_deps(template_name)
+ file_deps = sorted(list(filter(None, file_deps)))
+
+ context = copy(context) if context else {}
+ context["lang"] = lang
+
+ deps_dict = copy(context)
+ if context_deps_remove:
+ for key in context_deps_remove:
+ deps_dict.pop(key)
+ deps_dict['OUTPUT_FOLDER'] = self.config['OUTPUT_FOLDER']
+ deps_dict['TRANSLATIONS'] = self.config['TRANSLATIONS']
+ deps_dict['global'] = self.GLOBAL_CONTEXT
+ deps_dict['all_page_deps'] = self.ALL_PAGE_DEPS
+ if post_deps_dict:
+ deps_dict.update(post_deps_dict)
+
+ for k, v in self.GLOBAL_CONTEXT['template_hooks'].items():
+ deps_dict['||template_hooks|{0}||'.format(k)] = v.calculate_deps()
+
+ for k in self._GLOBAL_CONTEXT_TRANSLATABLE:
+ deps_dict[k] = deps_dict['global'][k](lang)
+ for k in self._ALL_PAGE_DEPS_TRANSLATABLE:
+ deps_dict[k] = deps_dict['all_page_deps'][k](lang)
+
+ deps_dict['navigation_links'] = deps_dict['global']['navigation_links'](lang)
+ deps_dict['navigation_alt_links'] = deps_dict['global']['navigation_alt_links'](lang)
+
+ task = {
+ 'name': os.path.normpath(output_name),
+ 'targets': [output_name],
+ 'file_dep': file_deps,
+ 'actions': [(self.render_template, [template_name, output_name,
+ context, url_type, is_fragment])],
+ 'clean': True,
+ 'uptodate': [config_changed(deps_dict, 'nikola.nikola.Nikola.generic_renderer')] + ([] if uptodate_deps is None else uptodate_deps)
+ }
+
+ return utils.apply_filters(task, filters)
+
def generic_page_renderer(self, lang, post, filters, context=None):
"""Render post fragments to final HTML pages."""
- context = context.copy() if context else {}
- deps = post.deps(lang) + \
- self.template_system.template_deps(post.template_name)
+ extension = post.compiler.extension()
+ output_name = os.path.join(self.config['OUTPUT_FOLDER'],
+ post.destination_path(lang, extension))
+
+ deps = post.deps(lang)
+ uptodate_deps = post.deps_uptodate(lang)
deps.extend(utils.get_asset_path(x, self.THEMES) for x in ('bundles', 'parent', 'engine'))
- deps = list(filter(None, deps))
+ _theme_ini = utils.get_asset_path(self.config['THEME'] + '.theme', self.THEMES)
+ if _theme_ini:
+ deps.append(_theme_ini)
+
+ context = copy(context) if context else {}
context['post'] = post
- context['lang'] = lang
context['title'] = post.title(lang)
context['description'] = post.description(lang)
context['permalink'] = post.permalink(lang)
+ if 'crumbs' not in context:
+ crumb_path = post.permalink(lang).lstrip('/')
+ if crumb_path.endswith(self.config['INDEX_FILE']):
+ crumb_path = crumb_path[:-len(self.config['INDEX_FILE'])]
+ if crumb_path.endswith('/'):
+ context['crumbs'] = utils.get_crumbs(crumb_path.rstrip('/'), is_file=False)
+ else:
+ context['crumbs'] = utils.get_crumbs(crumb_path, is_file=True)
if 'pagekind' not in context:
context['pagekind'] = ['generic_page']
if post.use_in_feeds:
context['enable_comments'] = True
else:
- context['enable_comments'] = self.config['COMMENTS_IN_STORIES']
- extension = self.get_compiler(post.source_path).extension()
- output_name = os.path.join(self.config['OUTPUT_FOLDER'],
- post.destination_path(lang, extension))
- deps_dict = copy(context)
- deps_dict.pop('post')
+ context['enable_comments'] = self.config['COMMENTS_IN_PAGES']
+
+ deps_dict = {}
if post.prev_post:
deps_dict['PREV_LINK'] = [post.prev_post.permalink(lang)]
if post.next_post:
deps_dict['NEXT_LINK'] = [post.next_post.permalink(lang)]
- deps_dict['OUTPUT_FOLDER'] = self.config['OUTPUT_FOLDER']
- deps_dict['TRANSLATIONS'] = self.config['TRANSLATIONS']
- deps_dict['global'] = self.GLOBAL_CONTEXT
deps_dict['comments'] = context['enable_comments']
-
- for k, v in self.GLOBAL_CONTEXT['template_hooks'].items():
- deps_dict['||template_hooks|{0}||'.format(k)] = v._items
-
- for k in self._GLOBAL_CONTEXT_TRANSLATABLE:
- deps_dict[k] = deps_dict['global'][k](lang)
-
- deps_dict['navigation_links'] = deps_dict['global']['navigation_links'](lang)
-
if post:
deps_dict['post_translations'] = post.translated_to
- task = {
- 'name': os.path.normpath(output_name),
- 'file_dep': sorted(deps),
- 'targets': [output_name],
- 'actions': [(self.render_template, [post.template_name,
- output_name, context])],
- 'clean': True,
- 'uptodate': [config_changed(deps_dict, 'nikola.nikola.Nikola.generic_page_renderer')] + post.deps_uptodate(lang),
- }
+ signal('render_post').send({
+ 'site': self,
+ 'post': post,
+ 'lang': lang,
+ 'context': context,
+ 'deps_dict': deps_dict,
+ })
- yield utils.apply_filters(task, filters)
+ yield self.generic_renderer(lang, output_name, post.template_name, filters,
+ file_deps=deps,
+ uptodate_deps=uptodate_deps,
+ context=context,
+ context_deps_remove=['post'],
+ post_deps_dict=deps_dict,
+ url_type=post.url_type)
- def generic_post_list_renderer(self, lang, posts, output_name,
- template_name, filters, extra_context):
+ def generic_post_list_renderer(self, lang, posts, output_name, template_name, filters, extra_context):
"""Render pages with lists of posts."""
deps = []
- deps += self.template_system.template_deps(template_name)
uptodate_deps = []
for post in posts:
deps += post.deps(lang)
uptodate_deps += post.deps_uptodate(lang)
+
context = {}
context["posts"] = posts
context["title"] = self.config['BLOG_TITLE'](lang)
context["description"] = self.config['BLOG_DESCRIPTION'](lang)
- context["lang"] = lang
context["prevlink"] = None
context["nextlink"] = None
- context.update(extra_context)
- deps_context = copy(context)
- deps_context["posts"] = [(p.meta[lang]['title'], p.permalink(lang)) for p in
- posts]
- deps_context["global"] = self.GLOBAL_CONTEXT
+ if extra_context:
+ context.update(extra_context)
+ if 'has_other_languages' not in context:
+ context['has_other_languages'] = False
- for k, v in self.GLOBAL_CONTEXT['template_hooks'].items():
- deps_context['||template_hooks|{0}||'.format(k)] = v._items
-
- for k in self._GLOBAL_CONTEXT_TRANSLATABLE:
- deps_context[k] = deps_context['global'][k](lang)
+ post_deps_dict = {}
+ post_deps_dict["posts"] = [(p.meta[lang]['title'], p.permalink(lang)) for p in posts]
- deps_context['navigation_links'] = deps_context['global']['navigation_links'](lang)
-
- task = {
- 'name': os.path.normpath(output_name),
- 'targets': [output_name],
- 'file_dep': sorted(deps),
- 'actions': [(self.render_template, [template_name, output_name,
- context])],
- 'clean': True,
- 'uptodate': [config_changed(deps_context, 'nikola.nikola.Nikola.generic_post_list_renderer')] + uptodate_deps
- }
-
- return utils.apply_filters(task, filters)
+ return self.generic_renderer(lang, output_name, template_name, filters,
+ file_deps=deps,
+ uptodate_deps=uptodate_deps,
+ context=context,
+ post_deps_dict=post_deps_dict)
def atom_feed_renderer(self, lang, posts, output_path, filters,
extra_context):
@@ -1711,43 +2400,42 @@ class Nikola(object):
link = lxml.etree.Element("link")
link.set("rel", link_rel)
link.set("type", link_type)
- link.set("href", link_href)
+ link.set("href", utils.encodelink(link_href))
return link
+ utils.LocaleBorg().set_locale(lang)
deps = []
uptodate_deps = []
for post in posts:
deps += post.deps(lang)
uptodate_deps += post.deps_uptodate(lang)
+
context = {}
+ blog_title = self.config['BLOG_TITLE'](lang)
context["posts"] = posts
- context["title"] = self.config['BLOG_TITLE'](lang)
+ context["title"] = blog_title
context["description"] = self.config['BLOG_DESCRIPTION'](lang)
context["lang"] = lang
- context["prevlink"] = None
- context["nextlink"] = None
- context["is_feed_stale"] = None
context.update(extra_context)
+
+ context["title"] = "{0} ({1})".format(blog_title, context["title"]) if blog_title != context["title"] else blog_title
+
deps_context = copy(context)
deps_context["posts"] = [(p.meta[lang]['title'], p.permalink(lang)) for p in
posts]
deps_context["global"] = self.GLOBAL_CONTEXT
+ deps_context["all_page_deps"] = self.ALL_PAGE_DEPS
for k in self._GLOBAL_CONTEXT_TRANSLATABLE:
deps_context[k] = deps_context['global'][k](lang)
+ for k in self._ALL_PAGE_DEPS_TRANSLATABLE:
+ deps_context[k] = deps_context['all_page_deps'][k](lang)
- deps_context['navigation_links'] = deps_context['global']['navigation_links'](lang)
-
- nslist = {}
- if context["is_feed_stale"] or (not context["feedpagenum"] == context["feedpagecount"] - 1 and not context["feedpagenum"] == 0):
- nslist["fh"] = "http://purl.org/syndication/history/1.0"
- if not self.config["RSS_TEASERS"]:
- nslist["xh"] = "http://www.w3.org/1999/xhtml"
feed_xsl_link = self.abs_link("/assets/xml/atom.xsl")
- feed_root = lxml.etree.Element("feed", nsmap=nslist)
+ feed_root = lxml.etree.Element("feed")
feed_root.addprevious(lxml.etree.ProcessingInstruction(
"xml-stylesheet",
- 'href="' + feed_xsl_link + '" type="text/xsl media="all"'))
+ 'href="' + utils.encodelink(feed_xsl_link) + '" type="text/xsl media="all"'))
feed_root.set("{http://www.w3.org/XML/1998/namespace}lang", lang)
feed_root.set("xmlns", "http://www.w3.org/2005/Atom")
feed_title = lxml.etree.SubElement(feed_root, "title")
@@ -1755,63 +2443,58 @@ class Nikola(object):
feed_id = lxml.etree.SubElement(feed_root, "id")
feed_id.text = self.abs_link(context["feedlink"])
feed_updated = lxml.etree.SubElement(feed_root, "updated")
- feed_updated.text = post.formatted_date('webiso', datetime.datetime.now(tz=dateutil.tz.tzutc()))
+ feed_updated.text = utils.LocaleBorg().formatted_date('webiso', datetime.datetime.now(tz=dateutil.tz.tzutc()))
feed_author = lxml.etree.SubElement(feed_root, "author")
feed_author_name = lxml.etree.SubElement(feed_author, "name")
feed_author_name.text = self.config["BLOG_AUTHOR"](lang)
feed_root.append(atom_link("self", "application/atom+xml",
self.abs_link(context["feedlink"])))
- # Older is "next" and newer is "previous" in paginated feeds (opposite of archived)
- if "nextfeedlink" in context:
- feed_root.append(atom_link("next", "application/atom+xml",
- self.abs_link(context["nextfeedlink"])))
- if "prevfeedlink" in context:
- feed_root.append(atom_link("previous", "application/atom+xml",
- self.abs_link(context["prevfeedlink"])))
- if context["is_feed_stale"] or not context["feedpagenum"] == 0:
- feed_root.append(atom_link("current", "application/atom+xml",
- self.abs_link(context["currentfeedlink"])))
- # Older is "prev-archive" and newer is "next-archive" in archived feeds (opposite of paginated)
- if "prevfeedlink" in context and (context["is_feed_stale"] or not context["feedpagenum"] == context["feedpagecount"] - 1):
- feed_root.append(atom_link("next-archive", "application/atom+xml",
- self.abs_link(context["prevfeedlink"])))
- if "nextfeedlink" in context:
- feed_root.append(atom_link("prev-archive", "application/atom+xml",
- self.abs_link(context["nextfeedlink"])))
- if context["is_feed_stale"] or not context["feedpagenum"] == context["feedpagecount"] - 1:
- lxml.etree.SubElement(feed_root, "{http://purl.org/syndication/history/1.0}archive")
feed_root.append(atom_link("alternate", "text/html",
self.abs_link(context["permalink"])))
feed_generator = lxml.etree.SubElement(feed_root, "generator")
- feed_generator.set("uri", "http://getnikola.com/")
+ feed_generator.set("uri", "https://getnikola.com/")
feed_generator.text = "Nikola"
feed_append_query = None
- if self.config["RSS_LINKS_APPEND_QUERY"]:
- feed_append_query = self.config["RSS_LINKS_APPEND_QUERY"].format(
+ if self.config["FEED_LINKS_APPEND_QUERY"]:
+ feed_append_query = self.config["FEED_LINKS_APPEND_QUERY"].format(
feedRelUri=context["feedlink"],
feedFormat="atom")
- for post in posts:
- data = post.text(lang, teaser_only=self.config["RSS_TEASERS"], strip_html=self.config["RSS_TEASERS"],
- rss_read_more_link=True, rss_links_append_query=feed_append_query)
- if not self.config["RSS_TEASERS"]:
+ def atom_post_text(post, text):
+ if not self.config["FEED_PLAIN"]:
+ if 'previewimage' in post.meta[lang] and post.meta[lang]['previewimage'] not in text:
+ text = "<figure><img src=\"{}\"></figure> {}".format(post.meta[lang]['previewimage'], text)
+
# FIXME: this is duplicated with code in Post.text() and generic_rss_renderer
try:
- doc = lxml.html.document_fromstring(data)
- doc.rewrite_links(lambda dst: self.url_replacer(post.permalink(), dst, lang, 'absolute'))
+ doc = lxml.html.document_fromstring(text)
+ doc.rewrite_links(lambda dst: self.url_replacer(post.permalink(lang), dst, lang, 'absolute'))
try:
body = doc.body
- data = (body.text or '') + ''.join(
+ text = (body.text or '') + ''.join(
[lxml.html.tostring(child, encoding='unicode')
for child in body.iterchildren()])
except IndexError: # No body there, it happens sometimes
- data = ''
+ text = ''
except lxml.etree.ParserError as e:
if str(e) == "Document is empty":
- data = ""
+ text = ""
else: # let other errors raise
- raise(e)
+ raise
+ return text.strip()
+
+ for post in posts:
+ summary = atom_post_text(post, post.text(lang, teaser_only=True,
+ strip_html=self.config["FEED_PLAIN"],
+ feed_read_more_link=True,
+ feed_links_append_query=feed_append_query))
+ content = None
+ if not self.config["FEED_TEASERS"]:
+ content = atom_post_text(post, post.text(lang, teaser_only=self.config["FEED_TEASERS"],
+ strip_html=self.config["FEED_PLAIN"],
+ feed_read_more_link=True,
+ feed_links_append_query=feed_append_query))
entry_root = lxml.etree.SubElement(feed_root, "entry")
entry_title = lxml.etree.SubElement(entry_root, "title")
@@ -1826,30 +2509,35 @@ class Nikola(object):
entry_author_name = lxml.etree.SubElement(entry_author, "name")
entry_author_name.text = post.author(lang)
entry_root.append(atom_link("alternate", "text/html",
- post.permalink(lang, absolute=True,
- query=feed_append_query)))
- if self.config["RSS_TEASERS"]:
- entry_summary = lxml.etree.SubElement(entry_root, "summary")
- entry_summary.text = data
+ post.permalink(lang, absolute=True,
+ query=feed_append_query)))
+ entry_summary = lxml.etree.SubElement(entry_root, "summary")
+ if not self.config["FEED_PLAIN"]:
+ entry_summary.set("type", "html")
else:
+ entry_summary.set("type", "text")
+ entry_summary.text = summary
+ if content:
entry_content = lxml.etree.SubElement(entry_root, "content")
- entry_content.set("type", "xhtml")
- entry_content_nsdiv = lxml.etree.SubElement(entry_content, "{http://www.w3.org/1999/xhtml}div")
- entry_content_nsdiv.text = data
- for category in post.tags:
+ if not self.config["FEED_PLAIN"]:
+ entry_content.set("type", "html")
+ else:
+ entry_content.set("type", "text")
+ entry_content.text = content
+ for category in post.tags_for_language(lang):
entry_category = lxml.etree.SubElement(entry_root, "category")
- entry_category.set("term", utils.slugify(category))
+ entry_category.set("term", utils.slugify(category, lang))
entry_category.set("label", category)
dst_dir = os.path.dirname(output_path)
utils.makedirs(dst_dir)
with io.open(output_path, "w+", encoding="utf-8") as atom_file:
data = lxml.etree.tostring(feed_root.getroottree(), encoding="UTF-8", pretty_print=True, xml_declaration=True)
- if isinstance(data, utils.bytes_str):
+ if isinstance(data, bytes):
data = data.decode('utf-8')
atom_file.write(data)
- def generic_index_renderer(self, lang, posts, indexes_title, template_name, context_source, kw, basename, page_link, page_path, additional_dependencies=[]):
+ def generic_index_renderer(self, lang, posts, indexes_title, template_name, context_source, kw, basename, page_link, page_path, additional_dependencies=None):
"""Create an index page.
lang: The language
@@ -1874,6 +2562,9 @@ class Nikola(object):
as the ones for page_link.
additional_dependencies: a list of dependencies which will be added
to task['uptodate']
+
+ Note: if context['featured'] is present, it must be a list of posts,
+ whose dependencies will be taken added to task['uptodate'].
"""
# Update kw
kw = kw.copy()
@@ -1883,12 +2574,11 @@ class Nikola(object):
kw["indexes_pages"] = self.config['INDEXES_PAGES'](lang)
kw["indexes_pages_main"] = self.config['INDEXES_PAGES_MAIN']
kw["indexes_static"] = self.config['INDEXES_STATIC']
- kw['indexes_prety_page_url'] = self.config["INDEXES_PRETTY_PAGE_URL"]
- kw['demote_headers'] = self.config['DEMOTE_HEADERS']
- kw['generate_atom'] = self.config["GENERATE_ATOM"]
- kw['feed_link_append_query'] = self.config["RSS_LINKS_APPEND_QUERY"]
- kw['feed_teasers'] = self.config["RSS_TEASERS"]
- kw['currentfeed'] = None
+ kw['indexes_pretty_page_url'] = self.config["INDEXES_PRETTY_PAGE_URL"]
+ kw['show_index_page_navigation'] = self.config['SHOW_INDEX_PAGE_NAVIGATION']
+
+ if additional_dependencies is None:
+ additional_dependencies = []
# Split in smaller lists
lists = []
@@ -1902,12 +2592,29 @@ class Nikola(object):
while posts:
lists.append(posts[:kw["index_display_post_count"]])
posts = posts[kw["index_display_post_count"]:]
+ if not lists:
+ lists.append([])
num_pages = len(lists)
+ displayed_page_numbers = [utils.get_displayed_page_number(i, num_pages, self) for i in range(num_pages)]
+ page_links = [page_link(i, page_number, num_pages, False) for i, page_number in enumerate(displayed_page_numbers)]
+ if kw['show_index_page_navigation']:
+ # Since the list displayed_page_numbers is not necessarily
+ # sorted -- in case INDEXES_STATIC is True, it is of the
+ # form [num_pages, 1, 2, ..., num_pages - 1] -- we order it
+ # via a map. This allows to not replicate the logic of
+ # utils.get_displayed_page_number() here.
+ if not kw["indexes_pages_main"] and not kw["indexes_static"]:
+ temp_map = {page_number: link for page_number, link in zip(displayed_page_numbers, page_links)}
+ else:
+ temp_map = {page_number - 1: link for page_number, link in zip(displayed_page_numbers, page_links)}
+ page_links_context = [temp_map[i] for i in range(num_pages)]
for i, post_list in enumerate(lists):
context = context_source.copy()
if 'pagekind' not in context:
context['pagekind'] = ['index']
- ipages_i = utils.get_displayed_page_number(i, num_pages, self)
+ if 'has_other_languages' not in context:
+ context['has_other_languages'] = False
+ ipages_i = displayed_page_numbers[i]
if kw["indexes_pages"]:
indexes_pages = kw["indexes_pages"] % ipages_i
else:
@@ -1943,20 +2650,29 @@ class Nikola(object):
if i < num_pages - 1:
nextlink = i + 1
if prevlink is not None:
- context["prevlink"] = page_link(prevlink,
- utils.get_displayed_page_number(prevlink, num_pages, self),
- num_pages, False)
- context["prevfeedlink"] = page_link(prevlink,
- utils.get_displayed_page_number(prevlink, num_pages, self),
+ context["prevlink"] = page_links[prevlink]
+ context["prevfeedlink"] = page_link(prevlink, displayed_page_numbers[prevlink],
num_pages, False, extension=".atom")
if nextlink is not None:
- context["nextlink"] = page_link(nextlink,
- utils.get_displayed_page_number(nextlink, num_pages, self),
- num_pages, False)
- context["nextfeedlink"] = page_link(nextlink,
- utils.get_displayed_page_number(nextlink, num_pages, self),
+ context["nextlink"] = page_links[nextlink]
+ context["nextfeedlink"] = page_link(nextlink, displayed_page_numbers[nextlink],
num_pages, False, extension=".atom")
- context["permalink"] = page_link(i, ipages_i, num_pages, False)
+ context['show_index_page_navigation'] = kw['show_index_page_navigation']
+ if kw['show_index_page_navigation']:
+ context['page_links'] = page_links_context
+ if not kw["indexes_pages_main"] and not kw["indexes_static"]:
+ context['current_page'] = ipages_i
+ else:
+ context['current_page'] = ipages_i - 1
+ context['prev_next_links_reversed'] = kw['indexes_static']
+ context["permalink"] = page_links[i]
+ context["is_frontmost_index"] = i == 0
+
+ # Add dependencies to featured posts
+ if 'featured' in context:
+ for post in context['featured']:
+ additional_dependencies += post.deps_uptodate(lang)
+
output_name = os.path.join(kw['output_folder'], page_path(i, ipages_i, num_pages, False))
task = self.generic_post_list_renderer(
lang,
@@ -1970,34 +2686,10 @@ class Nikola(object):
task['basename'] = basename
yield task
- if kw['generate_atom']:
- atom_output_name = os.path.join(kw['output_folder'], page_path(i, ipages_i, num_pages, False, extension=".atom"))
- context["feedlink"] = page_link(i, ipages_i, num_pages, False, extension=".atom")
- if not kw["currentfeed"]:
- kw["currentfeed"] = context["feedlink"]
- context["currentfeedlink"] = kw["currentfeed"]
- context["feedpagenum"] = i
- context["feedpagecount"] = num_pages
- atom_task = {
- "basename": basename,
- "name": atom_output_name,
- "file_dep": sorted([_.base_path for _ in post_list]),
- "targets": [atom_output_name],
- "actions": [(self.atom_feed_renderer,
- (lang,
- post_list,
- atom_output_name,
- kw['filters'],
- context,))],
- "clean": True,
- "uptodate": [utils.config_changed(kw, 'nikola.nikola.Nikola.atom_feed_renderer')] + additional_dependencies
- }
- yield utils.apply_filters(atom_task, kw['filters'])
-
- if kw["indexes_pages_main"] and kw['indexes_prety_page_url'](lang):
+ if kw["indexes_pages_main"] and kw['indexes_pretty_page_url'](lang):
# create redirection
- output_name = os.path.join(kw['output_folder'], page_path(0, utils.get_displayed_page_number(0, num_pages, self), num_pages, True))
- link = page_link(0, utils.get_displayed_page_number(0, num_pages, self), num_pages, False)
+ output_name = os.path.join(kw['output_folder'], page_path(0, displayed_page_numbers[0], num_pages, True))
+ link = page_links[0]
yield utils.apply_filters({
'basename': basename,
'name': output_name,
@@ -2007,156 +2699,58 @@ class Nikola(object):
'uptodate': [utils.config_changed(kw, 'nikola.nikola.Nikola.generic_index_renderer')],
}, kw["filters"])
- def __repr__(self):
- """Representation of a Nikola site."""
- return '<Nikola Site: {0!r}>'.format(self.config['BLOG_TITLE']())
-
-
-def sanitized_locales(locale_fallback, locale_default, locales, translations):
- """Sanitize all locales availble in Nikola.
-
- There will be one locale for each language in translations.
-
- Locales for languages not in translations are ignored.
-
- An explicit locale for a language can be specified in locales[language].
-
- Locales at the input must be in the string style (like 'en', 'en.utf8'), and
- the string can be unicode or bytes; at the output will be of type str, as
- required by locale.setlocale.
+ def generic_atom_renderer(self, lang, posts, context_source, kw, basename, classification, kind, additional_dependencies=None):
+ """Create an Atom feed.
- Explicit but invalid locales are replaced with the sanitized locale_fallback
-
- Languages with no explicit locale are set to
- the sanitized locale_default if it was explicitly set
- sanitized guesses compatible with v 6.0.4 if locale_default was None
-
- NOTE: never use locale.getlocale() , it can return values that
- locale.setlocale will not accept in Windows XP, 7 and pythons 2.6, 2.7, 3.3
- Examples: "Spanish", "French" can't do the full circle set / get / set
- """
- if sys.platform != 'win32':
- workaround_empty_LC_ALL_posix()
-
- # locales for languages not in translations are ignored
- extras = set(locales) - set(translations)
- if extras:
- msg = 'Unexpected languages in LOCALES, ignoring them: {0}'
- utils.LOGGER.warn(msg.format(', '.join(extras)))
- for lang in extras:
- del locales[lang]
-
- # py2x: get/setlocale related functions require the locale string as a str
- # so convert
- locale_fallback = str(locale_fallback) if locale_fallback else None
- locale_default = str(locale_default) if locale_default else None
- for lang in locales:
- locales[lang] = str(locales[lang])
-
- locale_fallback = valid_locale_fallback(locale_fallback)
-
- # explicit but invalid locales are replaced with the sanitized locale_fallback
- for lang in locales:
- if not is_valid_locale(locales[lang]):
- msg = 'Locale {0} for language {1} not accepted by python locale.'
- utils.LOGGER.warn(msg.format(locales[lang], lang))
- locales[lang] = locale_fallback
-
- # languages with no explicit locale
- missing = set(translations) - set(locales)
- if locale_default:
- # are set to the sanitized locale_default if it was explicitly set
- if not is_valid_locale(locale_default):
- msg = 'LOCALE_DEFAULT {0} could not be set, using {1}'
- utils.LOGGER.warn(msg.format(locale_default, locale_fallback))
- locale_default = locale_fallback
- for lang in missing:
- locales[lang] = locale_default
- else:
- # are set to sanitized guesses compatible with v 6.0.4 in Linux-Mac (was broken in Windows)
- if sys.platform == 'win32':
- guess_locale_fom_lang = guess_locale_from_lang_windows
- else:
- guess_locale_fom_lang = guess_locale_from_lang_posix
- for lang in missing:
- locale_n = guess_locale_fom_lang(lang)
- if not locale_n:
- locale_n = locale_fallback
- msg = "Could not guess locale for language {0}, using locale {1}"
- utils.LOGGER.warn(msg.format(lang, locale_n))
- locales[lang] = locale_n
-
- return locale_fallback, locale_default, locales
+ lang: The language
+ posts: A list of posts
+ context_source: This will be copied and extended and used as every
+ page's context
+ kw: An extended version will be used for uptodate dependencies
+ basename: Basename for task
+ classification: name of current classification (used to generate links)
+ kind: classification kind (used to generate links)
+ additional_dependencies: a list of dependencies which will be added
+ to task['uptodate']
+ """
+ # Update kw
+ kw = kw.copy()
+ kw["feed_length"] = self.config['FEED_LENGTH']
+ kw['generate_atom'] = self.config["GENERATE_ATOM"]
+ kw['feed_links_append_query'] = self.config["FEED_LINKS_APPEND_QUERY"]
+ kw['feed_teasers'] = self.config['FEED_TEASERS']
+ kw['feed_plain'] = self.config['FEED_PLAIN']
+ if additional_dependencies is None:
+ additional_dependencies = []
-def is_valid_locale(locale_n):
- """Check if locale (type str) is valid."""
- try:
- locale.setlocale(locale.LC_ALL, locale_n)
- return True
- except locale.Error:
- return False
+ post_list = posts[:kw["feed_length"]]
+ feedlink = self.link(kind + "_atom", classification, lang)
+ feedpath = self.path(kind + "_atom", classification, lang)
+ context = context_source.copy()
+ if 'has_other_languages' not in context:
+ context['has_other_languages'] = False
-def valid_locale_fallback(desired_locale=None):
- """Provide a default fallback_locale, a string that locale.setlocale will accept.
+ output_name = os.path.join(kw['output_folder'], feedpath)
+ context["feedlink"] = feedlink
+ task = {
+ "basename": basename,
+ "name": output_name,
+ "file_dep": sorted([_.base_path for _ in post_list]),
+ "task_dep": ['render_posts'],
+ "targets": [output_name],
+ "actions": [(self.atom_feed_renderer,
+ (lang,
+ post_list,
+ output_name,
+ kw['filters'],
+ context,))],
+ "clean": True,
+ "uptodate": [utils.config_changed(kw, 'nikola.nikola.Nikola.atom_feed_renderer')] + additional_dependencies
+ }
+ yield utils.apply_filters(task, kw['filters'])
- If desired_locale is provided must be of type str for py2x compatibility
- """
- # Whenever fallbacks change, adjust test TestHarcodedFallbacksWork
- candidates_windows = [str('English'), str('C')]
- candidates_posix = [str('en_US.utf8'), str('C')]
- candidates = candidates_windows if sys.platform == 'win32' else candidates_posix
- if desired_locale:
- candidates = list(candidates)
- candidates.insert(0, desired_locale)
- found_valid = False
- for locale_n in candidates:
- found_valid = is_valid_locale(locale_n)
- if found_valid:
- break
- if not found_valid:
- msg = 'Could not find a valid fallback locale, tried: {0}'
- utils.LOGGER.warn(msg.format(candidates))
- elif desired_locale and (desired_locale != locale_n):
- msg = 'Desired fallback locale {0} could not be set, using: {1}'
- utils.LOGGER.warn(msg.format(desired_locale, locale_n))
- return locale_n
-
-
-def guess_locale_from_lang_windows(lang):
- """Guess a locale, basing on Windows language."""
- locale_n = str(LEGAL_VALUES['_WINDOWS_LOCALE_GUESSES'].get(lang, None))
- if not is_valid_locale(locale_n):
- locale_n = None
- return locale_n
-
-
-def guess_locale_from_lang_posix(lang):
- """Guess a locale, basing on POSIX system language."""
- # compatibility v6.0.4
- if is_valid_locale(str(lang)):
- locale_n = str(lang)
- else:
- # this works in Travis when locale support set by Travis suggestion
- locale_n = str((locale.normalize(lang).split('.')[0]) + '.utf8')
- if not is_valid_locale(locale_n):
- # http://thread.gmane.org/gmane.comp.web.nikola/337/focus=343
- locale_n = str((locale.normalize(lang).split('.')[0]))
- if not is_valid_locale(locale_n):
- locale_n = None
- return locale_n
-
-
-def workaround_empty_LC_ALL_posix():
- # clunky hack: we have seen some posix locales with all or most of LC_*
- # defined to the same value, but with LC_ALL empty.
- # Manually doing what we do here seems to work for nikola in that case.
- # It is unknown if it will work when the LC_* aren't homogeneous
- try:
- lc_time = os.environ.get('LC_TIME', None)
- lc_all = os.environ.get('LC_ALL', None)
- if lc_time and not lc_all:
- os.environ['LC_ALL'] = lc_time
- except Exception:
- pass
+ def __repr__(self):
+ """Representation of a Nikola site."""
+ return '<Nikola Site: {0!r}>'.format(self.config['BLOG_TITLE'](self.config['DEFAULT_LANG']))