From b6f2eee7801bd9c42b2386d3067ee99098587170 Mon Sep 17 00:00:00 2001 From: Daniel Aleksandersen Date: Thu, 27 Aug 2015 15:09:38 +0200 Subject: [PATCH] Adds THEME_COLORS, POSTS_CATEGORIES, POSTS_CATEGORY_*, and HUSL color functions Issue #1980 --- CHANGES.txt | 11 +++ nikola/conf.py.in | 46 ++++++++++ .../base-jinja/templates/base_helper.tmpl | 1 + .../base-jinja/templates/categoryindex.tmpl | 21 +++++ .../data/themes/base/messages/messages_en.py | 2 + .../themes/base/templates/base_helper.tmpl | 1 + .../themes/base/templates/categoryindex.tmpl | 21 +++++ .../templates/base_helper.tmpl | 1 + .../bootstrap3/templates/base_helper.tmpl | 1 + nikola/nikola.py | 13 ++- nikola/plugins/task/indexes.py | 85 +++++++++++++++++++ nikola/post.py | 36 ++++++++ nikola/utils.py | 27 ++++++ requirements.txt | 1 + 14 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 nikola/data/themes/base-jinja/templates/categoryindex.tmpl create mode 100644 nikola/data/themes/base/templates/categoryindex.tmpl diff --git a/CHANGES.txt b/CHANGES.txt index ddae855b65..80e01abb9a 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,6 +4,17 @@ New in master Features -------- +* New ``THEME_COLOR`` option for customizing themes from a primary color + (Issue #1980) +* New ``POSTS`` output subfolders now generate categories by deault + (Issue #1980) +* New ``POSTS_CATEGORIES`` and ``POSTS_CATEGORY_*`` options for + configuring the new category pages (Issue #1980) +* For themers: Each ``post`` are now asssociated with category_color, + category_link, and category_name (Issue #1980) +* Each new category page has a auto-assigned color based on shifting + the hue of ``THEME_COLOR`` based on a hash of the category name, + can be overwritten with ``POSTS_CATEGORY_COLORS`` option (Issue #1980) * New ``TAG_PAGES_TITLES`` and ``CATEGORY_PAGES_TITLES`` options (Issue #1962) diff --git a/nikola/conf.py.in b/nikola/conf.py.in index 966280d391..11a710973d 100644 --- a/nikola/conf.py.in +++ b/nikola/conf.py.in @@ -89,6 +89,10 @@ NAVIGATION_LINKS = ${NAVIGATION_LINKS} # Name of the theme to use. THEME = ${THEME} +# Primary color of your theme. This will be used to customize your theme and +# auto-generate related colors in POSTS_CATEGORY_COLORS. Must be a HEX value. +THEME_COLOR = '#5670d4' + ############################################## # Below this point, everything is optional ############################################## @@ -208,6 +212,48 @@ COMPILERS = ${COMPILERS} # Warning: this option will change its default value to False in v8! WRITE_TAG_CLOUD = True +# Generate pages for categories. The site must have at least two categories +# for this option to take effect. It wouldn't build for just one category. +POSTS_CATEGORIES = True + +# Setting this to False generates a list page instead of an index. Indexes +# are the default and will apply GENERATE_ATOM if set. +# POSTS_CATEGORY_ARE_INDEXES = True + +# Each post and category page will have an associated color that can be used +# to style them with a recognizable color detail across your site. A color +# is assigned to each category based on shifting the hue of your THEME_COLOR +# at least 7.5 % while leaving the lightness and saturation untouched in the +# HUSL colorspace. You can overwrite colors by assigning them colors in HEX. +POSTS_CATEGORY_COLORS = { + DEFAULT_LANG: { + 'posts': '#49b11bf', + 'reviews': '#ffe200', + }, +} + +# Associate a description with a category. For use in meta description on +# category index pages or elsewhere in themes. +POSTS_CATEGORY_DESCRIPTIONS = { + DEFAULT_LANG: { + 'how-to': 'Learn how-to things properly with these amazing tutorials.', + }, +} + +# Categories are determined by their output directory set in POSTS by default, +# but can alternatively be determined from file metadata instead. +# POSTS_CATEGORY_FROM_META = False + +# Names are determined from the output directory name automatically or the +# metadata label. Unless overwritten below, names will use title cased and +# hyphens replaced by spaces. +# POSTS_CATEGORY_NAME = { +# DEFAULT_LANG: { +# 'posts': 'Blog Posts', +# 'uncategorized': 'Odds and Ends', +# }, +# } + # Paths for different autogenerated bits. These are combined with the # translation paths. diff --git a/nikola/data/themes/base-jinja/templates/base_helper.tmpl b/nikola/data/themes/base-jinja/templates/base_helper.tmpl index 8b4ed979f6..ef03cec79b 100644 --- a/nikola/data/themes/base-jinja/templates/base_helper.tmpl +++ b/nikola/data/themes/base-jinja/templates/base_helper.tmpl @@ -33,6 +33,7 @@ lang="{{ lang }}"> {% endif %} {{ html_stylesheets() }} + {{ html_feedlinks() }} diff --git a/nikola/data/themes/base-jinja/templates/categoryindex.tmpl b/nikola/data/themes/base-jinja/templates/categoryindex.tmpl new file mode 100644 index 0000000000..a8502cb0f1 --- /dev/null +++ b/nikola/data/themes/base-jinja/templates/categoryindex.tmpl @@ -0,0 +1,21 @@ +{# -*- coding: utf-8 -*- #} +{% extends 'index.tmpl' %} + +{% block extra_head %} + {{ super() }} + {% if generate_atom %} + + {% endif %} +{% endblock %} + +{% block content %} +
+
+

