{{ post.title()|e }}
{{ helper.html_pager() }} {{ comments.comment_link_script() }} -{{ helper.mathjax_script(posts) }} +{{ math.math_scripts_ifposts(posts) }} {% endblock %} diff --git a/nikola/data/themes/base-jinja/templates/index_helper.tmpl b/nikola/data/themes/base-jinja/templates/index_helper.tmpl index 2f9e8ea..bc57734 100644 --- a/nikola/data/themes/base-jinja/templates/index_helper.tmpl +++ b/nikola/data/themes/base-jinja/templates/index_helper.tmpl @@ -1,4 +1,5 @@ {# -*- coding: utf-8 -*- #} +{% import 'math_helper.tmpl' as math with context %} {% macro html_pager() %} {% if prevlink or nextlink %} %def> <%def name="html_translation_header()"> diff --git a/nikola/data/themes/base/templates/base_helper.tmpl b/nikola/data/themes/base/templates/base_helper.tmpl index 948cfba..18801ed 100644 --- a/nikola/data/themes/base/templates/base_helper.tmpl +++ b/nikola/data/themes/base/templates/base_helper.tmpl @@ -1,29 +1,25 @@ ## -*- coding: utf-8 -*- +<%namespace name="feeds_translations" file="feeds_translations_helper.tmpl" import="*"/> <%def name="html_headstart()"> --
%for langname in sorted(translations):
%if langname != lang:
-
- ${messages("LANGUAGE", langname)} +
- ${messages("LANGUAGE", langname)} %endif %endfor
+ % if generate_atom: + ${_html_feed_link('application/atom+xml', 'Atom feed', 'atom', classification, kind, language, name)} + % endif + % if generate_rss and kind != 'archive': + ${_html_feed_link('application/rss+xml', 'RSS feed', 'rss', classification, kind, language, name)} + % endif +
+ % endfor + % else: + % for language in translations_feedorder: ++ % if generate_atom: + ${_html_feed_link('application/atom+xml', 'Atom feed', 'atom', classification, kind, language)} + % endif + % if generate_rss and kind != 'archive': + ${_html_feed_link('application/rss+xml', 'RSS feed', 'rss', classification, kind, language)} + % endif +
+ % endfor + % endif + % endif +%def> + +<%def name="translation_link(kind)"> + % if has_other_languages and other_languages: +${messages("Also available in:")}
+ % for language, classification, name in other_languages: +${_html_translation_link(classification, kind, language, name)}
+ % endfor +${title|h}
%endif @@ -15,21 +16,39 @@ %endif %if folders: --
- % for folder, ftitle in folders:
-
- ${ftitle} - % endfor -
-
- %for image in photo_array:
-
-
-
- %endfor -
-
+ % for folder, ftitle in folders:
+
- 📂 ${ftitle|h} + % endfor +
${post.title()|h}
${helper.html_pager()} ${comments.comment_link_script()} -${helper.mathjax_script(posts)} +${math.math_scripts_ifposts(posts)} %block> diff --git a/nikola/data/themes/base/templates/index_helper.tmpl b/nikola/data/themes/base/templates/index_helper.tmpl index 9331b93..e400e3b 100644 --- a/nikola/data/themes/base/templates/index_helper.tmpl +++ b/nikola/data/themes/base/templates/index_helper.tmpl @@ -1,4 +1,5 @@ ## -*- coding: utf-8 -*- +<%namespace name="math" file="math_helper.tmpl"/> <%def name="html_pager()"> %if prevlink or nextlink:${title}
+${title|h}
-
- % for text, link in items:
-
- ${text} + % for text, link, count in items: +
- ${text|h} + % if count: + (${count}) + % endif % endfor
${title}
+${title|h}
-
% for post in posts:
-
- ${post.title()|h} +
- ${post.title()|h} % endfor
-
% for name in folders:
-
- ${name} +
- ${name|h} % endfor % for name in files: -
- ${name} +
- ${name|h} % endfor
${title} + % if source_link: + (${messages("Source")}) + % endif +
${code} % endif -% if source_link: - -% endif %block> - diff --git a/nikola/data/themes/base/templates/math_helper.tmpl b/nikola/data/themes/base/templates/math_helper.tmpl new file mode 100644 index 0000000..961b7ce --- /dev/null +++ b/nikola/data/themes/base/templates/math_helper.tmpl @@ -0,0 +1,69 @@ +### Note: at present, MathJax and KaTeX do not respect the USE_CDN configuration option +<%def name="math_scripts()"> + %if use_katex: + + + % if katex_auto_render: + + % else: + + % endif + %else: +### Note: given the size of MathJax; nikola will retrieve MathJax from a CDN regardless of use_cdn configuration + + % if mathjax_config: + ${mathjax_config} + % else: + + % endif + %endif +%def> + +<%def name="math_styles()"> + % if use_katex: + + % endif +%def> + +<%def name="math_scripts_ifpost(post)"> + %if post.has_math: + ${math_scripts()} + %endif +%def> + +<%def name="math_scripts_ifposts(posts)"> + %if any(post.has_math for post in posts): + ${math_scripts()} + %endif +%def> + +<%def name="math_styles_ifpost(post)"> + %if post.has_math: + ${math_styles()} + %endif +%def> + +<%def name="math_styles_ifposts(posts)"> + %if any(post.has_math for post in posts): + ${math_styles()} + %endif +%def> diff --git a/nikola/data/themes/base/templates/page.tmpl b/nikola/data/themes/base/templates/page.tmpl new file mode 100644 index 0000000..b2cd756 --- /dev/null +++ b/nikola/data/themes/base/templates/page.tmpl @@ -0,0 +1 @@ +<%inherit file="story.tmpl"/> diff --git a/nikola/data/themes/base/templates/pagination_helper.tmpl b/nikola/data/themes/base/templates/pagination_helper.tmpl new file mode 100644 index 0000000..91c1115 --- /dev/null +++ b/nikola/data/themes/base/templates/pagination_helper.tmpl @@ -0,0 +1,16 @@ +## -*- coding: utf-8 -*- +<%def name="page_navigation(current_page, page_links, prevlink, nextlink, prev_next_links_reversed, surrounding=5)"> + +%def> diff --git a/nikola/data/themes/base/templates/post.tmpl b/nikola/data/themes/base/templates/post.tmpl index cbb81ef..1f2f0a4 100644 --- a/nikola/data/themes/base/templates/post.tmpl +++ b/nikola/data/themes/base/templates/post.tmpl @@ -2,17 +2,15 @@ <%namespace name="helper" file="post_helper.tmpl"/> <%namespace name="pheader" file="post_header.tmpl"/> <%namespace name="comments" file="comments_helper.tmpl"/> +<%namespace name="math" file="math_helper.tmpl"/> <%inherit file="base.tmpl"/> <%block name="extra_head"> ${parent.extra_head()} % if post.meta('keywords'): - + % endif - %if post.description(): - - %endif - + %if post.prev_post: %endif @@ -25,6 +23,7 @@ ${helper.open_graph_metadata(post)} ${helper.twitter_card_information(post)} ${helper.meta_translations(post)} + ${math.math_styles_ifpost(post)} %block> <%block name="content"> @@ -45,7 +44,7 @@ ${comments.comment_form(post.permalink(absolute=True), post.title(), post._base_path)} % endif - ${helper.mathjax_script(post)} + ${math.math_scripts_ifpost(post)}-
% for post in posts:
- - ${post.formatted_date(date_format)} - - ${post.title(lang)|h} - + ${post.formatted_date(date_format)|h} + + ${post.title(lang)|h} + % endfor
${title|h}
%if description: -${description}
+${description}
%endif %if subcategories: ${messages('Subcategories:')} %endif + ${feeds_translations.translation_link(kind)}-
- % for post in posts:
-
- ${post.title()|h} - % endfor -
-
+ % for post in posts:
+
- ${post.title()|h} + % endfor +
-
- %for name, link in subcategories:
-
- ${name} - %endfor -
${title|h}
+ %if description: +${description}
+ %endif + %if subcategories: + ${messages('Subcategories:')} +-
+ %for name, link in subcategories:
+
- ${name|h} + %endfor +
${title}
+${title|h}
+{{ post.title()|e }}
+ +{{ featured[0].title() }}
+ {% if featured[0].previewimage %} ++ {{ featured[0].title() }} +
+ {% if featured[0].previewimage %} ++ {{ featured[0].title() }} +
+ {% if featured[0].previewimage %} +${post.title()|h}
+ +${featured[0].title()}
+ % if featured[0].previewimage: ++ ${featured[0].title()} +
+ % if featured[0].previewimage: ++ ${featured[0].title()} +
+ % if featured[0].previewimage: +{{ title|e }}
- {% endif %} - {% if post %} -- {{ post.text() }} -
- {% endif %} - {% if folders %} --
- {% for folder, ftitle in folders %}
-
- {{ ftitle }} - {% endfor %} -
-
-{% for name in folders %}
-
- {{ name }} -{% endfor %} -{% for name in files %} -
- {{ name }} -{% endfor %} -
{{ title }}
-{% if cat_items %} - {% if items %} -{{ messages("Categories") }}
- {% endif %} - {% for text, full_name, path, link, indent_levels, indent_change_before, indent_change_after in cat_hierarchy %} - {% for i in range(indent_change_before) %} --
- {% endfor %}
-
- {{ text }} - {% if indent_change_after <= 0 %} - - {% endif %} - {% for i in range(-indent_change_after) %} -
{{ messages("Tags") }}
- {% endif %} -{% endif %} -{% if items %} --
- {% for text, link in items %}
- {% if text not in hidden_tags %}
-
- {{ text }} - {% endif %} - {% endfor %} -
${title|h}
- %endif - %if post: -- ${post.text()} -
- %endif - %if folders: --
- % for folder, ftitle in folders:
-
- ${ftitle} - % endfor -
${title}
-% if cat_items: - % if items: -${messages("Categories")}
- % endif - % for text, full_name, path, link, indent_levels, indent_change_before, indent_change_after in cat_hierarchy: - % for i in range(indent_change_before): --
- % endfor
-
- ${text} - % if indent_change_after <= 0: - - % endif - % for i in range(-indent_change_after): -
${messages("Tags")}
- % endif -%endif -% if items: --
- % for text, link in items:
- % if text not in hidden_tags:
-
- ${text} - % endif - % endfor -
{{ messages("Authors") }}
+ +{% endif %} +{% if items %} +-
+ {% for text, link in items %}
+ {% if text not in hidden_authors %}
+
- {{ text|e }} + {% endif %} + {% endfor %} +
-
+ {% if prevlink %}
+
- {{ messages("Newer posts") }} + {% endif %} + {% if nextlink %} +
- {{ messages("Older posts") }} + {% endif %} +
-
+{% for name in folders %}
+
- 📂 {{ name|e }} +{% endfor %} +{% for name in files %} +
- 📄 {{ name|e }} +{% endfor %} +
{{ title }} + {% if source_link %} + ({{ messages("Source") }}) + {% endif %} +
+ {{ code }} +{% endif %} +{% endblock %} + +{% block sourcelink %} +{% if source_link and show_sourcelink %} + {{ ui.show_sourcelink(source_link) }} +{% endif %} +{% endblock %} diff --git a/nikola/data/themes/bootstrap4-jinja/templates/pagination_helper.tmpl b/nikola/data/themes/bootstrap4-jinja/templates/pagination_helper.tmpl new file mode 100644 index 0000000..30fe534 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/templates/pagination_helper.tmpl @@ -0,0 +1,40 @@ +{# -*- coding: utf-8 -*- #} +{% macro page_navigation(current_page, page_links, prevlink, nextlink, prev_next_links_reversed, surrounding=5) %} +-
+ {% if prev_next_links_reversed %}
+ {% if nextlink %}
+
- + {% else %} +
- + {% endif %} + {% else %} + {% if prevlink %} +
- + {% else %} +
- + {% endif %} + {% endif %} + {% for i, link in enumerate(page_links) %} + {% if (i - current_page)|abs <= surrounding or i == 0 or i == page_links|length - 1 %} +
- {{ i + 1 }}{{ ' (current)' if i == current_page else '' }} + {% elif i == current_page - surrounding - 1 or i == current_page + surrounding + 1 %} +
- + {% endif %} + {% endfor %} + {% if prev_next_links_reversed %} + {% if prevlink %} +
- + {% else %} +
- + {% endif %} + {% else %} + {% if nextlink %} +
- + {% else %} +
- + {% endif %} + {% endif %} +
{{ title|e }}
+{% if cat_items %} + {% if items %} +{{ messages("Categories") }}
+ {% endif %} + {% for text, full_name, path, link, indent_levels, indent_change_before, indent_change_after in cat_hierarchy %} + {% for i in range(indent_change_before) %} +-
+ {% endfor %}
+
- {{ text|e }} + {% if indent_change_after <= 0 %} + + {% endif %} + {% for i in range(-indent_change_after) %} +
{{ messages("Tags") }}
+ {% endif %} +{% endif %} +{% if items %} +-
+ {% for text, link in items %}
+ {% if text not in hidden_tags %}
+
- {{ text|e }} + {% endif %} + {% endfor %} +
${messages("Authors")}
+ +% endif +% if items: +-
+ % for text, link in items:
+ % if text not in hidden_authors:
+
- ${text|h} + % endif + % endfor +
-
+ %if prevlink:
+
- ${messages("Newer posts")} + %endif + %if nextlink: +
- ${messages("Older posts")} + %endif +
-
+% for name in folders:
+
- 📂 ${name|h} +% endfor +% for name in files: +
- 📄 ${name|h} +% endfor +
${title} + % if source_link: + (${messages("Source")}) + % endif +
+ ${code} +% endif +%block> + +<%block name="sourcelink"> +% if source_link and show_sourcelink: + ${ui.show_sourcelink(source_link)} +% endif +%block> diff --git a/nikola/data/themes/bootstrap4/templates/pagination_helper.tmpl b/nikola/data/themes/bootstrap4/templates/pagination_helper.tmpl new file mode 100644 index 0000000..da0e920 --- /dev/null +++ b/nikola/data/themes/bootstrap4/templates/pagination_helper.tmpl @@ -0,0 +1,40 @@ +## -*- coding: utf-8 -*- +<%def name="page_navigation(current_page, page_links, prevlink, nextlink, prev_next_links_reversed, surrounding=5)"> +-
+ % if prev_next_links_reversed:
+ % if nextlink:
+
- + % else: +
- + % endif + % else: + % if prevlink: +
- + % else: +
- + % endif + % endif + % for i, link in enumerate(page_links): + % if abs(i - current_page) <= surrounding or i == 0 or i == len(page_links) - 1: +
- ${i + 1}${' (current)' if i == current_page else ''} + % elif i == current_page - surrounding - 1 or i == current_page + surrounding + 1: +
- + % endif + % endfor + % if prev_next_links_reversed: + % if prevlink: +
- + % else: +
- + % endif + % else: + % if nextlink: +
- + % else: +
- + % endif + % endif +
${title|h}
+% if cat_items: + % if items: +${messages("Categories")}
+ % endif + % for text, full_name, path, link, indent_levels, indent_change_before, indent_change_after in cat_hierarchy: + % for i in range(indent_change_before): +-
+ % endfor
+
- ${text|h} + % if indent_change_after <= 0: + + % endif + % for i in range(-indent_change_after): +
${messages("Tags")}
+ % endif +%endif +% if items: +-
+ % for text, link in items:
+ % if text not in hidden_tags:
+
- ${text|h} + % endif + % endfor +
{read_more}… ({min_remaining_read})
' - -# Default pattern for translation files' names -DEFAULT_TRANSLATIONS_PATTERN = '{path}.{lang}.{ext}' +DEFAULT_FEED_READ_MORE_LINK = '{read_more}… ({min_remaining_read})
' 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'\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='') 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 = "- )
+
+ Both options allow word wrap and don't include line numbers when copying.
+ """
+
+ name = "HTML"
+ aliases = ["html"]
+ filenames = ["*.html", "*.htm"]
+
+ def __init__(self, **options):
+ """Initialize the formatter."""
+ super().__init__(**options)
+ self.linenos_name = self.options.get("linenos", "table")
+ if self.linenos_name is False:
+ self.linenos_val = False
+ self.linenos = 0
+ elif self.linenos_name is True:
+ self.linenos_name = "table"
+ if self.linenos_name is not False:
+ self.linenos_val = BetterLinenos(self.linenos_name)
+ self.linenos = 2 if self.linenos_val == BetterLinenos.OL else 1
+
+ def get_style_defs(self, arg=None, wrapper_classes=None):
+ """Generate CSS style definitions.
+
+ Return CSS style definitions for the classes produced by the current
+ highlighting style. ``arg`` can be a string or list of selectors to
+ insert before the token type classes. ``wrapper_classes`` are a list of
+ classes for the wrappers, defaults to the ``cssclass`` option.
+ """
+ base = super().get_style_defs(arg)
+ new_styles = (
+ ("{0} table, {0} tr, {0} td", "border-spacing: 0; border-collapse: separate; padding: 0"),
+ ("{0} pre", "white-space: pre-wrap; line-height: normal"),
+ (
+ "{0}table td.linenos",
+ "vertical-align: top; padding-left: 10px; padding-right: 10px; user-select: none; -webkit-user-select: none",
+ ),
+ # Hack for Safari (user-select does not affect copy-paste)
+ ("{0}table td.linenos code:before", "content: attr(data-line-number)"),
+ ("{0}table td.code", "overflow-wrap: normal; border-collapse: collapse"),
+ (
+ "{0}table td.code code",
+ "overflow: unset; border: none; padding: 0; margin: 0; white-space: pre-wrap; line-height: unset; background: none",
+ ),
+ ("{0} .lineno.nonumber", "list-style: none"),
+ )
+ new_styles_code = []
+ if wrapper_classes is None:
+ wrapper_classes = ["." + self.cssclass]
+ for cls, rule in new_styles:
+ new_styles_code.append(", ".join(cls.format(c) for c in wrapper_classes) + " { " + rule + " }")
+ return base + "\n" + "\n".join(new_styles_code)
+
+ def _wrap_tablelinenos(self, inner):
+ lncount = 0
+ codelines = []
+ for t, line in inner:
+ if t:
+ lncount += 1
+ codelines.append(line)
+
+ fl = self.linenostart
+ mw = len(str(lncount + fl - 1))
+ sp = self.linenospecial
+ st = self.linenostep
+ la = self.lineanchors
+ aln = self.anchorlinenos
+ nocls = self.noclasses
+ if sp:
+ lines = []
+
+ for i in range(fl, fl + lncount):
+ line_before = ""
+ line_after = ""
+ if i % st == 0:
+ if i % sp == 0:
+ if aln:
+ line_before = '' % (la, i)
+ line_after = ""
+ else:
+ line_before = ''
+ line_after = ""
+ elif aln:
+ line_before = '' % (la, i)
+ line_after = ""
+ lines.append((line_before, "%*d" % (mw, i), line_after))
+ else:
+ lines.append(("", "", ""))
+ else:
+ lines = []
+ for i in range(fl, fl + lncount):
+ line_before = ""
+ line_after = ""
+ if i % st == 0:
+ if aln:
+ line_before = '' % (la, i)
+ line_after = ""
+ lines.append((line_before, "%*d" % (mw, i), line_after))
+ else:
+ lines.append(("", "", ""))
+
+ yield 0, '
- ' % (style, num,) + line + " " + num += 1 + else: + for t, line in lines: + yield 1, ( + '
- ' + % (("; list-style: none" if num % st != 0 else ""), num) + line + " " + ) + num += 1 + elif sp: + for t, line in lines: + yield 1, '
- ' % ( + " special" if num % sp == 0 else "", + " nonumber" if num % st != 0 else "", + num, + ) + line + " " + num += 1 + else: + for t, line in lines: + yield 1, '
- ' % ( + "" if num % st != 0 else " nonumber", + num, + ) + line + " " + num += 1 + + yield 0, "
' + ln_b +
+ '' + ln_a + ' | ' + cl + " |
' + ln_b + '' + ln_a + ' | ' + cl + " |
- "
+ if self.noclasses:
+ if sp:
+ for t, line in lines:
+ if num % sp == 0:
+ style = "background-color: #ffffc0; padding: 0 5px 0 5px"
+ else:
+ style = "background-color: #f0f0f0; padding: 0 5px 0 5px"
+ if num % st != 0:
+ style += "; list-style: none"
+ yield 1, '
' if use_html else '', new_caption)
return new_caption
@@ -654,6 +730,26 @@ class CommandImportWordpress(Command, ImportMixin):
except TypeError: # old versions of the plugin don't support the additional argument
content = self.wordpress_page_compiler.compile_to_string(content)
return content, 'html', True
+ elif self.transform_to_markdown:
+ # First convert to HTML with WordPress plugin
+ additional_data = {}
+ if attachments is not None:
+ additional_data['attachments'] = attachments
+ try:
+ content = self.wordpress_page_compiler.compile_to_string(content, additional_data=additional_data)
+ except TypeError: # old versions of the plugin don't support the additional argument
+ content = self.wordpress_page_compiler.compile_to_string(content)
+ # Now convert to MarkDown with html2text
+ h = html2text.HTML2Text()
+ content = h.handle(content)
+ return content, 'md', False
+ elif self.html2text:
+ # TODO: what to do with [code] blocks?
+ # content = self.transform_code(content)
+ content = self.transform_caption(content, use_html=True)
+ h = html2text.HTML2Text()
+ content = h.handle(content)
+ return content, 'md', False
elif self.use_wordpress_compiler:
return content, 'wp', False
else:
@@ -686,7 +782,7 @@ class CommandImportWordpress(Command, ImportMixin):
elif approved == 'spam' or approved == 'trash':
pass
else:
- LOGGER.warn("Unknown comment approved status: " + str(approved))
+ LOGGER.warning("Unknown comment approved status: {0}".format(approved))
parent = int(get_text_tag(comment, "{{{0}}}comment_parent".format(wordpress_namespace), 0))
if parent == 0:
parent = None
@@ -724,6 +820,16 @@ class CommandImportWordpress(Command, ImportMixin):
write_header_line(fd, "wordpress_user_id", comment["user_id"])
fd.write(('\n' + comment['content']).encode('utf8'))
+ def _create_meta_and_content_filenames(self, slug, extension, lang, default_language, translations_config):
+ out_meta_filename = slug + '.meta'
+ out_content_filename = slug + '.' + extension
+ if lang and lang != default_language:
+ out_meta_filename = utils.get_translation_candidate(translations_config,
+ out_meta_filename, lang)
+ out_content_filename = utils.get_translation_candidate(translations_config,
+ out_content_filename, lang)
+ return out_meta_filename, out_content_filename
+
def _create_metadata(self, status, excerpt, tags, categories, post_name=None):
"""Create post metadata."""
other_meta = {'wp-status': status}
@@ -735,24 +841,48 @@ class CommandImportWordpress(Command, ImportMixin):
if text in self._category_paths:
cats.append(self._category_paths[text])
else:
- cats.append(utils.join_hierarchical_category_path([text]))
+ cats.append(hierarchy_utils.join_hierarchical_category_path([utils.html_unescape(text)]))
other_meta['categories'] = ','.join(cats)
if len(cats) > 0:
other_meta['category'] = cats[0]
if len(cats) > 1:
- LOGGER.warn(('Post "{0}" has more than one category! ' +
- 'Will only use the first one.').format(post_name))
- tags_cats = tags
+ LOGGER.warning(('Post "{0}" has more than one category! ' +
+ 'Will only use the first one.').format(post_name))
+ tags_cats = [utils.html_unescape(tag) for tag in tags]
else:
- tags_cats = tags + categories
+ tags_cats = [utils.html_unescape(tag) for tag in tags + categories]
return tags_cats, other_meta
+ _tag_sanitize_map = {True: {}, False: {}}
+
+ def _sanitize(self, tag, is_category):
+ if self.tag_saniziting_strategy == 'lower':
+ return tag.lower()
+ if tag.lower() not in self._tag_sanitize_map[is_category]:
+ self._tag_sanitize_map[is_category][tag.lower()] = [tag]
+ return tag
+ previous = self._tag_sanitize_map[is_category][tag.lower()]
+ if self.tag_saniziting_strategy == 'first':
+ if tag != previous[0]:
+ LOGGER.warning("Changing spelling of {0} name '{1}' to {2}.".format('category' if is_category else 'tag', tag, previous[0]))
+ return previous[0]
+ else:
+ LOGGER.error("Unknown tag sanitizing strategy '{0}'!".format(self.tag_saniziting_strategy))
+ sys.exit(1)
+ return tag
+
def import_postpage_item(self, item, wordpress_namespace, out_folder=None, attachments=None):
"""Take an item from the feed and creates a post file."""
if out_folder is None:
out_folder = 'posts'
title = get_text_tag(item, 'title', 'NO TITLE')
+
+ # titles can have line breaks in them, particularly when they are
+ # created by third-party tools that post to Wordpress.
+ # Handle windows-style and unix-style line endings.
+ title = title.replace('\r\n', ' ').replace('\n', ' ')
+
# link is something like http://foo.com/2012/09/01/hello-world/
# So, take the path, utils.slugify it, and that's our slug
link = get_text_tag(item, 'link', None)
@@ -760,7 +890,10 @@ class CommandImportWordpress(Command, ImportMixin):
path = unquote(parsed.path.strip('/'))
try:
- path = path.decode('utf8')
+ if isinstance(path, bytes):
+ path = path.decode('utf8', 'replace')
+ else:
+ path = path
except AttributeError:
pass
@@ -782,7 +915,7 @@ class CommandImportWordpress(Command, ImportMixin):
else:
if len(pathlist) > 1:
out_folder = os.path.join(*([out_folder] + pathlist[:-1]))
- slug = utils.slugify(pathlist[-1])
+ slug = utils.slugify(pathlist[-1], self.lang)
description = get_text_tag(item, 'description', '')
post_date = get_text_tag(
@@ -809,17 +942,19 @@ class CommandImportWordpress(Command, ImportMixin):
tags = []
categories = []
+ post_status = 'published'
+ has_math = "no"
if status == 'trash':
- LOGGER.warn('Trashed post "{0}" will not be imported.'.format(title))
+ LOGGER.warning('Trashed post "{0}" will not be imported.'.format(title))
return False
elif status == 'private':
- tags.append('private')
is_draft = False
is_private = True
+ post_status = 'private'
elif status != 'publish':
- tags.append('draft')
is_draft = True
is_private = False
+ post_status = 'draft'
else:
is_draft = False
is_private = False
@@ -831,14 +966,23 @@ class CommandImportWordpress(Command, ImportMixin):
type = tag.attrib['domain']
if text == 'Uncategorized' and type == 'category':
continue
- self.all_tags.add(text)
if type == 'category':
- categories.append(type)
+ categories.append(text)
else:
tags.append(text)
if '$latex' in content:
- tags.append('mathjax')
+ has_math = "yes"
+
+ for i, cat in enumerate(categories[:]):
+ cat = self._sanitize(cat, True)
+ categories[i] = cat
+ self.all_tags.add(cat)
+
+ for i, tag in enumerate(tags[:]):
+ tag = self._sanitize(tag, False)
+ tags[i] = tag
+ self.all_tags.add(tag)
# Find post format if it's there
post_format = 'wp'
@@ -849,53 +993,75 @@ class CommandImportWordpress(Command, ImportMixin):
post_format = 'wp'
if is_draft and self.exclude_drafts:
- LOGGER.notice('Draft "{0}" will not be imported.'.format(title))
+ LOGGER.warning('Draft "{0}" will not be imported.'.format(title))
return False
elif is_private and self.exclude_privates:
- LOGGER.notice('Private post "{0}" will not be imported.'.format(title))
+ LOGGER.warning('Private post "{0}" will not be imported.'.format(title))
return False
elif content.strip() or self.import_empty_items:
# If no content is found, no files are written.
self.url_map[link] = (self.context['SITE_URL'] +
out_folder.rstrip('/') + '/' + slug +
'.html').replace(os.sep, '/')
- if hasattr(self, "separate_qtranslate_content") \
- and self.separate_qtranslate_content:
- content_translations = separate_qtranslate_content(content)
+ default_language = self.context["DEFAULT_LANG"]
+ if self.separate_qtranslate_content:
+ content_translations = separate_qtranslate_tagged_langs(content)
+ title_translations = separate_qtranslate_tagged_langs(title)
else:
content_translations = {"": content}
- default_language = self.context["DEFAULT_LANG"]
+ title_translations = {"": title}
+ # in case of mistmatch between the languages found in the title and in the content
+ default_title = title_translations.get(default_language, title)
+ extra_languages = [lang for lang in content_translations.keys() if lang not in ("", default_language)]
+ for extra_lang in extra_languages:
+ self.extra_languages.add(extra_lang)
+ translations_dict = get_default_translations_dict(default_language, extra_languages)
+ current_translations_config = {
+ "DEFAULT_LANG": default_language,
+ "TRANSLATIONS": translations_dict,
+ "TRANSLATIONS_PATTERN": self.context["TRANSLATIONS_PATTERN"]
+ }
for lang, content in content_translations.items():
try:
content, extension, rewrite_html = self.transform_content(content, post_format, attachments)
- except:
+ except Exception:
LOGGER.error(('Cannot interpret post "{0}" (language {1}) with post ' +
'format {2}!').format(os.path.join(out_folder, slug), lang, post_format))
return False
- if lang:
- out_meta_filename = slug + '.meta'
- if lang == default_language:
- out_content_filename = slug + '.' + extension
- else:
- out_content_filename \
- = utils.get_translation_candidate(self.context,
- slug + "." + extension, lang)
- self.extra_languages.add(lang)
- meta_slug = slug
- else:
- out_meta_filename = slug + '.meta'
- out_content_filename = slug + '.' + extension
- meta_slug = slug
+
+ out_meta_filename, out_content_filename = self._create_meta_and_content_filenames(
+ slug, extension, lang, default_language, current_translations_config)
+
tags, other_meta = self._create_metadata(status, excerpt, tags, categories,
post_name=os.path.join(out_folder, slug))
- self.write_metadata(os.path.join(self.output_folder, out_folder,
- out_meta_filename),
- title, meta_slug, post_date, description, tags, **other_meta)
- self.write_content(
- os.path.join(self.output_folder,
- out_folder, out_content_filename),
- content,
- rewrite_html)
+ current_title = title_translations.get(lang, default_title)
+ meta = {
+ "title": current_title,
+ "slug": slug,
+ "date": post_date,
+ "description": description,
+ "tags": ','.join(tags),
+ "status": post_status,
+ "has_math": has_math,
+ }
+ meta.update(other_meta)
+ if self.onefile:
+ self.write_post(
+ os.path.join(self.output_folder,
+ out_folder, out_content_filename),
+ content,
+ meta,
+ self._get_compiler(),
+ rewrite_html)
+ else:
+ self.write_metadata(os.path.join(self.output_folder, out_folder,
+ out_meta_filename),
+ current_title, slug, post_date, description, tags, **other_meta)
+ self.write_content(
+ os.path.join(self.output_folder,
+ out_folder, out_content_filename),
+ content,
+ rewrite_html)
if self.export_comments:
comments = []
@@ -905,13 +1071,13 @@ class CommandImportWordpress(Command, ImportMixin):
comments.append(comment)
for comment in comments:
- comment_filename = slug + "." + str(comment['id']) + ".wpcomment"
+ comment_filename = "{0}.{1}.wpcomment".format(slug, comment['id'])
self._write_comment(os.path.join(self.output_folder, out_folder, comment_filename), comment)
return (out_folder, slug)
else:
- LOGGER.warn(('Not going to import "{0}" because it seems to contain'
- ' no content.').format(title))
+ LOGGER.warning(('Not going to import "{0}" because it seems to contain'
+ ' no content.').format(title))
return False
def _extract_item_info(self, item):
@@ -937,7 +1103,7 @@ class CommandImportWordpress(Command, ImportMixin):
if parent_id is not None and int(parent_id) != 0:
self.attachments[int(parent_id)][post_id] = data
else:
- LOGGER.warn("Attachment #{0} ({1}) has no parent!".format(post_id, data['files']))
+ LOGGER.warning("Attachment #{0} ({1}) has no parent!".format(post_id, data['files']))
def write_attachments_info(self, path, attachments):
"""Write attachments info file."""
@@ -955,7 +1121,7 @@ class CommandImportWordpress(Command, ImportMixin):
if post_type == 'post':
out_folder_slug = self.import_postpage_item(item, wordpress_namespace, 'posts', attachments)
else:
- out_folder_slug = self.import_postpage_item(item, wordpress_namespace, 'stories', attachments)
+ out_folder_slug = self.import_postpage_item(item, wordpress_namespace, 'pages', attachments)
# Process attachment data
if attachments is not None:
# If post was exported, store data
@@ -975,8 +1141,8 @@ class CommandImportWordpress(Command, ImportMixin):
self.process_item_if_post_or_page(item)
# Assign attachments to posts
for post_id in self.attachments:
- LOGGER.warn(("Found attachments for post or page #{0}, but didn't find post or page. " +
- "(Attachments: {1})").format(post_id, [e['files'][0] for e in self.attachments[post_id].values()]))
+ LOGGER.warning(("Found attachments for post or page #{0}, but didn't find post or page. " +
+ "(Attachments: {1})").format(post_id, [e['files'][0] for e in self.attachments[post_id].values()]))
def get_text_tag(tag, name, default):
@@ -990,15 +1156,20 @@ def get_text_tag(tag, name, default):
return default
-def separate_qtranslate_content(text):
- """Parse the content of a wordpress post or page and separate qtranslate languages.
+def separate_qtranslate_tagged_langs(text):
+ """Parse the content of a wordpress post or page and separate languages.
+
+ For qtranslateX tags: [:LL]blabla[:]
- qtranslate tags: blabla
+ Note: qtranslate* plugins had a troubled history and used various
+ tags over time, application of the 'modernize_qtranslate_tags'
+ function is required for this function to handle most of the legacy
+ cases.
"""
- # TODO: uniformize qtranslate tags =>
- qt_start = ""
- qt_end_with_lang_len = 5
+ qt_start = "[:"
+ qt_end = "]"
+ qt_end_len = len(qt_end)
+ qt_end_with_lang_len = qt_end_len + 2
qt_chunks = text.split(qt_start)
content_by_lang = {}
common_txt_list = []
@@ -1010,9 +1181,9 @@ def separate_qtranslate_content(text):
# be some piece of common text or tags, or just nothing
lang = "" # default language
c = c.lstrip(qt_end)
- if not c:
+ if not c.strip():
continue
- elif c[2:].startswith(qt_end):
+ elif c[2:qt_end_with_lang_len].startswith(qt_end):
# a language specific section (with language code at the begining)
lang = c[:2]
c = c[qt_end_with_lang_len:]
@@ -1033,3 +1204,26 @@ def separate_qtranslate_content(text):
for l in content_by_lang.keys():
content_by_lang[l] = " ".join(content_by_lang[l])
return content_by_lang
+
+
+def modernize_qtranslate_tags(xml_bytes):
+ """
+ Uniformize the "tag" used by various version of qtranslate.
+
+ The resulting byte string will only contain one set of qtranslate tags
+ (namely [:LG] and [:]), older ones being converted to new ones.
+ """
+ old_start_lang = re.compile(b"")
+ new_start_lang = b"[:\\1]"
+ old_end_lang = re.compile(b"")
+ new_end_lang = b"[:]"
+ title_match = re.compile(b"(.*?) ")
+ modern_starts = old_start_lang.sub(new_start_lang, xml_bytes)
+ modernized_bytes = old_end_lang.sub(new_end_lang, modern_starts)
+
+ def title_escape(match):
+ title = match.group(1)
+ title = title.replace(b"&", b"&").replace(b"<", b"<").replace(b">", b">")
+ return b"" + title + b" "
+ fixed_bytes = title_match.sub(title_escape, modernized_bytes)
+ return fixed_bytes
diff --git a/nikola/plugins/command/init.plugin b/nikola/plugins/command/init.plugin
index a5404c4..6ee27d3 100644
--- a/nikola/plugins/command/init.plugin
+++ b/nikola/plugins/command/init.plugin
@@ -5,9 +5,9 @@ module = init
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Create a new site.
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/init.py b/nikola/plugins/command/init.py
index 91ccdb4..0026edc 100644
--- a/nikola/plugins/command/init.py
+++ b/nikola/plugins/command/init.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,28 +26,28 @@
"""Create a new site."""
-from __future__ import print_function, unicode_literals
-import os
-import shutil
+import datetime
import io
import json
+import os
+import shutil
import textwrap
-import datetime
import unidecode
+from urllib.parse import urlsplit, urlunsplit
+
import dateutil.tz
import dateutil.zoneinfo
from mako.template import Template
from pkg_resources import resource_filename
-import tarfile
import nikola
-from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN, DEFAULT_INDEX_READ_MORE_LINK, DEFAULT_RSS_READ_MORE_LINK, LEGAL_VALUES, urlsplit, urlunsplit
+from nikola.nikola import DEFAULT_INDEX_READ_MORE_LINK, DEFAULT_FEED_READ_MORE_LINK, LEGAL_VALUES
from nikola.plugin_categories import Command
-from nikola.utils import ask, ask_yesno, get_logger, makedirs, STDERR_HANDLER, load_messages
+from nikola.utils import ask, ask_yesno, get_logger, makedirs, load_messages
from nikola.packages.tzlocal import get_localzone
-LOGGER = get_logger('init', STDERR_HANDLER)
+LOGGER = get_logger('init')
SAMPLE_CONF = {
'BLOG_AUTHOR': "Your Name",
@@ -55,48 +55,51 @@ SAMPLE_CONF = {
'SITE_URL': "https://example.com/",
'BLOG_EMAIL': "joe@demo.site",
'BLOG_DESCRIPTION': "This is a demo site for Nikola.",
- 'PRETTY_URLS': False,
- 'STRIP_INDEXES': False,
+ 'PRETTY_URLS': True,
+ 'STRIP_INDEXES': True,
'DEFAULT_LANG': "en",
'TRANSLATIONS': """{
DEFAULT_LANG: "",
# Example for another language:
# "es": "./es",
}""",
- 'THEME': 'bootstrap3',
+ 'THEME': LEGAL_VALUES['DEFAULT_THEME'],
'TIMEZONE': 'UTC',
'COMMENT_SYSTEM': 'disqus',
'COMMENT_SYSTEM_ID': 'nikolademo',
'CATEGORY_ALLOW_HIERARCHIES': False,
'CATEGORY_OUTPUT_FLAT_HIERARCHY': False,
- 'TRANSLATIONS_PATTERN': DEFAULT_TRANSLATIONS_PATTERN,
'INDEX_READ_MORE_LINK': DEFAULT_INDEX_READ_MORE_LINK,
- 'RSS_READ_MORE_LINK': DEFAULT_RSS_READ_MORE_LINK,
+ 'FEED_READ_MORE_LINK': DEFAULT_FEED_READ_MORE_LINK,
'POSTS': """(
("posts/*.rst", "posts", "post.tmpl"),
+ ("posts/*.md", "posts", "post.tmpl"),
("posts/*.txt", "posts", "post.tmpl"),
+ ("posts/*.html", "posts", "post.tmpl"),
)""",
'PAGES': """(
- ("stories/*.rst", "stories", "story.tmpl"),
- ("stories/*.txt", "stories", "story.tmpl"),
+ ("pages/*.rst", "pages", "page.tmpl"),
+ ("pages/*.md", "pages", "page.tmpl"),
+ ("pages/*.txt", "pages", "page.tmpl"),
+ ("pages/*.html", "pages", "page.tmpl"),
)""",
'COMPILERS': """{
- "rest": ('.rst', '.txt'),
- "markdown": ('.md', '.mdown', '.markdown'),
- "textile": ('.textile',),
- "txt2tags": ('.t2t',),
- "bbcode": ('.bb',),
- "wiki": ('.wiki',),
- "ipynb": ('.ipynb',),
- "html": ('.html', '.htm'),
+ "rest": ['.rst', '.txt'],
+ "markdown": ['.md', '.mdown', '.markdown'],
+ "textile": ['.textile'],
+ "txt2tags": ['.t2t'],
+ "bbcode": ['.bb'],
+ "wiki": ['.wiki'],
+ "ipynb": ['.ipynb'],
+ "html": ['.html', '.htm'],
# PHP files are rendered the usual way (i.e. with the full templates).
# The resulting files have .php extensions, making it possible to run
# them without reconfiguring your server to recognize them.
- "php": ('.php',),
+ "php": ['.php'],
# Pandoc detects the input from the source filename
# but is disabled by default as it would conflict
# with many of the others.
- # "pandoc": ('.rst', '.md', '.txt'),
+ # "pandoc": ['.rst', '.md', '.txt'],
}""",
'NAVIGATION_LINKS': """{
DEFAULT_LANG: (
@@ -106,6 +109,7 @@ SAMPLE_CONF = {
),
}""",
'REDIRECTIONS': [],
+ '_METADATA_MAPPING_FORMATS': ', '.join(LEGAL_VALUES['METADATA_MAPPING'])
}
@@ -169,6 +173,14 @@ def format_default_translations_config(additional_languages):
return "{{\n{0}\n}}".format("\n".join(lang_paths))
+def get_default_translations_dict(default_lang, additional_languages):
+ """Generate a TRANSLATIONS dict matching the config from 'format_default_translations_config'."""
+ tr = {default_lang: ''}
+ for l in additional_languages:
+ tr[l] = './' + l
+ return tr
+
+
def format_navigation_links(additional_languages, default_lang, messages, strip_indexes=False):
"""Return the string to configure NAVIGATION_LINKS."""
f = u"""\
@@ -210,17 +222,28 @@ def prepare_config(config):
"""Parse sample config with JSON."""
p = config.copy()
p.update({k: json.dumps(v, ensure_ascii=False) for k, v in p.items()
- if k not in ('POSTS', 'PAGES', 'COMPILERS', 'TRANSLATIONS', 'NAVIGATION_LINKS', '_SUPPORTED_LANGUAGES', '_SUPPORTED_COMMENT_SYSTEMS', 'INDEX_READ_MORE_LINK', 'RSS_READ_MORE_LINK')})
+ if k not in ('POSTS', 'PAGES', 'COMPILERS', 'TRANSLATIONS', 'NAVIGATION_LINKS', '_SUPPORTED_LANGUAGES', '_SUPPORTED_COMMENT_SYSTEMS', 'INDEX_READ_MORE_LINK', 'FEED_READ_MORE_LINK', '_METADATA_MAPPING_FORMATS')})
# READ_MORE_LINKs require some special treatment.
p['INDEX_READ_MORE_LINK'] = "'" + p['INDEX_READ_MORE_LINK'].replace("'", "\\'") + "'"
- p['RSS_READ_MORE_LINK'] = "'" + p['RSS_READ_MORE_LINK'].replace("'", "\\'") + "'"
+ p['FEED_READ_MORE_LINK'] = "'" + p['FEED_READ_MORE_LINK'].replace("'", "\\'") + "'"
# fix booleans and None
p.update({k: str(v) for k, v in config.items() if isinstance(v, bool) or v is None})
return p
-class CommandInit(Command):
+def test_destination(destination, demo=False):
+ """Check if the destination already exists, which can break demo site creation."""
+ # Issue #2214
+ if demo and os.path.exists(destination):
+ LOGGER.warning("The directory {0} already exists, and a new demo site cannot be initialized in an existing directory.".format(destination))
+ LOGGER.warning("Please remove the directory and try again, or use another directory.")
+ LOGGER.info("Hint: If you want to initialize a git repository in this directory, run `git init` in the directory after creating a Nikola site.")
+ return False
+ else:
+ return True
+
+class CommandInit(Command):
"""Create a new site."""
name = "init"
@@ -272,11 +295,11 @@ class CommandInit(Command):
@classmethod
def create_empty_site(cls, target):
"""Create an empty site with directories only."""
- for folder in ('files', 'galleries', 'listings', 'posts', 'stories'):
+ for folder in ('files', 'galleries', 'images', 'listings', 'posts', 'pages'):
makedirs(os.path.join(target, folder))
@staticmethod
- def ask_questions(target):
+ def ask_questions(target, demo=False):
"""Ask some questions about Nikola."""
def urlhandler(default, toconf):
answer = ask('Site URL', 'https://example.com/')
@@ -310,7 +333,6 @@ class CommandInit(Command):
def prettyhandler(default, toconf):
SAMPLE_CONF['PRETTY_URLS'] = ask_yesno('Enable pretty URLs (/page/ instead of /page.html) that don\'t need web server configuration?', default=True)
- SAMPLE_CONF['STRIP_INDEXES'] = SAMPLE_CONF['PRETTY_URLS']
def lhandler(default, toconf, show_header=True):
if show_header:
@@ -341,13 +363,12 @@ class CommandInit(Command):
# Get messages for navigation_links. In order to do this, we need
# to generate a throwaway TRANSLATIONS dict.
- tr = {default: ''}
- for l in langs:
- tr[l] = './' + l
+ tr = get_default_translations_dict(default, langs)
+
# Assuming that base contains all the locales, and that base does
# not inherit from anywhere.
try:
- messages = load_messages(['base'], tr, default)
+ messages = load_messages(['base'], tr, default, themes_dirs=['themes'])
SAMPLE_CONF['NAVIGATION_LINKS'] = format_navigation_links(langs, default, messages, SAMPLE_CONF['STRIP_INDEXES'])
except nikola.utils.LanguageNotFoundError as e:
print(" ERROR: the language '{0}' is not supported.".format(e.lang))
@@ -358,28 +379,28 @@ class CommandInit(Command):
def tzhandler(default, toconf):
print("\nPlease choose the correct time zone for your blog. Nikola uses the tz database.")
print("You can find your time zone here:")
- print("http://en.wikipedia.org/wiki/List_of_tz_database_time_zones")
+ print("https://en.wikipedia.org/wiki/List_of_tz_database_time_zones")
print("")
answered = False
while not answered:
try:
lz = get_localzone()
- except:
+ except Exception:
lz = None
answer = ask('Time zone', lz if lz else "UTC")
tz = dateutil.tz.gettz(answer)
if tz is None:
print(" WARNING: Time zone not found. Searching list of time zones for a match.")
- zonesfile = tarfile.open(fileobj=dateutil.zoneinfo.getzoneinfofile_stream())
- zonenames = [zone for zone in zonesfile.getnames() if answer.lower() in zone.lower()]
- if len(zonenames) == 1:
- tz = dateutil.tz.gettz(zonenames[0])
- answer = zonenames[0]
+ all_zones = dateutil.zoneinfo.get_zonefile_instance().zones
+ matching_zones = [zone for zone in all_zones if answer.lower() in zone.lower()]
+ if len(matching_zones) == 1:
+ tz = dateutil.tz.gettz(matching_zones[0])
+ answer = matching_zones[0]
print(" Picking '{0}'.".format(answer))
- elif len(zonenames) > 1:
+ elif len(matching_zones) > 1:
print(" The following time zones match your query:")
- print(' ' + '\n '.join(zonenames))
+ print(' ' + '\n '.join(matching_zones))
continue
if tz is not None:
@@ -441,7 +462,7 @@ class CommandInit(Command):
print("If you do not want to answer and want to go with the defaults instead, simply restart with the `-q` parameter.")
for query, default, toconf, destination in questions:
- if target and destination == '!target':
+ if target and destination == '!target' and test_destination(target, demo):
# Skip the destination question if we know it already
pass
else:
@@ -458,8 +479,9 @@ class CommandInit(Command):
if toconf:
SAMPLE_CONF[destination] = answer
if destination == '!target':
- while not answer:
- print(' ERROR: you need to specify a target directory.\n')
+ while not answer or not test_destination(answer, demo):
+ if not answer:
+ print(' ERROR: you need to specify a target directory.\n')
answer = ask(query, default)
STORAGE['target'] = answer
@@ -475,7 +497,7 @@ class CommandInit(Command):
except IndexError:
target = None
if not options.get('quiet'):
- st = self.ask_questions(target=target)
+ st = self.ask_questions(target=target, demo=options.get('demo'))
try:
if not target:
target = st['target']
@@ -488,11 +510,13 @@ class CommandInit(Command):
Options:
-q, --quiet Do not ask questions about config.
-d, --demo Create a site filled with example data.""")
- return False
+ return 1
if not options.get('demo'):
self.create_empty_site(target)
LOGGER.info('Created empty site at {0}.'.format(target))
else:
+ if not test_destination(target, True):
+ return 2
self.copy_sample_site(target)
LOGGER.info("A new site with example data has been created at "
"{0}.".format(target))
diff --git a/nikola/plugins/command/install_theme.plugin b/nikola/plugins/command/install_theme.plugin
deleted file mode 100644
index 8434f2e..0000000
--- a/nikola/plugins/command/install_theme.plugin
+++ /dev/null
@@ -1,13 +0,0 @@
-[Core]
-name = install_theme
-module = install_theme
-
-[Documentation]
-author = Roberto Alsina
-version = 1.0
-website = http://getnikola.com
-description = Install a theme into the current site.
-
-[Nikola]
-plugincategory = Command
-
diff --git a/nikola/plugins/command/install_theme.py b/nikola/plugins/command/install_theme.py
deleted file mode 100644
index f02252e..0000000
--- a/nikola/plugins/command/install_theme.py
+++ /dev/null
@@ -1,172 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# Copyright © 2012-2015 Roberto Alsina and others.
-
-# Permission is hereby granted, free of charge, to any
-# person obtaining a copy of this software and associated
-# documentation files (the "Software"), to deal in the
-# Software without restriction, including without limitation
-# the rights to use, copy, modify, merge, publish,
-# distribute, sublicense, and/or sell copies of the
-# Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice
-# shall be included in all copies or substantial portions of
-# the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
-# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
-# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
-# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
-# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
-# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
-# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-"""Install a theme."""
-
-from __future__ import print_function
-import os
-import io
-import time
-import requests
-
-import pygments
-from pygments.lexers import PythonLexer
-from pygments.formatters import TerminalFormatter
-
-from nikola.plugin_categories import Command
-from nikola import utils
-
-LOGGER = utils.get_logger('install_theme', utils.STDERR_HANDLER)
-
-
-class CommandInstallTheme(Command):
-
- """Install a theme."""
-
- name = "install_theme"
- doc_usage = "[[-u] theme_name] | [[-u] -l]"
- doc_purpose = "install theme into current site"
- output_dir = 'themes'
- cmd_options = [
- {
- 'name': 'list',
- 'short': 'l',
- 'long': 'list',
- 'type': bool,
- 'default': False,
- 'help': 'Show list of available themes.'
- },
- {
- 'name': 'url',
- 'short': 'u',
- 'long': 'url',
- 'type': str,
- 'help': "URL for the theme repository (default: "
- "https://themes.getnikola.com/v7/themes.json)",
- 'default': 'https://themes.getnikola.com/v7/themes.json'
- },
- {
- 'name': 'getpath',
- 'short': 'g',
- 'long': 'get-path',
- 'type': bool,
- 'default': False,
- 'help': "Print the path for installed theme",
- },
- ]
-
- def _execute(self, options, args):
- """Install theme into current site."""
- listing = options['list']
- url = options['url']
- if args:
- name = args[0]
- else:
- name = None
-
- if options['getpath'] and name:
- path = utils.get_theme_path(name)
- if path:
- print(path)
- else:
- print('not installed')
- return 0
-
- if name is None and not listing:
- LOGGER.error("This command needs either a theme name or the -l option.")
- return False
- try:
- data = requests.get(url).json()
- except requests.exceptions.SSLError:
- LOGGER.warning("SSL error, using http instead of https (press ^C to abort)")
- time.sleep(1)
- url = url.replace('https', 'http', 1)
- data = requests.get(url).json()
- if listing:
- print("Themes:")
- print("-------")
- for theme in sorted(data.keys()):
- print(theme)
- return True
- else:
- # `name` may be modified by the while loop.
- origname = name
- installstatus = self.do_install(name, data)
- # See if the theme's parent is available. If not, install it
- while True:
- parent_name = utils.get_parent_theme_name(name)
- if parent_name is None:
- break
- try:
- utils.get_theme_path(parent_name)
- break
- except: # Not available
- self.do_install(parent_name, data)
- name = parent_name
- if installstatus:
- LOGGER.notice('Remember to set THEME="{0}" in conf.py to use this theme.'.format(origname))
-
- def do_install(self, name, data):
- """Download and install a theme."""
- if name in data:
- utils.makedirs(self.output_dir)
- url = data[name]
- LOGGER.info("Downloading '{0}'".format(url))
- try:
- zip_data = requests.get(url).content
- except requests.exceptions.SSLError:
- LOGGER.warning("SSL error, using http instead of https (press ^C to abort)")
- time.sleep(1)
- url = url.replace('https', 'http', 1)
- zip_data = requests.get(url).content
-
- zip_file = io.BytesIO()
- zip_file.write(zip_data)
- LOGGER.info("Extracting '{0}' into themes/".format(name))
- utils.extract_all(zip_file)
- dest_path = os.path.join(self.output_dir, name)
- else:
- dest_path = os.path.join(self.output_dir, name)
- try:
- theme_path = utils.get_theme_path(name)
- LOGGER.error("Theme '{0}' is already installed in {1}".format(name, theme_path))
- except Exception:
- LOGGER.error("Can't find theme {0}".format(name))
-
- return False
-
- confpypath = os.path.join(dest_path, 'conf.py.sample')
- if os.path.exists(confpypath):
- LOGGER.notice('This theme has a sample config file. Integrate it with yours in order to make this theme work!')
- print('Contents of the conf.py.sample file:\n')
- with io.open(confpypath, 'r', encoding='utf-8') as fh:
- if self.site.colorful:
- print(utils.indent(pygments.highlight(
- fh.read(), PythonLexer(), TerminalFormatter()),
- 4 * ' '))
- else:
- print(utils.indent(fh.read(), 4 * ' '))
- return True
diff --git a/nikola/plugins/command/new_page.plugin b/nikola/plugins/command/new_page.plugin
index 145a419..8734805 100644
--- a/nikola/plugins/command/new_page.plugin
+++ b/nikola/plugins/command/new_page.plugin
@@ -5,9 +5,9 @@ module = new_page
[Documentation]
author = Roberto Alsina, Chris Warrick
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Create a new page.
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/new_page.py b/nikola/plugins/command/new_page.py
index 811e28b..0f7996a 100644
--- a/nikola/plugins/command/new_page.py
+++ b/nikola/plugins/command/new_page.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2015 Roberto Alsina, Chris Warrick and others.
+# Copyright © 2012-2020 Roberto Alsina, Chris Warrick and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -26,13 +26,11 @@
"""Create a new page."""
-from __future__ import unicode_literals, print_function
from nikola.plugin_categories import Command
class CommandNewPage(Command):
-
"""Create a new page."""
name = "new_page"
@@ -108,6 +106,7 @@ class CommandNewPage(Command):
options['tags'] = ''
options['schedule'] = False
options['is_page'] = True
+ options['date-path'] = False
# Even though stuff was split into `new_page`, it’s easier to do it
# there not to duplicate the code.
p = self.site.plugin_manager.getPluginByName('new_post', 'Command').plugin_object
diff --git a/nikola/plugins/command/new_post.plugin b/nikola/plugins/command/new_post.plugin
index d88469f..efdeb58 100644
--- a/nikola/plugins/command/new_post.plugin
+++ b/nikola/plugins/command/new_post.plugin
@@ -5,9 +5,9 @@ module = new_post
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Create a new post.
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/new_post.py b/nikola/plugins/command/new_post.py
index f9fe3ff..e6eabbd 100644
--- a/nikola/plugins/command/new_post.py
+++ b/nikola/plugins/command/new_post.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,23 +26,23 @@
"""Create a new post."""
-from __future__ import unicode_literals, print_function
import io
import datetime
+import operator
import os
-import sys
+import shutil
import subprocess
-import operator
+import sys
-from blinker import signal
import dateutil.tz
+from blinker import signal
from nikola.plugin_categories import Command
from nikola import utils
COMPILERS_DOC_LINK = 'https://getnikola.com/handbook.html#configuring-other-input-formats'
-POSTLOGGER = utils.get_logger('new_post', utils.STDERR_HANDLER)
-PAGELOGGER = utils.get_logger('new_page', utils.STDERR_HANDLER)
+POSTLOGGER = utils.get_logger('new_post')
+PAGELOGGER = utils.get_logger('new_page')
LOGGER = POSTLOGGER
@@ -89,7 +89,7 @@ def get_date(schedule=False, rule=None, last_date=None, tz=None, iso8601=False):
except ImportError:
LOGGER.error('To use the --schedule switch of new_post, '
'you have to install the "dateutil" package.')
- rrule = None # NOQA
+ rrule = None
if schedule and rrule and rule:
try:
rule_ = rrule.rrulestr(rule, dtstart=last_date or date)
@@ -110,11 +110,10 @@ def get_date(schedule=False, rule=None, last_date=None, tz=None, iso8601=False):
else:
tz_str = ' UTC'
- return date.strftime('%Y-%m-%d %H:%M:%S') + tz_str
+ return (date.strftime('%Y-%m-%d %H:%M:%S') + tz_str, date)
class CommandNewPost(Command):
-
"""Create a new post."""
name = "new_post"
@@ -204,7 +203,14 @@ class CommandNewPost(Command):
'default': '',
'help': 'Import an existing file instead of creating a placeholder'
},
-
+ {
+ 'name': 'date-path',
+ 'short': 'd',
+ 'long': 'date-path',
+ 'type': bool,
+ 'default': False,
+ 'help': 'Create post with date path (eg. year/month/day, see NEW_POST_DATE_PATH_FORMAT in config)'
+ },
]
def _execute(self, options, args):
@@ -234,6 +240,10 @@ class CommandNewPost(Command):
twofile = options['twofile']
import_file = options['import']
wants_available = options['available-formats']
+ date_path_opt = options['date-path']
+ date_path_auto = self.site.config['NEW_POST_DATE_PATH'] and content_type == 'post'
+ date_path_format = self.site.config['NEW_POST_DATE_PATH_FORMAT'].strip('/')
+ post_type = options.get('type', 'text')
if wants_available:
self.print_compilers()
@@ -255,16 +265,39 @@ class CommandNewPost(Command):
if "@" in content_format:
content_format, content_subformat = content_format.split("@")
- if not content_format: # Issue #400
+ if not content_format and path and not os.path.isdir(path):
+ # content_format not specified. If path was given, use
+ # it to guess (Issue #2798)
+ extension = os.path.splitext(path)[-1]
+ for compiler, extensions in self.site.config['COMPILERS'].items():
+ if extension in extensions:
+ content_format = compiler
+ if not content_format:
+ LOGGER.error("Unknown {0} extension {1}, maybe you need to install a plugin or enable an existing one?".format(content_type, extension))
+ return
+
+ elif not content_format and import_file:
+ # content_format not specified. If import_file was given, use
+ # it to guess (Issue #2798)
+ extension = os.path.splitext(import_file)[-1]
+ for compiler, extensions in self.site.config['COMPILERS'].items():
+ if extension in extensions:
+ content_format = compiler
+ if not content_format:
+ LOGGER.error("Unknown {0} extension {1}, maybe you need to install a plugin or enable an existing one?".format(content_type, extension))
+ return
+
+ elif not content_format: # Issue #400
content_format = get_default_compiler(
is_post,
self.site.config['COMPILERS'],
self.site.config['post_pages'])
- if content_format not in compiler_names:
- LOGGER.error("Unknown {0} format {1}, maybe you need to install a plugin?".format(content_type, content_format))
+ elif content_format not in compiler_names:
+ LOGGER.error("Unknown {0} format {1}, maybe you need to install a plugin or enable an existing one?".format(content_type, content_format))
self.print_compilers()
return
+
compiler_plugin = self.site.plugin_manager.getPluginByName(
content_format, "PageCompiler").plugin_object
@@ -286,7 +319,7 @@ class CommandNewPost(Command):
while not title:
title = utils.ask('Title')
- if isinstance(title, utils.bytes_str):
+ if isinstance(title, bytes):
try:
title = title.decode(sys.stdin.encoding)
except (AttributeError, TypeError): # for tests
@@ -294,28 +327,36 @@ class CommandNewPost(Command):
title = title.strip()
if not path:
- slug = utils.slugify(title)
+ slug = utils.slugify(title, lang=self.site.default_lang)
else:
- if isinstance(path, utils.bytes_str):
+ if isinstance(path, bytes):
try:
path = path.decode(sys.stdin.encoding)
except (AttributeError, TypeError): # for tests
path = path.decode('utf-8')
- slug = utils.slugify(os.path.splitext(os.path.basename(path))[0])
+ if os.path.isdir(path):
+ # If the user provides a directory, add the file name generated from title (Issue #2651)
+ slug = utils.slugify(title, lang=self.site.default_lang)
+ pattern = os.path.basename(entry[0])
+ suffix = pattern[1:]
+ path = os.path.join(path, slug + suffix)
+ else:
+ slug = utils.slugify(os.path.splitext(os.path.basename(path))[0], lang=self.site.default_lang)
- if isinstance(author, utils.bytes_str):
- try:
- author = author.decode(sys.stdin.encoding)
- except (AttributeError, TypeError): # for tests
- author = author.decode('utf-8')
+ if isinstance(author, bytes):
+ try:
+ author = author.decode(sys.stdin.encoding)
+ except (AttributeError, TypeError): # for tests
+ author = author.decode('utf-8')
# Calculate the date to use for the content
- schedule = options['schedule'] or self.site.config['SCHEDULE_ALL']
+ # SCHEDULE_ALL is post-only (Issue #2921)
+ schedule = options['schedule'] or (self.site.config['SCHEDULE_ALL'] and is_post)
rule = self.site.config['SCHEDULE_RULE']
self.site.scan_posts()
timeline = self.site.timeline
last_date = None if not timeline else timeline[0].date
- date = get_date(schedule, rule, last_date, self.site.tzinfo, self.site.config['FORCE_ISO8601'])
+ date, dateobj = get_date(schedule, rule, last_date, self.site.tzinfo, self.site.config['FORCE_ISO8601'])
data = {
'title': title,
'slug': slug,
@@ -323,16 +364,23 @@ class CommandNewPost(Command):
'tags': tags,
'link': '',
'description': '',
- 'type': 'text',
+ 'type': post_type,
}
- output_path = os.path.dirname(entry[0])
- meta_path = os.path.join(output_path, slug + ".meta")
- pattern = os.path.basename(entry[0])
- suffix = pattern[1:]
+
if not path:
+ pattern = os.path.basename(entry[0])
+ suffix = pattern[1:]
+ output_path = os.path.dirname(entry[0])
+ if date_path_auto or date_path_opt:
+ output_path += os.sep + dateobj.strftime(date_path_format)
+
txt_path = os.path.join(output_path, slug + suffix)
+ meta_path = os.path.join(output_path, slug + ".meta")
else:
+ if date_path_opt:
+ LOGGER.warning("A path has been specified, ignoring -d")
txt_path = os.path.join(self.site.original_cwd, path)
+ meta_path = os.path.splitext(txt_path)[0] + ".meta"
if (not onefile and os.path.isfile(meta_path)) or \
os.path.isfile(txt_path):
@@ -344,6 +392,9 @@ class CommandNewPost(Command):
signal('existing_' + content_type).send(self, **event)
LOGGER.error("The title already exists!")
+ LOGGER.info("Existing {0}'s text is at: {1}".format(content_type, txt_path))
+ if not onefile:
+ LOGGER.info("Existing {0}'s metadata is at: {1}".format(content_type, meta_path))
return 8
d_name = os.path.dirname(txt_path)
@@ -354,33 +405,38 @@ class CommandNewPost(Command):
metadata.update(self.site.config['ADDITIONAL_METADATA'])
data.update(metadata)
- # ipynb plugin needs the ipython kernel info. We get the kernel name
+ # ipynb plugin needs the Jupyter kernel info. We get the kernel name
# from the content_subformat and pass it to the compiler in the metadata
if content_format == "ipynb" and content_subformat is not None:
- metadata["ipython_kernel"] = content_subformat
+ metadata["jupyter_kernel"] = content_subformat
# Override onefile if not really supported.
if not compiler_plugin.supports_onefile and onefile:
onefile = False
- LOGGER.warn('This compiler does not support one-file posts.')
+ LOGGER.warning('This compiler does not support one-file posts.')
- if import_file:
- with io.open(import_file, 'r', encoding='utf-8') as fh:
+ if onefile and import_file:
+ with io.open(import_file, 'r', encoding='utf-8-sig') as fh:
content = fh.read()
- else:
+ elif not import_file:
if is_page:
content = self.site.MESSAGES[self.site.default_lang]["Write your page here."]
else:
content = self.site.MESSAGES[self.site.default_lang]["Write your post here."]
- compiler_plugin.create_post(
- txt_path, content=content, onefile=onefile, title=title,
- slug=slug, date=date, tags=tags, is_page=is_page, **metadata)
+
+ if (not onefile) and import_file:
+ # Two-file posts are copied on import (Issue #2380)
+ shutil.copy(import_file, txt_path)
+ else:
+ compiler_plugin.create_post(
+ txt_path, content=content, onefile=onefile, title=title,
+ slug=slug, date=date, tags=tags, is_page=is_page, type=post_type, **metadata)
event = dict(path=txt_path)
if not onefile: # write metadata file
with io.open(meta_path, "w+", encoding="utf8") as fd:
- fd.write(utils.write_metadata(data))
+ fd.write(utils.write_metadata(data, comment_wrap=False, site=self.site))
LOGGER.info("Your {0}'s metadata is at: {1}".format(content_type, meta_path))
event['meta_path'] = meta_path
LOGGER.info("Your {0}'s text is at: {1}".format(content_type, txt_path))
@@ -395,7 +451,7 @@ class CommandNewPost(Command):
if editor:
subprocess.call(to_run)
else:
- LOGGER.error('$EDITOR not set, cannot edit the post. Please do it manually.')
+ LOGGER.error('The $EDITOR environment variable is not set, cannot edit the post with \'-e\'. Please edit the post manually.')
def filter_post_pages(self, compiler, is_post):
"""Return the correct entry from post_pages.
@@ -512,6 +568,6 @@ class CommandNewPost(Command):
More compilers are available in the Plugins Index.
Compilers marked with ! and ~ require additional configuration:
- ! not in the PAGES/POSTS tuples (unused)
+ ! not in the POSTS/PAGES tuples and any post scanners (unused)
~ not in the COMPILERS dict (disabled)
Read more: {0}""".format(COMPILERS_DOC_LINK))
diff --git a/nikola/plugins/command/orphans.plugin b/nikola/plugins/command/orphans.plugin
index 669429d..5107032 100644
--- a/nikola/plugins/command/orphans.plugin
+++ b/nikola/plugins/command/orphans.plugin
@@ -5,9 +5,9 @@ module = orphans
[Documentation]
author = Roberto Alsina, Chris Warrick
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = List all orphans
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/orphans.py b/nikola/plugins/command/orphans.py
index b12cc67..0cf2e63 100644
--- a/nikola/plugins/command/orphans.py
+++ b/nikola/plugins/command/orphans.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2015 Roberto Alsina, Chris Warrick and others.
+# Copyright © 2012-2020 Roberto Alsina, Chris Warrick and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -26,7 +26,6 @@
"""List all orphans."""
-from __future__ import print_function
import os
from nikola.plugin_categories import Command
@@ -34,7 +33,6 @@ from nikola.plugins.command.check import real_scan_files
class CommandOrphans(Command):
-
"""List all orphans."""
name = "orphans"
diff --git a/nikola/plugins/command/plugin.plugin b/nikola/plugins/command/plugin.plugin
index d44dcf3..db99ceb 100644
--- a/nikola/plugins/command/plugin.plugin
+++ b/nikola/plugins/command/plugin.plugin
@@ -5,9 +5,9 @@ module = plugin
[Documentation]
author = Roberto Alsina and Chris Warrick
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Manage Nikola plugins
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/plugin.py b/nikola/plugins/command/plugin.py
index f892ee9..33dee23 100644
--- a/nikola/plugins/command/plugin.py
+++ b/nikola/plugins/command/plugin.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,9 +26,10 @@
"""Manage plugins."""
-from __future__ import print_function
import io
+import json.decoder
import os
+import sys
import shutil
import subprocess
import time
@@ -41,16 +42,15 @@ from pygments.formatters import TerminalFormatter
from nikola.plugin_categories import Command
from nikola import utils
-LOGGER = utils.get_logger('plugin', utils.STDERR_HANDLER)
+LOGGER = utils.get_logger('plugin')
class CommandPlugin(Command):
-
"""Manage plugins."""
json = None
name = "plugin"
- doc_usage = "[[-u][--user] --install name] | [[-u] [-l |--upgrade|--list-installed] | [--uninstall name]]"
+ doc_usage = "[-u url] [--user] [-i name] [-r name] [--upgrade] [-l] [--list-installed]"
doc_purpose = "manage plugins"
output_dir = None
needs_config = False
@@ -84,9 +84,8 @@ class CommandPlugin(Command):
'short': 'u',
'long': 'url',
'type': str,
- 'help': "URL for the plugin repository (default: "
- "https://plugins.getnikola.com/v7/plugins.json)",
- 'default': 'https://plugins.getnikola.com/v7/plugins.json'
+ 'help': "URL for the plugin repository",
+ 'default': 'https://plugins.getnikola.com/v8/plugins.json'
},
{
'name': 'user',
@@ -137,11 +136,11 @@ class CommandPlugin(Command):
self.output_dir = options.get('output_dir')
else:
if not self.site.configured and not user_mode and install:
- LOGGER.notice('No site found, assuming --user')
+ LOGGER.warning('No site found, assuming --user')
user_mode = True
if user_mode:
- self.output_dir = os.path.expanduser('~/.nikola/plugins')
+ self.output_dir = os.path.expanduser(os.path.join('~', '.nikola', 'plugins'))
else:
self.output_dir = 'plugins'
@@ -177,8 +176,20 @@ class CommandPlugin(Command):
plugins.append([plugin.name, p])
plugins.sort()
+ print('Installed Plugins:')
+ print('------------------')
+ maxlength = max(len(i[0]) for i in plugins)
+ if self.site.colorful:
+ formatstring = '\x1b[1m{0:<{2}}\x1b[0m at {1}'
+ else:
+ formatstring = '{0:<{2}} at {1}'
for name, path in plugins:
- print('{0} at {1}'.format(name, path))
+ print(formatstring.format(name, path, maxlength))
+ dp = self.site.config['DISABLED_PLUGINS']
+ if dp:
+ print('\n\nAlso, you have disabled these plugins: {}'.format(', '.join(dp)))
+ else:
+ print('\n\nNo plugins are disabled.')
return 0
def do_upgrade(self, url):
@@ -232,43 +243,32 @@ class CommandPlugin(Command):
utils.extract_all(zip_file, self.output_dir)
dest_path = os.path.join(self.output_dir, name)
else:
- try:
- plugin_path = utils.get_plugin_path(name)
- except:
- LOGGER.error("Can't find plugin " + name)
- return 1
-
- utils.makedirs(self.output_dir)
- dest_path = os.path.join(self.output_dir, name)
- if os.path.exists(dest_path):
- LOGGER.error("{0} is already installed".format(name))
- return 1
-
- LOGGER.info('Copying {0} into plugins'.format(plugin_path))
- shutil.copytree(plugin_path, dest_path)
+ LOGGER.error("Can't find plugin " + name)
+ return 1
reqpath = os.path.join(dest_path, 'requirements.txt')
if os.path.exists(reqpath):
- LOGGER.notice('This plugin has Python dependencies.')
+ LOGGER.warning('This plugin has Python dependencies.')
LOGGER.info('Installing dependencies with pip...')
try:
- subprocess.check_call(('pip', 'install', '-r', reqpath))
+ subprocess.check_call((sys.executable, '-m', 'pip', 'install', '-r', reqpath))
except subprocess.CalledProcessError:
LOGGER.error('Could not install the dependencies.')
print('Contents of the requirements.txt file:\n')
- with io.open(reqpath, 'r', encoding='utf-8') as fh:
+ with io.open(reqpath, 'r', encoding='utf-8-sig') as fh:
print(utils.indent(fh.read(), 4 * ' '))
print('You have to install those yourself or through a '
'package manager.')
else:
LOGGER.info('Dependency installation succeeded.')
+
reqnpypath = os.path.join(dest_path, 'requirements-nonpy.txt')
if os.path.exists(reqnpypath):
- LOGGER.notice('This plugin has third-party '
- 'dependencies you need to install '
- 'manually.')
+ LOGGER.warning('This plugin has third-party '
+ 'dependencies you need to install '
+ 'manually.')
print('Contents of the requirements-nonpy.txt file:\n')
- with io.open(reqnpypath, 'r', encoding='utf-8') as fh:
+ with io.open(reqnpypath, 'r', encoding='utf-8-sig') as fh:
for l in fh.readlines():
i, j = l.split('::')
print(utils.indent(i.strip(), 4 * ' '))
@@ -277,28 +277,50 @@ class CommandPlugin(Command):
print('You have to install those yourself or through a package '
'manager.')
+
+ req_plug_path = os.path.join(dest_path, 'requirements-plugins.txt')
+ if os.path.exists(req_plug_path):
+ LOGGER.info('This plugin requires other Nikola plugins.')
+ LOGGER.info('Installing plugins...')
+ plugin_failure = False
+ try:
+ with io.open(req_plug_path, 'r', encoding='utf-8-sig') as inf:
+ for plugname in inf.readlines():
+ plugin_failure = self.do_install(url, plugname.strip(), show_install_notes) != 0
+ except Exception:
+ plugin_failure = True
+ if plugin_failure:
+ LOGGER.error('Could not install a plugin.')
+ print('Contents of the requirements-plugins.txt file:\n')
+ with io.open(req_plug_path, 'r', encoding='utf-8-sig') as fh:
+ print(utils.indent(fh.read(), 4 * ' '))
+ print('You have to install those yourself manually.')
+ else:
+ LOGGER.info('Dependency installation succeeded.')
+
confpypath = os.path.join(dest_path, 'conf.py.sample')
if os.path.exists(confpypath) and show_install_notes:
- LOGGER.notice('This plugin has a sample config file. Integrate it with yours in order to make this plugin work!')
+ LOGGER.warning('This plugin has a sample config file. Integrate it with yours in order to make this plugin work!')
print('Contents of the conf.py.sample file:\n')
- with io.open(confpypath, 'r', encoding='utf-8') as fh:
+ with io.open(confpypath, 'r', encoding='utf-8-sig') as fh:
if self.site.colorful:
- print(utils.indent(pygments.highlight(
- fh.read(), PythonLexer(), TerminalFormatter()),
- 4 * ' '))
+ print(pygments.highlight(fh.read(), PythonLexer(), TerminalFormatter()))
else:
- print(utils.indent(fh.read(), 4 * ' '))
+ print(fh.read())
return 0
def do_uninstall(self, name):
"""Uninstall a plugin."""
for plugin in self.site.plugin_manager.getAllPlugins(): # FIXME: this is repeated thrice
- p = plugin.path
- if os.path.isdir(p):
- p = p + os.sep
- else:
- p = os.path.dirname(p)
if name == plugin.name: # Uninstall this one
+ p = plugin.path
+ if os.path.isdir(p):
+ # Plugins that have a package in them need to delete parent
+ # Issue #2356
+ p = p + os.sep
+ p = os.path.abspath(os.path.join(p, os.pardir))
+ else:
+ p = os.path.dirname(p)
LOGGER.warning('About to uninstall plugin: {0}'.format(name))
LOGGER.warning('This will delete {0}'.format(p))
sure = utils.ask_yesno('Are you sure?')
@@ -314,10 +336,19 @@ class CommandPlugin(Command):
"""Download the JSON file with all plugins."""
if self.json is None:
try:
- self.json = requests.get(url).json()
- except requests.exceptions.SSLError:
- LOGGER.warning("SSL error, using http instead of https (press ^C to abort)")
- time.sleep(1)
- url = url.replace('https', 'http', 1)
- self.json = requests.get(url).json()
+ try:
+ self.json = requests.get(url).json()
+ except requests.exceptions.SSLError:
+ LOGGER.warning("SSL error, using http instead of https (press ^C to abort)")
+ time.sleep(1)
+ url = url.replace('https', 'http', 1)
+ self.json = requests.get(url).json()
+ except json.decoder.JSONDecodeError as e:
+ LOGGER.error("Failed to decode JSON data in response from server.")
+ LOGGER.error("JSON error encountered: " + str(e))
+ LOGGER.error("This issue might be caused by server-side issues, or by to unusual activity in your "
+ "network (as determined by CloudFlare). Please visit https://plugins.getnikola.com/ in "
+ "a browser.")
+ sys.exit(2)
+
return self.json
diff --git a/nikola/plugins/command/rst2html.plugin b/nikola/plugins/command/rst2html.plugin
index 02c9276..6f2fb25 100644
--- a/nikola/plugins/command/rst2html.plugin
+++ b/nikola/plugins/command/rst2html.plugin
@@ -5,9 +5,9 @@ module = rst2html
[Documentation]
author = Chris Warrick
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Compile reStructuredText to HTML using the Nikola architecture
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/rst2html/__init__.py b/nikola/plugins/command/rst2html/__init__.py
index 06afffd..5576b35 100644
--- a/nikola/plugins/command/rst2html/__init__.py
+++ b/nikola/plugins/command/rst2html/__init__.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2015 Chris Warrick and others.
+# Copyright © 2015-2020 Chris Warrick and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -26,7 +26,6 @@
"""Compile reStructuredText to HTML, using Nikola architecture."""
-from __future__ import unicode_literals, print_function
import io
import lxml.html
@@ -36,7 +35,6 @@ from nikola.plugin_categories import Command
class CommandRst2Html(Command):
-
"""Compile reStructuredText to HTML, using Nikola architecture."""
name = "rst2html"
@@ -51,12 +49,12 @@ class CommandRst2Html(Command):
print("This command takes only one argument (input file name).")
return 2
source = args[0]
- with io.open(source, "r", encoding="utf8") as in_file:
+ with io.open(source, "r", encoding="utf-8-sig") as in_file:
data = in_file.read()
- output, error_level, deps = compiler.compile_html_string(data, source, True)
+ output, error_level, deps, shortcode_deps = compiler.compile_string(data, source, True)
- rstcss_path = resource_filename('nikola', 'data/themes/base/assets/css/rst.css')
- with io.open(rstcss_path, "r", encoding="utf8") as fh:
+ rstcss_path = resource_filename('nikola', 'data/themes/base/assets/css/rst_base.css')
+ with io.open(rstcss_path, "r", encoding="utf-8-sig") as fh:
rstcss = fh.read()
template_path = resource_filename('nikola', 'plugins/command/rst2html/rst2html.tmpl')
@@ -65,7 +63,7 @@ class CommandRst2Html(Command):
parser = lxml.html.HTMLParser(remove_blank_text=True)
doc = lxml.html.document_fromstring(template_output, parser)
html = b'\n' + lxml.html.tostring(doc, encoding='utf8', method='html', pretty_print=True)
- print(html)
+ print(html.decode('utf-8'))
if error_level < 3:
return 0
else:
diff --git a/nikola/plugins/command/serve.plugin b/nikola/plugins/command/serve.plugin
index aca71ec..aa40073 100644
--- a/nikola/plugins/command/serve.plugin
+++ b/nikola/plugins/command/serve.plugin
@@ -5,9 +5,9 @@ module = serve
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Start test server.
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/serve.py b/nikola/plugins/command/serve.py
index 0441c93..ede5179 100644
--- a/nikola/plugins/command/serve.py
+++ b/nikola/plugins/command/serve.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,43 +26,33 @@
"""Start test server."""
-from __future__ import print_function
import os
+import sys
import re
+import signal
import socket
import webbrowser
-try:
- from BaseHTTPServer import HTTPServer
- from SimpleHTTPServer import SimpleHTTPRequestHandler
-except ImportError:
- from http.server import HTTPServer # NOQA
- from http.server import SimpleHTTPRequestHandler # NOQA
-
-try:
- from StringIO import StringIO
-except ImportError:
- from io import BytesIO as StringIO # NOQA
-
+from http.server import HTTPServer
+from http.server import SimpleHTTPRequestHandler
+from io import BytesIO as StringIO
from nikola.plugin_categories import Command
-from nikola.utils import get_logger, STDERR_HANDLER
+from nikola.utils import dns_sd
class IPv6Server(HTTPServer):
-
"""An IPv6 HTTPServer."""
address_family = socket.AF_INET6
class CommandServe(Command):
-
"""Start test server."""
name = "serve"
doc_usage = "[options]"
doc_purpose = "start the test webserver"
- logger = None
+ dns_sd = None
cmd_options = (
{
@@ -71,7 +61,7 @@ class CommandServe(Command):
'long': 'port',
'default': 8000,
'type': int,
- 'help': 'Port number (default: 8000)',
+ 'help': 'Port number',
},
{
'name': 'address',
@@ -79,7 +69,7 @@ class CommandServe(Command):
'long': 'address',
'type': str,
'default': '',
- 'help': 'Address to bind (default: 0.0.0.0 – all local IPv4 interfaces)',
+ 'help': 'Address to bind, defaults to all local IPv4 interfaces',
},
{
'name': 'detach',
@@ -107,13 +97,24 @@ class CommandServe(Command):
},
)
+ def shutdown(self, signum=None, _frame=None):
+ """Shut down the server that is running detached."""
+ if self.dns_sd:
+ self.dns_sd.Reset()
+ if os.path.exists(self.serve_pidfile):
+ os.remove(self.serve_pidfile)
+ if not self.detached:
+ self.logger.info("Server is shutting down.")
+ if signum:
+ sys.exit(0)
+
def _execute(self, options, args):
"""Start test server."""
- self.logger = get_logger('serve', STDERR_HANDLER)
out_dir = self.site.config['OUTPUT_FOLDER']
if not os.path.isdir(out_dir):
self.logger.error("Missing '{0}' folder?".format(out_dir))
else:
+ self.serve_pidfile = os.path.abspath('nikolaserve.pid')
os.chdir(out_dir)
if '[' in options['address']:
options['address'] = options['address'].strip('[').strip(']')
@@ -129,37 +130,47 @@ class CommandServe(Command):
httpd = OurHTTP((options['address'], options['port']),
OurHTTPRequestHandler)
sa = httpd.socket.getsockname()
- self.logger.info("Serving HTTP on {0} port {1}...".format(*sa))
+ if ipv6:
+ server_url = "http://[{0}]:{1}/".format(*sa)
+ else:
+ server_url = "http://{0}:{1}/".format(*sa)
+ self.logger.info("Serving on {0} ...".format(server_url))
+
if options['browser']:
- if ipv6:
- server_url = "http://[{0}]:{1}/".format(*sa)
- else:
- server_url = "http://{0}:{1}/".format(*sa)
+ # Some browsers fail to load 0.0.0.0 (Issue #2755)
+ if sa[0] == '0.0.0.0':
+ server_url = "http://127.0.0.1:{1}/".format(*sa)
self.logger.info("Opening {0} in the default web browser...".format(server_url))
webbrowser.open(server_url)
if options['detach']:
+ self.detached = True
OurHTTPRequestHandler.quiet = True
try:
pid = os.fork()
if pid == 0:
+ signal.signal(signal.SIGTERM, self.shutdown)
httpd.serve_forever()
else:
- self.logger.info("Detached with PID {0}. Run `kill {0}` to stop the server.".format(pid))
- except AttributeError as e:
+ with open(self.serve_pidfile, 'w') as fh:
+ fh.write('{0}\n'.format(pid))
+ self.logger.info("Detached with PID {0}. Run `kill {0}` or `kill $(cat nikolaserve.pid)` to stop the server.".format(pid))
+ except AttributeError:
if os.name == 'nt':
self.logger.warning("Detaching is not available on Windows, server is running in the foreground.")
else:
- raise e
+ raise
else:
+ self.detached = False
try:
+ self.dns_sd = dns_sd(options['port'], (options['ipv6'] or '::' in options['address']))
+ signal.signal(signal.SIGTERM, self.shutdown)
httpd.serve_forever()
except KeyboardInterrupt:
- self.logger.info("Server is shutting down.")
+ self.shutdown()
return 130
class OurHTTPRequestHandler(SimpleHTTPRequestHandler):
-
"""A request handler, modified for Nikola."""
extensions_map = dict(SimpleHTTPRequestHandler.extensions_map)
@@ -171,8 +182,7 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler):
if self.quiet:
return
else:
- # Old-style class in Python 2.7, cannot use super()
- return SimpleHTTPRequestHandler.log_message(self, *args)
+ return super().log_message(*args)
# NOTICE: this is a patched version of send_head() to disable all sorts of
# caching. `nikola serve` is a development server, hence caching should
@@ -184,9 +194,9 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler):
# Note that it might break in future versions of Python, in which case we
# would need to do even more magic.
def send_head(self):
- """Common code for GET and HEAD commands.
+ """Send response code and MIME header.
- This sends the response code and MIME headers.
+ This is common code for GET and HEAD commands.
Return value is either a file object (which has to be copied
to the outputfile by the caller unless the command was HEAD,
@@ -197,10 +207,12 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler):
path = self.translate_path(self.path)
f = None
if os.path.isdir(path):
- if not self.path.endswith('/'):
+ path_parts = list(self.path.partition('?'))
+ if not path_parts[0].endswith('/'):
# redirect browser - doing basically what apache does
+ path_parts[0] += '/'
self.send_response(301)
- self.send_header("Location", self.path + "/")
+ self.send_header("Location", ''.join(path_parts))
# begin no-cache patch
# For redirects. With redirects, caching is even worse and can
# break more. Especially with 301 Moved Permanently redirects,
@@ -226,7 +238,7 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler):
# transmitted *less* than the content-length!
f = open(path, 'rb')
except IOError:
- self.send_error(404, "File not found")
+ self.send_error(404, "File not found: {}".format(path))
return None
filtered_bytes = None
@@ -234,7 +246,7 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler):
# Comment out any to allow local resolution of relative URLs.
data = f.read().decode('utf8')
f.close()
- data = re.sub(r' ]*)>', '', data, re.IGNORECASE)
+ data = re.sub(r' ]*)>', r'', data, flags=re.IGNORECASE)
data = data.encode('utf8')
f = StringIO()
f.write(data)
@@ -242,7 +254,10 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler):
f.seek(0)
self.send_response(200)
- self.send_header("Content-type", ctype)
+ if ctype.startswith('text/') or ctype.endswith('+xml'):
+ self.send_header("Content-Type", "{0}; charset=UTF-8".format(ctype))
+ else:
+ self.send_header("Content-Type", ctype)
if os.path.splitext(path)[1] == '.svgz':
# Special handling for svgz to make it work nice with browsers.
self.send_header("Content-Encoding", 'gzip')
diff --git a/nikola/plugins/command/status.plugin b/nikola/plugins/command/status.plugin
index 91390d2..7e2bd96 100644
--- a/nikola/plugins/command/status.plugin
+++ b/nikola/plugins/command/status.plugin
@@ -9,5 +9,5 @@ website = https://getnikola.com
description = Site status
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/status.py b/nikola/plugins/command/status.py
index 55e7f95..c96d13f 100644
--- a/nikola/plugins/command/status.py
+++ b/nikola/plugins/command/status.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,8 +26,6 @@
"""Display site status."""
-from __future__ import print_function
-import io
import os
from datetime import datetime
from dateutil.tz import gettz, tzlocal
@@ -36,14 +34,13 @@ from nikola.plugin_categories import Command
class CommandStatus(Command):
-
"""Display site status."""
name = "status"
doc_purpose = "display site status"
doc_description = "Show information about the posts and site deployment."
- doc_usage = '[-l|--list-drafts] [-m|--list-modified] [-s|--list-scheduled]'
+ doc_usage = '[-d|--list-drafts] [-m|--list-modified] [-p|--list-private] [-P|--list-published] [-s|--list-scheduled]'
logger = None
cmd_options = [
{
@@ -62,6 +59,22 @@ class CommandStatus(Command):
'default': False,
'help': 'List all modified files since last deployment',
},
+ {
+ 'name': 'list_private',
+ 'short': 'p',
+ 'long': 'list-private',
+ 'type': bool,
+ 'default': False,
+ 'help': 'List all private posts',
+ },
+ {
+ 'name': 'list_published',
+ 'short': 'P',
+ 'long': 'list-published',
+ 'type': bool,
+ 'default': False,
+ 'help': 'List all published posts',
+ },
{
'name': 'list_scheduled',
'short': 's',
@@ -76,16 +89,12 @@ class CommandStatus(Command):
"""Display site status."""
self.site.scan_posts()
- timestamp_path = os.path.join(self.site.config["CACHE_FOLDER"], "lastdeploy")
-
- last_deploy = None
-
- try:
- with io.open(timestamp_path, "r", encoding="utf8") as inf:
- last_deploy = datetime.strptime(inf.read().strip(), "%Y-%m-%dT%H:%M:%S.%f")
- last_deploy_offset = datetime.utcnow() - last_deploy
- except (IOError, Exception):
- print("It does not seem like you’ve ever deployed the site (or cache missing).")
+ last_deploy = self.site.state.get('last_deploy')
+ if last_deploy is not None:
+ last_deploy = datetime.strptime(last_deploy, "%Y-%m-%dT%H:%M:%S.%f")
+ last_deploy_offset = datetime.utcnow() - last_deploy
+ else:
+ print("It does not seem like you've ever deployed the site (or cache missing).")
if last_deploy:
@@ -111,12 +120,23 @@ class CommandStatus(Command):
posts_count = len(self.site.all_posts)
+ # find all published posts
+ posts_published = [post for post in self.site.all_posts if post.use_in_feeds]
+ posts_published = sorted(posts_published, key=lambda post: post.source_path)
+
+ # find all private posts
+ posts_private = [post for post in self.site.all_posts if post.is_private]
+ posts_private = sorted(posts_private, key=lambda post: post.source_path)
+
# find all drafts
posts_drafts = [post for post in self.site.all_posts if post.is_draft]
posts_drafts = sorted(posts_drafts, key=lambda post: post.source_path)
# find all scheduled posts with offset from now until publishing time
- posts_scheduled = [(post.date - now, post) for post in self.site.all_posts if post.publish_later]
+ posts_scheduled = [
+ (post.date - now, post) for post in self.site.all_posts
+ if post.publish_later and not (post.is_draft or post.is_private)
+ ]
posts_scheduled = sorted(posts_scheduled, key=lambda offset_post: (offset_post[0], offset_post[1].source_path))
if len(posts_scheduled) > 0:
@@ -129,7 +149,13 @@ class CommandStatus(Command):
if options['list_drafts']:
for post in posts_drafts:
print("Draft: '{0}' ({1}; source: {2})".format(post.meta('title'), post.permalink(), post.source_path))
- print("{0} posts in total, {1} scheduled, and {2} drafts.".format(posts_count, len(posts_scheduled), len(posts_drafts)))
+ if options['list_private']:
+ for post in posts_private:
+ print("Private: '{0}' ({1}; source: {2})".format(post.meta('title'), post.permalink(), post.source_path))
+ if options['list_published']:
+ for post in posts_published:
+ print("Published: '{0}' ({1}; source: {2})".format(post.meta('title'), post.permalink(), post.source_path))
+ print("{0} posts in total, {1} scheduled, {2} drafts, {3} private and {4} published.".format(posts_count, len(posts_scheduled), len(posts_drafts), len(posts_private), len(posts_published)))
def human_time(self, dt):
"""Translate time into a human-friendly representation."""
diff --git a/nikola/plugins/command/subtheme.plugin b/nikola/plugins/command/subtheme.plugin
new file mode 100644
index 0000000..d377e22
--- /dev/null
+++ b/nikola/plugins/command/subtheme.plugin
@@ -0,0 +1,13 @@
+[Core]
+name = subtheme
+module = subtheme
+
+[Documentation]
+author = Roberto Alsina
+version = 1.1
+website = https://getnikola.com/
+description = Given a swatch name and a parent theme, creates a custom subtheme.
+
+[Nikola]
+PluginCategory = Command
+
diff --git a/nikola/plugins/command/subtheme.py b/nikola/plugins/command/subtheme.py
new file mode 100644
index 0000000..554a241
--- /dev/null
+++ b/nikola/plugins/command/subtheme.py
@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+
+# 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
+# documentation files (the "Software"), to deal in the
+# Software without restriction, including without limitation
+# the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the
+# Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice
+# shall be included in all copies or substantial portions of
+# the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
+# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
+# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+"""Given a swatch name from bootswatch.com or hackerthemes.com and a parent theme, creates a custom theme."""
+
+import configparser
+import os
+
+import requests
+
+from nikola import utils
+from nikola.plugin_categories import Command
+
+LOGGER = utils.get_logger('subtheme')
+
+
+def _check_for_theme(theme, themes):
+ for t in themes:
+ if t.endswith(os.sep + theme):
+ return True
+ return False
+
+
+class CommandSubTheme(Command):
+ """Given a swatch name from bootswatch.com and a parent theme, creates a custom theme."""
+
+ name = "subtheme"
+ doc_usage = "[options]"
+ doc_purpose = "given a swatch name from bootswatch.com or hackerthemes.com and a parent theme, creates a custom"\
+ " theme"
+ cmd_options = [
+ {
+ 'name': 'name',
+ 'short': 'n',
+ 'long': 'name',
+ 'default': 'custom',
+ 'type': str,
+ 'help': 'New theme name',
+ },
+ {
+ 'name': 'swatch',
+ 'short': 's',
+ 'default': '',
+ 'type': str,
+ 'help': 'Name of the swatch from bootswatch.com.'
+ },
+ {
+ 'name': 'parent',
+ 'short': 'p',
+ 'long': 'parent',
+ 'default': 'bootstrap4',
+ 'help': 'Parent theme name',
+ },
+ ]
+
+ def _execute(self, options, args):
+ """Given a swatch name and a parent theme, creates a custom theme."""
+ name = options['name']
+ swatch = options['swatch']
+ if not swatch:
+ LOGGER.error('The -s option is mandatory')
+ return 1
+ parent = options['parent']
+ version = '4'
+
+ # Check which Bootstrap version to use
+ themes = utils.get_theme_chain(parent, self.site.themes_dirs)
+ if _check_for_theme('bootstrap', themes) or _check_for_theme('bootstrap-jinja', themes):
+ version = '2'
+ elif _check_for_theme('bootstrap3', themes) or _check_for_theme('bootstrap3-jinja', themes):
+ version = '3'
+ elif _check_for_theme('bootstrap4', themes) or _check_for_theme('bootstrap4-jinja', themes):
+ version = '4'
+ elif not _check_for_theme('bootstrap4', themes) and not _check_for_theme('bootstrap4-jinja', themes):
+ LOGGER.warning(
+ '"subtheme" only makes sense for themes that use bootstrap')
+ elif _check_for_theme('bootstrap3-gradients', themes) or _check_for_theme('bootstrap3-gradients-jinja', themes):
+ LOGGER.warning(
+ '"subtheme" doesn\'t work well with the bootstrap3-gradients family')
+
+ LOGGER.info("Creating '{0}' theme from '{1}' and '{2}'".format(
+ name, swatch, parent))
+ utils.makedirs(os.path.join('themes', name, 'assets', 'css'))
+ for fname in ('bootstrap.min.css', 'bootstrap.css'):
+ if swatch in [
+ 'bubblegum', 'business-tycoon', 'charming', 'daydream',
+ 'executive-suite', 'good-news', 'growth', 'harbor', 'hello-world',
+ 'neon-glow', 'pleasant', 'retro', 'vibrant-sea', 'wizardry']: # Hackerthemes
+ LOGGER.info(
+ 'Hackertheme-based subthemes often require you use a custom font for full effect.')
+ if version != '4':
+ LOGGER.error(
+ 'The hackertheme subthemes are only available for Bootstrap 4.')
+ return 1
+ if fname == 'bootstrap.css':
+ url = 'https://raw.githubusercontent.com/HackerThemes/theme-machine/master/dist/{swatch}/css/bootstrap4-{swatch}.css'.format(
+ swatch=swatch)
+ else:
+ url = 'https://raw.githubusercontent.com/HackerThemes/theme-machine/master/dist/{swatch}/css/bootstrap4-{swatch}.min.css'.format(
+ swatch=swatch)
+ else: # Bootswatch
+ url = 'https://bootswatch.com'
+ if version:
+ url += '/' + version
+ url = '/'.join((url, swatch, fname))
+ LOGGER.info("Downloading: " + url)
+ r = requests.get(url)
+ if r.status_code > 299:
+ LOGGER.error('Error {} getting {}', r.status_code, url)
+ return 1
+ data = r.text
+
+ with open(os.path.join('themes', name, 'assets', 'css', fname),
+ 'w+') as output:
+ output.write(data)
+
+ with open(os.path.join('themes', name, '%s.theme' % name), 'w+') as output:
+ parent_theme_data_path = utils.get_asset_path(
+ '%s.theme' % parent, themes)
+ cp = configparser.ConfigParser()
+ cp.read(parent_theme_data_path)
+ cp['Theme']['parent'] = parent
+ cp['Family'] = {'family': cp['Family']['family']}
+ cp.write(output)
+
+ LOGGER.info(
+ 'Theme created. Change the THEME setting to "{0}" to use it.'.format(name))
diff --git a/nikola/plugins/command/theme.plugin b/nikola/plugins/command/theme.plugin
new file mode 100644
index 0000000..421d027
--- /dev/null
+++ b/nikola/plugins/command/theme.plugin
@@ -0,0 +1,13 @@
+[Core]
+name = theme
+module = theme
+
+[Documentation]
+author = Roberto Alsina and Chris Warrick
+version = 1.0
+website = https://getnikola.com/
+description = Manage Nikola themes
+
+[Nikola]
+PluginCategory = Command
+
diff --git a/nikola/plugins/command/theme.py b/nikola/plugins/command/theme.py
new file mode 100644
index 0000000..6f4339a
--- /dev/null
+++ b/nikola/plugins/command/theme.py
@@ -0,0 +1,393 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © 2012-2020 Roberto Alsina, Chris Warrick and others.
+
+# Permission is hereby granted, free of charge, to any
+# person obtaining a copy of this software and associated
+# documentation files (the "Software"), to deal in the
+# Software without restriction, including without limitation
+# the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the
+# Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice
+# shall be included in all copies or substantial portions of
+# the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
+# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
+# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+"""Manage themes."""
+
+import configparser
+import io
+import json.decoder
+import os
+import shutil
+import sys
+import time
+
+import requests
+import pygments
+from pygments.lexers import PythonLexer
+from pygments.formatters import TerminalFormatter
+from pkg_resources import resource_filename
+
+from nikola.plugin_categories import Command
+from nikola import utils
+
+LOGGER = utils.get_logger('theme')
+
+
+class CommandTheme(Command):
+ """Manage themes."""
+
+ json = None
+ name = "theme"
+ doc_usage = "[-u url] [-i theme_name] [-r theme_name] [-l] [--list-installed] [-g] [-n theme_name] [-c template_name]"
+ doc_purpose = "manage themes"
+ output_dir = 'themes'
+ cmd_options = [
+ {
+ 'name': 'install',
+ 'short': 'i',
+ 'long': 'install',
+ 'type': str,
+ 'default': '',
+ 'help': 'Install a theme.'
+ },
+ {
+ 'name': 'uninstall',
+ 'long': 'uninstall',
+ 'short': 'r',
+ 'type': str,
+ 'default': '',
+ 'help': 'Uninstall a theme.'
+ },
+ {
+ 'name': 'list',
+ 'short': 'l',
+ 'long': 'list',
+ 'type': bool,
+ 'default': False,
+ 'help': 'Show list of available themes.'
+ },
+ {
+ 'name': 'list_installed',
+ 'long': 'list-installed',
+ 'type': bool,
+ 'help': "List the installed themes with their location.",
+ 'default': False
+ },
+ {
+ 'name': 'url',
+ 'short': 'u',
+ 'long': 'url',
+ 'type': str,
+ 'help': "URL for the theme repository",
+ 'default': 'https://themes.getnikola.com/v8/themes.json'
+ },
+ {
+ 'name': 'getpath',
+ 'short': 'g',
+ 'long': 'get-path',
+ 'type': str,
+ 'default': '',
+ 'help': "Print the path for installed theme",
+ },
+ {
+ 'name': 'copy-template',
+ 'short': 'c',
+ 'long': 'copy-template',
+ 'type': str,
+ 'default': '',
+ 'help': 'Copy a built-in template into templates/ or your theme',
+ },
+ {
+ 'name': 'new',
+ 'short': 'n',
+ 'long': 'new',
+ 'type': str,
+ 'default': '',
+ 'help': 'Create a new theme',
+ },
+ {
+ 'name': 'new_engine',
+ 'long': 'engine',
+ 'type': str,
+ 'default': 'mako',
+ 'help': 'Engine to use for new theme (mako or jinja)',
+ },
+ {
+ 'name': 'new_parent',
+ 'long': 'parent',
+ 'type': str,
+ 'default': 'base',
+ 'help': 'Parent to use for new theme',
+ },
+ {
+ 'name': 'new_legacy_meta',
+ 'long': 'legacy-meta',
+ 'type': bool,
+ 'default': False,
+ 'help': 'Create legacy meta files for new theme',
+ },
+ ]
+
+ def _execute(self, options, args):
+ """Install theme into current site."""
+ url = options['url']
+
+ # See the "mode" we need to operate in
+ install = options.get('install')
+ uninstall = options.get('uninstall')
+ list_available = options.get('list')
+ list_installed = options.get('list_installed')
+ get_path = options.get('getpath')
+ copy_template = options.get('copy-template')
+ new = options.get('new')
+ new_engine = options.get('new_engine')
+ new_parent = options.get('new_parent')
+ new_legacy_meta = options.get('new_legacy_meta')
+ command_count = [bool(x) for x in (
+ install,
+ uninstall,
+ list_available,
+ list_installed,
+ get_path,
+ copy_template,
+ new)].count(True)
+ if command_count > 1 or command_count == 0:
+ print(self.help())
+ return 2
+
+ if list_available:
+ return self.list_available(url)
+ elif list_installed:
+ return self.list_installed()
+ elif install:
+ return self.do_install_deps(url, install)
+ elif uninstall:
+ return self.do_uninstall(uninstall)
+ elif get_path:
+ return self.get_path(get_path)
+ elif copy_template:
+ return self.copy_template(copy_template)
+ elif new:
+ return self.new_theme(new, new_engine, new_parent, new_legacy_meta)
+
+ def do_install_deps(self, url, name):
+ """Install themes and their dependencies."""
+ data = self.get_json(url)
+ # `name` may be modified by the while loop.
+ origname = name
+ installstatus = self.do_install(name, data)
+ # See if the theme's parent is available. If not, install it
+ while True:
+ parent_name = utils.get_parent_theme_name(utils.get_theme_path_real(name, self.site.themes_dirs))
+ if parent_name is None:
+ break
+ try:
+ utils.get_theme_path_real(parent_name, self.site.themes_dirs)
+ break
+ except Exception: # Not available
+ self.do_install(parent_name, data)
+ name = parent_name
+ if installstatus:
+ LOGGER.info('Remember to set THEME="{0}" in conf.py to use this theme.'.format(origname))
+
+ def do_install(self, name, data):
+ """Download and install a theme."""
+ if name in data:
+ utils.makedirs(self.output_dir)
+ url = data[name]
+ LOGGER.info("Downloading '{0}'".format(url))
+ try:
+ zip_data = requests.get(url).content
+ except requests.exceptions.SSLError:
+ LOGGER.warning("SSL error, using http instead of https (press ^C to abort)")
+ time.sleep(1)
+ url = url.replace('https', 'http', 1)
+ zip_data = requests.get(url).content
+
+ zip_file = io.BytesIO()
+ zip_file.write(zip_data)
+ LOGGER.info("Extracting '{0}' into themes/".format(name))
+ utils.extract_all(zip_file)
+ dest_path = os.path.join(self.output_dir, name)
+ else:
+ dest_path = os.path.join(self.output_dir, name)
+ try:
+ theme_path = utils.get_theme_path_real(name, self.site.themes_dirs)
+ LOGGER.error("Theme '{0}' is already installed in {1}".format(name, theme_path))
+ except Exception:
+ LOGGER.error("Can't find theme {0}".format(name))
+
+ return False
+
+ confpypath = os.path.join(dest_path, 'conf.py.sample')
+ if os.path.exists(confpypath):
+ LOGGER.warning('This theme has a sample config file. Integrate it with yours in order to make this theme work!')
+ print('Contents of the conf.py.sample file:\n')
+ with io.open(confpypath, 'r', encoding='utf-8-sig') as fh:
+ if self.site.colorful:
+ print(pygments.highlight(fh.read(), PythonLexer(), TerminalFormatter()))
+ else:
+ print(fh.read())
+ return True
+
+ def do_uninstall(self, name):
+ """Uninstall a theme."""
+ try:
+ path = utils.get_theme_path_real(name, self.site.themes_dirs)
+ except Exception:
+ LOGGER.error('Unknown theme: {0}'.format(name))
+ return 1
+ # Don't uninstall builtin themes (Issue #2510)
+ blocked = os.path.dirname(utils.__file__)
+ if path.startswith(blocked):
+ LOGGER.error("Can't delete builtin theme: {0}".format(name))
+ return 1
+ LOGGER.warning('About to uninstall theme: {0}'.format(name))
+ LOGGER.warning('This will delete {0}'.format(path))
+ sure = utils.ask_yesno('Are you sure?')
+ if sure:
+ LOGGER.warning('Removing {0}'.format(path))
+ shutil.rmtree(path)
+ return 0
+ return 1
+
+ def get_path(self, name):
+ """Get path for an installed theme."""
+ try:
+ path = utils.get_theme_path_real(name, self.site.themes_dirs)
+ print(path)
+ except Exception:
+ print("not installed")
+ return 0
+
+ def list_available(self, url):
+ """List all available themes."""
+ data = self.get_json(url)
+ print("Available Themes:")
+ print("-----------------")
+ for theme in sorted(data.keys()):
+ print(theme)
+ return 0
+
+ def list_installed(self):
+ """List all installed themes."""
+ print("Installed Themes:")
+ print("-----------------")
+ themes = []
+ themes_dirs = self.site.themes_dirs + [resource_filename('nikola', os.path.join('data', 'themes'))]
+ for tdir in themes_dirs:
+ if os.path.isdir(tdir):
+ themes += [(i, os.path.join(tdir, i)) for i in os.listdir(tdir)]
+
+ for tname, tpath in sorted(set(themes)):
+ if os.path.isdir(tpath):
+ print("{0} at {1}".format(tname, tpath))
+
+ def copy_template(self, template):
+ """Copy the named template file from the parent to a local theme or to templates/."""
+ # Find template
+ t = self.site.template_system.get_template_path(template)
+ if t is None:
+ LOGGER.error("Cannot find template {0} in the lookup.".format(template))
+ return 2
+
+ # Figure out where to put it.
+ # Check if a local theme exists.
+ theme_path = utils.get_theme_path(self.site.THEMES[0])
+ if theme_path.startswith('themes' + os.sep):
+ # Theme in local themes/ directory
+ base = os.path.join(theme_path, 'templates')
+ else:
+ # Put it in templates/
+ base = 'templates'
+
+ if not os.path.exists(base):
+ os.mkdir(base)
+ LOGGER.info("Created directory {0}".format(base))
+
+ try:
+ out = shutil.copy(t, base)
+ LOGGER.info("Copied template from {0} to {1}".format(t, out))
+ except shutil.SameFileError:
+ LOGGER.error("This file already exists in your templates directory ({0}).".format(base))
+ return 3
+
+ def new_theme(self, name, engine, parent, create_legacy_meta=False):
+ """Create a new theme."""
+ base = 'themes'
+ themedir = os.path.join(base, name)
+ LOGGER.info("Creating theme {0} with parent {1} and engine {2} in {3}".format(name, parent, engine, themedir))
+ if not os.path.exists(base):
+ os.mkdir(base)
+ LOGGER.info("Created directory {0}".format(base))
+
+ # Check if engine and parent match
+ parent_engine = utils.get_template_engine(utils.get_theme_chain(parent, self.site.themes_dirs))
+
+ if parent_engine != engine:
+ LOGGER.error("Cannot use engine {0} because parent theme '{1}' uses {2}".format(engine, parent, parent_engine))
+ return 2
+
+ # Create theme
+ if not os.path.exists(themedir):
+ os.mkdir(themedir)
+ LOGGER.info("Created directory {0}".format(themedir))
+ else:
+ LOGGER.error("Theme already exists")
+ return 2
+
+ cp = configparser.ConfigParser()
+ cp['Theme'] = {
+ 'engine': engine,
+ 'parent': parent
+ }
+
+ theme_meta_path = os.path.join(themedir, name + '.theme')
+ with io.open(theme_meta_path, 'w', encoding='utf-8') as fh:
+ cp.write(fh)
+ LOGGER.info("Created file {0}".format(theme_meta_path))
+
+ if create_legacy_meta:
+ with io.open(os.path.join(themedir, 'parent'), 'w', encoding='utf-8') as fh:
+ fh.write(parent + '\n')
+ LOGGER.info("Created file {0}".format(os.path.join(themedir, 'parent')))
+ with io.open(os.path.join(themedir, 'engine'), 'w', encoding='utf-8') as fh:
+ fh.write(engine + '\n')
+ LOGGER.info("Created file {0}".format(os.path.join(themedir, 'engine')))
+
+ LOGGER.info("Theme {0} created successfully.".format(themedir))
+ LOGGER.info('Remember to set THEME="{0}" in conf.py to use this theme.'.format(name))
+
+ def get_json(self, url):
+ """Download the JSON file with all plugins."""
+ if self.json is None:
+ try:
+ try:
+ self.json = requests.get(url).json()
+ except requests.exceptions.SSLError:
+ LOGGER.warning("SSL error, using http instead of https (press ^C to abort)")
+ time.sleep(1)
+ url = url.replace('https', 'http', 1)
+ self.json = requests.get(url).json()
+ except json.decoder.JSONDecodeError as e:
+ LOGGER.error("Failed to decode JSON data in response from server.")
+ LOGGER.error("JSON error encountered:" + str(e))
+ LOGGER.error("This issue might be caused by server-side issues, or by to unusual activity in your "
+ "network (as determined by CloudFlare). Please visit https://themes.getnikola.com/ in "
+ "a browser.")
+ sys.exit(2)
+
+ return self.json
diff --git a/nikola/plugins/command/version.plugin b/nikola/plugins/command/version.plugin
index 4708bdb..a172e28 100644
--- a/nikola/plugins/command/version.plugin
+++ b/nikola/plugins/command/version.plugin
@@ -5,9 +5,9 @@ module = version
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Show nikola version
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/version.py b/nikola/plugins/command/version.py
index ad08f64..9b81343 100644
--- a/nikola/plugins/command/version.py
+++ b/nikola/plugins/command/version.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,19 +26,16 @@
"""Print Nikola version."""
-from __future__ import print_function
-import lxml
import requests
from nikola.plugin_categories import Command
from nikola import __version__
-URL = 'https://pypi.python.org/pypi?:action=doap&name=Nikola'
+URL = 'https://pypi.org/pypi/Nikola/json'
class CommandVersion(Command):
-
"""Print Nikola version."""
name = "version"
@@ -61,10 +58,11 @@ class CommandVersion(Command):
"""Print the version number."""
print("Nikola v" + __version__)
if options.get('check'):
- data = requests.get(URL).text
- doc = lxml.etree.fromstring(data.encode('utf8'))
- revision = doc.findall('*//{http://usefulinc.com/ns/doap#}revision')[0].text
- if revision == __version__:
+ data = requests.get(URL).json()
+ pypi_version = data['info']['version']
+ if pypi_version == __version__:
print("Nikola is up-to-date")
else:
- print("The latest version of Nikola is v{0} -- please upgrade using `pip install --upgrade Nikola=={0}` or your system package manager".format(revision))
+ print("The latest version of Nikola is v{0}. Please upgrade "
+ "using `pip install --upgrade Nikola=={0}` or your "
+ "system package manager.".format(pypi_version))
diff --git a/nikola/plugins/compile/__init__.py b/nikola/plugins/compile/__init__.py
index 60f1919..db78fce 100644
--- a/nikola/plugins/compile/__init__.py
+++ b/nikola/plugins/compile/__init__.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
diff --git a/nikola/plugins/compile/html.plugin b/nikola/plugins/compile/html.plugin
index 53ade61..be1f876 100644
--- a/nikola/plugins/compile/html.plugin
+++ b/nikola/plugins/compile/html.plugin
@@ -5,9 +5,9 @@ module = html
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Compile HTML into HTML (just copy)
[Nikola]
-plugincategory = Compiler
+PluginCategory = Compiler
friendlyname = HTML
diff --git a/nikola/plugins/compile/html.py b/nikola/plugins/compile/html.py
index 5f8b244..80b6713 100644
--- a/nikola/plugins/compile/html.py
+++ b/nikola/plugins/compile/html.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
@@ -24,33 +24,48 @@
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""Implementation of compile_html for HTML source files."""
+"""Page compiler plugin for HTML source files."""
-from __future__ import unicode_literals
-import os
import io
+import os
+
+import lxml.html
+from nikola import shortcodes as sc
from nikola.plugin_categories import PageCompiler
-from nikola.utils import makedirs, write_metadata
+from nikola.utils import LocaleBorg, makedirs, map_metadata, write_metadata
class CompileHtml(PageCompiler):
-
"""Compile HTML into HTML."""
name = "html"
friendly_name = "HTML"
+ supports_metadata = True
+
+ def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None):
+ """Compile HTML into HTML strings, with shortcode support."""
+ if not is_two_file:
+ _, data = self.split_metadata(data, post, lang)
+ new_data, shortcodes = sc.extract_shortcodes(data)
+ return self.site.apply_shortcodes_uuid(new_data, shortcodes, filename=source_path, extra_context={'post': post})
- def compile_html(self, source, dest, is_two_file=True):
- """Compile source file into HTML and save as dest."""
+ def compile(self, source, dest, is_two_file=True, post=None, lang=None):
+ """Compile the source file into HTML and save as dest."""
makedirs(os.path.dirname(dest))
- with io.open(dest, "w+", encoding="utf8") as out_file:
- with io.open(source, "r", encoding="utf8") as in_file:
+ with io.open(dest, "w+", encoding="utf-8") as out_file:
+ with io.open(source, "r", encoding="utf-8-sig") as in_file:
data = in_file.read()
- if not is_two_file:
- _, data = self.split_metadata(data)
+ data, shortcode_deps = self.compile_string(data, source, is_two_file, post, lang)
out_file.write(data)
+ if post is None:
+ if shortcode_deps:
+ self.logger.error(
+ "Cannot save dependencies for post {0} (post unknown)",
+ source)
+ else:
+ post._depfile[dest] += shortcode_deps
return True
def create_post(self, path, **kw):
@@ -65,9 +80,41 @@ class CompileHtml(PageCompiler):
makedirs(os.path.dirname(path))
if not content.endswith('\n'):
content += '\n'
- with io.open(path, "w+", encoding="utf8") as fd:
+ with io.open(path, "w+", encoding="utf-8") as fd:
if onefile:
- fd.write('\n\n')
+ fd.write(write_metadata(metadata, comment_wrap=True, site=self.site, compiler=self))
fd.write(content)
+
+ def read_metadata(self, post, file_metadata_regexp=None, unslugify_titles=False, lang=None):
+ """Read the metadata from a post's meta tags, and return a metadata dict."""
+ if lang is None:
+ lang = LocaleBorg().current_lang
+ source_path = post.translated_source_path(lang)
+
+ with io.open(source_path, 'r', encoding='utf-8-sig') as inf:
+ data = inf.read()
+
+ metadata = {}
+ try:
+ doc = lxml.html.document_fromstring(data)
+ except lxml.etree.ParserError as e:
+ # Issue #374 -> #2851
+ if str(e) == "Document is empty":
+ return {}
+ # let other errors raise
+ raise
+ title_tag = doc.find('*//title')
+ if title_tag is not None and title_tag.text:
+ metadata['title'] = title_tag.text
+ meta_tags = doc.findall('*//meta')
+ for tag in meta_tags:
+ k = tag.get('name', '').lower()
+ if not k:
+ continue
+ elif k == 'keywords':
+ k = 'tags'
+ content = tag.get('content')
+ if content:
+ metadata[k] = content
+ map_metadata(metadata, 'html_metadata', self.site.config)
+ return metadata
diff --git a/nikola/plugins/compile/ipynb.plugin b/nikola/plugins/compile/ipynb.plugin
index c369ab2..c146172 100644
--- a/nikola/plugins/compile/ipynb.plugin
+++ b/nikola/plugins/compile/ipynb.plugin
@@ -6,8 +6,8 @@ module = ipynb
author = Damian Avila, Chris Warrick and others
version = 2.0.0
website = http://www.damian.oquanta.info/
-description = Compile IPython notebooks into Nikola posts
+description = Compile Jupyter notebooks into Nikola posts
[Nikola]
-plugincategory = Compiler
-friendlyname = Jupyter/IPython Notebook
+PluginCategory = Compiler
+friendlyname = Jupyter Notebook
diff --git a/nikola/plugins/compile/ipynb.py b/nikola/plugins/compile/ipynb.py
index a9dedde..039604b 100644
--- a/nikola/plugins/compile/ipynb.py
+++ b/nikola/plugins/compile/ipynb.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2013-2015 Damián Avila, Chris Warrick and others.
+# Copyright © 2013-2020 Damián Avila, Chris Warrick and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -24,76 +24,95 @@
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""Implementation of compile_html based on nbconvert."""
+"""Page compiler plugin for nbconvert."""
-from __future__ import unicode_literals, print_function
import io
+import json
import os
-import sys
try:
- import IPython
- from IPython.nbconvert.exporters import HTMLExporter
- if IPython.version_info[0] >= 3: # API changed with 3.0.0
- from IPython import nbformat
- current_nbformat = nbformat.current_nbformat
- from IPython.kernel import kernelspec
- else:
- import IPython.nbformat.current as nbformat
- current_nbformat = 'json'
- kernelspec = None
-
- from IPython.config import Config
+ import nbconvert
+ from nbconvert.exporters import HTMLExporter
+ import nbformat
+ current_nbformat = nbformat.current_nbformat
+ from jupyter_client import kernelspec
+ from traitlets.config import Config
+ NBCONVERT_VERSION_MAJOR = int(nbconvert.__version__.partition(".")[0])
flag = True
except ImportError:
flag = None
+from nikola import shortcodes as sc
from nikola.plugin_categories import PageCompiler
-from nikola.utils import makedirs, req_missing, get_logger, STDERR_HANDLER
+from nikola.utils import makedirs, req_missing, LocaleBorg
class CompileIPynb(PageCompiler):
-
"""Compile IPynb into HTML."""
name = "ipynb"
- friendly_name = "Jupyter/IPython Notebook"
+ friendly_name = "Jupyter Notebook"
demote_headers = True
- default_kernel = 'python2' if sys.version_info[0] == 2 else 'python3'
-
- def set_site(self, site):
- """Set Nikola site."""
- self.logger = get_logger('compile_ipynb', STDERR_HANDLER)
- super(CompileIPynb, self).set_site(site)
+ default_kernel = 'python3'
+ supports_metadata = True
- def compile_html_string(self, source, is_two_file=True):
+ def _compile_string(self, nb_json):
"""Export notebooks as HTML strings."""
- if flag is None:
- req_missing(['ipython[notebook]>=2.0.0'], 'build this site (compile ipynb)')
- HTMLExporter.default_template = 'basic'
- c = Config(self.site.config['IPYNB_CONFIG'])
+ self._req_missing_ipynb()
+ c = Config(get_default_jupyter_config())
+ c.merge(Config(self.site.config['IPYNB_CONFIG']))
+ if 'template_file' not in self.site.config['IPYNB_CONFIG'].get('Exporter', {}):
+ if NBCONVERT_VERSION_MAJOR >= 6:
+ c['Exporter']['template_file'] = 'classic/base.html.j2'
+ else:
+ c['Exporter']['template_file'] = 'basic.tpl' # not a typo
exportHtml = HTMLExporter(config=c)
- with io.open(source, "r", encoding="utf8") as in_file:
- nb_json = nbformat.read(in_file, current_nbformat)
- (body, resources) = exportHtml.from_notebook_node(nb_json)
+ body, _ = exportHtml.from_notebook_node(nb_json)
return body
- def compile_html(self, source, dest, is_two_file=True):
- """Compile source file into HTML and save as dest."""
+ @staticmethod
+ def _nbformat_read(in_file):
+ return nbformat.read(in_file, current_nbformat)
+
+ def _req_missing_ipynb(self):
+ if flag is None:
+ req_missing(['notebook>=4.0.0'], 'build this site (compile ipynb)')
+
+ def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None):
+ """Compile notebooks into HTML strings."""
+ new_data, shortcodes = sc.extract_shortcodes(data)
+ output = self._compile_string(nbformat.reads(new_data, current_nbformat))
+ return self.site.apply_shortcodes_uuid(output, shortcodes, filename=source_path, extra_context={'post': post})
+
+ def compile(self, source, dest, is_two_file=False, post=None, lang=None):
+ """Compile the source file into HTML and save as dest."""
makedirs(os.path.dirname(dest))
- with io.open(dest, "w+", encoding="utf8") as out_file:
- out_file.write(self.compile_html_string(source, is_two_file))
+ with io.open(dest, "w+", encoding="utf-8") as out_file:
+ with io.open(source, "r", encoding="utf-8-sig") as in_file:
+ nb_str = in_file.read()
+ output, shortcode_deps = self.compile_string(nb_str, source,
+ is_two_file, post,
+ lang)
+ out_file.write(output)
+ if post is None:
+ if shortcode_deps:
+ self.logger.error(
+ "Cannot save dependencies for post {0} (post unknown)",
+ source)
+ else:
+ post._depfile[dest] += shortcode_deps
- def read_metadata(self, post, file_metadata_regexp=None, unslugify_titles=False, lang=None):
+ def read_metadata(self, post, lang=None):
"""Read metadata directly from ipynb file.
- As ipynb file support arbitrary metadata as json, the metadata used by Nikola
+ As ipynb files support arbitrary metadata as json, the metadata used by Nikola
will be assume to be in the 'nikola' subfield.
"""
- if flag is None:
- req_missing(['ipython[notebook]>=2.0.0'], 'build this site (compile ipynb)')
- source = post.source_path
- with io.open(source, "r", encoding="utf8") as in_file:
+ self._req_missing_ipynb()
+ if lang is None:
+ lang = LocaleBorg().current_lang
+ source = post.translated_source_path(lang)
+ with io.open(source, "r", encoding="utf-8-sig") as in_file:
nb_json = nbformat.read(in_file, current_nbformat)
# Metadata might not exist in two-file posts or in hand-crafted
# .ipynb files.
@@ -101,11 +120,10 @@ class CompileIPynb(PageCompiler):
def create_post(self, path, **kw):
"""Create a new post."""
- if flag is None:
- req_missing(['ipython[notebook]>=2.0.0'], 'build this site (compile ipynb)')
+ self._req_missing_ipynb()
content = kw.pop('content', None)
onefile = kw.pop('onefile', False)
- kernel = kw.pop('ipython_kernel', None)
+ kernel = kw.pop('jupyter_kernel', None)
# is_page is not needed to create the file
kw.pop('is_page', False)
@@ -119,40 +137,52 @@ class CompileIPynb(PageCompiler):
# imported .ipynb file, guaranteed to start with "{" because it’s JSON.
nb = nbformat.reads(content, current_nbformat)
else:
- if IPython.version_info[0] >= 3:
- nb = nbformat.v4.new_notebook()
- nb["cells"] = [nbformat.v4.new_markdown_cell(content)]
- else:
- nb = nbformat.new_notebook()
- nb["worksheets"] = [nbformat.new_worksheet(cells=[nbformat.new_text_cell('markdown', [content])])]
-
- if kernelspec is not None:
- if kernel is None:
- kernel = self.default_kernel
- self.logger.notice('No kernel specified, assuming "{0}".'.format(kernel))
-
- IPYNB_KERNELS = {}
- ksm = kernelspec.KernelSpecManager()
- for k in ksm.find_kernel_specs():
- IPYNB_KERNELS[k] = ksm.get_kernel_spec(k).to_dict()
- IPYNB_KERNELS[k]['name'] = k
- del IPYNB_KERNELS[k]['argv']
-
- if kernel not in IPYNB_KERNELS:
- self.logger.error('Unknown kernel "{0}". Maybe you mispelled it?'.format(kernel))
- self.logger.info("Available kernels: {0}".format(", ".join(sorted(IPYNB_KERNELS))))
- raise Exception('Unknown kernel "{0}"'.format(kernel))
-
- nb["metadata"]["kernelspec"] = IPYNB_KERNELS[kernel]
- else:
- # Older IPython versions don’t need kernelspecs.
- pass
+ nb = nbformat.v4.new_notebook()
+ nb["cells"] = [nbformat.v4.new_markdown_cell(content)]
+
+ if kernel is None:
+ kernel = self.default_kernel
+ self.logger.warning('No kernel specified, assuming "{0}".'.format(kernel))
+
+ IPYNB_KERNELS = {}
+ ksm = kernelspec.KernelSpecManager()
+ for k in ksm.find_kernel_specs():
+ IPYNB_KERNELS[k] = ksm.get_kernel_spec(k).to_dict()
+ IPYNB_KERNELS[k]['name'] = k
+ del IPYNB_KERNELS[k]['argv']
+
+ if kernel not in IPYNB_KERNELS:
+ self.logger.error('Unknown kernel "{0}". Maybe you mispelled it?'.format(kernel))
+ self.logger.info("Available kernels: {0}".format(", ".join(sorted(IPYNB_KERNELS))))
+ raise Exception('Unknown kernel "{0}"'.format(kernel))
+
+ nb["metadata"]["kernelspec"] = IPYNB_KERNELS[kernel]
if onefile:
nb["metadata"]["nikola"] = metadata
- with io.open(path, "w+", encoding="utf8") as fd:
- if IPython.version_info[0] >= 3:
- nbformat.write(nb, fd, 4)
- else:
- nbformat.write(nb, fd, 'ipynb')
+ with io.open(path, "w+", encoding="utf-8") as fd:
+ nbformat.write(nb, fd, 4)
+
+
+def get_default_jupyter_config():
+ """Search default jupyter configuration location paths.
+
+ Return dictionary from configuration json files.
+ """
+ config = {}
+ from jupyter_core.paths import jupyter_config_path
+
+ for parent in jupyter_config_path():
+ try:
+ for file in os.listdir(parent):
+ if 'nbconvert' in file and file.endswith('.json'):
+ abs_path = os.path.join(parent, file)
+ with open(abs_path) as config_file:
+ config.update(json.load(config_file))
+ except OSError:
+ # some paths jupyter uses to find configurations
+ # may not exist
+ pass
+
+ return config
diff --git a/nikola/plugins/compile/markdown.plugin b/nikola/plugins/compile/markdown.plugin
index f7d11b1..85c67c3 100644
--- a/nikola/plugins/compile/markdown.plugin
+++ b/nikola/plugins/compile/markdown.plugin
@@ -5,9 +5,9 @@ module = markdown
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Compile Markdown into HTML
[Nikola]
-plugincategory = Compiler
+PluginCategory = Compiler
friendlyname = Markdown
diff --git a/nikola/plugins/compile/markdown/__init__.py b/nikola/plugins/compile/markdown/__init__.py
index c1425a1..74e8c75 100644
--- a/nikola/plugins/compile/markdown/__init__.py
+++ b/nikola/plugins/compile/markdown/__init__.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
@@ -24,59 +24,110 @@
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""Implementation of compile_html based on markdown."""
-
-from __future__ import unicode_literals
+"""Page compiler plugin for Markdown."""
import io
+import json
import os
+import threading
+
+from nikola import shortcodes as sc
+from nikola.plugin_categories import PageCompiler
+from nikola.utils import makedirs, req_missing, write_metadata, LocaleBorg, map_metadata
try:
- from markdown import markdown
+ from markdown import Markdown
except ImportError:
- markdown = None # NOQA
- nikola_extension = None
- gist_extension = None
- podcast_extension = None
+ Markdown = None
-from nikola.plugin_categories import PageCompiler
-from nikola.utils import makedirs, req_missing, write_metadata
+class ThreadLocalMarkdown(threading.local):
+ """Convert Markdown to HTML using per-thread Markdown objects.
-class CompileMarkdown(PageCompiler):
+ See discussion in #2661.
+ """
+
+ def __init__(self, extensions, extension_configs):
+ """Create a Markdown instance."""
+ self.markdown = Markdown(extensions=extensions, extension_configs=extension_configs, output_format="html5")
+
+ def convert(self, data):
+ """Convert data to HTML and reset internal state."""
+ result = self.markdown.convert(data)
+ try:
+ meta = {}
+ for k in self.markdown.Meta: # This reads everything as lists
+ meta[k.lower()] = ','.join(self.markdown.Meta[k])
+ except Exception:
+ meta = {}
+ self.markdown.reset()
+ return result, meta
+
+class CompileMarkdown(PageCompiler):
"""Compile Markdown into HTML."""
name = "markdown"
friendly_name = "Markdown"
demote_headers = True
- extensions = []
site = None
+ supports_metadata = False
def set_site(self, site):
"""Set Nikola site."""
- super(CompileMarkdown, self).set_site(site)
+ super().set_site(site)
self.config_dependencies = []
+ extensions = []
for plugin_info in self.get_compiler_extensions():
self.config_dependencies.append(plugin_info.name)
- self.extensions.append(plugin_info.plugin_object)
+ extensions.append(plugin_info.plugin_object)
plugin_info.plugin_object.short_help = plugin_info.description
- self.config_dependencies.append(str(sorted(site.config.get("MARKDOWN_EXTENSIONS"))))
-
- def compile_html(self, source, dest, is_two_file=True):
- """Compile source file into HTML and save as dest."""
- if markdown is None:
+ site_extensions = self.site.config.get("MARKDOWN_EXTENSIONS")
+ self.config_dependencies.append(str(sorted(site_extensions)))
+ extensions.extend(site_extensions)
+
+ site_extension_configs = self.site.config.get("MARKDOWN_EXTENSION_CONFIGS")
+ if site_extension_configs:
+ self.config_dependencies.append(json.dumps(site_extension_configs.values, sort_keys=True))
+
+ if Markdown is not None:
+ self.converters = {}
+ for lang in self.site.config['TRANSLATIONS']:
+ lang_extension_configs = site_extension_configs(lang) if site_extension_configs else {}
+ self.converters[lang] = ThreadLocalMarkdown(extensions, lang_extension_configs)
+ self.supports_metadata = 'markdown.extensions.meta' in extensions
+
+ def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None):
+ """Compile Markdown into HTML strings."""
+ if lang is None:
+ lang = LocaleBorg().current_lang
+ if Markdown is None:
+ req_missing(['markdown'], 'build this site (compile Markdown)')
+ if not is_two_file:
+ _, data = self.split_metadata(data, post, lang)
+ new_data, shortcodes = sc.extract_shortcodes(data)
+ output, _ = self.converters[lang].convert(new_data)
+ output, shortcode_deps = self.site.apply_shortcodes_uuid(output, shortcodes, filename=source_path, extra_context={'post': post})
+ return output, shortcode_deps
+
+ def compile(self, source, dest, is_two_file=True, post=None, lang=None):
+ """Compile the source file into HTML and save as dest."""
+ if Markdown is None:
req_missing(['markdown'], 'build this site (compile Markdown)')
makedirs(os.path.dirname(dest))
- self.extensions += self.site.config.get("MARKDOWN_EXTENSIONS")
- with io.open(dest, "w+", encoding="utf8") as out_file:
- with io.open(source, "r", encoding="utf8") as in_file:
+ with io.open(dest, "w+", encoding="utf-8") as out_file:
+ with io.open(source, "r", encoding="utf-8-sig") as in_file:
data = in_file.read()
- if not is_two_file:
- _, data = self.split_metadata(data)
- output = markdown(data, self.extensions)
+ output, shortcode_deps = self.compile_string(data, source, is_two_file, post, lang)
out_file.write(output)
+ if post is None:
+ if shortcode_deps:
+ self.logger.error(
+ "Cannot save dependencies for post {0} (post unknown)",
+ source)
+ else:
+ post._depfile[dest] += shortcode_deps
def create_post(self, path, **kw):
"""Create a new post."""
@@ -91,9 +142,30 @@ class CompileMarkdown(PageCompiler):
makedirs(os.path.dirname(path))
if not content.endswith('\n'):
content += '\n'
- with io.open(path, "w+", encoding="utf8") as fd:
+ with io.open(path, "w+", encoding="utf-8") as fd:
if onefile:
- fd.write('\n\n')
+ fd.write(write_metadata(metadata, comment_wrap=True, site=self.site, compiler=self))
fd.write(content)
+
+ def read_metadata(self, post, lang=None):
+ """Read the metadata from a post, and return a metadata dict."""
+ lang = lang or self.site.config['DEFAULT_LANG']
+ if not self.supports_metadata:
+ return {}
+ if Markdown is None:
+ req_missing(['markdown'], 'build this site (compile Markdown)')
+ if lang is None:
+ lang = LocaleBorg().current_lang
+ source = post.translated_source_path(lang)
+ with io.open(source, 'r', encoding='utf-8-sig') as inf:
+ # Note: markdown meta returns lowercase keys
+ data = inf.read()
+ # If the metadata starts with "---" it's actually YAML and
+ # we should not let markdown parse it, because it will do
+ # bad things like setting empty tags to "''"
+ if data.startswith('---\n'):
+ return {}
+ _, meta = self.converters[lang].convert(data)
+ # Map metadata from other platforms to names Nikola expects (Issue #2817)
+ map_metadata(meta, 'markdown_metadata', self.site.config)
+ return meta
diff --git a/nikola/plugins/compile/markdown/mdx_gist.plugin b/nikola/plugins/compile/markdown/mdx_gist.plugin
index 7fe676c..f962cb7 100644
--- a/nikola/plugins/compile/markdown/mdx_gist.plugin
+++ b/nikola/plugins/compile/markdown/mdx_gist.plugin
@@ -4,11 +4,11 @@ module = mdx_gist
[Nikola]
compiler = markdown
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Roberto Alsina
version = 0.1
-website = http://getnikola.com
+website = https://getnikola.com/
description = Extension for embedding gists
diff --git a/nikola/plugins/compile/markdown/mdx_gist.py b/nikola/plugins/compile/markdown/mdx_gist.py
index f439fa2..f6ce20a 100644
--- a/nikola/plugins/compile/markdown/mdx_gist.py
+++ b/nikola/plugins/compile/markdown/mdx_gist.py
@@ -22,7 +22,7 @@
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# Warning: URL formats of "raw" gists are undocummented and subject to change.
-# See also: http://developer.github.com/v3/gists/
+# See also: https://developer.github.com/v3/gists/
#
# Inspired by "[Python] reStructuredText GitHub Gist directive"
# (https://gist.github.com/brianhsu/1407759), public domain by Brian Hsu
@@ -31,164 +31,54 @@ Extension to Python Markdown for Embedded Gists (gist.github.com).
Basic Example:
- >>> import markdown
- >>> text = '''
- ... Text of the gist:
- ... [:gist: 4747847]
- ... '''
- >>> html = markdown.markdown(text, [GistExtension()])
- >>> print(html)
-
Text of the gist: -
Text of the gist: -
Text of the gist: -
Text of the gist: -
Text of the gist: -
Text of the gist: -
Text of the gist: -
Text of the gist: -
Text of the gist: -
(.*?)
for ``double backticks``. (Code and extra logic based on html4css1 translator)
+def visit_literal(self, node):
+ """Output for double backticks."""
+ # special case: "code" role
+ classes = node.get('classes', [])
+ if 'code' in classes:
+ # filter 'code' from class arguments
+ node['classes'] = [cls for cls in classes if cls != 'code']
+ self.body.append(self.starttag(node, 'code', ''))
+ return
+ self.body.append(
+ self.starttag(node, 'code', '', CLASS='docutils literal'))
+ text = node.astext()
+ for token in self.words_and_spaces.findall(text):
+ if token.strip():
+ # Protect text like "--an-option" and the regular expression
+ # ``[+]?(\d+(\.\d*)?|\.\d+)`` from bad line wrapping
+ if self.in_word_wrap_point.search(token):
+ self.body.append('%s'
+ % self.encode(token))
+ else:
+ self.body.append(self.encode(token))
+ elif token in ('\n', ' '):
+ # Allow breaks at whitespace:
+ self.body.append(token)
+ else:
+ # Protect runs of multiple spaces; the last space can wrap:
+ self.body.append(' ' * (len(token) - 1) + ' ')
+ self.body.append('')
+ # Content already processed:
+ raise docutils.nodes.SkipNode
+
+
+setattr(docutils.writers.html5_polyglot.HTMLTranslator, 'visit_literal', visit_literal)
def rst2html(source, source_path=None, source_class=docutils.io.StringInput,
destination_path=None, reader=None,
parser=None, parser_name='restructuredtext', writer=None,
- writer_name='html', settings=None, settings_spec=None,
- settings_overrides=None, config_section=None,
+ writer_name='html5_polyglot', settings=None, settings_spec=None,
+ settings_overrides=None, config_section='nikola',
enable_exit_status=None, logger=None, l_add_ln=0, transforms=None):
"""Set up & run a ``Publisher``, and return a dictionary of document parts.
@@ -249,20 +353,22 @@ def rst2html(source, source_path=None, source_class=docutils.io.StringInput,
publish_parts(..., settings_overrides={'input_encoding': 'unicode'})
- Parameters: see `publish_programmatically`.
+ For a description of the parameters, see `publish_programmatically`.
WARNING: `reader` should be None (or NikolaReader()) if you want Nikola to report
reStructuredText syntax errors.
"""
if reader is None:
- reader = NikolaReader(transforms=transforms)
# For our custom logging, we have special needs and special settings we
# specify here.
# logger a logger from Nikola
# source source filename (docutils gets a string)
- # add_ln amount of metadata lines (see comment in compile_html above)
- reader.l_settings = {'logger': logger, 'source': source_path,
- 'add_ln': l_add_ln}
+ # add_ln amount of metadata lines (see comment in CompileRest.compile above)
+ reader = NikolaReader(transforms=transforms,
+ nikola_logging_settings={
+ 'logger': logger, 'source': source_path,
+ 'add_ln': l_add_ln
+ })
pub = docutils.core.Publisher(reader, parser, writer, settings=settings,
source_class=source_class,
@@ -275,4 +381,23 @@ def rst2html(source, source_path=None, source_class=docutils.io.StringInput,
pub.set_destination(None, destination_path)
pub.publish(enable_exit_status=enable_exit_status)
- return pub.writer.parts['docinfo'] + pub.writer.parts['fragment'], pub.document.reporter.max_level, pub.settings.record_dependencies
+ return pub.writer.parts['docinfo'] + pub.writer.parts['fragment'], pub.document.reporter.max_level, pub.settings.record_dependencies, pub.document
+
+
+# Alignment helpers for extensions
+_align_options_base = ('left', 'center', 'right')
+
+
+def _align_choice(argument):
+ return docutils.parsers.rst.directives.choice(argument, _align_options_base + ("none", ""))
+
+
+class RemoveDocinfo(docutils.transforms.Transform):
+ """Remove docinfo nodes."""
+
+ default_priority = 870
+
+ def apply(self):
+ """Remove docinfo nodes."""
+ for node in self.document.traverse(docutils.nodes.docinfo):
+ node.parent.remove(node)
diff --git a/nikola/plugins/compile/rest/chart.plugin b/nikola/plugins/compile/rest/chart.plugin
index 438abe4..4434477 100644
--- a/nikola/plugins/compile/rest/chart.plugin
+++ b/nikola/plugins/compile/rest/chart.plugin
@@ -4,11 +4,11 @@ module = chart
[Nikola]
compiler = rest
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Roberto Alsina
version = 0.1
-website = http://getnikola.com
+website = https://getnikola.com/
description = Chart directive based in PyGal
diff --git a/nikola/plugins/compile/rest/chart.py b/nikola/plugins/compile/rest/chart.py
index 88fdff3..17363cb 100644
--- a/nikola/plugins/compile/rest/chart.py
+++ b/nikola/plugins/compile/rest/chart.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
@@ -23,27 +23,22 @@
# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
"""Chart directive for reSTructuredText."""
-from ast import literal_eval
-
from docutils import nodes
from docutils.parsers.rst import Directive, directives
+from nikola.plugin_categories import RestExtension
+
try:
import pygal
except ImportError:
- pygal = None # NOQA
-
-from nikola.plugin_categories import RestExtension
-from nikola.utils import req_missing
+ pygal = None
_site = None
class Plugin(RestExtension):
-
"""Plugin for chart role."""
name = "rest_chart"
@@ -53,11 +48,10 @@ class Plugin(RestExtension):
global _site
_site = self.site = site
directives.register_directive('chart', Chart)
- return super(Plugin, self).set_site(site)
+ return super().set_site(site)
class Chart(Directive):
-
"""reStructuredText extension for inserting charts as SVG.
Usage:
@@ -74,52 +68,69 @@ class Chart(Directive):
has_content = True
required_arguments = 1
option_spec = {
- "copy": directives.unchanged,
+ "box_mode": directives.unchanged,
+ "classes": directives.unchanged,
"css": directives.unchanged,
+ "defs": directives.unchanged,
+ "data_file": directives.unchanged,
"disable_xml_declaration": directives.unchanged,
"dots_size": directives.unchanged,
+ "dynamic_print_values": directives.unchanged,
"explicit_size": directives.unchanged,
"fill": directives.unchanged,
- "font_sizes": directives.unchanged,
+ "force_uri_protocol": directives.unchanged,
+ "half_pie": directives.unchanged,
"height": directives.unchanged,
"human_readable": directives.unchanged,
"include_x_axis": directives.unchanged,
+ "inner_radius": directives.unchanged,
"interpolate": directives.unchanged,
"interpolation_parameters": directives.unchanged,
"interpolation_precision": directives.unchanged,
+ "inverse_y_axis": directives.unchanged,
"js": directives.unchanged,
- "label_font_size": directives.unchanged,
"legend_at_bottom": directives.unchanged,
+ "legend_at_bottom_columns": directives.unchanged,
"legend_box_size": directives.unchanged,
- "legend_font_size": directives.unchanged,
"logarithmic": directives.unchanged,
- "major_label_font_size": directives.unchanged,
"margin": directives.unchanged,
- "no_data_font_size": directives.unchanged,
+ "margin_bottom": directives.unchanged,
+ "margin_left": directives.unchanged,
+ "margin_right": directives.unchanged,
+ "margin_top": directives.unchanged,
+ "max_scale": directives.unchanged,
+ "min_scale": directives.unchanged,
+ "missing_value_fill_truncation": directives.unchanged,
"no_data_text": directives.unchanged,
"no_prefix": directives.unchanged,
"order_min": directives.unchanged,
"pretty_print": directives.unchanged,
+ "print_labels": directives.unchanged,
"print_values": directives.unchanged,
+ "print_values_position": directives.unchanged,
"print_zeroes": directives.unchanged,
"range": directives.unchanged,
"rounded_bars": directives.unchanged,
+ "secondary_range": directives.unchanged,
"show_dots": directives.unchanged,
"show_legend": directives.unchanged,
"show_minor_x_labels": directives.unchanged,
+ "show_minor_y_labels": directives.unchanged,
+ "show_only_major_dots": directives.unchanged,
+ "show_x_guides": directives.unchanged,
+ "show_x_labels": directives.unchanged,
+ "show_y_guides": directives.unchanged,
"show_y_labels": directives.unchanged,
"spacing": directives.unchanged,
+ "stack_from_top": directives.unchanged,
"strict": directives.unchanged,
"stroke": directives.unchanged,
+ "stroke_style": directives.unchanged,
"style": directives.unchanged,
"title": directives.unchanged,
- "title_font_size": directives.unchanged,
- "to_dict": directives.unchanged,
"tooltip_border_radius": directives.unchanged,
- "tooltip_font_size": directives.unchanged,
"truncate_label": directives.unchanged,
"truncate_legend": directives.unchanged,
- "value_font_size": directives.unchanged,
"value_formatter": directives.unchanged,
"width": directives.unchanged,
"x_label_rotation": directives.unchanged,
@@ -128,37 +139,23 @@ class Chart(Directive):
"x_labels_major_count": directives.unchanged,
"x_labels_major_every": directives.unchanged,
"x_title": directives.unchanged,
+ "x_value_formatter": directives.unchanged,
+ "xrange": directives.unchanged,
"y_label_rotation": directives.unchanged,
"y_labels": directives.unchanged,
+ "y_labels_major": directives.unchanged,
+ "y_labels_major_count": directives.unchanged,
+ "y_labels_major_every": directives.unchanged,
"y_title": directives.unchanged,
"zero": directives.unchanged,
}
def run(self):
"""Run the directive."""
- if pygal is None:
- msg = req_missing(['pygal'], 'use the Chart directive', optional=True)
- return [nodes.raw('', '{0}'.format(msg), format='html')]
- options = {}
- if 'style' in self.options:
- style_name = self.options.pop('style')
- else:
- style_name = 'BlueStyle'
- if '(' in style_name: # Parametric style
- style = eval('pygal.style.' + style_name)
- else:
- style = getattr(pygal.style, style_name)
- for k, v in self.options.items():
- options[k] = literal_eval(v)
-
- chart = getattr(pygal, self.arguments[0])(style=style)
- chart.config(**options)
- for line in self.content:
- label, series = literal_eval('({0})'.format(line))
- chart.add(label, series)
- data = chart.render().decode('utf8')
- if _site and _site.invariant:
- import re
- data = re.sub('id="chart-[a-f0-9\-]+"', 'id="chart-foobar"', data)
- data = re.sub('#chart-[a-f0-9\-]+', '#chart-foobar', data)
- return [nodes.raw('', data, format='html')]
+ self.options['site'] = None
+ html = _site.plugin_manager.getPluginByName(
+ 'chart', 'ShortcodePlugin').plugin_object.handler(
+ self.arguments[0],
+ data='\n'.join(self.content),
+ **self.options)
+ return [nodes.raw('', html, format='html')]
diff --git a/nikola/plugins/compile/rest/doc.plugin b/nikola/plugins/compile/rest/doc.plugin
index facdd03..3b5c9c7 100644
--- a/nikola/plugins/compile/rest/doc.plugin
+++ b/nikola/plugins/compile/rest/doc.plugin
@@ -4,11 +4,11 @@ module = doc
[Nikola]
compiler = rest
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Manuel Kaufmann
version = 0.1
-website = http://getnikola.com
+website = https://getnikola.com/
description = Role to link another page / post from the blog
diff --git a/nikola/plugins/compile/rest/doc.py b/nikola/plugins/compile/rest/doc.py
index 99cce81..705c0bc 100644
--- a/nikola/plugins/compile/rest/doc.py
+++ b/nikola/plugins/compile/rest/doc.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
@@ -29,12 +29,11 @@
from docutils import nodes
from docutils.parsers.rst import roles
-from nikola.utils import split_explicit_title
+from nikola.utils import split_explicit_title, LOGGER, slugify
from nikola.plugin_categories import RestExtension
class Plugin(RestExtension):
-
"""Plugin for doc role."""
name = 'rest_doc'
@@ -43,16 +42,13 @@ class Plugin(RestExtension):
"""Set Nikola site."""
self.site = site
roles.register_canonical_role('doc', doc_role)
+ self.site.register_shortcode('doc', doc_shortcode)
doc_role.site = site
- return super(Plugin, self).set_site(site)
+ return super().set_site(site)
-def doc_role(name, rawtext, text, lineno, inliner,
- options={}, content=[]):
- """Handle the doc role."""
- # split link's text and post's slug in role content
- has_explicit_title, title, slug = split_explicit_title(text)
- # check if the slug given is part of our blog posts/pages
+def _find_post(slug):
+ """Find a post with the given slug in posts or pages."""
twin_slugs = False
post = None
for p in doc_role.site.timeline:
@@ -62,27 +58,72 @@ def doc_role(name, rawtext, text, lineno, inliner,
else:
twin_slugs = True
break
+ return post, twin_slugs
+
+
+def _doc_link(rawtext, text, options={}, content=[]):
+ """Handle the doc role."""
+ # split link's text and post's slug in role content
+ has_explicit_title, title, slug = split_explicit_title(text)
+ if '#' in slug:
+ slug, fragment = slug.split('#', 1)
+ else:
+ fragment = None
+
+ # Look for the unslugified input first, then try to slugify (Issue #3450)
+ post, twin_slugs = _find_post(slug)
+ if post is None:
+ slug = slugify(slug)
+ post, twin_slugs = _find_post(slug)
try:
if post is None:
- raise ValueError
+ raise ValueError("No post with matching slug found.")
except ValueError:
+ return False, False, None, None, slug
+
+ if not has_explicit_title:
+ # use post's title as link's text
+ title = post.title()
+ permalink = post.permalink()
+ if fragment:
+ permalink += '#' + fragment
+
+ return True, twin_slugs, title, permalink, slug
+
+
+def doc_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
+ """Handle the doc role."""
+ success, twin_slugs, title, permalink, slug = _doc_link(rawtext, text, options, content)
+ if success:
+ if twin_slugs:
+ inliner.reporter.warning(
+ 'More than one post with the same slug. Using "{0}"'.format(permalink))
+ LOGGER.warning(
+ 'More than one post with the same slug. Using "{0}" for doc role'.format(permalink))
+ node = make_link_node(rawtext, title, permalink, options)
+ return [node], []
+ else:
msg = inliner.reporter.error(
'"{0}" slug doesn\'t exist.'.format(slug),
line=lineno)
prb = inliner.problematic(rawtext, rawtext, msg)
return [prb], [msg]
- if not has_explicit_title:
- # use post's title as link's text
- title = post.title()
- permalink = post.permalink()
- if twin_slugs:
- msg = inliner.reporter.warning(
- 'More than one post with the same slug. Using "{0}"'.format(permalink))
- node = make_link_node(rawtext, title, permalink, options)
- return [node], []
+def doc_shortcode(*args, **kwargs):
+ """Implement the doc shortcode."""
+ text = kwargs['data']
+ success, twin_slugs, title, permalink, slug = _doc_link(text, text, LOGGER)
+ if success:
+ if twin_slugs:
+ LOGGER.warning(
+ 'More than one post with the same slug. Using "{0}" for doc shortcode'.format(permalink))
+ return '{1}'.format(permalink, title)
+ else:
+ LOGGER.error(
+ '"{0}" slug doesn\'t exist.'.format(slug))
+ return 'Invalid link: {0}'.format(text)
def make_link_node(rawtext, text, url, options):
diff --git a/nikola/plugins/compile/rest/gist.plugin b/nikola/plugins/compile/rest/gist.plugin
index 9fa2e82..4a8a3a7 100644
--- a/nikola/plugins/compile/rest/gist.plugin
+++ b/nikola/plugins/compile/rest/gist.plugin
@@ -4,11 +4,11 @@ module = gist
[Nikola]
compiler = rest
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Roberto Alsina
version = 0.1
-website = http://getnikola.com
+website = https://getnikola.com/
description = Gist directive
diff --git a/nikola/plugins/compile/rest/gist.py b/nikola/plugins/compile/rest/gist.py
index 736ee37..08aa46d 100644
--- a/nikola/plugins/compile/rest/gist.py
+++ b/nikola/plugins/compile/rest/gist.py
@@ -11,7 +11,6 @@ from nikola.plugin_categories import RestExtension
class Plugin(RestExtension):
-
"""Plugin for gist directive."""
name = "rest_gist"
@@ -20,11 +19,10 @@ class Plugin(RestExtension):
"""Set Nikola site."""
self.site = site
directives.register_directive('gist', GitHubGist)
- return super(Plugin, self).set_site(site)
+ return super().set_site(site)
class GitHubGist(Directive):
-
"""Embed GitHub Gist.
Usage:
diff --git a/nikola/plugins/compile/rest/listing.plugin b/nikola/plugins/compile/rest/listing.plugin
index 85c780f..5239f92 100644
--- a/nikola/plugins/compile/rest/listing.plugin
+++ b/nikola/plugins/compile/rest/listing.plugin
@@ -4,11 +4,11 @@ module = listing
[Nikola]
compiler = rest
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Roberto Alsina
version = 0.1
-website = http://getnikola.com
+website = https://getnikola.com/
description = Extension for source listings
diff --git a/nikola/plugins/compile/rest/listing.py b/nikola/plugins/compile/rest/listing.py
index 4871bf3..e5a73fa 100644
--- a/nikola/plugins/compile/rest/listing.py
+++ b/nikola/plugins/compile/rest/listing.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
@@ -28,26 +28,21 @@
"""Define and register a listing directive using the existing CodeBlock."""
-from __future__ import unicode_literals
import io
import os
import uuid
-try:
- from urlparse import urlunsplit
-except ImportError:
- from urllib.parse import urlunsplit # NOQA
+from urllib.parse import urlunsplit
import docutils.parsers.rst.directives.body
import docutils.parsers.rst.directives.misc
+import pygments
+import pygments.util
from docutils import core
from docutils import nodes
from docutils.parsers.rst import Directive, directives
from docutils.parsers.rst.roles import set_classes
from docutils.parsers.rst.directives.misc import Include
-
from pygments.lexers import get_lexer_by_name
-import pygments
-import pygments.util
from nikola import utils
from nikola.plugin_categories import RestExtension
@@ -55,7 +50,6 @@ from nikola.plugin_categories import RestExtension
# A sanitized version of docutils.parsers.rst.directives.body.CodeBlock.
class CodeBlock(Directive):
-
"""Parse and mark up content of a code block."""
optional_arguments = 1
@@ -120,13 +114,13 @@ class CodeBlock(Directive):
return [node]
+
# Monkey-patch: replace insane docutils CodeBlock with our implementation.
docutils.parsers.rst.directives.body.CodeBlock = CodeBlock
docutils.parsers.rst.directives.misc.CodeBlock = CodeBlock
class Plugin(RestExtension):
-
"""Plugin for listing directive."""
name = "rest_listing"
@@ -138,12 +132,13 @@ class Plugin(RestExtension):
# leaving these to make the code directive work with
# docutils < 0.9
CodeBlock.site = site
+ Listing.site = site
directives.register_directive('code', CodeBlock)
directives.register_directive('code-block', CodeBlock)
directives.register_directive('sourcecode', CodeBlock)
directives.register_directive('listing', Listing)
Listing.folders = site.config['LISTINGS_FOLDERS']
- return super(Plugin, self).set_site(site)
+ return super().set_site(site)
# Add sphinx compatibility option
@@ -152,7 +147,6 @@ listing_spec['linenos'] = directives.unchanged
class Listing(Include):
-
"""Create a highlighted block of code from a file in listings/.
Usage:
@@ -171,7 +165,12 @@ class Listing(Include):
"""Run listing directive."""
_fname = self.arguments.pop(0)
fname = _fname.replace('/', os.sep)
- lang = self.arguments.pop(0)
+ try:
+ lang = self.arguments.pop(0)
+ self.options['code'] = lang
+ except IndexError:
+ self.options['literal'] = True
+
if len(self.folders) == 1:
listings_folder = next(iter(self.folders.keys()))
if fname.startswith(listings_folder):
@@ -181,22 +180,27 @@ class Listing(Include):
else:
fpath = os.path.join(fname) # must be new syntax: specify folder name
self.arguments.insert(0, fpath)
- self.options['code'] = lang
if 'linenos' in self.options:
self.options['number-lines'] = self.options['linenos']
- with io.open(fpath, 'r+', encoding='utf8') as fileobject:
+ with io.open(fpath, 'r+', encoding='utf-8-sig') as fileobject:
self.content = fileobject.read().splitlines()
self.state.document.settings.record_dependencies.add(fpath)
target = urlunsplit(("link", 'listing', fpath.replace('\\', '/'), '', ''))
+ src_target = urlunsplit(("link", 'listing_source', fpath.replace('\\', '/'), '', ''))
+ src_label = self.site.MESSAGES('Source')
generated_nodes = (
- [core.publish_doctree('`{0} <{1}>`_'.format(_fname, target))[0]])
+ [core.publish_doctree('`{0} <{1}>`_ `({2}) <{3}>`_' .format(
+ _fname, target, src_label, src_target))[0]])
generated_nodes += self.get_code_from_file(fileobject)
return generated_nodes
def get_code_from_file(self, data):
"""Create CodeBlock nodes from file object content."""
- return super(Listing, self).run()
+ return super().run()
def assert_has_content(self):
- """Listing has no content, override check from superclass."""
+ """Override check from superclass with nothing.
+
+ Listing has no content, override check from superclass.
+ """
pass
diff --git a/nikola/plugins/compile/rest/media.plugin b/nikola/plugins/compile/rest/media.plugin
index 9803c8f..396c2f9 100644
--- a/nikola/plugins/compile/rest/media.plugin
+++ b/nikola/plugins/compile/rest/media.plugin
@@ -4,11 +4,11 @@ module = media
[Nikola]
compiler = rest
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Roberto Alsina
version = 0.1
-website = http://getnikola.com
+website = https://getnikola.com/
description = Directive to support oembed via micawber
diff --git a/nikola/plugins/compile/rest/media.py b/nikola/plugins/compile/rest/media.py
index 345e331..d29d0a2 100644
--- a/nikola/plugins/compile/rest/media.py
+++ b/nikola/plugins/compile/rest/media.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
@@ -29,18 +29,16 @@
from docutils import nodes
from docutils.parsers.rst import Directive, directives
+from nikola.plugin_categories import RestExtension
+from nikola.utils import req_missing
+
try:
import micawber
except ImportError:
- micawber = None # NOQA
-
-
-from nikola.plugin_categories import RestExtension
-from nikola.utils import req_missing
+ micawber = None
class Plugin(RestExtension):
-
"""Plugin for reST media directive."""
name = "rest_media"
@@ -49,11 +47,11 @@ class Plugin(RestExtension):
"""Set Nikola site."""
self.site = site
directives.register_directive('media', Media)
- return super(Plugin, self).set_site(site)
+ self.site.register_shortcode('media', _gen_media_embed)
+ return super().set_site(site)
class Media(Directive):
-
"""reST extension for inserting any sort of media using micawber."""
has_content = False
@@ -62,9 +60,13 @@ class Media(Directive):
def run(self):
"""Run media directive."""
- if micawber is None:
- msg = req_missing(['micawber'], 'use the media directive', optional=True)
- return [nodes.raw('', '{0}'.format(msg), format='html')]
+ html = _gen_media_embed(" ".join(self.arguments))
+ return [nodes.raw('', html, format='html')]
+
- providers = micawber.bootstrap_basic()
- return [nodes.raw('', micawber.parse_text(" ".join(self.arguments), providers), format='html')]
+def _gen_media_embed(url, *q, **kw):
+ if micawber is None:
+ msg = req_missing(['micawber'], 'use the media directive', optional=True)
+ return '{0}'.format(msg)
+ providers = micawber.bootstrap_basic()
+ return micawber.parse_text(url, providers)
diff --git a/nikola/plugins/compile/rest/post_list.plugin b/nikola/plugins/compile/rest/post_list.plugin
index 48969bf..68abaef 100644
--- a/nikola/plugins/compile/rest/post_list.plugin
+++ b/nikola/plugins/compile/rest/post_list.plugin
@@ -4,11 +4,11 @@ module = post_list
[Nikola]
compiler = rest
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Udo Spallek
-version = 0.1
-website = http://getnikola.com
-description = Includes a list of posts with tag and slide based filters.
+version = 0.2
+website = https://getnikola.com/
+description = Includes a list of posts with tag and slice based filters.
diff --git a/nikola/plugins/compile/rest/post_list.py b/nikola/plugins/compile/rest/post_list.py
index a22ee85..f7e95ed 100644
--- a/nikola/plugins/compile/rest/post_list.py
+++ b/nikola/plugins/compile/rest/post_list.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2013-2015 Udo Spallek, Roberto Alsina and others.
+# Copyright © 2013-2020 Udo Spallek, Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -23,15 +23,8 @@
# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
"""Post list directive for reStructuredText."""
-from __future__ import unicode_literals
-
-import os
-import uuid
-import natsort
-
from docutils import nodes
from docutils.parsers.rst import Directive, directives
@@ -43,7 +36,6 @@ from nikola.plugin_categories import RestExtension
class Plugin(RestExtension):
-
"""Plugin for reST post-list directive."""
name = "rest_post_list"
@@ -51,74 +43,14 @@ class Plugin(RestExtension):
def set_site(self, site):
"""Set Nikola site."""
self.site = site
- directives.register_directive('post-list', PostList)
- PostList.site = site
- return super(Plugin, self).set_site(site)
-
-
-class PostList(Directive):
-
- """Provide a reStructuredText directive to create a list of posts.
-
- Post List
- =========
- :Directive Arguments: None.
- :Directive Options: lang, start, stop, reverse, sort, tags, categories, slugs, all, template, id
- :Directive Content: None.
-
- The posts appearing in the list can be filtered by options.
- *List slicing* is provided with the *start*, *stop* and *reverse* options.
-
- The following not required options are recognized:
-
- ``start`` : integer
- The index of the first post to show.
- A negative value like ``-3`` will show the *last* three posts in the
- post-list.
- Defaults to None.
-
- ``stop`` : integer
- The index of the last post to show.
- A value negative value like ``-1`` will show every post, but not the
- *last* in the post-list.
- Defaults to None.
-
- ``reverse`` : flag
- Reverse the order of the post-list.
- Defaults is to not reverse the order of posts.
-
- ``sort``: string
- Sort post list by one of each post's attributes, usually ``title`` or a
- custom ``priority``. Defaults to None (chronological sorting).
-
- ``tags`` : string [, string...]
- Filter posts to show only posts having at least one of the ``tags``.
- Defaults to None.
-
- ``categories`` : string [, string...]
- Filter posts to show only posts having one of the ``categories``.
- Defaults to None.
+ directives.register_directive('post-list', PostListDirective)
+ directives.register_directive('post_list', PostListDirective)
+ PostListDirective.site = site
+ return super().set_site(site)
- ``slugs`` : string [, string...]
- Filter posts to show only posts having at least one of the ``slugs``.
- Defaults to None.
- ``all`` : flag
- Shows all posts and pages in the post list.
- Defaults to show only posts with set *use_in_feeds*.
-
- ``lang`` : string
- The language of post *titles* and *links*.
- Defaults to default language.
-
- ``template`` : string
- The name of an alternative template to render the post-list.
- Defaults to ``post_list_directive.tmpl``
-
- ``id`` : string
- A manual id for the post list.
- Defaults to a random name composed by 'post_list_' + uuid.uuid4().hex.
- """
+class PostListDirective(Directive):
+ """Provide a reStructuredText directive to create a list of posts."""
option_spec = {
'start': int,
@@ -126,12 +58,16 @@ class PostList(Directive):
'reverse': directives.flag,
'sort': directives.unchanged,
'tags': directives.unchanged,
+ 'require_all_tags': directives.flag,
'categories': directives.unchanged,
+ 'sections': directives.unchanged,
'slugs': directives.unchanged,
- 'all': directives.flag,
+ 'post_type': directives.unchanged,
+ 'type': directives.unchanged,
'lang': directives.unchanged,
'template': directives.path,
'id': directives.unchanged,
+ 'date': directives.unchanged,
}
def run(self):
@@ -140,73 +76,42 @@ class PostList(Directive):
stop = self.options.get('stop')
reverse = self.options.get('reverse', False)
tags = self.options.get('tags')
- tags = [t.strip().lower() for t in tags.split(',')] if tags else []
+ require_all_tags = 'require_all_tags' in self.options
categories = self.options.get('categories')
- categories = [c.strip().lower() for c in categories.split(',')] if categories else []
+ sections = self.options.get('sections')
slugs = self.options.get('slugs')
- slugs = [s.strip() for s in slugs.split(',')] if slugs else []
- show_all = self.options.get('all', False)
+ post_type = self.options.get('post_type')
+ type = self.options.get('type', False)
lang = self.options.get('lang', utils.LocaleBorg().current_lang)
template = self.options.get('template', 'post_list_directive.tmpl')
sort = self.options.get('sort')
- if self.site.invariant: # for testing purposes
- post_list_id = self.options.get('id', 'post_list_' + 'fixedvaluethatisnotauuid')
- else:
- post_list_id = self.options.get('id', 'post_list_' + uuid.uuid4().hex)
-
- filtered_timeline = []
- posts = []
- step = -1 if reverse is None else None
- if show_all is None:
- timeline = [p for p in self.site.timeline]
+ date = self.options.get('date')
+ filename = self.state.document.settings._nikola_source_path
+
+ output, deps = self.site.plugin_manager.getPluginByName(
+ 'post_list', 'ShortcodePlugin').plugin_object.handler(
+ start,
+ stop,
+ reverse,
+ tags,
+ require_all_tags,
+ categories,
+ sections,
+ slugs,
+ post_type,
+ type,
+ lang,
+ template,
+ sort,
+ state=self.state,
+ site=self.site,
+ date=date,
+ filename=filename)
+ self.state.document.settings.record_dependencies.add(
+ "####MAGIC####TIMELINE")
+ for d in deps:
+ self.state.document.settings.record_dependencies.add(d)
+ if output:
+ return [nodes.raw('', output, format='html')]
else:
- timeline = [p for p in self.site.timeline if p.use_in_feeds]
-
- if categories:
- timeline = [p for p in timeline if p.meta('category', lang=lang).lower() in categories]
-
- for post in timeline:
- if tags:
- cont = True
- tags_lower = [t.lower() for t in post.tags]
- for tag in tags:
- if tag in tags_lower:
- cont = False
-
- if cont:
- continue
-
- filtered_timeline.append(post)
-
- if sort:
- filtered_timeline = natsort.natsorted(filtered_timeline, key=lambda post: post.meta[lang][sort], alg=natsort.ns.F | natsort.ns.IC)
-
- for post in filtered_timeline[start:stop:step]:
- if slugs:
- cont = True
- for slug in slugs:
- if slug == post.meta('slug'):
- cont = False
-
- if cont:
- continue
-
- bp = post.translated_base_path(lang)
- if os.path.exists(bp):
- self.state.document.settings.record_dependencies.add(bp)
-
- posts += [post]
-
- if not posts:
return []
- self.state.document.settings.record_dependencies.add("####MAGIC####TIMELINE")
-
- template_data = {
- 'lang': lang,
- 'posts': posts,
- 'date_format': self.site.GLOBAL_CONTEXT.get('date_format'),
- 'post_list_id': post_list_id,
- }
- output = self.site.template_system.render_template(
- template, None, template_data)
- return [nodes.raw('', output, format='html')]
diff --git a/nikola/plugins/compile/rest/slides.plugin b/nikola/plugins/compile/rest/slides.plugin
deleted file mode 100644
index 5c05b89..0000000
--- a/nikola/plugins/compile/rest/slides.plugin
+++ /dev/null
@@ -1,14 +0,0 @@
-[Core]
-name = rest_slides
-module = slides
-
-[Nikola]
-compiler = rest
-plugincategory = CompilerExtension
-
-[Documentation]
-author = Roberto Alsina
-version = 0.1
-website = http://getnikola.com
-description = Slides directive
-
diff --git a/nikola/plugins/compile/rest/slides.py b/nikola/plugins/compile/rest/slides.py
deleted file mode 100644
index 2522e55..0000000
--- a/nikola/plugins/compile/rest/slides.py
+++ /dev/null
@@ -1,80 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# Copyright © 2012-2015 Roberto Alsina and others.
-
-# Permission is hereby granted, free of charge, to any
-# person obtaining a copy of this software and associated
-# documentation files (the "Software"), to deal in the
-# Software without restriction, including without limitation
-# the rights to use, copy, modify, merge, publish,
-# distribute, sublicense, and/or sell copies of the
-# Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice
-# shall be included in all copies or substantial portions of
-# the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
-# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
-# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
-# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
-# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
-# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
-# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-"""Slides directive for reStructuredText."""
-
-from __future__ import unicode_literals
-
-import uuid
-
-from docutils import nodes
-from docutils.parsers.rst import Directive, directives
-
-from nikola.plugin_categories import RestExtension
-
-
-class Plugin(RestExtension):
-
- """Plugin for reST slides directive."""
-
- name = "rest_slides"
-
- def set_site(self, site):
- """Set Nikola site."""
- self.site = site
- directives.register_directive('slides', Slides)
- Slides.site = site
- return super(Plugin, self).set_site(site)
-
-
-class Slides(Directive):
-
- """reST extension for inserting slideshows."""
-
- has_content = True
-
- def run(self):
- """Run the slides directive."""
- if len(self.content) == 0: # pragma: no cover
- return
-
- if self.site.invariant: # for testing purposes
- carousel_id = 'slides_' + 'fixedvaluethatisnotauuid'
- else:
- carousel_id = 'slides_' + uuid.uuid4().hex
-
- output = self.site.template_system.render_template(
- 'slides.tmpl',
- None,
- {
- 'slides_content': self.content,
- 'carousel_id': carousel_id,
- }
- )
- return [nodes.raw('', output, format='html')]
-
-
-directives.register_directive('slides', Slides)
diff --git a/nikola/plugins/compile/rest/soundcloud.plugin b/nikola/plugins/compile/rest/soundcloud.plugin
index 75469e4..f85a964 100644
--- a/nikola/plugins/compile/rest/soundcloud.plugin
+++ b/nikola/plugins/compile/rest/soundcloud.plugin
@@ -4,11 +4,11 @@ module = soundcloud
[Nikola]
compiler = rest
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Roberto Alsina
version = 0.1
-website = http://getnikola.com
+website = https://getnikola.com/
description = Soundcloud directive
diff --git a/nikola/plugins/compile/rest/soundcloud.py b/nikola/plugins/compile/rest/soundcloud.py
index 30134a9..5dbcfc3 100644
--- a/nikola/plugins/compile/rest/soundcloud.py
+++ b/nikola/plugins/compile/rest/soundcloud.py
@@ -1,16 +1,39 @@
# -*- coding: utf-8 -*-
+# 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
+# documentation files (the "Software"), to deal in the
+# Software without restriction, including without limitation
+# the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the
+# Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice
+# shall be included in all copies or substantial portions of
+# the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
+# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
+# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
"""SoundCloud directive for reStructuredText."""
from docutils import nodes
from docutils.parsers.rst import Directive, directives
-
+from nikola.plugins.compile.rest import _align_choice, _align_options_base
from nikola.plugin_categories import RestExtension
class Plugin(RestExtension):
-
"""Plugin for soundclound directive."""
name = "rest_soundcloud"
@@ -20,18 +43,19 @@ class Plugin(RestExtension):
self.site = site
directives.register_directive('soundcloud', SoundCloud)
directives.register_directive('soundcloud_playlist', SoundCloudPlaylist)
- return super(Plugin, self).set_site(site)
+ return super().set_site(site)
-CODE = ("""
+
{{ messages("Comments") }}
- {{ comments.comment_form(post.permalink(absolute=True), post.title(), post._base_path) }} -