diff options
Diffstat (limited to 'nikola/nikola.py')
| -rw-r--r-- | nikola/nikola.py | 838 |
1 files changed, 637 insertions, 201 deletions
diff --git a/nikola/nikola.py b/nikola/nikola.py index e0af7ad..0a62360 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-2016 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -57,7 +57,8 @@ from yapsy.PluginManager import PluginManager from blinker import signal from .post import Post # NOQA -from . import DEBUG, utils +from .state import Persistor +from . import DEBUG, utils, shortcodes from .plugin_categories import ( Command, LateTask, @@ -65,6 +66,7 @@ from .plugin_categories import ( CompilerExtension, MarkdownExtension, RestExtension, + ShortcodePlugin, Task, TaskMultiplier, TemplateSystem, @@ -80,7 +82,7 @@ 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_FEED_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}' @@ -105,6 +107,7 @@ LEGAL_VALUES = { 'ar': 'Arabic', 'az': 'Azerbaijani', 'bg': 'Bulgarian', + 'bs': 'Bosnian', 'ca': 'Catalan', ('cs', 'cz'): 'Czech', 'da': 'Danish', @@ -118,59 +121,94 @@ LEGAL_VALUES = { 'fa': 'Persian', 'fi': 'Finnish', 'fr': 'French', + 'gl': 'Galician', + 'he': 'Hebrew', 'hi': 'Hindi', 'hr': 'Croatian', + 'hu': 'Hungarian', 'id': 'Indonesian', 'it': 'Italian', ('ja', '!jp'): 'Japanese', 'ko': 'Korean', - 'nb': 'Norwegian Bokmål', + 'lt': 'Lithuanian', + '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', ('tr', '!tr_TR'): 'Turkish', 'ur': 'Urdu', 'uk': 'Ukrainian', 'zh_cn': 'Chinese (Simplified)', + 'zh_tw': 'Chinese (Traditional)' }, '_WINDOWS_LOCALE_GUESSES': { # TODO incomplete - # some languages may need that the appropiate Microsoft Language Pack be instaled. + # some languages may need that the appropriate Microsoft Language Pack be installed. + "ar": "Arabic", + "az": "Azeri (Latin)", "bg": "Bulgarian", + "bs": "Bosnian", "ca": "Catalan", + "cs": "Czech", + "da": "Danish", "de": "German", "el": "Greek", "en": "English", - "eo": "Esperanto", + # "eo": "Esperanto", # Not available "es": "Spanish", - "fa": "Farsi", # Persian + "et": "Estonian", + "eu": "Basque", + "fa": "Persian", # Persian + "fi": "Finnish", "fr": "French", + "gl": "Galician", + "he": "Hebrew", + "hi": "Hindi", "hr": "Croatian", + "hu": "Hungarian", + "id": "Indonesian", "it": "Italian", - "jp": "Japanese", + "ja": "Japanese", + "ko": "Korean", + "nb": "Norwegian", # Not Bokmål, as Windows doesn't find it for unknown reasons. "nl": "Dutch", + "pa": "Punjabi", "pl": "Polish", + "pt": "Portuguese_Portugal", "pt_br": "Portuguese_Brazil", "ru": "Russian", - "sl_si": "Slovenian", - "tr_tr": "Turkish", + "sk": "Slovak", + "sl": "Slovenian", + "sq": "Albanian", + "sr": "Serbian", + "sr_latin": "Serbian (Latin)", + "sv": "Swedish", + "te": "Telugu", + "tr": "Turkish", + "uk": "Ukrainian", + "ur": "Urdu", "zh_cn": "Chinese_China", # Chinese (Simplified) + "zh_tw": "Chinese_Taiwan", # 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'), + 'RTL_LANGUAGES': ('ar', 'fa', 'he', 'ur'), 'COLORBOX_LOCALES': defaultdict( str, ar='ar', @@ -186,56 +224,77 @@ LEGAL_VALUES = { fa='fa', fi='fi', fr='fr', + he='he', hr='hr', + hu='hu', id='id', it='it', ja='ja', ko='kr', # kr is South Korea, ko is the Korean language + lt='lt', nb='no', nl='nl', pl='pl', + pt='pt-BR', # hope nobody will mind 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 + sr_latin='sr', sv='sv', tr='tr', uk='uk', - zh_cn='zh-CN' + zh_cn='zh-CN', + zh_tw='zh-TW' ), 'MOMENTJS_LOCALES': defaultdict( str, ar='ar', + az='az', bg='bg', bn='bn', + bs='bs', ca='ca', cs='cs', cz='cs', da='da', de='de', - en='', + 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', nb='nb', nl='nl', pl='pl', + pt='pt', pt_br='pt-br', ru='ru', sk='sk', sl='sl', + sq='sq', sr='sr-cyrl', + sr_latin='sr', sv='sv', tr='tr', - zh_cn='zh-cn' + uk='uk', + zh_cn='zh-cn', + zh_tw='zh-tw' ), 'PYPHEN_LOCALES': { 'bg': 'bg', @@ -245,22 +304,50 @@ LEGAL_VALUES = { 'da': 'da', 'de': 'de', 'el': 'el', - 'en': 'en', + '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': { + 'ca': 'ca', + 'da': 'da', + 'de': 'de', + 'en': 'en', + 'eo': 'eo', + 'es': 'es', + 'fi': 'fi', + 'fr': 'fr', + 'gl': 'gl', + 'he': 'he', + 'it': 'it', + 'ja': 'ja', + 'lt': 'lt', + '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' + } } @@ -268,14 +355,19 @@ 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.warn("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. @@ -305,6 +397,7 @@ class Nikola(object): self._scanned = False self._template_system = None self._THEMES = None + self._MESSAGES = None self.debug = DEBUG self.loghandlers = utils.STDERR_HANDLER # TODO remove on v8 self.colorful = config.pop('__colorful__', False) @@ -315,6 +408,7 @@ class Nikola(object): self.configuration_filename = config.pop('__configuration_filename__', False) self.configured = bool(config) self.injected_deps = defaultdict(list) + self.shortcode_registry = {} self.rst_transforms = [] self.template_hooks = { @@ -335,6 +429,10 @@ class Nikola(object): '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_DESCRIPTION': 'Default Description', @@ -343,13 +441,14 @@ class Nikola(object): '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_PAGES_TITLES': {}, 'CATEGORY_PREFIX': 'cat_', 'CATEGORY_ALLOW_HIERARCHIES': False, 'CATEGORY_OUTPUT_FLAT_HIERARCHY': 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'), @@ -374,7 +473,10 @@ class Nikola(object): '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, @@ -383,12 +485,14 @@ class Nikola(object): 'FILES_FOLDERS': {'files': ''}, 'FILTERS': {}, 'FORCE_ISO8601': False, + 'FRONT_INDEX_HEADER': '', 'GALLERY_FOLDERS': {'galleries': 'galleries'}, 'GALLERY_SORT_BY_DATE': True, 'GLOBAL_CONTEXT_FILLER': [], 'GZIP_COMMAND': None, 'GZIP_FILES': False, 'GZIP_EXTENSIONS': ('.txt', '.htm', '.html', '.css', '.js', '.json', '.xml'), + 'HIDDEN_AUTHORS': [], 'HIDDEN_TAGS': [], 'HIDDEN_CATEGORIES': [], 'HYPHENATE': False, @@ -404,6 +508,7 @@ class Nikola(object): 'INDEXES_STATIC': True, 'INDEX_PATH': '', 'IPYNB_CONFIG': {}, + 'KATEX_AUTO_RENDER': '', 'LESS_COMPILER': 'lessc', 'LESS_OPTIONS': [], 'LICENSE': '', @@ -417,48 +522,64 @@ class Nikola(object): 'OLD_THEME_SUPPORT': True, 'OUTPUT_FOLDER': 'output', 'POSTS': (("posts/*.txt", "posts", "post.tmpl"),), + 'POSTS_SECTIONS': True, + 'POSTS_SECTION_COLORS': {}, + 'POSTS_SECTION_ARE_INDEXES': True, + 'POSTS_SECTION_DESCRIPTIONS': "", + 'POSTS_SECTION_FROM_META': False, + 'POSTS_SECTION_NAME': "", + 'POSTS_SECTION_TITLE': "{name}", + 'PRESERVE_EXIF_DATA': False, + # TODO: change in v8 'PAGES': (("stories/*.txt", "stories", "story.tmpl"),), 'PANDOC_OPTIONS': [], 'PRETTY_URLS': False, '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, + 'FEED_TEASERS': True, + 'FEED_PLAIN': False, + 'FEED_PREVIEWIMAGE': True, + 'FEED_READ_MORE_LINK': DEFAULT_FEED_READ_MORE_LINK, + 'FEED_LINKS_APPEND_QUERY': False, 'GENERATE_RSS': True, 'RSS_LINK': None, 'RSS_PATH': '', - 'RSS_PLAIN': False, - 'RSS_TEASERS': True, 'SASS_COMPILER': 'sass', 'SASS_OPTIONS': [], 'SEARCH_FORM': '', 'SHOW_BLOG_TITLE': True, '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, + 'PAGE_INDEX': False, 'STRIP_INDEXES': False, 'SITEMAP_INCLUDE_FILELESS_DIRS': True, 'TAG_PATH': 'categories', 'TAG_PAGES_ARE_INDEXES': False, 'TAG_PAGES_DESCRIPTIONS': {}, + 'TAG_PAGES_TITLES': {}, + 'TAGS_INDEX_PATH': '', 'TAGLIST_MINIMUM_POSTS': 1, 'TEMPLATE_FILTERS': {}, 'THEME': 'bootstrap3', + 'THEME_COLOR': '#5670d4', # light "corporate blue" 'THEME_REVEAL_CONFIG_SUBTHEME': 'sky', 'THEME_REVEAL_CONFIG_TRANSITION': 'cube', 'THUMBNAIL_SIZE': 180, 'UNSLUGIFY_TITLES': False, # WARNING: conf.py.in overrides this with True for backwards compatibility 'URL_TYPE': 'rel_path', + 'USE_BASE_TAG': False, 'USE_BUNDLES': True, 'USE_CDN': False, 'USE_CDN_WARNING': True, 'USE_FILENAME_AS_TITLE': True, + 'USE_KATEX': False, 'USE_OPEN_GRAPH': True, 'USE_SLUGIFY': True, 'TIMEZONE': 'UTC', @@ -472,6 +593,7 @@ class Nikola(object): '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 } # set global_context for template rendering @@ -509,11 +631,23 @@ class Nikola(object): 'BODY_END', 'EXTRA_HEAD_DATA', 'NAVIGATION_LINKS', + 'FRONT_INDEX_HEADER', 'INDEX_READ_MORE_LINK', - 'RSS_READ_MORE_LINK', + 'FEED_READ_MORE_LINK', 'INDEXES_TITLE', + 'POSTS_SECTION_COLORS', + 'POSTS_SECTION_DESCRIPTIONS', + 'POSTS_SECTION_NAME', + 'POSTS_SECTION_TITLE', 'INDEXES_PAGES', - 'INDEXES_PRETTY_PAGE_URL',) + 'INDEXES_PRETTY_PAGE_URL', + # PATH options (Issue #1914) + 'TAG_PATH', + 'TAGS_INDEX_PATH', + 'CATEGORY_PATH', + 'DATE_FORMAT', + 'JS_DATE_FORMAT', + ) self._GLOBAL_CONTEXT_TRANSLATABLE = ('blog_author', 'blog_title', @@ -524,16 +658,41 @@ class Nikola(object): 'social_buttons_code', 'search_form', 'body_end', - 'extra_head_data',) + 'extra_head_data', + 'date_format', + 'js_date_format', + 'posts_section_colors', + 'posts_section_descriptions', + 'posts_section_name', + 'posts_section_title', + 'front_index_header', + ) # WARNING: navigation_links SHOULD NOT be added to the list above. # Themes ask for [lang] there and we should provide it. + # We first have to massage JS_DATE_FORMAT, otherwise we run into trouble + if 'JS_DATE_FORMAT' in self.config: + if isinstance(self.config['JS_DATE_FORMAT'], dict): + for k in self.config['JS_DATE_FORMAT']: + self.config['JS_DATE_FORMAT'][k] = json.dumps(self.config['JS_DATE_FORMAT'][k]) + else: + self.config['JS_DATE_FORMAT'] = json.dumps(self.config['JS_DATE_FORMAT']) + for i in self.TRANSLATABLE_SETTINGS: try: self.config[i] = utils.TranslatableSetting(i, self.config[i], self.config['TRANSLATIONS']) except KeyError: pass + # A EXIF_WHITELIST implies you want to keep EXIF data + if self.config['EXIF_WHITELIST'] and not self.config['PRESERVE_EXIF_DATA']: + utils.LOGGER.warn('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.warn('You are setting PRESERVE_EXIF_DATA and not EXIF_WHITELIST so EXIF data is not really kept.') + # Handle CONTENT_FOOTER properly. # We provide the arguments to format in CONTENT_FOOTER_FORMATS. self.config['CONTENT_FOOTER'].langformat(self.config['CONTENT_FOOTER_FORMATS']) @@ -555,6 +714,38 @@ class Nikola(object): for i1, i2, i3 in self.config['PAGES']: self.config['post_pages'].append([i1, i2, i3, False]) + # RSS_TEASERS has been replaced with FEED_TEASERS + # TODO: remove on v8 + if 'RSS_TEASERS' in config: + utils.LOGGER.warn('The RSS_TEASERS option is deprecated, use FEED_TEASERS instead.') + if 'FEED_TEASERS' in config: + utils.LOGGER.warn('FEED_TEASERS conflicts with RSS_TEASERS, ignoring RSS_TEASERS.') + self.config['FEED_TEASERS'] = config['RSS_TEASERS'] + + # RSS_PLAIN has been replaced with FEED_PLAIN + # TODO: remove on v8 + if 'RSS_PLAIN' in config: + utils.LOGGER.warn('The RSS_PLAIN option is deprecated, use FEED_PLAIN instead.') + if 'FEED_PLAIN' in config: + utils.LOGGER.warn('FEED_PLIN conflicts with RSS_PLAIN, ignoring RSS_PLAIN.') + self.config['FEED_PLAIN'] = config['RSS_PLAIN'] + + # RSS_LINKS_APPEND_QUERY has been replaced with FEED_LINKS_APPEND_QUERY + # TODO: remove on v8 + if 'RSS_LINKS_APPEND_QUERY' in config: + utils.LOGGER.warn('The RSS_LINKS_APPEND_QUERY option is deprecated, use FEED_LINKS_APPEND_QUERY instead.') + if 'FEED_LINKS_APPEND_QUERY' in config: + utils.LOGGER.warn('FEED_LINKS_APPEND_QUERY conflicts with RSS_LINKS_APPEND_QUERY, ignoring RSS_LINKS_APPEND_QUERY.') + self.config['FEED_LINKS_APPEND_QUERY'] = config['RSS_LINKS_APPEND_QUERY'] + + # RSS_READ_MORE_LINK has been replaced with FEED_READ_MORE_LINK + # TODO: remove on v8 + if 'RSS_READ_MORE_LINK' in config: + utils.LOGGER.warn('The RSS_READ_MORE_LINK option is deprecated, use FEED_READ_MORE_LINK instead.') + if 'FEED_READ_MORE_LINK' in config: + utils.LOGGER.warn('FEED_READ_MORE_LINK conflicts with RSS_READ_MORE_LINK, ignoring RSS_READ_MORE_LINK') + self.config['FEED_READ_MORE_LINK'] = utils.TranslatableSetting('FEED_READ_MORE_LINK', config['RSS_READ_MORE_LINK'], self.config['TRANSLATIONS']) + # DEFAULT_TRANSLATIONS_PATTERN was changed from "p.e.l" to "p.l.e" # TODO: remove on v8 if 'TRANSLATIONS_PATTERN' not in self.config: @@ -634,7 +825,7 @@ class Nikola(object): 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'] @@ -680,6 +871,15 @@ class Nikola(object): 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.") + # Rename stories to pages (#1891, #2518) + # TODO: remove in v8 + if 'COMMENTS_IN_STORIES' in config: + utils.LOGGER.warn('The COMMENTS_IN_STORIES option is deprecated, use COMMENTS_IN_PAGES instead.') + self.config['COMMENTS_IN_PAGES'] = config['COMMENTS_IN_STORIES'] + if 'STORY_INDEX' in config: + utils.LOGGER.warn('The STORY_INDEX option is deprecated, use PAGE_INDEX instead.') + self.config['PAGE_INDEX'] = config['STORY_INDEX'] + # We use one global tzinfo object all over Nikola. try: self.tzinfo = dateutil.tz.gettz(self.config['TIMEZONE']) @@ -702,6 +902,9 @@ class Nikola(object): candidate = utils.get_translation_candidate(self.config, "f" + ext, lang) compilers[compiler].add(candidate) + # Get search path for themes + self.themes_dirs = ['themes'] + self.config['EXTRA_THEMES_DIRS'] + # Avoid redundant compilers # Remove compilers that match nothing in POSTS/PAGES # And put them in "bad compilers" @@ -715,9 +918,21 @@ class Nikola(object): else: self.bad_compilers.add(k) - self._set_global_context() + self._set_global_context_from_config() + 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 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,6 +944,7 @@ class Nikola(object): "CompilerExtension": CompilerExtension, "MarkdownExtension": MarkdownExtension, "RestExtension": RestExtension, + "ShortcodePlugin": ShortcodePlugin, "SignalHandler": SignalHandler, "ConfigPlugin": ConfigPlugin, "PostScanner": PostScanner, @@ -736,47 +952,71 @@ class Nikola(object): self.plugin_manager.getPluginLocator().setPluginInfoExtension('plugin') extra_plugins_dirs = self.config['EXTRA_PLUGINS_DIRS'] if sys.version_info[0] == 3: - places = [ + self._plugin_places = [ resource_filename('nikola', 'plugins'), - os.path.join(os.getcwd(), 'plugins'), os.path.expanduser('~/.nikola/plugins'), + os.path.join(os.getcwd(), 'plugins'), ] + [path for path in extra_plugins_dirs if path] else: - places = [ + self._plugin_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_manager.getPluginLocator().setPluginPlaces(places) + 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 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) - 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 + 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 compiler extension {}', p[-1].name) + self.plugin_manager._candidates = list(set(self.plugin_manager._candidates) - bad_candidates) + + # 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 + + plugin_dict = defaultdict(list) + for data in self.plugin_manager._candidates: + plugin_dict[data[2].name].append(data) + self.plugin_manager._candidates = [] + for name, 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])) + self.plugin_manager._candidates.append(plugins[-1]) + self.plugin_manager.loadPlugins() self._activate_plugins_of_category("SignalHandler") @@ -803,6 +1043,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 = {} @@ -813,11 +1056,15 @@ class Nikola(object): plugin_info.plugin_object self._activate_plugins_of_category("ConfigPlugin") - + self._register_templated_shortcodes() 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,13 +1075,16 @@ 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_AUTHOR_PATH'] = self.config['SLUG_AUTHOR_PATH'] 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_base_tag'] = self.config['USE_BASE_TAG'] 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['favicons'] = self.config['FAVICONS'] self._GLOBAL_CONTEXT['date_format'] = self.config.get('DATE_FORMAT') self._GLOBAL_CONTEXT['blog_author'] = self.config.get('BLOG_AUTHOR') @@ -842,6 +1092,9 @@ class Nikola(object): 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') + 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 # TODO: remove in v8 self._GLOBAL_CONTEXT['blog_desc'] = self.config.get('BLOG_DESCRIPTION') @@ -858,6 +1111,8 @@ 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['use_katex'] = self.config.get('USE_KATEX') + self._GLOBAL_CONTEXT['katex_auto_render'] = self.config.get('KATEX_AUTO_RENDER') 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['content_footer'] = self.config.get( @@ -879,12 +1134,20 @@ 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['js_date_format'] = self.config.get('JS_DATE_FORMAT') self._GLOBAL_CONTEXT['colorbox_locales'] = LEGAL_VALUES['COLORBOX_LOCALES'] self._GLOBAL_CONTEXT['momentjs_locales'] = LEGAL_VALUES['MOMENTJS_LOCALES'] 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 + self._GLOBAL_CONTEXT['posts_sections'] = self.config.get('POSTS_SECTIONS') + self._GLOBAL_CONTEXT['posts_section_are_indexes'] = self.config.get('POSTS_SECTION_ARE_INDEXES') + self._GLOBAL_CONTEXT['posts_section_colors'] = self.config.get('POSTS_SECTION_COLORS') + self._GLOBAL_CONTEXT['posts_section_descriptions'] = self.config.get('POSTS_SECTION_DESCRIPTIONS') + self._GLOBAL_CONTEXT['posts_section_from_meta'] = self.config.get('POSTS_SECTION_FROM_META') + self._GLOBAL_CONTEXT['posts_section_name'] = self.config.get('POSTS_SECTION_NAME') + self._GLOBAL_CONTEXT['posts_section_title'] = self.config.get('POSTS_SECTION_TITLE') # 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). @@ -893,6 +1156,16 @@ class Nikola(object): 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 + 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,11 +1179,13 @@ 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'] != 'bootstrap3': + utils.LOGGER.warn('''Cannot load theme "{0}", using 'bootstrap3' instead.'''.format(self.config['THEME'])) + self.config['THEME'] = 'bootstrap3' + 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( @@ -924,9 +1199,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) @@ -1009,7 +1287,7 @@ class Nikola(object): return compile_html - def render_template(self, template_name, output_name, context): + def render_template(self, template_name, output_name, context, url_type=None): """Render a template with the global context. If ``output_name`` is None, will return a string and all URL @@ -1017,6 +1295,9 @@ 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. """ local_context = {} local_context["template_name"] = template_name @@ -1053,22 +1334,22 @@ 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']) + self.rewrite_links(doc, src, context['lang'], url_type) data = b'<!DOCTYPE html>\n' + lxml.html.tostring(doc, encoding='utf8', method='html', pretty_print=True) 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): @@ -1171,7 +1452,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 @@ -1186,13 +1467,90 @@ class Nikola(object): return result + 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. + + """ + 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.warn('Shortcode name conflict: {}', name) + return + self.shortcode_registry[name] = f + + # XXX in v8, get rid of with_dependencies + def apply_shortcodes(self, data, filename=None, lang=None, with_dependencies=False, extra_context={}): + """Apply shortcodes from the registry on data.""" + if lang is None: + lang = utils.LocaleBorg().current_lang + return shortcodes.apply_shortcodes(data, self.shortcode_registry, self, filename, lang=lang, with_dependencies=with_dependencies, extra_context=extra_context) + 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.""" rss_obj = utils.ExtendedRSS2( title=title, - link=link, + link=utils.encodelink(link), description=description, lastBuildDate=datetime.datetime.utcnow(), generator='https://getnikola.com/', @@ -1213,10 +1571,12 @@ class Nikola(object): 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 self.config["FEED_PREVIEWIMAGE"] and '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) @@ -1290,8 +1650,8 @@ 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" @@ -1308,7 +1668,6 @@ class Nikola(object): 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']) @@ -1324,13 +1683,25 @@ class Nikola(object): return "" 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,7 +1709,12 @@ class Nikola(object): return [] def slug_path(self, name, lang): - """Handle slug paths.""" + """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)) @@ -1348,7 +1724,12 @@ class Nikola(object): return [_f for _f in results[0].permalink(lang).split('/') if _f] 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)) @@ -1366,7 +1747,9 @@ class Nikola(object): def link(self, *args): """Create a link.""" - return self.path(*args, is_link=True) + url = self.path(*args, is_link=True) + url = utils.encodelink(url) + return url def abs_link(self, dst, protocol_relative=False): """Get an absolute link.""" @@ -1378,6 +1761,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 +1776,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 +1787,9 @@ 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 file_exists(self, path, not_empty=False): """Check if the file exists. If not_empty is True, it also must not be empty.""" @@ -1526,6 +1912,7 @@ 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 = [] @@ -1536,27 +1923,28 @@ class Nikola(object): 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) + if _tag_slugified in slugged_tags[lang]: + 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, lang) == _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[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 +1957,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,13 +1972,16 @@ class Nikola(object): quit = True self.post_per_file[dest] = post self.post_per_file[src_dest] = post + 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.sort(key=lambda p: + (int(p.meta('priority')) if p.meta('priority') else 0, + p.date, p.source_path)) thing.reverse() self._sort_category_hierarchy() @@ -1604,15 +1996,71 @@ 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): + """Helper function 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 + """ + 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 + 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._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) + + 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])], + '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 = self.get_compiler(post.source_path).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)) + + 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) @@ -1621,85 +2069,49 @@ class Nikola(object): 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), - } - - 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) - 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 - - 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) - - deps_context['navigation_links'] = deps_context['global']['navigation_links'](lang) + if extra_context: + context.update(extra_context) - 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 - } + post_deps_dict = {} + post_deps_dict["posts"] = [(p.meta[lang]['title'], p.permalink(lang)) for p in posts] - 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,9 +2123,10 @@ 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: @@ -1739,15 +2152,13 @@ class Nikola(object): 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): + if context["is_feed_stale"] or "feedpagenum" in context and (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.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,7 +2166,7 @@ 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) @@ -1768,50 +2179,64 @@ class Nikola(object): 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: + if context["is_feed_stale"] or "feedpagenum" in context and 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): + if "prevfeedlink" in context and (context["is_feed_stale"] or "feedpagenum" in context and 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: + if context["is_feed_stale"] or "feedpagenum" and 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 self.config["FEED_PREVIEWIMAGE"] and '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) + 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") @@ -1828,17 +2253,22 @@ class Nikola(object): 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 + 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) @@ -1886,8 +2316,7 @@ class Nikola(object): 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['feed_link_append_query'] = self.config["FEED_LINKS_APPEND_QUERY"] kw['currentfeed'] = None # Split in smaller lists @@ -1978,10 +2407,14 @@ class Nikola(object): context["currentfeedlink"] = kw["currentfeed"] context["feedpagenum"] = i context["feedpagecount"] = num_pages + kw['feed_teasers'] = self.config['FEED_TEASERS'] + kw['feed_plain'] = self.config['FEED_PLAIN'] + kw['feed_previewimage'] = self.config['FEED_PREVIEWIMAGE'] atom_task = { "basename": basename, "name": atom_output_name, "file_dep": sorted([_.base_path for _ in post_list]), + "task_dep": ['render_posts'], "targets": [atom_output_name], "actions": [(self.atom_feed_renderer, (lang, @@ -2009,7 +2442,7 @@ class Nikola(object): def __repr__(self): """Representation of a Nikola site.""" - return '<Nikola Site: {0!r}>'.format(self.config['BLOG_TITLE']()) + return '<Nikola Site: {0!r}>'.format(self.config['BLOG_TITLE'](self.config['DEFAULT_LANG'])) def sanitized_locales(locale_fallback, locale_default, locales, translations): @@ -2084,6 +2517,9 @@ def sanitized_locales(locale_fallback, locale_default, locales, translations): locale_n = locale_fallback msg = "Could not guess locale for language {0}, using locale {1}" utils.LOGGER.warn(msg.format(lang, locale_n)) + utils.LOGGER.warn("Please fix your OS locale configuration or use the LOCALES option in conf.py to specify your preferred locale.") + if sys.platform != 'win32': + utils.LOGGER.warn("Make sure to use an UTF-8 locale to ensure Unicode support.") locales[lang] = locale_n return locale_fallback, locale_default, locales @@ -2105,7 +2541,7 @@ def valid_locale_fallback(desired_locale=None): """ # Whenever fallbacks change, adjust test TestHarcodedFallbacksWork candidates_windows = [str('English'), str('C')] - candidates_posix = [str('en_US.utf8'), str('C')] + candidates_posix = [str('en_US.UTF-8'), str('C')] candidates = candidates_windows if sys.platform == 'win32' else candidates_posix if desired_locale: candidates = list(candidates) @@ -2139,7 +2575,7 @@ def guess_locale_from_lang_posix(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') + locale_n = str((locale.normalize(lang).split('.')[0]) + '.UTF-8') 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])) |