{{ posts[0].category_name() }}

+ {% if generate_atom %} + + {% endif %} +
+ {{ parent.content() }} + +{% endblock %} diff --git a/nikola/data/themes/base/messages/messages_en.py b/nikola/data/themes/base/messages/messages_en.py index 29af257405..a86e2d3ce9 100644 --- a/nikola/data/themes/base/messages/messages_en.py +++ b/nikola/data/themes/base/messages/messages_en.py @@ -36,4 +36,6 @@ "Write your post here.": "Write your post here.", "old posts, page %d": "old posts, page %d", "page %d": "page %d", + "uncategorized": "Uncategorized", + "updates": "Updates" } diff --git a/nikola/data/themes/base/templates/base_helper.tmpl b/nikola/data/themes/base/templates/base_helper.tmpl index 948cfba09d..c0607743ed 100644 --- a/nikola/data/themes/base/templates/base_helper.tmpl +++ b/nikola/data/themes/base/templates/base_helper.tmpl @@ -33,6 +33,7 @@ lang="${lang}"> %endif ${html_stylesheets()} + ${html_feedlinks()} diff --git a/nikola/data/themes/base/templates/categoryindex.tmpl b/nikola/data/themes/base/templates/categoryindex.tmpl new file mode 100644 index 0000000000..d90ec3cba5 --- /dev/null +++ b/nikola/data/themes/base/templates/categoryindex.tmpl @@ -0,0 +1,21 @@ +## -*- coding: utf-8 -*- +<%inherit file="index.tmpl"/> + +<%block name="extra_head"> + ${parent.extra_head()} + % if generate_atom: + + % endif + + +<%block name="content"> +
+
+

${posts[0].category_name()}

+ % if generate_atom: + + % endif +
+ ${parent.content()} + + diff --git a/nikola/data/themes/bootstrap3-jinja/templates/base_helper.tmpl b/nikola/data/themes/bootstrap3-jinja/templates/base_helper.tmpl index 5ab4dcb31a..91a3c4c212 100644 --- a/nikola/data/themes/bootstrap3-jinja/templates/base_helper.tmpl +++ b/nikola/data/themes/bootstrap3-jinja/templates/base_helper.tmpl @@ -38,6 +38,7 @@ lang="{{ lang }}"> {% endif %} {{ html_stylesheets() }} + {{ html_feedlinks() }} diff --git a/nikola/data/themes/bootstrap3/templates/base_helper.tmpl b/nikola/data/themes/bootstrap3/templates/base_helper.tmpl index a1e7508e65..220ad0b0b3 100644 --- a/nikola/data/themes/bootstrap3/templates/base_helper.tmpl +++ b/nikola/data/themes/bootstrap3/templates/base_helper.tmpl @@ -38,6 +38,7 @@ lang="${lang}"> %endif ${html_stylesheets()} + ${html_feedlinks()} diff --git a/nikola/nikola.py b/nikola/nikola.py index d8a10449a2..a8ed21816b 100644 --- a/nikola/nikola.py +++ b/nikola/nikola.py @@ -418,6 +418,12 @@ def __init__(self, **config): 'OLD_THEME_SUPPORT': True, 'OUTPUT_FOLDER': 'output', 'POSTS': (("posts/*.txt", "posts", "post.tmpl"),), + 'POSTS_CATEGORIES': True, + 'POSTS_CATEGORY_ARE_INDEXES': True, + 'POSTS_CATEGORY_DESCRIPTIONS': "", + 'POSTS_CATEGORY_FROM_META': False, + 'POSTS_CATEGORY_NAME': "", + 'POSTS_CATEGORY_TITLE': "%s", 'PAGES': (("stories/*.txt", "stories", "story.tmpl"),), 'PANDOC_OPTIONS': [], 'PRETTY_URLS': False, @@ -452,6 +458,7 @@ def __init__(self, **config): 'TAGLIST_MINIMUM_POSTS': 1, 'TEMPLATE_FILTERS': {}, 'THEME': 'bootstrap3', + 'THEME_COLOR': '#5670d4', # light "corporate blue" 'THEME_REVEAL_CONFIG_SUBTHEME': 'sky', 'THEME_REVEAL_CONFIG_TRANSITION': 'cube', 'THUMBNAIL_SIZE': 180, @@ -514,6 +521,10 @@ def __init__(self, **config): 'INDEX_READ_MORE_LINK', 'RSS_READ_MORE_LINK', 'INDEXES_TITLE', + 'POSTS_CATEGORY_COLORS', + 'POSTS_CATEGORY_DESCRIPTIONS', + 'POSTS_CATEGORY_NAME', + 'POSTS_CATEGORY_TITLE', 'INDEXES_PAGES', 'INDEXES_PRETTY_PAGE_URL',) @@ -837,6 +848,7 @@ def _set_global_context(self): 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['favicons'] = self.config['FAVICONS'] self._GLOBAL_CONTEXT['date_format'] = self.config.get('DATE_FORMAT') self._GLOBAL_CONTEXT['blog_author'] = self.config.get('BLOG_AUTHOR') @@ -1310,7 +1322,6 @@ def path(self, kind, name, lang=None, is_link=False): try: path = self.path_handlers[kind](name, lang) path = [os.path.normpath(p) for p in path if p != '.'] # Fix Issue #1028 - if is_link: link = '/' + ('/'.join(path)) index_len = len(self.config['INDEX_FILE']) diff --git a/nikola/plugins/task/indexes.py b/nikola/plugins/task/indexes.py index c02818eb3a..28d4672b15 100644 --- a/nikola/plugins/task/indexes.py +++ b/nikola/plugins/task/indexes.py @@ -29,11 +29,16 @@ from __future__ import unicode_literals from collections import defaultdict import os +try: + from urlparse import urljoin +except ImportError: + from urllib.parse import urljoin # NOQA from nikola.plugin_categories import Task from nikola import utils + class Indexes(Task): """Render the blog indexes.""" @@ -44,6 +49,8 @@ def set_site(self, site): """Set Nikola site.""" site.register_path_handler('index', self.index_path) site.register_path_handler('index_atom', self.index_atom_path) + site.register_path_handler('cat_index', self.index_cat_path) + site.register_path_handler('cat_index_atom', self.index_cat_atom_path) return super(Indexes, self).set_site(site) def gen_tasks(self): @@ -56,9 +63,11 @@ def gen_tasks(self): "messages": self.site.MESSAGES, "output_folder": self.site.config['OUTPUT_FOLDER'], "filters": self.site.config['FILTERS'], + "index_file": self.site.config['INDEX_FILE'], "show_untranslated_posts": self.site.config['SHOW_UNTRANSLATED_POSTS'], "index_display_post_count": self.site.config['INDEX_DISPLAY_POST_COUNT'], "indexes_title": self.site.config['INDEXES_TITLE'], + "strip_indexes": self.site.config['STRIP_INDEXES'], "blog_title": self.site.config["BLOG_TITLE"], "generate_atom": self.site.config["GENERATE_ATOM"], } @@ -66,6 +75,7 @@ def gen_tasks(self): template_name = "index.tmpl" posts = self.site.posts self.number_of_pages = dict() + self.number_of_pages_cat = dict() for lang in kw["translations"]: def page_link(i, displayed_i, num_pages, force_addition, extension=None): feed = "_atom" if extension == ".atom" else "" @@ -90,6 +100,59 @@ def page_path(i, displayed_i, num_pages, force_addition, extension=None): yield self.site.generic_index_renderer(lang, filtered_posts, indexes_title, template_name, context, kw, 'render_indexes', page_link, page_path) + if self.site.config['POSTS_CATEGORIES']: + + kw["posts_category_are_indexes"] = self.site.config['POSTS_CATEGORY_ARE_INDEXES'] + kw["posts_category_title"] = self.site.config['POSTS_CATEGORY_TITLE'](lang) + + index_len = len(kw['index_file']) + + groups = defaultdict(list) + for p in filtered_posts: + groups[p.category_slug(lang)].append(p) + + # don't build categories when there is only one, aka. default setups + if not len(groups.items()) > 1: + continue + + for dirname, post_list in groups.items(): + + if not lang in self.number_of_pages_cat: + self.number_of_pages_cat[lang] = dict() + self.number_of_pages_cat[lang][dirname] = (len(post_list) + kw['index_display_post_count'] - 1) // kw['index_display_post_count'] + + def cat_link(i, displayed_i, num_pages, force_addition, extension=None): + feed = "_atom" if extension == ".atom" else "" + return utils.adjust_name_for_index_link(self.site.link("cat_index" + feed, dirname, lang), i, displayed_i, + lang, self.site, force_addition, extension) + + def cat_path(i, displayed_i, num_pages, force_addition, extension=None): + feed = "_atom" if extension == ".atom" else "" + return utils.adjust_name_for_index_path(self.site.path("cat_index" + feed, dirname, lang), i, displayed_i, + lang, self.site, force_addition, extension) + + context = {} + + short_destination = os.path.join(dirname, kw['index_file']) + link = short_destination.replace('\\', '/') + if kw['strip_indexes'] and link[-(1 + index_len):] == '/' + kw['index_file']: + link = link[:-index_len] + context["permalink"] = link + context["pagekind"] = ["posts_category_page"] + context["description"] = self.site.config['POSTS_CATEGORY_DESCRIPTIONS'](lang)[dirname] if dirname in self.site.config['POSTS_CATEGORY_DESCRIPTIONS'](lang) else "" + + if kw["posts_category_are_indexes"]: + context["pagekind"].append("index") + indexes_title = post_list[0].category_name(lang) # all posts in the list share category name + task = self.site.generic_index_renderer(lang, post_list, indexes_title, "categoryindex.tmpl", context, kw, self.name, cat_link, cat_path) + else: + context["pagekind"].append("list") + output_name = os.path.join(kw['output_folder'], dirname, kw['index_file']) + task = self.site.generic_post_list_renderer(lang, post_list, output_name, "list.tmpl", kw['filters'], context) + task['uptodate'] = [utils.config_changed(kw, 'nikola.plugins.task.indexes')] + task['basename'] = self.name + yield task + if not self.site.config["STORY_INDEX"]: return kw = { @@ -163,6 +226,28 @@ def index_path(self, name, lang, is_feed=False): self.site, extension=extension) + def index_cat_path(self, name, lang, is_feed=False): + """Return path to an index.""" + extension = None + + if is_feed: + extension = ".atom" + index_file = os.path.splitext(self.site.config['INDEX_FILE'])[0] + extension + else: + index_file = self.site.config['INDEX_FILE'] + return utils.adjust_name_for_index_path_list([_f for _f in [self.site.config['TRANSLATIONS'][lang], + name, + index_file] if _f], + None, + utils.get_displayed_page_number(None, self.number_of_pages_cat[lang][name], self.site), + lang, + self.site, + extension=extension) + def index_atom_path(self, name, lang): """Return path to an Atom index.""" return self.index_path(name, lang, is_feed=True) + + def index_cat_atom_path(self, name, lang): + """Return path to an Atom index for categories.""" + return self.index_cat_path(name, lang, is_feed=True) diff --git a/nikola/post.py b/nikola/post.py index 0ec2f2a10e..49c629c0dc 100644 --- a/nikola/post.py +++ b/nikola/post.py @@ -745,6 +745,42 @@ def destination_path(self, lang=None, extension='.html', sep=os.sep): path = path[2:] return path + def category_color(self, lang=None): + slug = self.category_slug(lang) + if slug in self.config['POSTS_CATEGORY_COLORS'](lang): + return self.config['POSTS_CATEGORY_COLORS'](lang)[slug] + base = self.config['THEME_COLOR'] + return utils.colorize_str_from_base_color(slug, base) + + def category_link(self, lang=None): + slug = self.category_slug(lang) + if not self.pretty_urls: + link = urljoin('/'+ slug + '/', self.index_file) + else: + link = '/' + slug + '/' + return link + + def category_name(self, lang=None): + slug = self.category_slug(lang) + if slug in self.config['POSTS_CATEGORY_NAME'](lang): + name = self.config['POSTS_CATEGORY_NAME'](lang)[slug] + else: + name = slug.replace('-', ' ').title() + return name + + def category_slug(self, lang=None): + if not self.config['POSTS_CATEGORY_FROM_META']: + dest = self.destination_path(lang) + if dest[-(1 + len(self.index_file)):] == '/' + self.index_file: + dest = dest[:-(1 + len(self.index_file))] + dirname = os.path.dirname(dest) + slug = dirname + if not dirname or dirname == '.': + slug = self.messages[lang]["uncategorized"] + else: + slug = self.meta[lang]['category'].split(',')[0] if 'category' in self.meta[lang] else self.messages[lang]["uncategorized"] + return slug + def permalink(self, lang=None, absolute=False, extension='.html', query=None): """Return permalink for a post.""" if lang is None: diff --git a/nikola/utils.py b/nikola/utils.py index 3a268ffad2..e175f966f2 100644 --- a/nikola/utils.py +++ b/nikola/utils.py @@ -31,6 +31,7 @@ import datetime import dateutil.tz import hashlib +import husl import io import locale import logging @@ -1673,6 +1674,32 @@ def escape(s): return '/'.join([escape(p) for p in category_path]) +def colorize_str_from_base_color(string, base_color): + """ Find a perceptual similar color from a base color based on the hash of a sring. + + Make up to 16 attempts (number of bytes returned by hashing) at picking a + hue for our color at least 27° away from the base color, leaving lightness + and saturation untouched using HUSL colorspace. """ + def hash_str(string, pos): + return hashlib.md5(string.encode('utf-8')).digest()[pos] + + def degreediff(dega, degb): + return min(abs(dega - degb), abs((degb - dega) + 360)) + + def husl_similar_from_base(string, base_color): + h,s,l = husl.hex_to_husl(base_color) + old_h = h + idx = 0 + while degreediff(old_h, h) < 27 and idx < 16: + print("%i: %f vs %f (diff %f)" % (idx, h, old_h, degreediff(old_h, h))) + h = 360.0 * (float(hash_str(string, idx)) / 255) + idx += 1 + print(str(h) + husl.husl_to_hex(h,s,l)) + return husl.husl_to_hex(h,s,l) + + return husl_similar_from_base(string, base_color) + + # Stolen from textwrap in Python 3.4.3. def indent(text, prefix, predicate=None): """Add 'prefix' to the beginning of selected lines in 'text'. diff --git a/requirements.txt b/requirements.txt index 5d2c7d2a4c..0b5fe4e934 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ blinker>=1.3 setuptools>=5.4.1 natsort>=3.5.2 requests>=2.2.0 +husl>=4.0.2