summaryrefslogtreecommitdiffstats
path: root/nikola/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'nikola/plugins')
-rw-r--r--nikola/plugins/__init__.py2
-rw-r--r--nikola/plugins/basic_import.py61
-rw-r--r--nikola/plugins/command/__init__.py2
-rw-r--r--nikola/plugins/command/auto.plugin4
-rw-r--r--nikola/plugins/command/auto/__init__.py695
l---------nikola/plugins/command/auto/livereload.js2
-rw-r--r--nikola/plugins/command/bootswatch_theme.plugin13
-rw-r--r--nikola/plugins/command/bootswatch_theme.py106
-rw-r--r--nikola/plugins/command/check.plugin4
-rw-r--r--nikola/plugins/command/check.py228
-rw-r--r--nikola/plugins/command/console.plugin4
-rw-r--r--nikola/plugins/command/console.py52
-rw-r--r--nikola/plugins/command/default_config.plugin13
-rw-r--r--nikola/plugins/command/default_config.py54
-rw-r--r--nikola/plugins/command/deploy.plugin4
-rw-r--r--nikola/plugins/command/deploy.py76
-rw-r--r--nikola/plugins/command/github_deploy.plugin4
-rw-r--r--nikola/plugins/command/github_deploy.py129
-rw-r--r--nikola/plugins/command/import_wordpress.plugin4
-rw-r--r--nikola/plugins/command/import_wordpress.py480
-rw-r--r--nikola/plugins/command/init.plugin4
-rw-r--r--nikola/plugins/command/init.py124
-rw-r--r--nikola/plugins/command/install_theme.plugin13
-rw-r--r--nikola/plugins/command/install_theme.py172
-rw-r--r--nikola/plugins/command/new_page.plugin4
-rw-r--r--nikola/plugins/command/new_page.py5
-rw-r--r--nikola/plugins/command/new_post.plugin4
-rw-r--r--nikola/plugins/command/new_post.py140
-rw-r--r--nikola/plugins/command/orphans.plugin4
-rw-r--r--nikola/plugins/command/orphans.py4
-rw-r--r--nikola/plugins/command/plugin.plugin4
-rw-r--r--nikola/plugins/command/plugin.py129
-rw-r--r--nikola/plugins/command/rst2html.plugin4
-rw-r--r--nikola/plugins/command/rst2html/__init__.py14
-rw-r--r--nikola/plugins/command/serve.plugin4
-rw-r--r--nikola/plugins/command/serve.py95
-rw-r--r--nikola/plugins/command/status.plugin2
-rw-r--r--nikola/plugins/command/status.py60
-rw-r--r--nikola/plugins/command/subtheme.plugin13
-rw-r--r--nikola/plugins/command/subtheme.py150
-rw-r--r--nikola/plugins/command/theme.plugin13
-rw-r--r--nikola/plugins/command/theme.py393
-rw-r--r--nikola/plugins/command/version.plugin4
-rw-r--r--nikola/plugins/command/version.py18
-rw-r--r--nikola/plugins/compile/__init__.py2
-rw-r--r--nikola/plugins/compile/html.plugin4
-rw-r--r--nikola/plugins/compile/html.py79
-rw-r--r--nikola/plugins/compile/ipynb.plugin6
-rw-r--r--nikola/plugins/compile/ipynb.py188
-rw-r--r--nikola/plugins/compile/markdown.plugin4
-rw-r--r--nikola/plugins/compile/markdown/__init__.py132
-rw-r--r--nikola/plugins/compile/markdown/mdx_gist.plugin4
-rw-r--r--nikola/plugins/compile/markdown/mdx_gist.py180
-rw-r--r--nikola/plugins/compile/markdown/mdx_nikola.plugin4
-rw-r--r--nikola/plugins/compile/markdown/mdx_nikola.py35
-rw-r--r--nikola/plugins/compile/markdown/mdx_podcast.plugin4
-rw-r--r--nikola/plugins/compile/markdown/mdx_podcast.py16
-rw-r--r--nikola/plugins/compile/pandoc.plugin4
-rw-r--r--nikola/plugins/compile/pandoc.py31
-rw-r--r--nikola/plugins/compile/php.plugin4
-rw-r--r--nikola/plugins/compile/php.py23
-rw-r--r--nikola/plugins/compile/rest.plugin6
-rw-r--r--nikola/plugins/compile/rest/__init__.py277
-rw-r--r--nikola/plugins/compile/rest/chart.plugin4
-rw-r--r--nikola/plugins/compile/rest/chart.py91
-rw-r--r--nikola/plugins/compile/rest/doc.plugin4
-rw-r--r--nikola/plugins/compile/rest/doc.py81
-rw-r--r--nikola/plugins/compile/rest/gist.plugin4
-rw-r--r--nikola/plugins/compile/rest/gist.py4
-rw-r--r--nikola/plugins/compile/rest/listing.plugin4
-rw-r--r--nikola/plugins/compile/rest/listing.py42
-rw-r--r--nikola/plugins/compile/rest/media.plugin4
-rw-r--r--nikola/plugins/compile/rest/media.py30
-rw-r--r--nikola/plugins/compile/rest/post_list.plugin8
-rw-r--r--nikola/plugins/compile/rest/post_list.py183
-rw-r--r--nikola/plugins/compile/rest/slides.plugin14
-rw-r--r--nikola/plugins/compile/rest/slides.py80
-rw-r--r--nikola/plugins/compile/rest/soundcloud.plugin4
-rw-r--r--nikola/plugins/compile/rest/soundcloud.py46
-rw-r--r--nikola/plugins/compile/rest/thumbnail.plugin4
-rw-r--r--nikola/plugins/compile/rest/thumbnail.py12
-rw-r--r--nikola/plugins/compile/rest/vimeo.plugin2
-rw-r--r--nikola/plugins/compile/rest/vimeo.py25
-rw-r--r--nikola/plugins/compile/rest/youtube.plugin2
-rw-r--r--nikola/plugins/compile/rest/youtube.py33
-rw-r--r--nikola/plugins/misc/__init__.py2
-rw-r--r--nikola/plugins/misc/scan_posts.plugin2
-rw-r--r--nikola/plugins/misc/scan_posts.py46
-rw-r--r--nikola/plugins/misc/taxonomies_classifier.plugin12
-rw-r--r--nikola/plugins/misc/taxonomies_classifier.py335
-rw-r--r--nikola/plugins/shortcode/chart.plugin13
-rw-r--r--nikola/plugins/shortcode/chart.py90
-rw-r--r--nikola/plugins/shortcode/emoji.plugin13
-rw-r--r--nikola/plugins/shortcode/emoji/__init__.py46
-rw-r--r--nikola/plugins/shortcode/emoji/data/Activity.json418
-rw-r--r--nikola/plugins/shortcode/emoji/data/Flags.json998
-rw-r--r--nikola/plugins/shortcode/emoji/data/Food.json274
-rw-r--r--nikola/plugins/shortcode/emoji/data/LICENSE25
-rw-r--r--nikola/plugins/shortcode/emoji/data/Nature.json594
-rw-r--r--nikola/plugins/shortcode/emoji/data/Objects.json718
-rw-r--r--nikola/plugins/shortcode/emoji/data/People.json1922
-rw-r--r--nikola/plugins/shortcode/emoji/data/Symbols.json1082
-rw-r--r--nikola/plugins/shortcode/emoji/data/Travel.json466
-rw-r--r--nikola/plugins/shortcode/gist.plugin13
-rw-r--r--nikola/plugins/shortcode/gist.py50
-rw-r--r--nikola/plugins/shortcode/listing.plugin13
-rw-r--r--nikola/plugins/shortcode/listing.py77
-rw-r--r--nikola/plugins/shortcode/post_list.plugin13
-rw-r--r--nikola/plugins/shortcode/post_list.py245
-rw-r--r--nikola/plugins/shortcode/thumbnail.plugin12
-rw-r--r--nikola/plugins/shortcode/thumbnail.py69
-rw-r--r--nikola/plugins/task/__init__.py2
-rw-r--r--nikola/plugins/task/archive.plugin6
-rw-r--r--nikola/plugins/task/archive.py404
-rw-r--r--nikola/plugins/task/authors.plugin12
-rw-r--r--nikola/plugins/task/authors.py159
-rw-r--r--nikola/plugins/task/bundles.plugin6
-rw-r--r--nikola/plugins/task/bundles.py89
-rw-r--r--nikola/plugins/task/categories.plugin12
-rw-r--r--nikola/plugins/task/categories.py248
-rw-r--r--nikola/plugins/task/copy_assets.plugin4
-rw-r--r--nikola/plugins/task/copy_assets.py47
-rw-r--r--nikola/plugins/task/copy_files.plugin4
-rw-r--r--nikola/plugins/task/copy_files.py3
-rw-r--r--nikola/plugins/task/galleries.plugin4
-rw-r--r--nikola/plugins/task/galleries.py324
-rw-r--r--nikola/plugins/task/gzip.plugin4
-rw-r--r--nikola/plugins/task/gzip.py3
-rw-r--r--nikola/plugins/task/indexes.plugin7
-rw-r--r--nikola/plugins/task/indexes.py223
-rw-r--r--nikola/plugins/task/listings.plugin4
-rw-r--r--nikola/plugins/task/listings.py121
-rw-r--r--nikola/plugins/task/page_index.plugin12
-rw-r--r--nikola/plugins/task/page_index.py111
-rw-r--r--nikola/plugins/task/pages.plugin4
-rw-r--r--nikola/plugins/task/pages.py23
-rw-r--r--nikola/plugins/task/posts.plugin4
-rw-r--r--nikola/plugins/task/posts.py23
-rw-r--r--nikola/plugins/task/redirect.plugin4
-rw-r--r--nikola/plugins/task/redirect.py9
-rw-r--r--nikola/plugins/task/robots.plugin4
-rw-r--r--nikola/plugins/task/robots.py19
-rw-r--r--nikola/plugins/task/rss.plugin13
-rw-r--r--nikola/plugins/task/rss.py111
-rw-r--r--nikola/plugins/task/scale_images.plugin4
-rw-r--r--nikola/plugins/task/scale_images.py35
-rw-r--r--nikola/plugins/task/sitemap.plugin4
-rw-r--r--nikola/plugins/task/sitemap.py (renamed from nikola/plugins/task/sitemap/__init__.py)58
-rw-r--r--nikola/plugins/task/sources.plugin4
-rw-r--r--nikola/plugins/task/sources.py11
-rw-r--r--nikola/plugins/task/tags.plugin7
-rw-r--r--nikola/plugins/task/tags.py502
-rw-r--r--nikola/plugins/task/taxonomies.plugin12
-rw-r--r--nikola/plugins/task/taxonomies.py459
-rw-r--r--nikola/plugins/template/__init__.py2
-rw-r--r--nikola/plugins/template/jinja.plugin4
-rw-r--r--nikola/plugins/template/jinja.py90
-rw-r--r--nikola/plugins/template/mako.plugin4
-rw-r--r--nikola/plugins/template/mako.py61
159 files changed, 12577 insertions, 3249 deletions
diff --git a/nikola/plugins/__init__.py b/nikola/plugins/__init__.py
index b83f43f..70c8c0d 100644
--- a/nikola/plugins/__init__.py
+++ b/nikola/plugins/__init__.py
@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
"""Plugins for Nikola."""
-
-from __future__ import absolute_import
diff --git a/nikola/plugins/basic_import.py b/nikola/plugins/basic_import.py
index 073a539..3e6e21e 100644
--- a/nikola/plugins/basic_import.py
+++ b/nikola/plugins/basic_import.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,21 +26,15 @@
"""Mixin for importer plugins."""
-from __future__ import unicode_literals, print_function
import io
import csv
import datetime
import os
-import sys
-from pkg_resources import resource_filename
-
-try:
- from urlparse import urlparse
-except ImportError:
- from urllib.parse import urlparse # NOQA
+from urllib.parse import urlparse
from lxml import etree, html
from mako.template import Template
+from pkg_resources import resource_filename
from nikola import utils
@@ -48,7 +42,6 @@ links = {}
class ImportMixin(object):
-
"""Mixin with common used methods."""
name = "import_mixin"
@@ -77,8 +70,11 @@ class ImportMixin(object):
return channel
@staticmethod
- def configure_redirections(url_map):
+ def configure_redirections(url_map, base_dir=''):
"""Configure redirections from an url_map."""
+ index = base_dir + 'index.html'
+ if index.startswith('/'):
+ index = index[1:]
redirections = []
for k, v in url_map.items():
if not k[-1] == '/':
@@ -87,11 +83,10 @@ class ImportMixin(object):
# remove the initial "/" because src is a relative file path
src = (urlparse(k).path + 'index.html')[1:]
dst = (urlparse(v).path)
- if src == 'index.html':
- utils.LOGGER.warn("Can't do a redirect for: {0!r}".format(k))
+ if src == index:
+ utils.LOGGER.warning("Can't do a redirect for: {0!r}".format(k))
else:
redirections.append((src, dst))
-
return redirections
def generate_base_site(self):
@@ -100,8 +95,8 @@ class ImportMixin(object):
os.system('nikola init -q ' + self.output_folder)
else:
self.import_into_existing_site = True
- utils.LOGGER.notice('The folder {0} already exists - assuming that this is a '
- 'already existing Nikola site.'.format(self.output_folder))
+ utils.LOGGER.warning('The folder {0} already exists - assuming that this is a '
+ 'already existing Nikola site.'.format(self.output_folder))
filename = resource_filename('nikola', 'conf.py.in')
# The 'strict_undefined=True' will give the missing symbol name if any,
@@ -126,9 +121,12 @@ class ImportMixin(object):
def write_content(cls, filename, content, rewrite_html=True):
"""Write content to file."""
if rewrite_html:
- doc = html.document_fromstring(content)
- doc.rewrite_links(replacer)
- content = html.tostring(doc, encoding='utf8')
+ try:
+ doc = html.document_fromstring(content)
+ doc.rewrite_links(replacer)
+ content = html.tostring(doc, encoding='utf8')
+ except etree.ParserError:
+ content = content.encode('utf-8')
else:
content = content.encode('utf-8')
@@ -136,8 +134,25 @@ class ImportMixin(object):
with open(filename, "wb+") as fd:
fd.write(content)
- @staticmethod
- def write_metadata(filename, title, slug, post_date, description, tags, **kwargs):
+ @classmethod
+ def write_post(cls, filename, content, headers, compiler, rewrite_html=True):
+ """Ask the specified compiler to write the post to disk."""
+ if rewrite_html:
+ try:
+ doc = html.document_fromstring(content)
+ doc.rewrite_links(replacer)
+ content = html.tostring(doc, encoding='utf8')
+ except etree.ParserError:
+ pass
+ if isinstance(content, bytes):
+ content = content.decode('utf-8')
+ compiler.create_post(
+ filename,
+ content=content,
+ onefile=True,
+ **headers)
+
+ def write_metadata(self, filename, title, slug, post_date, description, tags, **kwargs):
"""Write metadata to meta file."""
if not description:
description = ""
@@ -146,13 +161,13 @@ class ImportMixin(object):
with io.open(filename, "w+", encoding="utf8") as fd:
data = {'title': title, 'slug': slug, 'date': post_date, 'tags': ','.join(tags), 'description': description}
data.update(kwargs)
- fd.write(utils.write_metadata(data))
+ fd.write(utils.write_metadata(data, site=self.site, comment_wrap=False))
@staticmethod
def write_urlmap_csv(output_file, url_map):
"""Write urlmap to csv file."""
utils.makedirs(os.path.dirname(output_file))
- fmode = 'wb+' if sys.version_info[0] == 2 else 'w+'
+ fmode = 'w+'
with io.open(output_file, fmode) as fd:
csv_writer = csv.writer(fd)
for item in url_map.items():
diff --git a/nikola/plugins/command/__init__.py b/nikola/plugins/command/__init__.py
index 2aa5267..cdd1560 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
diff --git a/nikola/plugins/command/auto.plugin b/nikola/plugins/command/auto.plugin
index 3e2b17d..a847e14 100644
--- a/nikola/plugins/command/auto.plugin
+++ b/nikola/plugins/command/auto.plugin
@@ -5,9 +5,9 @@ module = auto
[Documentation]
author = Roberto Alsina
version = 2.1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Automatically detect site changes, rebuild and optionally refresh a browser.
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/auto/__init__.py b/nikola/plugins/command/auto/__init__.py
index 71f9624..6bedcac 100644
--- a/nikola/plugins/command/auto/__init__.py
+++ b/nikola/plugins/command/auto/__init__.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2015 Roberto Alsina and others.
+# Copyright © 2012-2020 Chris Warrick, Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -26,66 +26,56 @@
"""Automatic rebuilds for Nikola."""
-from __future__ import print_function
-
-import json
+import asyncio
+import datetime
import mimetypes
import os
import re
+import stat
import subprocess
import sys
-import time
-try:
- from urlparse import urlparse
- from urllib2 import unquote
-except ImportError:
- from urllib.parse import urlparse, unquote # NOQA
+import typing
import webbrowser
-from wsgiref.simple_server import make_server
-import wsgiref.util
-from blinker import signal
+import pkg_resources
+
+from nikola.plugin_categories import Command
+from nikola.utils import dns_sd, req_missing, get_theme_path, makedirs
+
try:
- from ws4py.websocket import WebSocket
- from ws4py.server.wsgirefserver import WSGIServer, WebSocketWSGIRequestHandler, WebSocketWSGIHandler
- from ws4py.server.wsgiutils import WebSocketWSGIApplication
- from ws4py.messaging import TextMessage
+ import aiohttp
+ from aiohttp import web
+ from aiohttp.web_urldispatcher import StaticResource
+ from aiohttp.web_exceptions import HTTPNotFound, HTTPForbidden, HTTPMovedPermanently
+ from aiohttp.web_response import Response
+ from aiohttp.web_fileresponse import FileResponse
except ImportError:
- WebSocket = object
+ aiohttp = web = None
+ StaticResource = HTTPNotFound = HTTPForbidden = Response = FileResponse = object
+
try:
- import watchdog
from watchdog.observers import Observer
- from watchdog.events import FileSystemEventHandler, PatternMatchingEventHandler
except ImportError:
- watchdog = None
- FileSystemEventHandler = object
- PatternMatchingEventHandler = object
-
+ Observer = None
-from nikola.plugin_categories import Command
-from nikola.utils import req_missing, get_logger, get_theme_path, STDERR_HANDLER
LRJS_PATH = os.path.join(os.path.dirname(__file__), 'livereload.js')
-error_signal = signal('error')
-refresh_signal = signal('refresh')
+REBUILDING_REFRESH_DELAY = 0.35
+IDLE_REFRESH_DELAY = 0.05
-ERROR_N = '''<html>
-<head>
-</head>
-<boody>
-ERROR {}
-</body>
-</html>
-'''
+if sys.platform == 'win32':
+ asyncio.set_event_loop(asyncio.ProactorEventLoop())
class CommandAuto(Command):
-
"""Automatic rebuilds for Nikola."""
name = "auto"
- logger = None
has_server = True
doc_purpose = "builds and serves a site; automatically detects site changes, rebuilds, and optionally refreshes a browser"
+ dns_sd = None
+ delta_last_rebuild = datetime.timedelta(milliseconds=100)
+ web_runner = None # type: web.AppRunner
+
cmd_options = [
{
'name': 'port',
@@ -93,7 +83,7 @@ class CommandAuto(Command):
'long': 'port',
'default': 8000,
'type': int,
- 'help': 'Port nummber (default: 8000)',
+ 'help': 'Port number',
},
{
'name': 'address',
@@ -101,7 +91,7 @@ class CommandAuto(Command):
'long': 'address',
'type': str,
'default': '127.0.0.1',
- 'help': 'Address to bind (default: 127.0.0.1 – localhost)',
+ 'help': 'Address to bind',
},
{
'name': 'browser',
@@ -126,26 +116,50 @@ class CommandAuto(Command):
'type': bool,
'help': 'Disable the server, automate rebuilds only'
},
+ {
+ 'name': 'process',
+ 'short': 'n',
+ 'long': 'process',
+ 'default': 0,
+ 'type': int,
+ 'help': 'Number of subprocesses (nikola build argument)'
+ },
+ {
+ 'name': 'parallel-type',
+ 'short': 'P',
+ 'long': 'parallel-type',
+ 'default': 'process',
+ 'type': str,
+ 'help': "Parallelization mode ('process' or 'thread', nikola build argument)"
+ },
]
def _execute(self, options, args):
"""Start the watcher."""
- self.logger = get_logger('auto', STDERR_HANDLER)
- LRSocket.logger = self.logger
-
- if WebSocket is object and watchdog is None:
- req_missing(['ws4py', 'watchdog'], 'use the "auto" command')
- elif WebSocket is object:
- req_missing(['ws4py'], 'use the "auto" command')
- elif watchdog is None:
+ self.sockets = []
+ self.rebuild_queue = asyncio.Queue()
+ self.reload_queue = asyncio.Queue()
+ self.last_rebuild = datetime.datetime.now()
+ self.is_rebuilding = False
+
+ if aiohttp is None and Observer is None:
+ req_missing(['aiohttp', 'watchdog'], 'use the "auto" command')
+ elif aiohttp is None:
+ req_missing(['aiohttp'], 'use the "auto" command')
+ elif Observer is None:
req_missing(['watchdog'], 'use the "auto" command')
- self.cmd_arguments = ['nikola', 'build']
+ if sys.argv[0].endswith('__main__.py'):
+ self.nikola_cmd = [sys.executable, '-m', 'nikola', 'build']
+ else:
+ self.nikola_cmd = [sys.argv[0], 'build']
+
if self.site.configuration_filename != 'conf.py':
- self.cmd_arguments = ['--conf=' + self.site.configuration_filename] + self.cmd_arguments
+ self.nikola_cmd.append('--conf=' + self.site.configuration_filename)
- # Run an initial build so we are up-to-date
- subprocess.call(self.cmd_arguments)
+ if options and options.get('process'):
+ self.nikola_cmd += ['--process={}'.format(options['process']),
+ '--parallel-type={}'.format(options['parallel-type'])]
port = options and options.get('port')
self.snippet = '''<script>document.write('<script src="http://'
@@ -154,9 +168,9 @@ class CommandAuto(Command):
+ 'script>')</script>
</head>'''.format(port)
- # Do not duplicate entries -- otherwise, multiple rebuilds are triggered
+ # Deduplicate entries by using a set -- otherwise, multiple rebuilds are triggered
watched = set([
- 'templates/',
+ 'templates/'
] + [get_theme_path(name) for name in self.site.THEMES])
for item in self.site.config['post_pages']:
watched.add(os.path.dirname(item[0]))
@@ -166,8 +180,17 @@ class CommandAuto(Command):
watched.add(item)
for item in self.site.config['LISTINGS_FOLDERS']:
watched.add(item)
+ for item in self.site.config['IMAGE_FOLDERS']:
+ watched.add(item)
+ for item in self.site._plugin_places:
+ watched.add(item)
+ # Nikola itself (useful for developers)
+ watched.add(pkg_resources.resource_filename('nikola', ''))
out_folder = self.site.config['OUTPUT_FOLDER']
+ if not os.path.exists(out_folder):
+ makedirs(out_folder)
+
if options and options.get('browser'):
browser = True
else:
@@ -176,285 +199,387 @@ class CommandAuto(Command):
if options['ipv6']:
dhost = '::'
else:
- dhost = None
+ dhost = '0.0.0.0'
host = options['address'].strip('[').strip(']') or dhost
+ # Prepare asyncio event loop
+ # Required for subprocessing to work
+ loop = asyncio.get_event_loop()
+
+ # Set debug setting
+ loop.set_debug(self.site.debug)
+
# Server can be disabled (Issue #1883)
self.has_server = not options['no-server']
- # Instantiate global observer
- observer = Observer()
if self.has_server:
- # Watch output folders and trigger reloads
- observer.schedule(OurWatchHandler(self.do_refresh), out_folder, recursive=True)
+ loop.run_until_complete(self.set_up_server(host, port, out_folder))
+
+ # Run an initial build so we are up-to-date. The server is running, but we are not watching yet.
+ loop.run_until_complete(self.run_initial_rebuild())
+
+ self.wd_observer = Observer()
+ # Watch output folders and trigger reloads
+ if self.has_server:
+ self.wd_observer.schedule(NikolaEventHandler(self.reload_page, loop), out_folder, recursive=True)
# Watch input folders and trigger rebuilds
for p in watched:
if os.path.exists(p):
- observer.schedule(OurWatchHandler(self.do_rebuild), p, recursive=True)
+ self.wd_observer.schedule(NikolaEventHandler(self.queue_rebuild, loop), p, recursive=True)
# Watch config file (a bit of a hack, but we need a directory)
_conf_fn = os.path.abspath(self.site.configuration_filename or 'conf.py')
_conf_dn = os.path.dirname(_conf_fn)
- observer.schedule(ConfigWatchHandler(_conf_fn, self.do_rebuild), _conf_dn, recursive=False)
+ self.wd_observer.schedule(ConfigEventHandler(_conf_fn, self.queue_rebuild, loop), _conf_dn, recursive=False)
+ self.wd_observer.start()
- try:
- self.logger.info("Watching files for changes...")
- observer.start()
- except KeyboardInterrupt:
- pass
+ win_sleeper = None
+ # https://bugs.python.org/issue23057 (fixed in Python 3.8)
+ if sys.platform == 'win32' and sys.version_info < (3, 8):
+ win_sleeper = asyncio.ensure_future(windows_ctrlc_workaround())
- parent = self
-
- class Mixed(WebSocketWSGIApplication):
-
- """A class that supports WS and HTTP protocols on the same port."""
+ if not self.has_server:
+ self.logger.info("Watching for changes...")
+ # Run the event loop forever (no server mode).
+ try:
+ # Run rebuild queue
+ loop.run_until_complete(self.run_rebuild_queue())
- def __call__(self, environ, start_response):
- if environ.get('HTTP_UPGRADE') is None:
- return parent.serve_static(environ, start_response)
- return super(Mixed, self).__call__(environ, start_response)
+ loop.run_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ if win_sleeper:
+ win_sleeper.cancel()
+ self.wd_observer.stop()
+ self.wd_observer.join()
+ loop.close()
+ return
- if self.has_server:
- ws = make_server(
- host, port, server_class=WSGIServer,
- handler_class=WebSocketWSGIRequestHandler,
- app=Mixed(handler_cls=LRSocket)
- )
- ws.initialize_websockets_manager()
- self.logger.info("Serving HTTP on {0} port {1}...".format(host, port))
- if browser:
- if options['ipv6'] or '::' in host:
- server_url = "http://[{0}]:{1}/".format(host, port)
- else:
- server_url = "http://{0}:{1}/".format(host, port)
+ if options['ipv6'] or '::' in host:
+ server_url = "http://[{0}]:{1}/".format(host, port)
+ else:
+ server_url = "http://{0}:{1}/".format(host, port)
+ self.logger.info("Serving on {0} ...".format(server_url))
- self.logger.info("Opening {0} in the default web browser...".format(server_url))
- # Yes, this is racy
- webbrowser.open('http://{0}:{1}'.format(host, port))
+ if browser:
+ # Some browsers fail to load 0.0.0.0 (Issue #2755)
+ if host == '0.0.0.0':
+ server_url = "http://127.0.0.1:{0}/".format(port)
+ self.logger.info("Opening {0} in the default web browser...".format(server_url))
+ webbrowser.open(server_url)
- try:
- ws.serve_forever()
- except KeyboardInterrupt:
- self.logger.info("Server is shutting down.")
- # This is a hack, but something is locking up in a futex
- # and exit() doesn't work.
- os.kill(os.getpid(), 15)
- else:
- # Workaround: can’t have nothing running (instant exit)
- # but also can’t join threads (no way to exit)
- # The joys of threading.
- try:
- while True:
- time.sleep(1)
- except KeyboardInterrupt:
- self.logger.info("Shutting down.")
- # This is a hack, but something is locking up in a futex
- # and exit() doesn't work.
- os.kill(os.getpid(), 15)
+ # Run the event loop forever and handle shutdowns.
+ try:
+ # Run rebuild queue
+ rebuild_queue_fut = asyncio.ensure_future(self.run_rebuild_queue())
+ reload_queue_fut = asyncio.ensure_future(self.run_reload_queue())
- def do_rebuild(self, event):
+ self.dns_sd = dns_sd(port, (options['ipv6'] or '::' in host))
+ loop.run_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ self.logger.info("Server is shutting down.")
+ if win_sleeper:
+ win_sleeper.cancel()
+ if self.dns_sd:
+ self.dns_sd.Reset()
+ rebuild_queue_fut.cancel()
+ reload_queue_fut.cancel()
+ loop.run_until_complete(self.web_runner.cleanup())
+ self.wd_observer.stop()
+ self.wd_observer.join()
+ loop.close()
+
+ async def set_up_server(self, host: str, port: int, out_folder: str) -> None:
+ """Set up aiohttp server and start it."""
+ webapp = web.Application()
+ webapp.router.add_get('/livereload.js', self.serve_livereload_js)
+ webapp.router.add_get('/robots.txt', self.serve_robots_txt)
+ webapp.router.add_route('*', '/livereload', self.websocket_handler)
+ resource = IndexHtmlStaticResource(True, self.snippet, '', out_folder)
+ webapp.router.register_resource(resource)
+ webapp.on_shutdown.append(self.remove_websockets)
+
+ self.web_runner = web.AppRunner(webapp)
+ await self.web_runner.setup()
+ website = web.TCPSite(self.web_runner, host, port)
+ await website.start()
+
+ async def run_initial_rebuild(self) -> None:
+ """Run an initial rebuild."""
+ await self._rebuild_site()
+ # If there are any clients, have them reload the root.
+ await self._send_reload_command(self.site.config['INDEX_FILE'])
+
+ async def queue_rebuild(self, event) -> None:
"""Rebuild the site."""
# Move events have a dest_path, some editors like gedit use a
# move on larger save operations for write protection
event_path = event.dest_path if hasattr(event, 'dest_path') else event.src_path
- fname = os.path.basename(event_path)
- if (fname.endswith('~') or
- fname.startswith('.') or
- os.path.isdir(event_path)): # Skip on folders, these are usually duplicates
+ if sys.platform == 'win32':
+ # Windows hidden files support
+ is_hidden = os.stat(event_path).st_file_attributes & stat.FILE_ATTRIBUTE_HIDDEN
+ else:
+ is_hidden = False
+ has_hidden_component = any(p.startswith('.') for p in event_path.split(os.sep))
+ if (is_hidden or has_hidden_component or
+ '__pycache__' in event_path or
+ event_path.endswith(('.pyc', '.pyo', '.pyd', '_bak', '~')) or
+ event.is_directory): # Skip on folders, these are usually duplicates
return
- self.logger.info('REBUILDING SITE (from {0})'.format(event_path))
- p = subprocess.Popen(self.cmd_arguments, stderr=subprocess.PIPE)
- error = p.stderr.read()
- errord = error.decode('utf-8')
- if p.wait() != 0:
- self.logger.error(errord)
- error_signal.send(error=errord)
+
+ self.logger.debug('Queuing rebuild from {0}'.format(event_path))
+ await self.rebuild_queue.put((datetime.datetime.now(), event_path))
+
+ async def run_rebuild_queue(self) -> None:
+ """Run rebuilds from a queue (Nikola can only build in a single instance)."""
+ while True:
+ date, event_path = await self.rebuild_queue.get()
+ if date < (self.last_rebuild + self.delta_last_rebuild):
+ self.logger.debug("Skipping rebuild from {0} (within delta)".format(event_path))
+ continue
+ await self._rebuild_site(event_path)
+
+ async def _rebuild_site(self, event_path: typing.Optional[str] = None) -> None:
+ """Rebuild the site."""
+ self.is_rebuilding = True
+ self.last_rebuild = datetime.datetime.now()
+ if event_path:
+ self.logger.info('REBUILDING SITE (from {0})'.format(event_path))
else:
- print(errord)
+ self.logger.info('REBUILDING SITE')
+
+ p = await asyncio.create_subprocess_exec(*self.nikola_cmd, stderr=subprocess.PIPE)
+ exit_code = await p.wait()
+ out = (await p.stderr.read()).decode('utf-8')
- def do_refresh(self, event):
- """Refresh the page."""
+ if exit_code != 0:
+ self.logger.error("Rebuild failed\n" + out)
+ await self.send_to_websockets({'command': 'alert', 'message': out})
+ else:
+ self.logger.info("Rebuild successful\n" + out)
+
+ self.is_rebuilding = False
+
+ async def run_reload_queue(self) -> None:
+ """Send reloads from a queue to limit CPU usage."""
+ while True:
+ p = await self.reload_queue.get()
+ self.logger.info('REFRESHING: {0}'.format(p))
+ await self._send_reload_command(p)
+ if self.is_rebuilding:
+ await asyncio.sleep(REBUILDING_REFRESH_DELAY)
+ else:
+ await asyncio.sleep(IDLE_REFRESH_DELAY)
+
+ async def _send_reload_command(self, path: str) -> None:
+ """Send a reload command."""
+ await self.send_to_websockets({'command': 'reload', 'path': path, 'liveCSS': True})
+
+ async def reload_page(self, event) -> None:
+ """Reload the page."""
# Move events have a dest_path, some editors like gedit use a
# move on larger save operations for write protection
- event_path = event.dest_path if hasattr(event, 'dest_path') else event.src_path
- self.logger.info('REFRESHING: {0}'.format(event_path))
- p = os.path.relpath(event_path, os.path.abspath(self.site.config['OUTPUT_FOLDER']))
- refresh_signal.send(path=p)
-
- def serve_static(self, environ, start_response):
- """Trivial static file server."""
- uri = wsgiref.util.request_uri(environ)
- p_uri = urlparse(uri)
- f_path = os.path.join(self.site.config['OUTPUT_FOLDER'], *[unquote(x) for x in p_uri.path.split('/')])
-
- # ‘Pretty’ URIs and root are assumed to be HTML
- mimetype = 'text/html' if uri.endswith('/') else mimetypes.guess_type(uri)[0] or 'application/octet-stream'
-
- if os.path.isdir(f_path):
- if not f_path.endswith('/'): # Redirect to avoid breakage
- start_response('301 Redirect', [('Location', p_uri.path + '/')])
- return []
- f_path = os.path.join(f_path, self.site.config['INDEX_FILE'])
- mimetype = 'text/html'
-
- if p_uri.path == '/robots.txt':
- start_response('200 OK', [('Content-type', 'text/plain')])
- return ['User-Agent: *\nDisallow: /\n'.encode('utf-8')]
- elif os.path.isfile(f_path):
- with open(f_path, 'rb') as fd:
- start_response('200 OK', [('Content-type', mimetype)])
- return [self.file_filter(mimetype, fd.read())]
- elif p_uri.path == '/livereload.js':
- with open(LRJS_PATH, 'rb') as fd:
- start_response('200 OK', [('Content-type', mimetype)])
- return [self.file_filter(mimetype, fd.read())]
- start_response('404 ERR', [])
- return [self.file_filter('text/html', ERROR_N.format(404).format(uri).encode('utf-8'))]
-
- def file_filter(self, mimetype, data):
- """Apply necessary changes to document before serving."""
- if mimetype == 'text/html':
- data = data.decode('utf8')
- data = self.remove_base_tag(data)
- data = self.inject_js(data)
- data = data.encode('utf8')
- return data
-
- def inject_js(self, data):
- """Inject livereload.js."""
- data = re.sub('</head>', self.snippet, data, 1, re.IGNORECASE)
- return data
-
- def remove_base_tag(self, data):
- """Comment out any <base> to allow local resolution of relative URLs."""
- data = re.sub(r'<base\s([^>]*)>', '<!--base \g<1>-->', data, re.IGNORECASE)
- return data
-
-
-pending = []
-
-
-class LRSocket(WebSocket):
-
- """Speak Livereload protocol."""
-
- def __init__(self, *a, **kw):
- """Initialize protocol handler."""
- refresh_signal.connect(self.notify)
- error_signal.connect(self.send_error)
- super(LRSocket, self).__init__(*a, **kw)
-
- def received_message(self, message):
- """Handle received message."""
- message = json.loads(message.data.decode('utf8'))
- self.logger.info('<--- {0}'.format(message))
- response = None
- if message['command'] == 'hello': # Handshake
- response = {
- 'command': 'hello',
- 'protocols': [
- 'http://livereload.com/protocols/official-7',
- ],
- 'serverName': 'nikola-livereload',
- }
- elif message['command'] == 'info': # Someone connected
- self.logger.info('****** Browser connected: {0}'.format(message.get('url')))
- self.logger.info('****** sending {0} pending messages'.format(len(pending)))
- while pending:
- msg = pending.pop()
- self.logger.info('---> {0}'.format(msg.data))
- self.send(msg, msg.is_binary)
+ if event:
+ event_path = event.dest_path if hasattr(event, 'dest_path') else event.src_path
else:
- response = {
- 'command': 'alert',
- 'message': 'HEY',
- }
- if response is not None:
- response = json.dumps(response)
- self.logger.info('---> {0}'.format(response))
- response = TextMessage(response)
- self.send(response, response.is_binary)
-
- def notify(self, sender, path):
- """Send reload requests to the client."""
- p = os.path.join('/', path)
- message = {
- 'command': 'reload',
- 'liveCSS': True,
- 'path': p,
- }
- response = json.dumps(message)
- self.logger.info('---> {0}'.format(p))
- response = TextMessage(response)
- if self.stream is None: # No client connected or whatever
- pending.append(response)
- else:
- self.send(response, response.is_binary)
+ event_path = self.site.config['OUTPUT_FOLDER']
+ p = os.path.relpath(event_path, os.path.abspath(self.site.config['OUTPUT_FOLDER'])).replace(os.sep, '/')
+ await self.reload_queue.put(p)
+
+ async def serve_livereload_js(self, request):
+ """Handle requests to /livereload.js and serve the JS file."""
+ return FileResponse(LRJS_PATH)
+
+ async def serve_robots_txt(self, request):
+ """Handle requests to /robots.txt."""
+ return Response(body=b'User-Agent: *\nDisallow: /\n', content_type='text/plain', charset='utf-8')
+
+ async def websocket_handler(self, request):
+ """Handle requests to /livereload and initiate WebSocket communication."""
+ ws = web.WebSocketResponse()
+ await ws.prepare(request)
+ self.sockets.append(ws)
+
+ while True:
+ msg = await ws.receive()
+
+ self.logger.debug("Received message: {0}".format(msg))
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ message = msg.json()
+ if message['command'] == 'hello':
+ response = {
+ 'command': 'hello',
+ 'protocols': [
+ 'http://livereload.com/protocols/official-7',
+ ],
+ 'serverName': 'Nikola Auto (livereload)',
+ }
+ await ws.send_json(response)
+ elif message['command'] != 'info':
+ self.logger.warning("Unknown command in message: {0}".format(message))
+ elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
+ break
+ elif msg.type == aiohttp.WSMsgType.CLOSE:
+ self.logger.debug("Closing WebSocket")
+ await ws.close()
+ break
+ elif msg.type == aiohttp.WSMsgType.ERROR:
+ self.logger.error('WebSocket connection closed with exception {0}'.format(ws.exception()))
+ break
+ else:
+ self.logger.warning("Received unknown message: {0}".format(msg))
+
+ self.sockets.remove(ws)
+ self.logger.debug("WebSocket connection closed: {0}".format(ws))
+
+ return ws
+
+ async def remove_websockets(self, app) -> None:
+ """Remove all websockets."""
+ for ws in self.sockets:
+ await ws.close()
+ self.sockets.clear()
+
+ async def send_to_websockets(self, message: dict) -> None:
+ """Send a message to all open WebSockets."""
+ to_delete = []
+ for ws in self.sockets:
+ if ws.closed:
+ to_delete.append(ws)
+ continue
- def send_error(self, sender, error=None):
- """Send reload requests to the client."""
- if self.stream is None: # No client connected or whatever
- return
- message = {
- 'command': 'alert',
- 'message': error,
- }
- response = json.dumps(message)
- response = TextMessage(response)
- if self.stream is None: # No client connected or whatever
- pending.append(response)
+ try:
+ await ws.send_json(message)
+ if ws._close_code:
+ await ws.close()
+ to_delete.append(ws)
+ except RuntimeError as e:
+ if 'closed' in e.args[0]:
+ self.logger.warning("WebSocket {0} closed uncleanly".format(ws))
+ to_delete.append(ws)
+ else:
+ raise
+
+ for ws in to_delete:
+ self.sockets.remove(ws)
+
+
+async def windows_ctrlc_workaround() -> None:
+ """Work around bpo-23057."""
+ # https://bugs.python.org/issue23057
+ while True:
+ await asyncio.sleep(1)
+
+
+class IndexHtmlStaticResource(StaticResource):
+ """A StaticResource implementation that serves /index.html in directory roots."""
+
+ modify_html = True
+ snippet = "</head>"
+
+ def __init__(self, modify_html=True, snippet="</head>", *args, **kwargs):
+ """Initialize a resource."""
+ self.modify_html = modify_html
+ self.snippet = snippet
+ super().__init__(*args, **kwargs)
+
+ async def _handle(self, request: 'web.Request') -> 'web.Response':
+ """Handle incoming requests (pass to handle_file)."""
+ filename = request.match_info['filename']
+ return await self.handle_file(request, filename)
+
+ async def handle_file(self, request: 'web.Request', filename: str, from_index=None) -> 'web.Response':
+ """Handle file requests."""
+ try:
+ filepath = self._directory.joinpath(filename).resolve()
+ if not self._follow_symlinks:
+ filepath.relative_to(self._directory)
+ except (ValueError, FileNotFoundError) as error:
+ # relatively safe
+ raise HTTPNotFound() from error
+ except Exception as error:
+ # perm error or other kind!
+ request.app.logger.exception(error)
+ raise HTTPNotFound() from error
+
+ # on opening a dir, load it's contents if allowed
+ if filepath.is_dir():
+ if filename.endswith('/') or not filename:
+ ret = await self.handle_file(request, filename + 'index.html', from_index=filename)
+ else:
+ # Redirect and add trailing slash so relative links work (Issue #3140)
+ new_url = request.rel_url.path + '/'
+ if request.rel_url.query_string:
+ new_url += '?' + request.rel_url.query_string
+ raise HTTPMovedPermanently(new_url)
+ elif filepath.is_file():
+ ct, encoding = mimetypes.guess_type(str(filepath))
+ encoding = encoding or 'utf-8'
+ if ct == 'text/html' and self.modify_html:
+ if sys.version_info[0] == 3 and sys.version_info[1] <= 5:
+ # Python 3.4 and 3.5 do not accept pathlib.Path objects in calls to open()
+ filepath = str(filepath)
+ with open(filepath, 'r', encoding=encoding) as fh:
+ text = fh.read()
+ text = self.transform_html(text)
+ ret = Response(text=text, content_type=ct, charset=encoding)
+ else:
+ ret = FileResponse(filepath, chunk_size=self._chunk_size)
+ elif from_index:
+ filepath = self._directory.joinpath(from_index).resolve()
+ try:
+ return Response(text=self._directory_as_html(filepath),
+ content_type="text/html")
+ except PermissionError:
+ raise HTTPForbidden
else:
- self.send(response, response.is_binary)
+ raise HTTPNotFound
+ return ret
-class OurWatchHandler(FileSystemEventHandler):
+ def transform_html(self, text: str) -> str:
+ """Apply some transforms to HTML content."""
+ # Inject livereload.js
+ text = text.replace('</head>', self.snippet, 1)
+ # Disable <base> tag
+ text = re.sub(r'<base\s([^>]*)>', r'<!--base \g<1>-->', text, flags=re.IGNORECASE)
+ return text
- """A Nikola-specific handler for Watchdog."""
- def __init__(self, function):
+# Based on code from the 'hachiko' library by John Biesnecker — thanks!
+# https://github.com/biesnecker/hachiko
+class NikolaEventHandler:
+ """A Nikola-specific event handler for Watchdog. Based on code from hachiko."""
+
+ def __init__(self, function, loop):
"""Initialize the handler."""
self.function = function
- super(OurWatchHandler, self).__init__()
+ self.loop = loop
- def on_any_event(self, event):
- """Call the provided function on any event."""
- self.function(event)
+ async def on_any_event(self, event):
+ """Handle all file events."""
+ await self.function(event)
+ def dispatch(self, event):
+ """Dispatch events to handler."""
+ self.loop.call_soon_threadsafe(asyncio.ensure_future, self.on_any_event(event))
-class ConfigWatchHandler(FileSystemEventHandler):
+class ConfigEventHandler(NikolaEventHandler):
"""A Nikola-specific handler for Watchdog that handles the config file (as a workaround)."""
- def __init__(self, configuration_filename, function):
+ def __init__(self, configuration_filename, function, loop):
"""Initialize the handler."""
self.configuration_filename = configuration_filename
self.function = function
+ self.loop = loop
- def on_any_event(self, event):
- """Call the provided function on any event."""
+ async def on_any_event(self, event):
+ """Handle file events if they concern the configuration file."""
if event._src_path == self.configuration_filename:
- self.function(event)
-
-
-try:
- # Monkeypatch to hide Broken Pipe Errors
- f = WebSocketWSGIHandler.finish_response
-
- if sys.version_info[0] == 3:
- EX = BrokenPipeError # NOQA
- else:
- EX = IOError
-
- def finish_response(self):
- """Monkeypatched finish_response that ignores broken pipes."""
- try:
- f(self)
- except EX: # Client closed the connection, not a real error
- pass
-
- WebSocketWSGIHandler.finish_response = finish_response
-except NameError:
- # In case there is no WebSocketWSGIHandler because of a failed import.
- pass
+ await self.function(event)
diff --git a/nikola/plugins/command/auto/livereload.js b/nikola/plugins/command/auto/livereload.js
index b4cafb3..282dce5 120000
--- a/nikola/plugins/command/auto/livereload.js
+++ b/nikola/plugins/command/auto/livereload.js
@@ -1 +1 @@
-../../../../bower_components/livereload-js/dist/livereload.js \ No newline at end of file
+../../../../npm_assets/node_modules/livereload-js/dist/livereload.js \ No newline at end of file
diff --git a/nikola/plugins/command/bootswatch_theme.plugin b/nikola/plugins/command/bootswatch_theme.plugin
deleted file mode 100644
index fc25045..0000000
--- a/nikola/plugins/command/bootswatch_theme.plugin
+++ /dev/null
@@ -1,13 +0,0 @@
-[Core]
-name = bootswatch_theme
-module = bootswatch_theme
-
-[Documentation]
-author = Roberto Alsina
-version = 1.0
-website = http://getnikola.com
-description = Given a swatch name and a parent theme, creates a custom theme.
-
-[Nikola]
-plugincategory = Command
-
diff --git a/nikola/plugins/command/bootswatch_theme.py b/nikola/plugins/command/bootswatch_theme.py
deleted file mode 100644
index b5644a1..0000000
--- a/nikola/plugins/command/bootswatch_theme.py
+++ /dev/null
@@ -1,106 +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.
-
-"""Given a swatch name from bootswatch.com and a parent theme, creates a custom theme."""
-
-from __future__ import print_function
-import os
-import requests
-
-from nikola.plugin_categories import Command
-from nikola import utils
-
-LOGGER = utils.get_logger('bootswatch_theme', utils.STDERR_HANDLER)
-
-
-class CommandBootswatchTheme(Command):
-
- """Given a swatch name from bootswatch.com and a parent theme, creates a custom theme."""
-
- name = "bootswatch_theme"
- doc_usage = "[options]"
- doc_purpose = "given a swatch name from bootswatch.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 (default: custom)',
- },
- {
- 'name': 'swatch',
- 'short': 's',
- 'default': '',
- 'type': str,
- 'help': 'Name of the swatch from bootswatch.com.'
- },
- {
- 'name': 'parent',
- 'short': 'p',
- 'long': 'parent',
- 'default': 'bootstrap3',
- 'help': 'Parent theme name (default: bootstrap3)',
- },
- ]
-
- 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 = ''
-
- # See if we need bootswatch for bootstrap v2 or v3
- themes = utils.get_theme_chain(parent)
- if 'bootstrap3' not in themes and 'bootstrap3-jinja' not in themes:
- version = '2'
- elif 'bootstrap' not in themes and 'bootstrap-jinja' not in themes:
- LOGGER.warn('"bootswatch_theme" only makes sense for themes that use bootstrap')
- elif 'bootstrap3-gradients' in themes or 'bootstrap3-gradients-jinja' in themes:
- LOGGER.warn('"bootswatch_theme" 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'):
- url = 'http://bootswatch.com'
- if version:
- url += '/' + version
- url = '/'.join((url, swatch, fname))
- LOGGER.info("Downloading: " + url)
- data = requests.get(url).text
- with open(os.path.join('themes', name, 'assets', 'css', fname),
- 'wb+') as output:
- output.write(data.encode('utf-8'))
-
- with open(os.path.join('themes', name, 'parent'), 'wb+') as output:
- output.write(parent.encode('utf-8'))
- LOGGER.notice('Theme created. Change the THEME setting to "{0}" to use it.'.format(name))
diff --git a/nikola/plugins/command/check.plugin b/nikola/plugins/command/check.plugin
index e380e64..bc6ede3 100644
--- a/nikola/plugins/command/check.plugin
+++ b/nikola/plugins/command/check.plugin
@@ -5,9 +5,9 @@ module = check
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Check the generated site
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/check.py b/nikola/plugins/command/check.py
index abf183e..cac6000 100644
--- a/nikola/plugins/command/check.py
+++ b/nikola/plugins/command/check.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,27 +26,25 @@
"""Check the generated site."""
-from __future__ import print_function
-from collections import defaultdict
+import logging
import os
import re
import sys
import time
-try:
- from urllib import unquote
- from urlparse import urlparse, urljoin, urldefrag
-except ImportError:
- from urllib.parse import unquote, urlparse, urljoin, urldefrag # NOQA
+from collections import defaultdict
+from urllib.parse import unquote, urlparse, urljoin, urldefrag
-from doit.loader import generate_tasks
import lxml.html
import requests
+from doit.loader import generate_tasks
from nikola.plugin_categories import Command
-from nikola.utils import get_logger, STDERR_HANDLER
-def _call_nikola_list(site):
+def _call_nikola_list(site, cache=None):
+ if cache is not None:
+ if 'files' in cache and 'deps' in cache:
+ return cache['files'], cache['deps']
files = []
deps = defaultdict(list)
for task in generate_tasks('render_site', site.gen_tasks('render_site', "Task", '')):
@@ -57,16 +55,19 @@ def _call_nikola_list(site):
files.extend(task.targets)
for target in task.targets:
deps[target].extend(task.file_dep)
+ if cache is not None:
+ cache['files'] = files
+ cache['deps'] = deps
return files, deps
-def real_scan_files(site):
+def real_scan_files(site, cache=None):
"""Scan for files."""
task_fnames = set([])
real_fnames = set([])
output_folder = site.config['OUTPUT_FOLDER']
# First check that all targets are generated in the right places
- for fname in _call_nikola_list(site)[0]:
+ for fname in _call_nikola_list(site, cache)[0]:
fname = fname.strip()
if fname.startswith(output_folder):
task_fnames.add(fname)
@@ -94,11 +95,9 @@ def fs_relpath_from_url_path(url_path):
class CommandCheck(Command):
-
"""Check the generated site."""
name = "check"
- logger = None
doc_usage = "[-v] (-l [--find-sources] [-r] | -f [--clean-files])"
doc_purpose = "check links and files in the generated site"
@@ -153,39 +152,41 @@ class CommandCheck(Command):
def _execute(self, options, args):
"""Check the generated site."""
- self.logger = get_logger('check', STDERR_HANDLER)
-
if not options['links'] and not options['files'] and not options['clean']:
print(self.help())
- return False
+ return 1
if options['verbose']:
- self.logger.level = 1
+ self.logger.level = logging.DEBUG
else:
- self.logger.level = 4
+ self.logger.level = logging.WARNING
+ failure = False
if options['links']:
- failure = self.scan_links(options['find_sources'], options['remote'])
+ failure |= self.scan_links(options['find_sources'], options['remote'])
if options['files']:
- failure = self.scan_files()
+ failure |= self.scan_files()
if options['clean']:
- failure = self.clean_files()
+ failure |= self.clean_files()
if failure:
return 1
existing_targets = set([])
checked_remote_targets = {}
+ cache = {}
def analyze(self, fname, find_sources=False, check_remote=False):
"""Analyze links on a page."""
rv = False
self.whitelist = [re.compile(x) for x in self.site.config['LINK_CHECK_WHITELIST']]
+ self.internal_redirects = [urljoin('/', _[0]) for _ in self.site.config['REDIRECTIONS']]
base_url = urlparse(self.site.config['BASE_URL'])
self.existing_targets.add(self.site.config['SITE_URL'])
self.existing_targets.add(self.site.config['BASE_URL'])
url_type = self.site.config['URL_TYPE']
+ atom_extension = self.site.config['ATOM_EXTENSION']
deps = {}
if find_sources:
- deps = _call_nikola_list(self.site)[1]
+ deps = _call_nikola_list(self.site, self.cache)[1]
if url_type in ('absolute', 'full_path'):
url_netloc_to_root = urlparse(self.site.config['BASE_URL']).path
@@ -196,24 +197,66 @@ class CommandCheck(Command):
# Do not look at links in the cache, which are not parsed by
# anyone and may result in false positives. Problems arise
# with galleries, for example. Full rationale: (Issue #1447)
- self.logger.notice("Ignoring {0} (in cache, links may be incorrect)".format(filename))
+ self.logger.warning("Ignoring {0} (in cache, links may be incorrect)".format(filename))
return False
if not os.path.exists(fname):
# Quietly ignore files that don’t exist; use `nikola check -f` instead (Issue #1831)
return False
- d = lxml.html.fromstring(open(filename, 'rb').read())
- for l in d.iterlinks():
+ if '.html' == fname[-5:]:
+ with open(filename, 'rb') as inf:
+ d = lxml.html.fromstring(inf.read())
+ extra_objs = lxml.html.fromstring('<html/>')
+
+ # Turn elements with a srcset attribute into individual img elements with src attributes
+ for obj in list(d.xpath('(*//img|*//source)')):
+ if 'srcset' in obj.attrib:
+ for srcset_item in obj.attrib['srcset'].split(','):
+ extra_objs.append(lxml.etree.Element('img', src=srcset_item.strip().split(' ')[0]))
+ link_elements = list(d.iterlinks()) + list(extra_objs.iterlinks())
+ # Extract links from XML formats to minimal HTML, allowing those to go through the link checks
+ elif atom_extension == filename[-len(atom_extension):]:
+ d = lxml.etree.parse(filename)
+ link_elements = lxml.html.fromstring('<html/>')
+ for elm in d.findall('*//{http://www.w3.org/2005/Atom}link'):
+ feed_link = elm.attrib['href'].split('?')[0].strip() # strip FEED_LINKS_APPEND_QUERY
+ link_elements.append(lxml.etree.Element('a', href=feed_link))
+ link_elements = list(link_elements.iterlinks())
+ elif filename.endswith('sitemap.xml') or filename.endswith('sitemapindex.xml'):
+ d = lxml.etree.parse(filename)
+ link_elements = lxml.html.fromstring('<html/>')
+ for elm in d.getroot().findall("*//{http://www.sitemaps.org/schemas/sitemap/0.9}loc"):
+ link_elements.append(lxml.etree.Element('a', href=elm.text.strip()))
+ link_elements = list(link_elements.iterlinks())
+ else: # unsupported file type
+ return False
+
+ for l in link_elements:
target = l[2]
if target == "#":
continue
- target, _ = urldefrag(target)
+ target = urldefrag(target)[0]
+
+ if any([urlparse(target).netloc.endswith(_) for _ in ['example.com', 'example.net', 'example.org']]):
+ self.logger.debug("Not testing example address \"{0}\".".format(target))
+ continue
+
+ # absolute URL to root-relative
+ if target.startswith(base_url.geturl()):
+ target = target.replace(base_url.geturl(), '/')
+
parsed = urlparse(target)
# Warn about links from https to http (mixed-security)
if base_url.netloc == parsed.netloc and base_url.scheme == "https" and parsed.scheme == "http":
- self.logger.warn("Mixed-content security for link in {0}: {1}".format(filename, target))
+ self.logger.warning("Mixed-content security for link in {0}: {1}".format(filename, target))
+
+ # Link to an internal REDIRECTIONS page
+ if target in self.internal_redirects:
+ redir_status_code = 301
+ redir_target = [_dest for _target, _dest in self.site.config['REDIRECTIONS'] if urljoin('/', _target) == target][0]
+ self.logger.warning("Remote link moved PERMANENTLY to \"{0}\" and should be updated in {1}: {2} [HTTP: 301]".format(redir_target, filename, target))
# Absolute links to other domains, skip
# Absolute links when using only paths, skip.
@@ -221,19 +264,17 @@ class CommandCheck(Command):
((parsed.scheme or target.startswith('//')) and url_type in ('rel_path', 'full_path')):
if not check_remote or parsed.scheme not in ["http", "https"]:
continue
- if parsed.netloc == base_url.netloc: # absolute URL to self.site
- continue
if target in self.checked_remote_targets: # already checked this exact target
- if self.checked_remote_targets[target] in [301, 307]:
- self.logger.warn("Remote link PERMANENTLY redirected in {0}: {1} [Error {2}]".format(filename, target, self.checked_remote_targets[target]))
- elif self.checked_remote_targets[target] in [302, 308]:
- self.logger.info("Remote link temporarily redirected in {1}: {2} [HTTP: {3}]".format(filename, target, self.checked_remote_targets[target]))
+ if self.checked_remote_targets[target] in [301, 308]:
+ self.logger.warning("Remote link PERMANENTLY redirected in {0}: {1} [Error {2}]".format(filename, target, self.checked_remote_targets[target]))
+ elif self.checked_remote_targets[target] in [302, 307]:
+ self.logger.debug("Remote link temporarily redirected in {0}: {1} [HTTP: {2}]".format(filename, target, self.checked_remote_targets[target]))
elif self.checked_remote_targets[target] > 399:
self.logger.error("Broken link in {0}: {1} [Error {2}]".format(filename, target, self.checked_remote_targets[target]))
continue
# Skip whitelisted targets
- if any(re.search(_, target) for _ in self.whitelist):
+ if any(pattern.search(target) for pattern in self.whitelist):
continue
# Check the remote link works
@@ -253,9 +294,9 @@ class CommandCheck(Command):
resp = requests.get(target, headers=req_headers, allow_redirects=True)
# Permanent redirects should be updated
if redir_status_code in [301, 308]:
- self.logger.warn("Remote link moved PERMANENTLY to \"{0}\" and should be updated in {1}: {2} [HTTP: {3}]".format(resp.url, filename, target, redir_status_code))
+ self.logger.warning("Remote link moved PERMANENTLY to \"{0}\" and should be updated in {1}: {2} [HTTP: {3}]".format(resp.url, filename, target, redir_status_code))
if redir_status_code in [302, 307]:
- self.logger.info("Remote link temporarily redirected to \"{0}\" in {1}: {2} [HTTP: {3}]".format(resp.url, filename, target, redir_status_code))
+ self.logger.debug("Remote link temporarily redirected to \"{0}\" in {1}: {2} [HTTP: {3}]".format(resp.url, filename, target, redir_status_code))
self.checked_remote_targets[resp.url] = resp.status_code
self.checked_remote_targets[target] = redir_status_code
else:
@@ -267,7 +308,7 @@ class CommandCheck(Command):
elif resp.status_code <= 399: # The address leads *somewhere* that is not an error
self.logger.debug("Successfully checked remote link in {0}: {1} [HTTP: {2}]".format(filename, target, resp.status_code))
continue
- self.logger.warn("Could not check remote link in {0}: {1} [Unknown problem]".format(filename, target))
+ self.logger.warning("Could not check remote link in {0}: {1} [Unknown problem]".format(filename, target))
continue
if url_type == 'rel_path':
@@ -275,60 +316,95 @@ class CommandCheck(Command):
target_filename = os.path.abspath(
os.path.join(self.site.config['OUTPUT_FOLDER'], unquote(target.lstrip('/'))))
else: # Relative path
+ unquoted_target = unquote(target).encode('utf-8')
target_filename = os.path.abspath(
- os.path.join(os.path.dirname(filename), unquote(target)))
+ os.path.join(os.path.dirname(filename).encode('utf-8'), unquoted_target))
- elif url_type in ('full_path', 'absolute'):
+ else:
+ relative = False
if url_type == 'absolute':
# convert to 'full_path' case, ie url relative to root
- url_rel_path = parsed.path[len(url_netloc_to_root):]
+ if parsed.path.startswith(url_netloc_to_root):
+ url_rel_path = parsed.path[len(url_netloc_to_root):]
+ else:
+ url_rel_path = parsed.path
+ if not url_rel_path.startswith('/'):
+ relative = True
else:
# convert to relative to base path
- url_rel_path = target[len(url_netloc_to_root):]
+ if target.startswith(url_netloc_to_root):
+ url_rel_path = target[len(url_netloc_to_root):]
+ else:
+ url_rel_path = target
+ if not url_rel_path.startswith('/'):
+ relative = True
if url_rel_path == '' or url_rel_path.endswith('/'):
url_rel_path = urljoin(url_rel_path, self.site.config['INDEX_FILE'])
- fs_rel_path = fs_relpath_from_url_path(url_rel_path)
- target_filename = os.path.join(self.site.config['OUTPUT_FOLDER'], fs_rel_path)
+ if relative:
+ unquoted_target = unquote(target).encode('utf-8')
+ target_filename = os.path.abspath(
+ os.path.join(os.path.dirname(filename).encode('utf-8'), unquoted_target))
+ else:
+ fs_rel_path = fs_relpath_from_url_path(url_rel_path)
+ target_filename = os.path.join(self.site.config['OUTPUT_FOLDER'], fs_rel_path)
- if any(re.search(x, target_filename) for x in self.whitelist):
+ if isinstance(target_filename, str):
+ target_filename_str = target_filename
+ else:
+ target_filename_str = target_filename.decode("utf-8", errors="surrogateescape")
+
+ if any(pattern.search(target_filename_str) for pattern in self.whitelist):
continue
+
elif target_filename not in self.existing_targets:
if os.path.exists(target_filename):
- self.logger.notice("Good link {0} => {1}".format(target, target_filename))
+ self.logger.info("Good link {0} => {1}".format(target, target_filename))
self.existing_targets.add(target_filename)
else:
rv = True
- self.logger.warn("Broken link in {0}: {1}".format(filename, target))
+ self.logger.warning("Broken link in {0}: {1}".format(filename, target))
if find_sources:
- self.logger.warn("Possible sources:")
- self.logger.warn("\n".join(deps[filename]))
- self.logger.warn("===============================\n")
+ self.logger.warning("Possible sources:")
+ self.logger.warning("\n".join(deps[filename]))
+ self.logger.warning("===============================\n")
except Exception as exc:
- self.logger.error("Error with: {0} {1}".format(filename, exc))
+ self.logger.error(u"Error with: {0} {1}".format(filename, exc))
return rv
def scan_links(self, find_sources=False, check_remote=False):
"""Check links on the site."""
- self.logger.info("Checking Links:")
- self.logger.info("===============\n")
- self.logger.notice("{0} mode".format(self.site.config['URL_TYPE']))
+ self.logger.debug("Checking Links:")
+ self.logger.debug("===============\n")
+ self.logger.debug("{0} mode".format(self.site.config['URL_TYPE']))
failure = False
+ atom_extension = self.site.config['ATOM_EXTENSION']
# Maybe we should just examine all HTML files
output_folder = self.site.config['OUTPUT_FOLDER']
- for fname in _call_nikola_list(self.site)[0]:
- if fname.startswith(output_folder) and '.html' == fname[-5:]:
- if self.analyze(fname, find_sources, check_remote):
- failure = True
+
+ if urlparse(self.site.config['BASE_URL']).netloc == 'example.com':
+ self.logger.error("You've not changed the SITE_URL (or BASE_URL) setting from \"example.com\"!")
+
+ for fname in _call_nikola_list(self.site, self.cache)[0]:
+ if fname.startswith(output_folder):
+ if '.html' == fname[-5:]:
+ if self.analyze(fname, find_sources, check_remote):
+ failure = True
+ if atom_extension == fname[-len(atom_extension):]:
+ if self.analyze(fname, find_sources, False):
+ failure = True
+ if fname.endswith('sitemap.xml') or fname.endswith('sitemapindex.xml'):
+ if self.analyze(fname, find_sources, False):
+ failure = True
if not failure:
- self.logger.info("All links checked.")
+ self.logger.debug("All links checked.")
return failure
def scan_files(self):
"""Check files in the site, find missing and orphaned files."""
failure = False
- self.logger.info("Checking Files:")
- self.logger.info("===============\n")
- only_on_output, only_on_input = real_scan_files(self.site)
+ self.logger.debug("Checking Files:")
+ self.logger.debug("===============\n")
+ only_on_output, only_on_input = real_scan_files(self.site, self.cache)
# Ignore folders
only_on_output = [p for p in only_on_output if not os.path.isdir(p)]
@@ -336,26 +412,28 @@ class CommandCheck(Command):
if only_on_output:
only_on_output.sort()
- self.logger.warn("Files from unknown origins (orphans):")
+ self.logger.warning("Files from unknown origins (orphans):")
for f in only_on_output:
- self.logger.warn(f)
+ self.logger.warning(f)
failure = True
if only_on_input:
only_on_input.sort()
- self.logger.warn("Files not generated:")
+ self.logger.warning("Files not generated:")
for f in only_on_input:
- self.logger.warn(f)
+ self.logger.warning(f)
if not failure:
- self.logger.info("All files checked.")
+ self.logger.debug("All files checked.")
return failure
def clean_files(self):
"""Remove orphaned files."""
- only_on_output, _ = real_scan_files(self.site)
+ only_on_output, _ = real_scan_files(self.site, self.cache)
for f in only_on_output:
- self.logger.info('removed: {0}'.format(f))
+ self.logger.debug('removed: {0}'.format(f))
os.unlink(f)
+ warn_flag = bool(only_on_output)
+
# Find empty directories and remove them
output_folder = self.site.config['OUTPUT_FOLDER']
all_dirs = []
@@ -365,7 +443,13 @@ class CommandCheck(Command):
for d in all_dirs:
try:
os.rmdir(d)
- self.logger.info('removed: {0}/'.format(d))
+ self.logger.debug('removed: {0}/'.format(d))
+ warn_flag = True
except OSError:
pass
- return True
+
+ if warn_flag:
+ self.logger.warning('Some files or directories have been removed, your site may need rebuilding')
+ return True
+
+ return False
diff --git a/nikola/plugins/command/console.plugin b/nikola/plugins/command/console.plugin
index 333762c..35e3585 100644
--- a/nikola/plugins/command/console.plugin
+++ b/nikola/plugins/command/console.plugin
@@ -5,9 +5,9 @@ module = console
[Documentation]
author = Chris Warrick, Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Start a debugging python console
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/console.py b/nikola/plugins/command/console.py
index 539fa08..b4342b4 100644
--- a/nikola/plugins/command/console.py
+++ b/nikola/plugins/command/console.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2015 Chris Warrick, Roberto Alsina and others.
+# Copyright © 2012-2020 Chris Warrick, 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,26 @@
"""Start debugging console."""
-from __future__ import print_function, unicode_literals
import os
from nikola import __version__
from nikola.plugin_categories import Command
-from nikola.utils import get_logger, STDERR_HANDLER, req_missing, Commands
+from nikola.utils import get_logger, req_missing, Commands
-LOGGER = get_logger('console', STDERR_HANDLER)
+LOGGER = get_logger('console')
class CommandConsole(Command):
-
"""Start debugging console."""
name = "console"
shells = ['ipython', 'bpython', 'plain']
doc_purpose = "start an interactive Python console with access to your site"
doc_description = """\
-The site engine is accessible as `site`, the config file as `conf`, and commands are available as `commands`.
+The site engine is accessible as `site` and `nikola_site`, the config file as `conf`, and commands are available as `commands`.
If there is no console to use specified (as -b, -i, -p) it tries IPython, then falls back to bpython, and finally falls back to the plain Python console."""
- header = "Nikola v" + __version__ + " -- {0} Console (conf = configuration file, site = site engine, commands = nikola commands)"
+ header = "Nikola v" + __version__ + " -- {0} Console (conf = configuration file, site, nikola_site = site engine, commands = nikola commands)"
cmd_options = [
{
'name': 'bpython',
@@ -73,35 +71,52 @@ If there is no console to use specified (as -b, -i, -p) it tries IPython, then f
'default': False,
'help': 'Use the plain Python interpreter',
},
+ {
+ 'name': 'command',
+ 'short': 'c',
+ 'long': 'command',
+ 'type': str,
+ 'default': None,
+ 'help': 'Run a single command',
+ },
+ {
+ 'name': 'script',
+ 'short': 's',
+ 'long': 'script',
+ 'type': str,
+ 'default': None,
+ 'help': 'Execute a python script in the console context',
+ },
]
def ipython(self, willful=True):
- """IPython shell."""
+ """Run an IPython shell."""
try:
import IPython
- except ImportError as e:
+ except ImportError:
if willful:
req_missing(['IPython'], 'use the IPython console')
- raise e # That’s how _execute knows whether to try something else.
+ raise # That’s how _execute knows whether to try something else.
else:
site = self.context['site'] # NOQA
+ nikola_site = self.context['nikola_site'] # NOQA
conf = self.context['conf'] # NOQA
commands = self.context['commands'] # NOQA
IPython.embed(header=self.header.format('IPython'))
def bpython(self, willful=True):
- """bpython shell."""
+ """Run a bpython shell."""
try:
import bpython
- except ImportError as e:
+ except ImportError:
if willful:
req_missing(['bpython'], 'use the bpython console')
- raise e # That’s how _execute knows whether to try something else.
+ raise # That’s how _execute knows whether to try something else.
else:
bpython.embed(banner=self.header.format('bpython'), locals_=self.context)
def plain(self, willful=True):
- """Plain Python shell."""
+ """Run a plain Python shell."""
import code
try:
import readline
@@ -131,9 +146,16 @@ If there is no console to use specified (as -b, -i, -p) it tries IPython, then f
self.context = {
'conf': self.site.config,
'site': self.site,
+ 'nikola_site': self.site,
'commands': self.site.commands,
}
- if options['bpython']:
+ if options['command']:
+ exec(options['command'], None, self.context)
+ elif options['script']:
+ with open(options['script']) as inf:
+ code = compile(inf.read(), options['script'], 'exec')
+ exec(code, None, self.context)
+ elif options['bpython']:
self.bpython(True)
elif options['ipython']:
self.ipython(True)
diff --git a/nikola/plugins/command/default_config.plugin b/nikola/plugins/command/default_config.plugin
new file mode 100644
index 0000000..af279f6
--- /dev/null
+++ b/nikola/plugins/command/default_config.plugin
@@ -0,0 +1,13 @@
+[Core]
+name = default_config
+module = default_config
+
+[Documentation]
+author = Roberto Alsina
+version = 1.0
+website = https://getnikola.com/
+description = Show the default configuration.
+
+[Nikola]
+PluginCategory = Command
+
diff --git a/nikola/plugins/command/default_config.py b/nikola/plugins/command/default_config.py
new file mode 100644
index 0000000..036f4d1
--- /dev/null
+++ b/nikola/plugins/command/default_config.py
@@ -0,0 +1,54 @@
+# -*- 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.
+
+"""Show the default configuration."""
+
+import sys
+
+import nikola.plugins.command.init
+from nikola.plugin_categories import Command
+from nikola.utils import get_logger
+
+
+LOGGER = get_logger('default_config')
+
+
+class CommandShowConfig(Command):
+ """Show the default configuration."""
+
+ name = "default_config"
+
+ doc_usage = ""
+ needs_config = False
+ doc_purpose = "Print the default Nikola configuration."
+ cmd_options = []
+
+ def _execute(self, options=None, args=None):
+ """Show the default configuration."""
+ try:
+ print(nikola.plugins.command.init.CommandInit.create_configuration_to_string())
+ except Exception:
+ sys.stdout.buffer.write(nikola.plugins.command.init.CommandInit.create_configuration_to_string().encode('utf-8'))
diff --git a/nikola/plugins/command/deploy.plugin b/nikola/plugins/command/deploy.plugin
index 4743ca2..7cff28d 100644
--- a/nikola/plugins/command/deploy.plugin
+++ b/nikola/plugins/command/deploy.plugin
@@ -5,9 +5,9 @@ module = deploy
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Deploy the site
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/deploy.py b/nikola/plugins/command/deploy.py
index 821ea11..5273b58 100644
--- a/nikola/plugins/command/deploy.py
+++ b/nikola/plugins/command/deploy.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,57 +26,48 @@
"""Deploy site."""
-from __future__ import print_function
-import io
-from datetime import datetime
-from dateutil.tz import gettz
-import os
import subprocess
import time
+from datetime import datetime
+import dateutil
from blinker import signal
+from dateutil.tz import gettz
from nikola.plugin_categories import Command
-from nikola.utils import get_logger, remove_file, unicode_str, makedirs, STDERR_HANDLER
+from nikola.utils import clean_before_deployment
class CommandDeploy(Command):
-
"""Deploy site."""
name = "deploy"
- doc_usage = "[[preset [preset...]]"
+ doc_usage = "[preset [preset...]]"
doc_purpose = "deploy the site"
doc_description = "Deploy the site by executing deploy commands from the presets listed on the command line. If no presets are specified, `default` is executed."
- logger = None
def _execute(self, command, args):
"""Execute the deploy command."""
- self.logger = get_logger('deploy', STDERR_HANDLER)
- # Get last successful deploy date
- timestamp_path = os.path.join(self.site.config['CACHE_FOLDER'], 'lastdeploy')
- if self.site.config['COMMENT_SYSTEM_ID'] == 'nikolademo':
- self.logger.warn("\nWARNING WARNING WARNING WARNING\n"
- "You are deploying using the nikolademo Disqus account.\n"
- "That means you will not be able to moderate the comments in your own site.\n"
- "And is probably not what you want to do.\n"
- "Think about it for 5 seconds, I'll wait :-)\n\n")
+ # Get last-deploy from persistent state
+ last_deploy = self.site.state.get('last_deploy')
+ if last_deploy is not None:
+ last_deploy = dateutil.parser.parse(last_deploy)
+ clean = False
+
+ if self.site.config['COMMENT_SYSTEM'] and self.site.config['COMMENT_SYSTEM_ID'] == 'nikolademo':
+ self.logger.warning("\nWARNING WARNING WARNING WARNING\n"
+ "You are deploying using the nikolademo Disqus account.\n"
+ "That means you will not be able to moderate the comments in your own site.\n"
+ "And is probably not what you want to do.\n"
+ "Think about it for 5 seconds, I'll wait :-)\n"
+ "(press Ctrl+C to abort)\n")
time.sleep(5)
- deploy_drafts = self.site.config.get('DEPLOY_DRAFTS', True)
- deploy_future = self.site.config.get('DEPLOY_FUTURE', False)
- undeployed_posts = []
- if not (deploy_drafts and deploy_future):
- # Remove drafts and future posts
- out_dir = self.site.config['OUTPUT_FOLDER']
- self.site.scan_posts()
- for post in self.site.timeline:
- if (not deploy_drafts and post.is_draft) or \
- (not deploy_future and post.publish_later):
- remove_file(os.path.join(out_dir, post.destination_path()))
- remove_file(os.path.join(out_dir, post.source_path))
- undeployed_posts.append(post)
+ # Remove drafts and future posts if requested
+ undeployed_posts = clean_before_deployment(self.site)
+ if undeployed_posts:
+ self.logger.warning("Deleted {0} posts due to DEPLOY_* settings".format(len(undeployed_posts)))
if args:
presets = args
@@ -87,7 +78,7 @@ class CommandDeploy(Command):
for preset in presets:
try:
self.site.config['DEPLOY_COMMANDS'][preset]
- except:
+ except KeyError:
self.logger.error('No such preset: {0}'.format(preset))
return 255
@@ -98,27 +89,22 @@ class CommandDeploy(Command):
try:
subprocess.check_call(command, shell=True)
except subprocess.CalledProcessError as e:
- self.logger.error('Failed deployment — command {0} '
+ self.logger.error('Failed deployment -- command {0} '
'returned {1}'.format(e.cmd, e.returncode))
return e.returncode
self.logger.info("Successful deployment")
- 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")
- clean = False
- except (IOError, Exception) as e:
- self.logger.debug("Problem when reading `{0}`: {1}".format(timestamp_path, e))
- last_deploy = datetime(1970, 1, 1)
- clean = True
new_deploy = datetime.utcnow()
self._emit_deploy_event(last_deploy, new_deploy, clean, undeployed_posts)
- makedirs(self.site.config['CACHE_FOLDER'])
# Store timestamp of successful deployment
- with io.open(timestamp_path, 'w+', encoding='utf8') as outf:
- outf.write(unicode_str(new_deploy.isoformat()))
+ self.site.state.set('last_deploy', new_deploy.isoformat())
+ if clean:
+ self.logger.info(
+ 'Looks like this is the first time you deployed this site. '
+ 'Let us know you are using Nikola '
+ 'at <https://users.getnikola.com/add/> if you want!')
def _emit_deploy_event(self, last_deploy, new_deploy, clean=False, undeployed=None):
"""Emit events for all timeline entries newer than last deploy.
diff --git a/nikola/plugins/command/github_deploy.plugin b/nikola/plugins/command/github_deploy.plugin
index e793548..fbdd3bf 100644
--- a/nikola/plugins/command/github_deploy.plugin
+++ b/nikola/plugins/command/github_deploy.plugin
@@ -5,9 +5,9 @@ module = github_deploy
[Documentation]
author = Puneeth Chaganti
version = 1,0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Deploy the site to GitHub pages.
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/github_deploy.py b/nikola/plugins/command/github_deploy.py
index 0ab9332..d2c1f3f 100644
--- a/nikola/plugins/command/github_deploy.py
+++ b/nikola/plugins/command/github_deploy.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2014-2015 Puneeth Chaganti and others.
+# Copyright © 2014-2020 Puneeth Chaganti and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -26,16 +26,13 @@
"""Deploy site to GitHub Pages."""
-from __future__ import print_function
-from datetime import datetime
-import io
import os
import subprocess
from textwrap import dedent
from nikola.plugin_categories import Command
from nikola.plugins.command.check import real_scan_files
-from nikola.utils import get_logger, req_missing, makedirs, unicode_str, STDERR_HANDLER
+from nikola.utils import req_missing, clean_before_deployment
from nikola.__main__ import main
from nikola import __version__
@@ -53,32 +50,41 @@ def check_ghp_import_installed():
except OSError:
# req_missing defaults to `python=True` — and it’s meant to be like this.
# `ghp-import` is installed via pip, but the only way to use it is by executing the script it installs.
- req_missing(['ghp-import'], 'deploy the site to GitHub Pages')
+ req_missing(['ghp-import2'], 'deploy the site to GitHub Pages')
-class CommandGitHubDeploy(Command):
+class DeployFailedException(Exception):
+ """An internal exception for deployment errors."""
+
+ pass
+
+class CommandGitHubDeploy(Command):
"""Deploy site to GitHub Pages."""
name = 'github_deploy'
- doc_usage = ''
+ doc_usage = '[-m COMMIT_MESSAGE]'
doc_purpose = 'deploy the site to GitHub Pages'
doc_description = dedent(
"""\
- This command can be used to deploy your site to GitHub Pages.
+ This command can be used to deploy your site to GitHub Pages. It uses ghp-import to do this task. It also optionally commits to the source branch.
- It uses ghp-import to do this task.
-
- """
+ Configuration help: https://getnikola.com/handbook.html#deploying-to-github"""
)
-
- logger = None
-
- def _execute(self, command, args):
+ cmd_options = [
+ {
+ 'name': 'commit_message',
+ 'short': 'm',
+ 'long': 'message',
+ 'default': 'Nikola auto commit.',
+ 'type': str,
+ 'help': 'Commit message',
+ },
+ ]
+
+ def _execute(self, options, args):
"""Run the deployment."""
- self.logger = get_logger(CommandGitHubDeploy.name, STDERR_HANDLER)
-
# Check if ghp-import is installed
check_ghp_import_installed()
@@ -93,41 +99,74 @@ class CommandGitHubDeploy(Command):
for f in only_on_output:
os.unlink(f)
- # Commit and push
- self._commit_and_push()
-
- return
+ # Remove drafts and future posts if requested (Issue #2406)
+ undeployed_posts = clean_before_deployment(self.site)
+ if undeployed_posts:
+ self.logger.warning("Deleted {0} posts due to DEPLOY_* settings".format(len(undeployed_posts)))
- def _commit_and_push(self):
- """Commit all the files and push."""
- source = self.site.config['GITHUB_SOURCE_BRANCH']
- deploy = self.site.config['GITHUB_DEPLOY_BRANCH']
- remote = self.site.config['GITHUB_REMOTE_NAME']
- source_commit = uni_check_output(['git', 'rev-parse', source])
- commit_message = (
- 'Nikola auto commit.\n\n'
- 'Source commit: %s'
- 'Nikola version: %s' % (source_commit, __version__)
- )
- output_folder = self.site.config['OUTPUT_FOLDER']
-
- command = ['ghp-import', '-n', '-m', commit_message, '-p', '-r', remote, '-b', deploy, output_folder]
+ # Commit and push
+ return self._commit_and_push(options['commit_message'])
+ def _run_command(self, command, xfail=False):
+ """Run a command that may or may not fail."""
self.logger.info("==> {0}".format(command))
try:
subprocess.check_call(command)
+ return 0
except subprocess.CalledProcessError as e:
+ if xfail:
+ return e.returncode
self.logger.error(
- 'Failed GitHub deployment — command {0} '
+ 'Failed GitHub deployment -- command {0} '
'returned {1}'.format(e.cmd, e.returncode)
)
- return e.returncode
+ raise DeployFailedException(e.returncode)
- self.logger.info("Successful deployment")
+ def _commit_and_push(self, commit_first_line):
+ """Commit all the files and push."""
+ source = self.site.config['GITHUB_SOURCE_BRANCH']
+ deploy = self.site.config['GITHUB_DEPLOY_BRANCH']
+ remote = self.site.config['GITHUB_REMOTE_NAME']
+ autocommit = self.site.config['GITHUB_COMMIT_SOURCE']
+ try:
+ if autocommit:
+ commit_message = (
+ '{0}\n\n'
+ 'Nikola version: {1}'.format(commit_first_line, __version__)
+ )
+ e = self._run_command(['git', 'checkout', source], True)
+ if e != 0:
+ self._run_command(['git', 'checkout', '-b', source])
+ self._run_command(['git', 'add', '.'])
+ # Figure out if there is anything to commit
+ e = self._run_command(['git', 'diff-index', '--quiet', 'HEAD'], True)
+ if e != 0:
+ self._run_command(['git', 'commit', '-am', commit_message])
+ else:
+ self.logger.info('Nothing to commit to source branch.')
+
+ try:
+ source_commit = uni_check_output(['git', 'rev-parse', source])
+ except subprocess.CalledProcessError:
+ try:
+ source_commit = uni_check_output(['git', 'rev-parse', 'HEAD'])
+ except subprocess.CalledProcessError:
+ source_commit = '?'
+
+ commit_message = (
+ '{0}\n\n'
+ 'Source commit: {1}'
+ 'Nikola version: {2}'.format(commit_first_line, source_commit, __version__)
+ )
+ output_folder = self.site.config['OUTPUT_FOLDER']
+
+ command = ['ghp-import', '-n', '-m', commit_message, '-p', '-r', remote, '-b', deploy, output_folder]
- # Store timestamp of successful deployment
- timestamp_path = os.path.join(self.site.config["CACHE_FOLDER"], "lastdeploy")
- new_deploy = datetime.utcnow()
- makedirs(self.site.config["CACHE_FOLDER"])
- with io.open(timestamp_path, "w+", encoding="utf8") as outf:
- outf.write(unicode_str(new_deploy.isoformat()))
+ self._run_command(command)
+
+ if autocommit:
+ self._run_command(['git', 'push', '-u', remote, source])
+ except DeployFailedException as e:
+ return e.args[0]
+
+ self.logger.info("Successful deployment")
diff --git a/nikola/plugins/command/import_wordpress.plugin b/nikola/plugins/command/import_wordpress.plugin
index 6c4384e..46df1ef 100644
--- a/nikola/plugins/command/import_wordpress.plugin
+++ b/nikola/plugins/command/import_wordpress.plugin
@@ -5,9 +5,9 @@ module = import_wordpress
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Import a wordpress site from a XML dump (requires markdown).
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/import_wordpress.py b/nikola/plugins/command/import_wordpress.py
index a652ec8..5e2aee6 100644
--- a/nikola/plugins/command/import_wordpress.py
+++ b/nikola/plugins/command/import_wordpress.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,41 +26,45 @@
"""Import a WordPress dump."""
-from __future__ import unicode_literals, print_function
-import os
-import re
-import sys
import datetime
import io
import json
+import os
+import re
+import sys
+from collections import defaultdict
+from urllib.parse import urlparse, unquote
+
import requests
from lxml import etree
-from collections import defaultdict
+
+from nikola.plugin_categories import Command
+from nikola import utils, hierarchy_utils
+from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN
+from nikola.utils import req_missing
+from nikola.plugins.basic_import import ImportMixin, links
+from nikola.plugins.command.init import (
+ SAMPLE_CONF, prepare_config,
+ format_default_translations_config,
+ get_default_translations_dict
+)
try:
- from urlparse import urlparse
- from urllib import unquote
+ import html2text
except ImportError:
- from urllib.parse import urlparse, unquote # NOQA
+ html2text = None
try:
import phpserialize
except ImportError:
- phpserialize = None # NOQA
+ phpserialize = None
-from nikola.plugin_categories import Command
-from nikola import utils
-from nikola.utils import req_missing
-from nikola.plugins.basic_import import ImportMixin, links
-from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN
-from nikola.plugins.command.init import SAMPLE_CONF, prepare_config, format_default_translations_config
-
-LOGGER = utils.get_logger('import_wordpress', utils.STDERR_HANDLER)
+LOGGER = utils.get_logger('import_wordpress')
def install_plugin(site, plugin_name, output_dir=None, show_install_notes=False):
"""Install a Nikola plugin."""
- LOGGER.notice("Installing plugin '{0}'".format(plugin_name))
+ LOGGER.info("Installing plugin '{0}'".format(plugin_name))
# Get hold of the 'plugin' plugin
plugin_installer_info = site.plugin_manager.getPluginByName('plugin', 'Command')
if plugin_installer_info is None:
@@ -88,7 +92,6 @@ def install_plugin(site, plugin_name, output_dir=None, show_install_notes=False)
class CommandImportWordpress(Command, ImportMixin):
-
"""Import a WordPress dump."""
name = "import_wordpress"
@@ -144,15 +147,22 @@ class CommandImportWordpress(Command, ImportMixin):
'long': 'qtranslate',
'default': False,
'type': bool,
- 'help': "Look for translations generated by qtranslate plugin",
- # WARNING: won't recover translated titles that actually
- # don't seem to be part of the wordpress XML export at the
- # time of writing :(
+ 'help': """Look for translations generated by qtranslate plugin.
+WARNING: a default wordpress export won't allow to recover title translations.
+For this to be possible consider applying the hack suggested at
+https://github.com/qtranslate/qtranslate-xt/issues/199 :
+
+In wp-admin/includes/export.php change
+`echo apply_filters( 'the_title_rss', $post->post_title );
+
+to
+`echo apply_filters( 'the_title_export', $post->post_title );
+"""
},
{
'name': 'translations_pattern',
'long': 'translations_pattern',
- 'default': None,
+ 'default': DEFAULT_TRANSLATIONS_PATTERN,
'type': str,
'help': "The pattern for translation files names",
},
@@ -171,6 +181,20 @@ class CommandImportWordpress(Command, ImportMixin):
'help': "Export comments as .wpcomment files",
},
{
+ 'name': 'html2text',
+ 'long': 'html2text',
+ 'default': False,
+ 'type': bool,
+ 'help': "Uses html2text (needs to be installed with pip) to transform WordPress posts to MarkDown during import",
+ },
+ {
+ 'name': 'transform_to_markdown',
+ 'long': 'transform-to-markdown',
+ 'default': False,
+ 'type': bool,
+ 'help': "Uses WordPress page compiler to transform WordPress posts to HTML and then use html2text to transform them to MarkDown during import",
+ },
+ {
'name': 'transform_to_html',
'long': 'transform-to-html',
'default': False,
@@ -191,9 +215,36 @@ class CommandImportWordpress(Command, ImportMixin):
'type': bool,
'help': "Automatically installs the WordPress page compiler (either locally or in the new site) if required by other options.\nWarning: the compiler is GPL software!",
},
+ {
+ 'name': 'tag_sanitizing_strategy',
+ 'long': 'tag-sanitizing-strategy',
+ 'default': 'first',
+ 'help': 'lower: Convert all tag and category names to lower case\nfirst: Keep first spelling of tag or category name',
+ },
+ {
+ 'name': 'one_file',
+ 'long': 'one-file',
+ 'default': False,
+ 'type': bool,
+ 'help': "Save imported posts in the more modern one-file format.",
+ },
]
all_tags = set([])
+ def _get_compiler(self):
+ """Return whatever compiler we will use."""
+ self._find_wordpress_compiler()
+ if self.wordpress_page_compiler is not None:
+ return self.wordpress_page_compiler
+ plugin_info = self.site.plugin_manager.getPluginByName('markdown', 'PageCompiler')
+ if plugin_info is not None:
+ if not plugin_info.is_activated:
+ self.site.plugin_manager.activatePluginByName(plugin_info.name)
+ plugin_info.plugin_object.set_site(self.site)
+ return plugin_info.plugin_object
+ else:
+ LOGGER.error("Can't find markdown post compiler.")
+
def _find_wordpress_compiler(self):
"""Find WordPress compiler plugin."""
if self.wordpress_page_compiler is not None:
@@ -214,9 +265,11 @@ class CommandImportWordpress(Command, ImportMixin):
options['output_folder'] = args.pop(0)
if args:
- LOGGER.warn('You specified additional arguments ({0}). Please consider '
- 'putting these arguments before the filename if you '
- 'are running into problems.'.format(args))
+ LOGGER.warning('You specified additional arguments ({0}). Please consider '
+ 'putting these arguments before the filename if you '
+ 'are running into problems.'.format(args))
+
+ self.onefile = options.get('one_file', False)
self.import_into_existing_site = False
self.url_map = {}
@@ -234,11 +287,16 @@ class CommandImportWordpress(Command, ImportMixin):
self.export_categories_as_categories = options.get('export_categories_as_categories', False)
self.export_comments = options.get('export_comments', False)
+ self.html2text = options.get('html2text', False)
+ self.transform_to_markdown = options.get('transform_to_markdown', False)
+
self.transform_to_html = options.get('transform_to_html', False)
self.use_wordpress_compiler = options.get('use_wordpress_compiler', False)
self.install_wordpress_compiler = options.get('install_wordpress_compiler', False)
self.wordpress_page_compiler = None
+ self.tag_saniziting_strategy = options.get('tag_saniziting_strategy', 'first')
+
self.auth = None
if options.get('download_auth') is not None:
username_password = options.get('download_auth')
@@ -250,10 +308,18 @@ class CommandImportWordpress(Command, ImportMixin):
self.separate_qtranslate_content = options.get('separate_qtranslate_content')
self.translations_pattern = options.get('translations_pattern')
- if self.transform_to_html and self.use_wordpress_compiler:
- LOGGER.warn("It does not make sense to combine --transform-to-html with --use-wordpress-compiler, as the first converts all posts to HTML and the latter option affects zero posts.")
+ count = (1 if self.html2text else 0) + (1 if self.transform_to_html else 0) + (1 if self.transform_to_markdown else 0)
+ if count > 1:
+ LOGGER.error("You can use at most one of the options --html2text, --transform-to-html and --transform-to-markdown.")
+ return False
+ if (self.html2text or self.transform_to_html or self.transform_to_markdown) and self.use_wordpress_compiler:
+ LOGGER.warning("It does not make sense to combine --use-wordpress-compiler with any of --html2text, --transform-to-html and --transform-to-markdown, as the latter convert all posts to HTML and the first option then affects zero posts.")
+
+ if (self.html2text or self.transform_to_markdown) and not html2text:
+ LOGGER.error("You need to install html2text via 'pip install html2text' before you can use the --html2text and --transform-to-markdown options.")
+ return False
- if self.transform_to_html:
+ if self.transform_to_html or self.transform_to_markdown:
self._find_wordpress_compiler()
if not self.wordpress_page_compiler and self.install_wordpress_compiler:
if not install_plugin(self.site, 'wordpress_compiler', output_dir='plugins'): # local install
@@ -279,14 +345,14 @@ class CommandImportWordpress(Command, ImportMixin):
# cat_id = get_text_tag(cat, '{{{0}}}term_id'.format(wordpress_namespace), None)
cat_slug = get_text_tag(cat, '{{{0}}}category_nicename'.format(wordpress_namespace), None)
cat_parent_slug = get_text_tag(cat, '{{{0}}}category_parent'.format(wordpress_namespace), None)
- cat_name = get_text_tag(cat, '{{{0}}}cat_name'.format(wordpress_namespace), None)
+ cat_name = utils.html_unescape(get_text_tag(cat, '{{{0}}}cat_name'.format(wordpress_namespace), None))
cat_path = [cat_name]
if cat_parent_slug in cat_map:
cat_path = cat_map[cat_parent_slug] + cat_path
cat_map[cat_slug] = cat_path
self._category_paths = dict()
for cat, path in cat_map.items():
- self._category_paths[cat] = utils.join_hierarchical_category_path(path)
+ self._category_paths[cat] = hierarchy_utils.join_hierarchical_category_path(path)
def _execute(self, options={}, args=[]):
"""Import a WordPress blog from an export file into a Nikola site."""
@@ -313,21 +379,16 @@ class CommandImportWordpress(Command, ImportMixin):
if phpserialize is None:
req_missing(['phpserialize'], 'import WordPress dumps without --no-downloads')
- channel = self.get_channel_from_file(self.wordpress_export_file)
+ export_file_preprocessor = modernize_qtranslate_tags if self.separate_qtranslate_content else None
+ channel = self.get_channel_from_file(self.wordpress_export_file, export_file_preprocessor)
self._prepare(channel)
conf_template = self.generate_base_site()
- # If user has specified a custom pattern for translation files we
- # need to fix the config
- if self.translations_pattern:
- self.context['TRANSLATIONS_PATTERN'] = self.translations_pattern
-
self.import_posts(channel)
-
self.context['TRANSLATIONS'] = format_default_translations_config(
self.extra_languages)
self.context['REDIRECTIONS'] = self.configure_redirections(
- self.url_map)
+ self.url_map, self.base_dir)
if self.timezone:
self.context['TIMEZONE'] = self.timezone
if self.export_categories_as_categories:
@@ -337,10 +398,13 @@ class CommandImportWordpress(Command, ImportMixin):
# Add tag redirects
for tag in self.all_tags:
try:
- tag_str = tag.decode('utf8')
+ if isinstance(tag, bytes):
+ tag_str = tag.decode('utf8', 'replace')
+ else:
+ tag_str = tag
except AttributeError:
tag_str = tag
- tag = utils.slugify(tag_str)
+ tag = utils.slugify(tag_str, self.lang)
src_url = '{}tag/{}'.format(self.context['SITE_URL'], tag)
dst_url = self.site.link('tag', tag)
if src_url != dst_url:
@@ -357,9 +421,9 @@ class CommandImportWordpress(Command, ImportMixin):
if not install_plugin(self.site, 'wordpress_compiler', output_dir=os.path.join(self.output_folder, 'plugins')):
return False
else:
- LOGGER.warn("Make sure to install the WordPress page compiler via")
- LOGGER.warn(" nikola plugin -i wordpress_compiler")
- LOGGER.warn("in your imported blog's folder ({0}), if you haven't installed it system-wide or user-wide. Otherwise, your newly imported blog won't compile.".format(self.output_folder))
+ LOGGER.warning("Make sure to install the WordPress page compiler via")
+ LOGGER.warning(" nikola plugin -i wordpress_compiler")
+ LOGGER.warning("in your imported blog's folder ({0}), if you haven't installed it system-wide or user-wide. Otherwise, your newly imported blog won't compile.".format(self.output_folder))
@classmethod
def read_xml_file(cls, filename):
@@ -372,12 +436,19 @@ class CommandImportWordpress(Command, ImportMixin):
if b'<atom:link rel=' in line:
continue
xml.append(line)
- return b'\n'.join(xml)
+ return b''.join(xml)
@classmethod
- def get_channel_from_file(cls, filename):
- """Get channel from XML file."""
- tree = etree.fromstring(cls.read_xml_file(filename))
+ def get_channel_from_file(cls, filename, xml_preprocessor=None):
+ """Get channel from XML file.
+
+ An optional 'xml_preprocessor' allows to modify the xml
+ (typically to deal with variations in tags injected by some WP plugin)
+ """
+ xml_string = cls.read_xml_file(filename)
+ if xml_preprocessor:
+ xml_string = xml_preprocessor(xml_string)
+ tree = etree.fromstring(xml_string)
channel = tree.find('channel')
return channel
@@ -386,8 +457,12 @@ class CommandImportWordpress(Command, ImportMixin):
wordpress_namespace = channel.nsmap['wp']
context = SAMPLE_CONF.copy()
- context['DEFAULT_LANG'] = get_text_tag(channel, 'language', 'en')[:2]
- context['TRANSLATIONS_PATTERN'] = DEFAULT_TRANSLATIONS_PATTERN
+ self.lang = get_text_tag(channel, 'language', 'en')[:2]
+ context['DEFAULT_LANG'] = self.lang
+ # If user has specified a custom pattern for translation files we
+ # need to fix the config
+ context['TRANSLATIONS_PATTERN'] = self.translations_pattern
+
context['BLOG_TITLE'] = get_text_tag(channel, 'title',
'PUT TITLE HERE')
context['BLOG_DESCRIPTION'] = get_text_tag(
@@ -418,17 +493,17 @@ class CommandImportWordpress(Command, ImportMixin):
PAGES = '(\n'
for extension in extensions:
POSTS += ' ("posts/*.{0}", "posts", "post.tmpl"),\n'.format(extension)
- PAGES += ' ("stories/*.{0}", "stories", "story.tmpl"),\n'.format(extension)
+ PAGES += ' ("pages/*.{0}", "pages", "page.tmpl"),\n'.format(extension)
POSTS += ')\n'
PAGES += ')\n'
context['POSTS'] = POSTS
context['PAGES'] = PAGES
COMPILERS = '{\n'
- COMPILERS += ''' "rest": ('.txt', '.rst'),''' + '\n'
- COMPILERS += ''' "markdown": ('.md', '.mdown', '.markdown'),''' + '\n'
- COMPILERS += ''' "html": ('.html', '.htm'),''' + '\n'
+ COMPILERS += ''' "rest": ['.txt', '.rst'],''' + '\n'
+ COMPILERS += ''' "markdown": ['.md', '.mdown', '.markdown'],''' + '\n'
+ COMPILERS += ''' "html": ['.html', '.htm'],''' + '\n'
if self.use_wordpress_compiler:
- COMPILERS += ''' "wordpress": ('.wp'),''' + '\n'
+ COMPILERS += ''' "wordpress": ['.wp'],''' + '\n'
COMPILERS += '}'
context['COMPILERS'] = COMPILERS
@@ -436,18 +511,15 @@ class CommandImportWordpress(Command, ImportMixin):
def download_url_content_to_file(self, url, dst_path):
"""Download some content (attachments) to a file."""
- if self.no_downloads:
- return
-
try:
request = requests.get(url, auth=self.auth)
if request.status_code >= 400:
- LOGGER.warn("Downloading {0} to {1} failed with HTTP status code {2}".format(url, dst_path, request.status_code))
+ LOGGER.warning("Downloading {0} to {1} failed with HTTP status code {2}".format(url, dst_path, request.status_code))
return
with open(dst_path, 'wb+') as fd:
fd.write(request.content)
except requests.exceptions.ConnectionError as err:
- LOGGER.warn("Downloading {0} to {1} failed: {2}".format(url, dst_path, err))
+ LOGGER.warning("Downloading {0} to {1} failed: {2}".format(url, dst_path, err))
def import_attachment(self, item, wordpress_namespace):
"""Import an attachment to the site."""
@@ -458,10 +530,13 @@ class CommandImportWordpress(Command, ImportMixin):
'foo')
path = urlparse(url).path
dst_path = os.path.join(*([self.output_folder, 'files'] + list(path.split('/'))))
- dst_dir = os.path.dirname(dst_path)
- utils.makedirs(dst_dir)
- LOGGER.info("Downloading {0} => {1}".format(url, dst_path))
- self.download_url_content_to_file(url, dst_path)
+ if self.no_downloads:
+ LOGGER.info("Skipping downloading {0} => {1}".format(url, dst_path))
+ else:
+ dst_dir = os.path.dirname(dst_path)
+ utils.makedirs(dst_dir)
+ LOGGER.info("Downloading {0} => {1}".format(url, dst_path))
+ self.download_url_content_to_file(url, dst_path)
dst_url = '/'.join(dst_path.split(os.sep)[2:])
links[link] = '/' + dst_url
links[url] = '/' + dst_url
@@ -485,14 +560,7 @@ class CommandImportWordpress(Command, ImportMixin):
# that the export should give you the power to insert
# your blogging into another site or system its not.
# Why don't they just use JSON?
- if sys.version_info[0] == 2:
- try:
- metadata = phpserialize.loads(utils.sys_encode(meta_value.text))
- except ValueError:
- # local encoding might be wrong sometimes
- metadata = phpserialize.loads(meta_value.text.encode('utf-8'))
- else:
- metadata = phpserialize.loads(meta_value.text.encode('utf-8'))
+ metadata = phpserialize.loads(meta_value.text.encode('utf-8'))
meta_key = b'image_meta'
size_key = b'sizes'
@@ -507,6 +575,8 @@ class CommandImportWordpress(Command, ImportMixin):
if meta_key in metadata:
image_meta = metadata[meta_key]
+ if not image_meta:
+ continue
dst_meta = {}
def add(our_key, wp_key, is_int=False, ignore_zero=False, is_float=False):
@@ -517,6 +587,9 @@ class CommandImportWordpress(Command, ImportMixin):
if ignore_zero and value == 0:
return
elif is_float:
+ # in some locales (like fr) and for old posts there may be a comma here.
+ if isinstance(value, bytes):
+ value = value.replace(b",", b".")
value = float(value)
if ignore_zero and value == 0:
return
@@ -552,15 +625,18 @@ class CommandImportWordpress(Command, ImportMixin):
meta = {}
meta['size'] = size.decode('utf-8')
if width_key in metadata[size_key][size] and height_key in metadata[size_key][size]:
- meta['width'] = metadata[size_key][size][width_key]
- meta['height'] = metadata[size_key][size][height_key]
+ meta['width'] = int(metadata[size_key][size][width_key])
+ meta['height'] = int(metadata[size_key][size][height_key])
path = urlparse(url).path
dst_path = os.path.join(*([self.output_folder, 'files'] + list(path.split('/'))))
- dst_dir = os.path.dirname(dst_path)
- utils.makedirs(dst_dir)
- LOGGER.info("Downloading {0} => {1}".format(url, dst_path))
- self.download_url_content_to_file(url, dst_path)
+ if self.no_downloads:
+ LOGGER.info("Skipping downloading {0} => {1}".format(url, dst_path))
+ else:
+ dst_dir = os.path.dirname(dst_path)
+ utils.makedirs(dst_dir)
+ LOGGER.info("Downloading {0} => {1}".format(url, dst_path))
+ self.download_url_content_to_file(url, dst_path)
dst_url = '/'.join(dst_path.split(os.sep)[2:])
links[url] = '/' + dst_url
@@ -604,7 +680,7 @@ class CommandImportWordpress(Command, ImportMixin):
def transform_code(self, content):
"""Transform code blocks."""
- # http://en.support.wordpress.com/code/posting-source-code/. There are
+ # https://en.support.wordpress.com/code/posting-source-code/. There are
# a ton of things not supported here. We only do a basic [code
# lang="x"] -> ```x translation, and remove quoted html entities (<,
# >, &, and ").
@@ -628,10 +704,10 @@ class CommandImportWordpress(Command, ImportMixin):
return content
@staticmethod
- def transform_caption(content):
+ def transform_caption(content, use_html=False):
"""Transform captions."""
- new_caption = re.sub(r'\[/caption\]', '', content)
- new_caption = re.sub(r'\[caption.*\]', '', new_caption)
+ new_caption = re.sub(r'\[/caption\]', '</h1>' if use_html else '', content)
+ new_caption = re.sub(r'\[caption.*\]', '<h1>' 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: <!--:LL-->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 <!--/en--> => <!--:-->
- qt_start = "<!--:"
- qt_end = "-->"
- 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"<!--:?(\\w{2})-->")
+ new_start_lang = b"[:\\1]"
+ old_end_lang = re.compile(b"<!--(/\\w{2}|:)-->")
+ new_end_lang = b"[:]"
+ title_match = re.compile(b"<title>(.*?)</title>")
+ 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"&amp;").replace(b"<", b"&lt;").replace(b">", b"&gt;")
+ return b"<title>" + title + b"</title>"
+ 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'<!DOCTYPE html>\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 <base> to allow local resolution of relative URLs.
data = f.read().decode('utf8')
f.close()
- data = re.sub(r'<base\s([^>]*)>', '<!--base \g<1>-->', data, re.IGNORECASE)
+ data = re.sub(r'<base\s([^>]*)>', r'<!--base \g<1>-->', 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 = [
{
@@ -63,6 +60,22 @@ class CommandStatus(Command):
'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',
'long': 'list-scheduled',
@@ -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')
- fd.write(write_metadata(metadata))
- 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')
- fd.write(write_metadata(metadata))
- 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)
- <p>Text of the gist:
- <div class="gist">
- <script src="https://gist.github.com/4747847.js"></script>
- <noscript>
- <pre>import this</pre>
- </noscript>
- </div>
- </p>
+ Text of the gist:
+ [:gist: 4747847]
Example with filename:
- >>> import markdown
- >>> text = '''
- ... Text of the gist:
- ... [:gist: 4747847 zen.py]
- ... '''
- >>> html = markdown.markdown(text, [GistExtension()])
- >>> print(html)
- <p>Text of the gist:
- <div class="gist">
- <script src="https://gist.github.com/4747847.js?file=zen.py"></script>
- <noscript>
- <pre>import this</pre>
- </noscript>
- </div>
- </p>
+ Text of the gist:
+ [:gist: 4747847 zen.py]
Basic Example with hexidecimal id:
- >>> import markdown
- >>> text = '''
- ... Text of the gist:
- ... [:gist: c4a43d6fdce612284ac0]
- ... '''
- >>> html = markdown.markdown(text, [GistExtension()])
- >>> print(html)
- <p>Text of the gist:
- <div class="gist">
- <script src="https://gist.github.com/c4a43d6fdce612284ac0.js"></script>
- <noscript>
- <pre>Moo</pre>
- </noscript>
- </div>
- </p>
+ Text of the gist:
+ [:gist: c4a43d6fdce612284ac0]
Example with hexidecimal id filename:
- >>> import markdown
- >>> text = '''
- ... Text of the gist:
- ... [:gist: c4a43d6fdce612284ac0 cow.txt]
- ... '''
- >>> html = markdown.markdown(text, [GistExtension()])
- >>> print(html)
- <p>Text of the gist:
- <div class="gist">
- <script src="https://gist.github.com/c4a43d6fdce612284ac0.js?file=cow.txt"></script>
- <noscript>
- <pre>Moo</pre>
- </noscript>
- </div>
- </p>
+ Text of the gist:
+ [:gist: c4a43d6fdce612284ac0 cow.txt]
Example using reStructuredText syntax:
- >>> import markdown
- >>> text = '''
- ... Text of the gist:
- ... .. gist:: 4747847 zen.py
- ... '''
- >>> html = markdown.markdown(text, [GistExtension()])
- >>> print(html)
- <p>Text of the gist:
- <div class="gist">
- <script src="https://gist.github.com/4747847.js?file=zen.py"></script>
- <noscript>
- <pre>import this</pre>
- </noscript>
- </div>
- </p>
+ Text of the gist:
+ .. gist:: 4747847 zen.py
Example using hexidecimal ID with reStructuredText syntax:
- >>> import markdown
- >>> text = '''
- ... Text of the gist:
- ... .. gist:: c4a43d6fdce612284ac0
- ... '''
- >>> html = markdown.markdown(text, [GistExtension()])
- >>> print(html)
- <p>Text of the gist:
- <div class="gist">
- <script src="https://gist.github.com/c4a43d6fdce612284ac0.js"></script>
- <noscript>
- <pre>Moo</pre>
- </noscript>
- </div>
- </p>
+ Text of the gist:
+ .. gist:: c4a43d6fdce612284ac0
Example using hexidecimal ID and filename with reStructuredText syntax:
- >>> import markdown
- >>> text = '''
- ... Text of the gist:
- ... .. gist:: c4a43d6fdce612284ac0 cow.txt
- ... '''
- >>> html = markdown.markdown(text, [GistExtension()])
- >>> print(html)
- <p>Text of the gist:
- <div class="gist">
- <script src="https://gist.github.com/c4a43d6fdce612284ac0.js?file=cow.txt"></script>
- <noscript>
- <pre>Moo</pre>
- </noscript>
- </div>
- </p>
+ Text of the gist:
+ .. gist:: c4a43d6fdce612284ac0 cow.txt
Error Case: non-existent Gist ID:
- >>> import markdown
- >>> text = '''
- ... Text of the gist:
- ... [:gist: 0]
- ... '''
- >>> html = markdown.markdown(text, [GistExtension()])
- >>> print(html)
- <p>Text of the gist:
- <div class="gist">
- <script src="https://gist.github.com/0.js"></script>
- <noscript><!-- WARNING: Received a 404 response from Gist URL: https://gist.githubusercontent.com/raw/0 --></noscript>
- </div>
- </p>
-
-Error Case: non-existent file:
-
- >>> import markdown
- >>> text = '''
- ... Text of the gist:
- ... [:gist: 4747847 doesntexist.py]
- ... '''
- >>> html = markdown.markdown(text, [GistExtension()])
- >>> print(html)
- <p>Text of the gist:
- <div class="gist">
- <script src="https://gist.github.com/4747847.js?file=doesntexist.py"></script>
- <noscript><!-- WARNING: Received a 404 response from Gist URL: https://gist.githubusercontent.com/raw/4747847/doesntexist.py --></noscript>
- </div>
- </p>
+ Text of the gist:
+ [:gist: 0]
+
+Error Case: non-existent file:
+
+ Text of the gist:
+ [:gist: 4747847 doesntexist.py]
"""
-from __future__ import unicode_literals, print_function
+import requests
+
+from nikola.plugin_categories import MarkdownExtension
+from nikola.utils import get_logger
try:
from markdown.extensions import Extension
@@ -200,12 +90,8 @@ except ImportError:
# the markdown compiler will fail first
Extension = Pattern = object
-from nikola.plugin_categories import MarkdownExtension
-from nikola.utils import get_logger, STDERR_HANDLER
-
-import requests
-LOGGER = get_logger('compile_markdown.mdx_gist', STDERR_HANDLER)
+LOGGER = get_logger('compile_markdown.mdx_gist')
GIST_JS_URL = "https://gist.github.com/{0}.js"
GIST_FILE_JS_URL = "https://gist.github.com/{0}.js?file={1}"
@@ -217,7 +103,6 @@ GIST_RST_RE = r'(?m)^\.\.\s*gist::\s*(?P<gist_id>[^\]\s]+)(?:\s*(?P<filename>.+?
class GistFetchException(Exception):
-
"""Raised when attempt to fetch content of a Gist from github.com fails."""
def __init__(self, url, status_code):
@@ -228,7 +113,6 @@ class GistFetchException(Exception):
class GistPattern(Pattern):
-
"""InlinePattern for footnote markers in a document's body text."""
def __init__(self, pattern, configs):
@@ -282,7 +166,7 @@ class GistPattern(Pattern):
pre_elem.text = AtomicString(raw_gist)
except GistFetchException as e:
- LOGGER.warn(e.message)
+ LOGGER.warning(e.message)
warning_comment = etree.Comment(' WARNING: {0} '.format(e.message))
noscript_elem.append(warning_comment)
@@ -290,7 +174,6 @@ class GistPattern(Pattern):
class GistExtension(MarkdownExtension, Extension):
-
"""Gist extension for Markdown."""
def __init__(self, configs={}):
@@ -302,15 +185,15 @@ class GistExtension(MarkdownExtension, Extension):
for key, value in configs:
self.setConfig(key, value)
- def extendMarkdown(self, md, md_globals):
+ def extendMarkdown(self, md, md_globals=None):
"""Extend Markdown."""
gist_md_pattern = GistPattern(GIST_MD_RE, self.getConfigs())
gist_md_pattern.md = md
- md.inlinePatterns.add('gist', gist_md_pattern, "<not_strong")
+ md.inlinePatterns.register(gist_md_pattern, 'gist', 175)
gist_rst_pattern = GistPattern(GIST_RST_RE, self.getConfigs())
gist_rst_pattern.md = md
- md.inlinePatterns.add('gist-rst', gist_rst_pattern, ">gist")
+ md.inlinePatterns.register(gist_rst_pattern, 'gist-rst', 176)
md.registerExtension(self)
@@ -319,6 +202,7 @@ def makeExtension(configs=None): # pragma: no cover
"""Make Markdown extension."""
return GistExtension(configs)
+
if __name__ == '__main__':
import doctest
diff --git a/nikola/plugins/compile/markdown/mdx_nikola.plugin b/nikola/plugins/compile/markdown/mdx_nikola.plugin
index 12e4fb6..9751598 100644
--- a/nikola/plugins/compile/markdown/mdx_nikola.plugin
+++ b/nikola/plugins/compile/markdown/mdx_nikola.plugin
@@ -4,11 +4,11 @@ module = mdx_nikola
[Nikola]
compiler = markdown
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Roberto Alsina
version = 0.1
-website = http://getnikola.com
+website = https://getnikola.com/
description = Nikola-specific Markdown extensions
diff --git a/nikola/plugins/compile/markdown/mdx_nikola.py b/nikola/plugins/compile/markdown/mdx_nikola.py
index 54cc18c..06a6d9a 100644
--- a/nikola/plugins/compile/markdown/mdx_nikola.py
+++ b/nikola/plugins/compile/markdown/mdx_nikola.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2015 Roberto Alsina and others.
+# Copyright © 2012-2020 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -24,25 +24,31 @@
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""Markdown Extension for Nikola-specific post-processing."""
+"""Markdown Extension for Nikola.
+
+- Specific post-processing.
+- Strikethrough inline patterns.
+"""
-from __future__ import unicode_literals
import re
+
+from nikola.plugin_categories import MarkdownExtension
+
try:
from markdown.postprocessors import Postprocessor
+ from markdown.inlinepatterns import SimpleTagPattern
from markdown.extensions import Extension
except ImportError:
# No need to catch this, if you try to use this without Markdown,
# the markdown compiler will fail first
- Postprocessor = Extension = object
+ Postprocessor = SimpleTagPattern = Extension = object
-from nikola.plugin_categories import MarkdownExtension
CODERE = re.compile('<div class="codehilite"><pre>(.*?)</pre></div>', flags=re.MULTILINE | re.DOTALL)
+STRIKE_RE = r"(~{2})(.+?)(~{2})" # ~~strike~~
class NikolaPostProcessor(Postprocessor):
-
"""Nikola-specific post-processing for Markdown."""
def run(self, text):
@@ -57,13 +63,22 @@ class NikolaPostProcessor(Postprocessor):
class NikolaExtension(MarkdownExtension, Extension):
+ """Nikola Markdown extensions."""
- """Extension for injecting the postprocessor."""
-
- def extendMarkdown(self, md, md_globals):
+ def _add_nikola_post_processor(self, md):
"""Extend Markdown with the postprocessor."""
pp = NikolaPostProcessor()
- md.postprocessors.add('nikola_post_processor', pp, '_end')
+ md.postprocessors.register(pp, 'nikola_post_processor', 1)
+
+ def _add_strikethrough_inline_pattern(self, md):
+ """Support PHP-Markdown style strikethrough, for example: ``~~strike~~``."""
+ pattern = SimpleTagPattern(STRIKE_RE, 'del')
+ md.inlinePatterns.register(pattern, 'strikethrough', 175)
+
+ def extendMarkdown(self, md, md_globals=None):
+ """Extend markdown to Nikola flavours."""
+ self._add_nikola_post_processor(md)
+ self._add_strikethrough_inline_pattern(md)
md.registerExtension(self)
diff --git a/nikola/plugins/compile/markdown/mdx_podcast.plugin b/nikola/plugins/compile/markdown/mdx_podcast.plugin
index c92a8a0..df5260d 100644
--- a/nikola/plugins/compile/markdown/mdx_podcast.plugin
+++ b/nikola/plugins/compile/markdown/mdx_podcast.plugin
@@ -4,11 +4,11 @@ module = mdx_podcast
[Nikola]
compiler = markdown
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Roberto Alsina
version = 0.1
-website = http://getnikola.com
+website = https://getnikola.com/
description = Markdown extensions for embedding podcasts and other audio files
diff --git a/nikola/plugins/compile/markdown/mdx_podcast.py b/nikola/plugins/compile/markdown/mdx_podcast.py
index 61afdbf..5090407 100644
--- a/nikola/plugins/compile/markdown/mdx_podcast.py
+++ b/nikola/plugins/compile/markdown/mdx_podcast.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
#
-# Copyright © 2013-2015 Michael Rabbitt, Roberto Alsina and others.
+# Copyright © 2013-2020 Michael Rabbitt, 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
@@ -30,13 +30,12 @@ Extension to Python Markdown for Embedded Audio.
Basic Example:
>>> import markdown
->>> text = "[podcast]http://archive.org/download/Rebeldes_Stereotipos/rs20120609_1.mp3[/podcast]"
+>>> text = "[podcast]https://archive.org/download/Rebeldes_Stereotipos/rs20120609_1.mp3[/podcast]"
>>> html = markdown.markdown(text, [PodcastExtension()])
>>> print(html)
-<p><audio controls=""><source src="http://archive.org/download/Rebeldes_Stereotipos/rs20120609_1.mp3" type="audio/mpeg"></source></audio></p>
+<p><audio controls=""><source src="https://archive.org/download/Rebeldes_Stereotipos/rs20120609_1.mp3" type="audio/mpeg"></source></audio></p>
"""
-from __future__ import print_function, unicode_literals
from nikola.plugin_categories import MarkdownExtension
try:
from markdown.extensions import Extension
@@ -51,7 +50,6 @@ PODCAST_RE = r'\[podcast\](?P<url>.+)\[/podcast\]'
class PodcastPattern(Pattern):
-
"""InlinePattern for footnote markers in a document's body text."""
def __init__(self, pattern, configs):
@@ -70,8 +68,7 @@ class PodcastPattern(Pattern):
class PodcastExtension(MarkdownExtension, Extension):
-
- """"Podcast extension for Markdown."""
+ """Podcast extension for Markdown."""
def __init__(self, configs={}):
"""Initialize extension."""
@@ -82,11 +79,11 @@ class PodcastExtension(MarkdownExtension, Extension):
for key, value in configs:
self.setConfig(key, value)
- def extendMarkdown(self, md, md_globals):
+ def extendMarkdown(self, md, md_globals=None):
"""Extend Markdown."""
podcast_md_pattern = PodcastPattern(PODCAST_RE, self.getConfigs())
podcast_md_pattern.md = md
- md.inlinePatterns.add('podcast', podcast_md_pattern, "<not_strong")
+ md.inlinePatterns.register(podcast_md_pattern, 'podcast', 175)
md.registerExtension(self)
@@ -94,6 +91,7 @@ def makeExtension(configs=None): # pragma: no cover
"""Make Markdown extension."""
return PodcastExtension(configs)
+
if __name__ == '__main__':
import doctest
doctest.testmod(optionflags=(doctest.NORMALIZE_WHITESPACE +
diff --git a/nikola/plugins/compile/pandoc.plugin b/nikola/plugins/compile/pandoc.plugin
index 3ff3668..8f339e4 100644
--- a/nikola/plugins/compile/pandoc.plugin
+++ b/nikola/plugins/compile/pandoc.plugin
@@ -5,9 +5,9 @@ module = pandoc
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Compile markups into HTML using pandoc
[Nikola]
-plugincategory = Compiler
+PluginCategory = Compiler
friendlyname = Pandoc
diff --git a/nikola/plugins/compile/pandoc.py b/nikola/plugins/compile/pandoc.py
index 3030626..af14344 100644
--- a/nikola/plugins/compile/pandoc.py
+++ b/nikola/plugins/compile/pandoc.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,12 +24,11 @@
# 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 pandoc.
+"""Page compiler plugin for pandoc.
You will need, of course, to install pandoc
"""
-from __future__ import unicode_literals
import io
import os
@@ -40,7 +39,6 @@ from nikola.utils import req_missing, makedirs, write_metadata
class CompilePandoc(PageCompiler):
-
"""Compile markups into HTML using pandoc."""
name = "pandoc"
@@ -49,17 +47,32 @@ class CompilePandoc(PageCompiler):
def set_site(self, site):
"""Set Nikola site."""
self.config_dependencies = [str(site.config['PANDOC_OPTIONS'])]
- super(CompilePandoc, self).set_site(site)
+ super().set_site(site)
- 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))
try:
subprocess.check_call(['pandoc', '-o', dest, source] + self.site.config['PANDOC_OPTIONS'])
+ with open(dest, 'r', encoding='utf-8-sig') as inf:
+ output, shortcode_deps = self.site.apply_shortcodes(inf.read())
+ with open(dest, 'w', encoding='utf-8') as outf:
+ outf.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
except OSError as e:
if e.strreror == 'No such file or directory':
req_missing(['pandoc'], 'build this site (compile with pandoc)', python=False)
+ def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None):
+ """Compile into HTML strings."""
+ raise ValueError("Pandoc compiler does not support compile_string due to multiple output formats")
+
def create_post(self, path, **kw):
"""Create a new post."""
content = kw.pop('content', None)
@@ -74,7 +87,5 @@ class CompilePandoc(PageCompiler):
content += '\n'
with io.open(path, "w+", encoding="utf8") as fd:
if onefile:
- fd.write('<!--\n')
- fd.write(write_metadata(metadata))
- fd.write('-->\n\n')
+ fd.write(write_metadata(metadata, comment_wrap=True, site=self.site, compiler=self))
fd.write(content)
diff --git a/nikola/plugins/compile/php.plugin b/nikola/plugins/compile/php.plugin
index 151c022..13384bd 100644
--- a/nikola/plugins/compile/php.plugin
+++ b/nikola/plugins/compile/php.plugin
@@ -5,9 +5,9 @@ module = php
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Compile PHP into HTML (just copy and name the file .php)
[Nikola]
-plugincategory = Compiler
+PluginCategory = Compiler
friendlyname = PHP
diff --git a/nikola/plugins/compile/php.py b/nikola/plugins/compile/php.py
index 28f4923..818e10d 100644
--- a/nikola/plugins/compile/php.py
+++ b/nikola/plugins/compile/php.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,27 +24,24 @@
# 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+php."""
+"""Page compiler plugin for PHP."""
-from __future__ import unicode_literals
-
-import os
import io
+import os
+from hashlib import md5
from nikola.plugin_categories import PageCompiler
from nikola.utils import makedirs, write_metadata
-from hashlib import md5
class CompilePhp(PageCompiler):
-
"""Compile PHP into PHP."""
name = "php"
friendly_name = "PHP"
- 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 open(source, "rb") as in_file:
@@ -52,6 +49,10 @@ class CompilePhp(PageCompiler):
out_file.write('<!-- __NIKOLA_PHP_TEMPLATE_INJECTION source:{0} checksum:{1}__ -->'.format(source, hash))
return True
+ def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None):
+ """Compile PHP into HTML strings."""
+ return data, []
+
def create_post(self, path, **kw):
"""Create a new post."""
content = kw.pop('content', None)
@@ -77,9 +78,7 @@ class CompilePhp(PageCompiler):
content += '\n'
with io.open(path, "w+", encoding="utf8") as fd:
if onefile:
- fd.write('<!--\n')
- fd.write(write_metadata(metadata))
- fd.write('-->\n\n')
+ fd.write(write_metadata(metadata, comment_wrap=True, site=self.site, compiler=self))
fd.write(content)
def extension(self):
diff --git a/nikola/plugins/compile/rest.plugin b/nikola/plugins/compile/rest.plugin
index cf842c7..43bdf2d 100644
--- a/nikola/plugins/compile/rest.plugin
+++ b/nikola/plugins/compile/rest.plugin
@@ -5,9 +5,9 @@ module = rest
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
-description = Compile reSt into HTML
+website = https://getnikola.com/
+description = Compile reST into HTML
[Nikola]
-plugincategory = Compiler
+PluginCategory = Compiler
friendlyname = reStructuredText
diff --git a/nikola/plugins/compile/rest/__init__.py b/nikola/plugins/compile/rest/__init__.py
index b99e872..44da076 100644
--- a/nikola/plugins/compile/rest/__init__.py
+++ b/nikola/plugins/compile/rest/__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,87 +26,138 @@
"""reStructuredText compiler for Nikola."""
-from __future__ import unicode_literals
import io
+import logging
import os
import docutils.core
import docutils.nodes
+import docutils.transforms
import docutils.utils
import docutils.io
import docutils.readers.standalone
-import docutils.writers.html4css1
+import docutils.writers.html5_polyglot
+import docutils.parsers.rst.directives
+from docutils.parsers.rst import roles
+from nikola.nikola import LEGAL_VALUES
+from nikola.metadata_extractors import MetaCondition
from nikola.plugin_categories import PageCompiler
-from nikola.utils import unicode_str, get_logger, makedirs, write_metadata, STDERR_HANDLER
+from nikola.utils import (
+ makedirs,
+ write_metadata,
+ LocaleBorg,
+ map_metadata
+)
class CompileRest(PageCompiler):
-
"""Compile reStructuredText into HTML."""
name = "rest"
friendly_name = "reStructuredText"
demote_headers = True
logger = None
-
- def _read_extra_deps(self, post):
- """Read contents of .dep file and returns them as a list."""
- dep_path = post.base_path + '.dep'
- if os.path.isfile(dep_path):
- with io.open(dep_path, 'r+', encoding='utf8') as depf:
- deps = [l.strip() for l in depf.readlines()]
- return deps
- return []
-
- def register_extra_dependencies(self, post):
- """Add dependency to post object to check .dep file."""
- post.add_dependency(lambda: self._read_extra_deps(post), 'fragment')
-
- def compile_html_string(self, data, source_path=None, is_two_file=True):
+ supports_metadata = True
+ metadata_conditions = [(MetaCondition.config_bool, "USE_REST_DOCINFO_METADATA")]
+
+ def read_metadata(self, post, lang=None):
+ """Read the metadata from a post, and return a metadata dict."""
+ if lang is None:
+ lang = LocaleBorg().current_lang
+ source_path = post.translated_source_path(lang)
+
+ # Silence reST errors, some of which are due to a different
+ # environment. Real issues will be reported while compiling.
+ null_logger = logging.getLogger('NULL')
+ null_logger.setLevel(1000)
+ with io.open(source_path, 'r', encoding='utf-8-sig') as inf:
+ data = inf.read()
+ _, _, _, document = rst2html(data, logger=null_logger, source_path=source_path, transforms=self.site.rst_transforms)
+ meta = {}
+ if 'title' in document:
+ meta['title'] = document['title']
+ for docinfo in document.traverse(docutils.nodes.docinfo):
+ for element in docinfo.children:
+ if element.tagname == 'field': # custom fields (e.g. summary)
+ name_elem, body_elem = element.children
+ name = name_elem.astext()
+ value = body_elem.astext()
+ elif element.tagname == 'authors': # author list
+ name = element.tagname
+ value = [element.astext() for element in element.children]
+ else: # standard fields (e.g. address)
+ name = element.tagname
+ value = element.astext()
+ name = name.lower()
+
+ meta[name] = value
+
+ # Put 'authors' meta field contents in 'author', too
+ if 'authors' in meta and 'author' not in meta:
+ meta['author'] = '; '.join(meta['authors'])
+
+ # Map metadata from other platforms to names Nikola expects (Issue #2817)
+ map_metadata(meta, 'rest_docinfo', self.site.config)
+ return meta
+
+ def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None):
"""Compile reST into HTML strings."""
# If errors occur, this will be added to the line number reported by
# docutils so the line number matches the actual line number (off by
# 7 with default metadata, could be more or less depending on the post).
add_ln = 0
if not is_two_file:
- m_data, data = self.split_metadata(data)
+ m_data, data = self.split_metadata(data, post, lang)
add_ln = len(m_data.splitlines()) + 1
default_template_path = os.path.join(os.path.dirname(__file__), 'template.txt')
- output, error_level, deps = rst2html(
- data, settings_overrides={
- 'initial_header_level': 1,
- 'record_dependencies': True,
- 'stylesheet_path': None,
- 'link_stylesheet': True,
- 'syntax_highlight': 'short',
- 'math_output': 'mathjax',
- 'template': default_template_path,
- }, logger=self.logger, source_path=source_path, l_add_ln=add_ln, transforms=self.site.rst_transforms)
- if not isinstance(output, unicode_str):
+ settings_overrides = {
+ 'initial_header_level': 1,
+ 'record_dependencies': True,
+ 'stylesheet_path': None,
+ 'link_stylesheet': True,
+ 'syntax_highlight': 'short',
+ # This path is not used by Nikola, but we need something to silence
+ # warnings about it from reST.
+ 'math_output': 'mathjax /assets/js/mathjax.js',
+ 'template': default_template_path,
+ 'language_code': LEGAL_VALUES['DOCUTILS_LOCALES'].get(LocaleBorg().current_lang, 'en'),
+ 'doctitle_xform': self.site.config.get('USE_REST_DOCINFO_METADATA'),
+ 'file_insertion_enabled': self.site.config.get('REST_FILE_INSERTION_ENABLED'),
+ }
+
+ from nikola import shortcodes as sc
+ new_data, shortcodes = sc.extract_shortcodes(data)
+ if self.site.config.get('HIDE_REST_DOCINFO', False):
+ self.site.rst_transforms.append(RemoveDocinfo)
+ output, error_level, deps, _ = rst2html(
+ new_data, settings_overrides=settings_overrides, logger=self.logger, source_path=source_path, l_add_ln=add_ln, transforms=self.site.rst_transforms)
+ if not isinstance(output, str):
# To prevent some weird bugs here or there.
# Original issue: empty files. `output` became a bytestring.
output = output.decode('utf-8')
- return output, error_level, deps
- def compile_html(self, source, dest, is_two_file=True):
- """Compile source file into HTML and save as dest."""
+ output, shortcode_deps = self.site.apply_shortcodes_uuid(output, shortcodes, filename=source_path, extra_context={'post': post})
+ return output, error_level, deps, 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."""
makedirs(os.path.dirname(dest))
error_level = 100
- 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()
- output, error_level, deps = self.compile_html_string(data, source, is_two_file)
+ output, error_level, deps, shortcode_deps = self.compile_string(data, source, is_two_file, post, lang)
out_file.write(output)
- deps_path = dest + '.dep'
- if deps.list:
- deps.list = [p for p in deps.list if p != dest] # Don't depend on yourself (#1671)
- with io.open(deps_path, "w+", encoding="utf8") as deps_file:
- deps_file.write('\n'.join(deps.list))
+ if post is None:
+ if deps.list:
+ self.logger.error(
+ "Cannot save dependencies for post {0} (post unknown)",
+ source)
else:
- if os.path.isfile(deps_path):
- os.unlink(deps_path)
+ post._depfile[dest] += deps.list
+ post._depfile[dest] += shortcode_deps
if error_level < 3:
return True
else:
@@ -124,23 +175,21 @@ class CompileRest(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(write_metadata(metadata))
- fd.write('\n')
+ fd.write(write_metadata(metadata, comment_wrap=False, site=self.site, compiler=self))
fd.write(content)
def set_site(self, site):
"""Set Nikola site."""
- super(CompileRest, self).set_site(site)
+ super().set_site(site)
self.config_dependencies = []
for plugin_info in self.get_compiler_extensions():
self.config_dependencies.append(plugin_info.name)
plugin_info.plugin_object.short_help = plugin_info.description
- self.logger = get_logger('compile_rest', STDERR_HANDLER)
if not site.debug:
- self.logger.level = 4
+ self.logger.level = logging.WARNING
def get_observer(settings):
@@ -150,19 +199,25 @@ def get_observer(settings):
Error code mapping:
- +------+---------+------+----------+
- | dNUM | dNAME | lNUM | lNAME | d = docutils, l = logbook
- +------+---------+------+----------+
- | 0 | DEBUG | 1 | DEBUG |
- | 1 | INFO | 2 | INFO |
- | 2 | WARNING | 4 | WARNING |
- | 3 | ERROR | 5 | ERROR |
- | 4 | SEVERE | 6 | CRITICAL |
- +------+---------+------+----------+
+ +----------+----------+
+ | docutils | logging |
+ +----------+----------+
+ | DEBUG | DEBUG |
+ | INFO | INFO |
+ | WARNING | WARNING |
+ | ERROR | ERROR |
+ | SEVERE | CRITICAL |
+ +----------+----------+
"""
- errormap = {0: 1, 1: 2, 2: 4, 3: 5, 4: 6}
+ errormap = {
+ docutils.utils.Reporter.DEBUG_LEVEL: logging.DEBUG,
+ docutils.utils.Reporter.INFO_LEVEL: logging.INFO,
+ docutils.utils.Reporter.WARNING_LEVEL: logging.WARNING,
+ docutils.utils.Reporter.ERROR_LEVEL: logging.ERROR,
+ docutils.utils.Reporter.SEVERE_LEVEL: logging.CRITICAL
+ }
text = docutils.nodes.Element.astext(msg)
- line = msg['line'] + settings['add_ln'] if 'line' in msg else 0
+ line = msg['line'] + settings['add_ln'] if 'line' in msg else ''
out = '[{source}{colon}{line}] {text}'.format(
source=settings['source'], colon=(':' if line else ''),
line=line, text=text)
@@ -172,12 +227,14 @@ def get_observer(settings):
class NikolaReader(docutils.readers.standalone.Reader):
-
"""Nikola-specific docutils reader."""
+ config_section = 'nikola'
+
def __init__(self, *args, **kwargs):
"""Initialize the reader."""
self.transforms = kwargs.pop('transforms', [])
+ self.logging_settings = kwargs.pop('nikola_logging_settings', {})
docutils.readers.standalone.Reader.__init__(self, *args, **kwargs)
def get_transforms(self):
@@ -188,15 +245,26 @@ class NikolaReader(docutils.readers.standalone.Reader):
"""Create and return a new empty document tree (root node)."""
document = docutils.utils.new_document(self.source.source_path, self.settings)
document.reporter.stream = False
- document.reporter.attach_observer(get_observer(self.l_settings))
+ document.reporter.attach_observer(get_observer(self.logging_settings))
return document
+def shortcode_role(name, rawtext, text, lineno, inliner,
+ options={}, content=[]):
+ """Return a shortcode role that passes through raw inline HTML."""
+ return [docutils.nodes.raw('', text, format='html')], []
+
+
+roles.register_canonical_role('raw-html', shortcode_role)
+roles.register_canonical_role('html', shortcode_role)
+roles.register_canonical_role('sc', shortcode_role)
+
+
def add_node(node, visit_function=None, depart_function=None):
"""Register a Docutils node class.
This function is completely optional. It is a same concept as
- `Sphinx add_node function <http://sphinx-doc.org/ext/appapi.html#sphinx.application.Sphinx.add_node>`_.
+ `Sphinx add_node function <http://sphinx-doc.org/extdev/appapi.html#sphinx.application.Sphinx.add_node>`_.
For example::
@@ -208,7 +276,7 @@ def add_node(node, visit_function=None, depart_function=None):
self.site = site
directives.register_directive('math', MathDirective)
add_node(MathBlock, visit_Math, depart_Math)
- return super(Plugin, self).set_site(site)
+ return super().set_site(site)
class MathDirective(Directive):
def run(self):
@@ -227,16 +295,52 @@ def add_node(node, visit_function=None, depart_function=None):
"""
docutils.nodes._add_node_class_names([node.__name__])
if visit_function:
- setattr(docutils.writers.html4css1.HTMLTranslator, 'visit_' + node.__name__, visit_function)
+ setattr(docutils.writers.html5_polyglot.HTMLTranslator, 'visit_' + node.__name__, visit_function)
if depart_function:
- setattr(docutils.writers.html4css1.HTMLTranslator, 'depart_' + node.__name__, depart_function)
+ setattr(docutils.writers.html5_polyglot.HTMLTranslator, 'depart_' + node.__name__, depart_function)
+
+
+# Output <code> for ``double backticks``. (Code and extra logic based on html4css1 translator)
+def visit_literal(self, node):
+ """Output <code> 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('<span class="pre">%s</span>'
+ % 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('&nbsp;' * (len(token) - 1) + ' ')
+ self.body.append('</code>')
+ # 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('', '<div class="text-error">{0}</div>'.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 '<a href="{0}">{1}</a>'.format(permalink, title)
+ else:
+ LOGGER.error(
+ '"{0}" slug doesn\'t exist.'.format(slug))
+ return '<span class="error text-error" style="color: red;">Invalid link: {0}</span>'.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('', '<div class="text-error">{0}</div>'.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 '<div class="text-error">{0}</div>'.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 = ("""<iframe width="{width}" height="{height}"
+CODE = """\
+<div class="soundcloud-player{align}">
+<iframe width="{width}" height="{height}"
scrolling="no" frameborder="no"
-src="https://w.soundcloud.com/player/?url=http://api.soundcloud.com/{preslug}/"""
- """{sid}">
-</iframe>""")
+src="https://w.soundcloud.com/player/?url=http://api.soundcloud.com/{preslug}/{sid}">
+</iframe>
+</div>"""
class SoundCloud(Directive):
-
"""reST extension for inserting SoundCloud embedded music.
Usage:
@@ -46,6 +70,7 @@ class SoundCloud(Directive):
option_spec = {
'width': directives.positive_int,
'height': directives.positive_int,
+ "align": _align_choice
}
preslug = "tracks"
@@ -59,6 +84,10 @@ class SoundCloud(Directive):
'preslug': self.preslug,
}
options.update(self.options)
+ if self.options.get('align') in _align_options_base:
+ options['align'] = ' align-' + self.options['align']
+ else:
+ options['align'] = ''
return [nodes.raw('', CODE.format(**options), format='html')]
def check_content(self):
@@ -70,7 +99,6 @@ class SoundCloud(Directive):
class SoundCloudPlaylist(SoundCloud):
-
"""reST directive for SoundCloud playlists."""
preslug = "playlists"
diff --git a/nikola/plugins/compile/rest/thumbnail.plugin b/nikola/plugins/compile/rest/thumbnail.plugin
index 0084310..e7b649d 100644
--- a/nikola/plugins/compile/rest/thumbnail.plugin
+++ b/nikola/plugins/compile/rest/thumbnail.plugin
@@ -4,11 +4,11 @@ module = thumbnail
[Nikola]
compiler = rest
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
author = Pelle Nilsson
version = 0.1
-website = http://getnikola.com
+website = https://getnikola.com/
description = reST directive to facilitate enlargeable images with thumbnails
diff --git a/nikola/plugins/compile/rest/thumbnail.py b/nikola/plugins/compile/rest/thumbnail.py
index 1fae06c..06ca9e4 100644
--- a/nikola/plugins/compile/rest/thumbnail.py
+++ b/nikola/plugins/compile/rest/thumbnail.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2014-2015 Pelle Nilsson and others.
+# Copyright © 2014-2020 Pelle Nilsson and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -35,7 +35,6 @@ from nikola.plugin_categories import RestExtension
class Plugin(RestExtension):
-
"""Plugin for thumbnail directive."""
name = "rest_thumbnail"
@@ -44,11 +43,10 @@ class Plugin(RestExtension):
"""Set Nikola site."""
self.site = site
directives.register_directive('thumbnail', Thumbnail)
- return super(Plugin, self).set_site(site)
+ return super().set_site(site)
class Thumbnail(Figure):
-
"""Thumbnail directive for reST."""
def align(argument):
@@ -70,8 +68,12 @@ class Thumbnail(Figure):
def run(self):
"""Run the thumbnail directive."""
uri = directives.uri(self.arguments[0])
+ if uri.endswith('.svg'):
+ # the ? at the end makes docutil output an <img> instead of an object for the svg, which lightboxes may require
+ self.arguments[0] = '.thumbnail'.join(os.path.splitext(uri)) + '?'
+ else:
+ self.arguments[0] = '.thumbnail'.join(os.path.splitext(uri))
self.options['target'] = uri
- self.arguments[0] = '.thumbnail'.join(os.path.splitext(uri))
if self.content:
(node,) = Figure.run(self)
else:
diff --git a/nikola/plugins/compile/rest/vimeo.plugin b/nikola/plugins/compile/rest/vimeo.plugin
index 688f981..89b171b 100644
--- a/nikola/plugins/compile/rest/vimeo.plugin
+++ b/nikola/plugins/compile/rest/vimeo.plugin
@@ -4,7 +4,7 @@ module = vimeo
[Nikola]
compiler = rest
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
description = Vimeo directive
diff --git a/nikola/plugins/compile/rest/vimeo.py b/nikola/plugins/compile/rest/vimeo.py
index c694a87..7047b03 100644
--- a/nikola/plugins/compile/rest/vimeo.py
+++ b/nikola/plugins/compile/rest/vimeo.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,18 +26,17 @@
"""Vimeo directive for reStructuredText."""
-from docutils import nodes
-from docutils.parsers.rst import Directive, directives
-
-import requests
import json
+import requests
+from docutils import nodes
+from docutils.parsers.rst import Directive, directives
from nikola.plugin_categories import RestExtension
+from nikola.plugins.compile.rest import _align_choice, _align_options_base
class Plugin(RestExtension):
-
"""Plugin for vimeo reST directive."""
name = "rest_vimeo"
@@ -46,13 +45,15 @@ class Plugin(RestExtension):
"""Set Nikola site."""
self.site = site
directives.register_directive('vimeo', Vimeo)
- return super(Plugin, self).set_site(site)
+ return super().set_site(site)
-CODE = """<iframe src="//player.vimeo.com/video/{vimeo_id}"
+CODE = """<div class="vimeo-video{align}">
+<iframe src="https://player.vimeo.com/video/{vimeo_id}"
width="{width}" height="{height}"
frameborder="0" webkitAllowFullScreen="webkitAllowFullScreen" mozallowfullscreen="mozallowfullscreen" allowFullScreen="allowFullScreen">
</iframe>
+</div>
"""
VIDEO_DEFAULT_HEIGHT = 500
@@ -60,7 +61,6 @@ VIDEO_DEFAULT_WIDTH = 281
class Vimeo(Directive):
-
"""reST extension for inserting vimeo embedded videos.
Usage:
@@ -75,6 +75,7 @@ class Vimeo(Directive):
option_spec = {
"width": directives.positive_int,
"height": directives.positive_int,
+ "align": _align_choice
}
# set to False for not querying the vimeo api for size
@@ -94,6 +95,10 @@ class Vimeo(Directive):
return err
self.set_video_size()
options.update(self.options)
+ if self.options.get('align') in _align_options_base:
+ options['align'] = ' align-' + self.options['align']
+ else:
+ options['align'] = ''
return [nodes.raw('', CODE.format(**options), format='html')]
def check_modules(self):
@@ -109,7 +114,7 @@ class Vimeo(Directive):
if json: # we can attempt to retrieve video attributes from vimeo
try:
- url = ('//vimeo.com/api/v2/video/{0}'
+ url = ('https://vimeo.com/api/v2/video/{0}'
'.json'.format(self.arguments[0]))
data = requests.get(url).text
video_attributes = json.loads(data)[0]
diff --git a/nikola/plugins/compile/rest/youtube.plugin b/nikola/plugins/compile/rest/youtube.plugin
index 5fbd67b..d83d0f8 100644
--- a/nikola/plugins/compile/rest/youtube.plugin
+++ b/nikola/plugins/compile/rest/youtube.plugin
@@ -4,7 +4,7 @@ module = youtube
[Nikola]
compiler = rest
-plugincategory = CompilerExtension
+PluginCategory = CompilerExtension
[Documentation]
version = 0.1
diff --git a/nikola/plugins/compile/rest/youtube.py b/nikola/plugins/compile/rest/youtube.py
index 6c5c211..d52ec64 100644
--- a/nikola/plugins/compile/rest/youtube.py
+++ b/nikola/plugins/compile/rest/youtube.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 Directive, directives
-
+from nikola.plugins.compile.rest import _align_choice, _align_options_base
from nikola.plugin_categories import RestExtension
class Plugin(RestExtension):
-
"""Plugin for the youtube directive."""
name = "rest_youtube"
@@ -43,18 +42,19 @@ class Plugin(RestExtension):
"""Set Nikola site."""
self.site = site
directives.register_directive('youtube', Youtube)
- return super(Plugin, self).set_site(site)
+ return super().set_site(site)
CODE = """\
-<iframe width="{width}"
-height="{height}"
-src="//www.youtube.com/embed/{yid}?rel=0&amp;hd=1&amp;wmode=transparent"
-></iframe>"""
+<div class="youtube-video{align}">
+<iframe width="{width}" height="{height}"
+src="https://www.youtube-nocookie.com/embed/{yid}?rel=0&wmode=transparent"
+frameborder="0" allow="encrypted-media" allowfullscreen
+></iframe>
+</div>"""
class Youtube(Directive):
-
"""reST extension for inserting youtube embedded videos.
Usage:
@@ -67,8 +67,9 @@ class Youtube(Directive):
has_content = True
required_arguments = 1
option_spec = {
- "width": directives.positive_int,
- "height": directives.positive_int,
+ "width": directives.unchanged,
+ "height": directives.unchanged,
+ "align": _align_choice
}
def run(self):
@@ -76,10 +77,14 @@ class Youtube(Directive):
self.check_content()
options = {
'yid': self.arguments[0],
- 'width': 425,
- 'height': 344,
+ 'width': 560,
+ 'height': 315,
}
- options.update(self.options)
+ options.update({k: v for k, v in self.options.items() if v})
+ if self.options.get('align') in _align_options_base:
+ options['align'] = ' align-' + self.options['align']
+ else:
+ options['align'] = ''
return [nodes.raw('', CODE.format(**options), format='html')]
def check_content(self):
diff --git a/nikola/plugins/misc/__init__.py b/nikola/plugins/misc/__init__.py
index c0d8961..1e7e6e1 100644
--- a/nikola/plugins/misc/__init__.py
+++ b/nikola/plugins/misc/__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/misc/scan_posts.plugin b/nikola/plugins/misc/scan_posts.plugin
index 6d2351f..f4af811 100644
--- a/nikola/plugins/misc/scan_posts.plugin
+++ b/nikola/plugins/misc/scan_posts.plugin
@@ -5,6 +5,6 @@ Module = scan_posts
[Documentation]
Author = Roberto Alsina
Version = 1.0
-Website = http://getnikola.com
+Website = https://getnikola.com/
Description = Scan posts and create timeline
diff --git a/nikola/plugins/misc/scan_posts.py b/nikola/plugins/misc/scan_posts.py
index 1f4f995..8812779 100644
--- a/nikola/plugins/misc/scan_posts.py
+++ b/nikola/plugins/misc/scan_posts.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,7 +26,6 @@
"""The default post scanner."""
-from __future__ import unicode_literals, print_function
import glob
import os
import sys
@@ -35,9 +34,10 @@ from nikola.plugin_categories import PostScanner
from nikola import utils
from nikola.post import Post
+LOGGER = utils.get_logger('scan_posts')
-class ScanPosts(PostScanner):
+class ScanPosts(PostScanner):
"""Scan posts in the site."""
name = "scan_posts"
@@ -54,10 +54,10 @@ class ScanPosts(PostScanner):
self.site.config['post_pages']:
if not self.site.quiet:
print(".", end='', file=sys.stderr)
+ destination_translatable = utils.TranslatableSetting('destination', destination, self.site.config['TRANSLATIONS'])
dirname = os.path.dirname(wildcard)
for dirpath, _, _ in os.walk(dirname, followlinks=True):
- dest_dir = os.path.normpath(os.path.join(destination,
- os.path.relpath(dirpath, dirname))) # output/destination/foo/
+ rel_dest_dir = os.path.relpath(dirpath, dirname)
# Get all the untranslated paths
dir_glob = os.path.join(dirpath, os.path.basename(wildcard)) # posts/foo/*.rst
untranslated = glob.glob(dir_glob)
@@ -83,20 +83,30 @@ class ScanPosts(PostScanner):
if not any([x.startswith('.')
for x in p.split(os.sep)])]
- for base_path in full_list:
+ for base_path in sorted(full_list):
if base_path in seen:
continue
- else:
- seen.add(base_path)
- post = Post(
- base_path,
- self.site.config,
- dest_dir,
- use_in_feeds,
- self.site.MESSAGES,
- template_name,
- self.site.get_compiler(base_path)
- )
- timeline.append(post)
+ try:
+ post = Post(
+ base_path,
+ self.site.config,
+ rel_dest_dir,
+ use_in_feeds,
+ self.site.MESSAGES,
+ template_name,
+ self.site.get_compiler(base_path),
+ destination_base=destination_translatable,
+ metadata_extractors_by=self.site.metadata_extractors_by
+ )
+ for lang in post.translated_to:
+ seen.add(post.translated_source_path(lang))
+ timeline.append(post)
+ except Exception:
+ LOGGER.error('Error reading post {}'.format(base_path))
+ raise
return timeline
+
+ def supported_extensions(self):
+ """Return a list of supported file extensions, or None if such a list isn't known beforehand."""
+ return list({os.path.splitext(x[0])[1] for x in self.site.config['post_pages']})
diff --git a/nikola/plugins/misc/taxonomies_classifier.plugin b/nikola/plugins/misc/taxonomies_classifier.plugin
new file mode 100644
index 0000000..55c59af
--- /dev/null
+++ b/nikola/plugins/misc/taxonomies_classifier.plugin
@@ -0,0 +1,12 @@
+[Core]
+name = classify_taxonomies
+module = taxonomies_classifier
+
+[Documentation]
+author = Roberto Alsina
+version = 1.0
+website = https://getnikola.com/
+description = Classifies the timeline into taxonomies.
+
+[Nikola]
+PluginCategory = SignalHandler
diff --git a/nikola/plugins/misc/taxonomies_classifier.py b/nikola/plugins/misc/taxonomies_classifier.py
new file mode 100644
index 0000000..da8045b
--- /dev/null
+++ b/nikola/plugins/misc/taxonomies_classifier.py
@@ -0,0 +1,335 @@
+# -*- 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.
+
+"""Render the taxonomy overviews, classification pages and feeds."""
+
+import functools
+import os
+import sys
+from collections import defaultdict
+
+import blinker
+import natsort
+
+from nikola.plugin_categories import SignalHandler
+from nikola import utils, hierarchy_utils
+
+
+class TaxonomiesClassifier(SignalHandler):
+ """Classify posts and pages by taxonomies."""
+
+ name = "classify_taxonomies"
+
+ def _do_classification(self, site):
+ # Needed to avoid strange errors during tests
+ if site is not self.site:
+ return
+
+ # Get list of enabled taxonomy plugins and initialize data structures
+ taxonomies = site.taxonomy_plugins.values()
+ site.posts_per_classification = {}
+ for taxonomy in taxonomies:
+ site.posts_per_classification[taxonomy.classification_name] = {
+ lang: defaultdict(set) for lang in site.config['TRANSLATIONS'].keys()
+ }
+
+ # Classify posts
+ for post in site.timeline:
+ # Do classify pages, but don’t classify posts that are hidden
+ # (draft/private/future)
+ if post.is_post and not post.use_in_feeds:
+ continue
+ for taxonomy in taxonomies:
+ if taxonomy.apply_to_posts if post.is_post else taxonomy.apply_to_pages:
+ classifications = {}
+ for lang in site.config['TRANSLATIONS'].keys():
+ # Extract classifications for this language
+ classifications[lang] = taxonomy.classify(post, lang)
+ if not taxonomy.more_than_one_classifications_per_post and len(classifications[lang]) > 1:
+ raise ValueError("Too many {0} classifications for post {1}".format(taxonomy.classification_name, post.source_path))
+ # Add post to sets
+ for classification in classifications[lang]:
+ while True:
+ site.posts_per_classification[taxonomy.classification_name][lang][classification].add(post)
+ if not taxonomy.include_posts_from_subhierarchies or not taxonomy.has_hierarchy:
+ break
+ classification_path = taxonomy.extract_hierarchy(classification)
+ if len(classification_path) <= 1:
+ if len(classification_path) == 0 or not taxonomy.include_posts_into_hierarchy_root:
+ break
+ classification = taxonomy.recombine_classification_from_hierarchy(classification_path[:-1])
+
+ # Sort everything.
+ site.page_count_per_classification = {}
+ site.hierarchy_per_classification = {}
+ site.flat_hierarchy_per_classification = {}
+ site.hierarchy_lookup_per_classification = {}
+ for taxonomy in taxonomies:
+ site.page_count_per_classification[taxonomy.classification_name] = {}
+ # Sort post lists
+ for lang, posts_per_classification in site.posts_per_classification[taxonomy.classification_name].items():
+ # Ensure implicit classifications are inserted
+ for classification in taxonomy.get_implicit_classifications(lang):
+ if classification not in posts_per_classification:
+ posts_per_classification[classification] = []
+ site.page_count_per_classification[taxonomy.classification_name][lang] = {}
+ # Convert sets to lists and sort them
+ for classification in list(posts_per_classification.keys()):
+ posts = list(posts_per_classification[classification])
+ posts = self.site.sort_posts_chronologically(posts, lang)
+ taxonomy.sort_posts(posts, classification, lang)
+ posts_per_classification[classification] = posts
+ # Create hierarchy information
+ if taxonomy.has_hierarchy:
+ site.hierarchy_per_classification[taxonomy.classification_name] = {}
+ site.flat_hierarchy_per_classification[taxonomy.classification_name] = {}
+ site.hierarchy_lookup_per_classification[taxonomy.classification_name] = {}
+ for lang, posts_per_classification in site.posts_per_classification[taxonomy.classification_name].items():
+ # Compose hierarchy
+ hierarchy = {}
+ for classification in posts_per_classification.keys():
+ hier = taxonomy.extract_hierarchy(classification)
+ node = hierarchy
+ for he in hier:
+ if he not in node:
+ node[he] = {}
+ node = node[he]
+ hierarchy_lookup = {}
+
+ def create_hierarchy(hierarchy, parent=None, level=0):
+ """Create hierarchy."""
+ result = {}
+ for name, children in hierarchy.items():
+ node = hierarchy_utils.TreeNode(name, parent)
+ node.children = create_hierarchy(children, node, level + 1)
+ node.classification_path = [pn.name for pn in node.get_path()]
+ node.classification_name = taxonomy.recombine_classification_from_hierarchy(node.classification_path)
+ hierarchy_lookup[node.classification_name] = node
+ result[node.name] = node
+ classifications = natsort.natsorted(result.keys(), alg=natsort.ns.F | natsort.ns.IC)
+ taxonomy.sort_classifications(classifications, lang, level=level)
+ return [result[classification] for classification in classifications]
+
+ root_list = create_hierarchy(hierarchy)
+ if '' in posts_per_classification:
+ node = hierarchy_utils.TreeNode('', parent=None)
+ node.children = root_list
+ node.classification_path = []
+ node.classification_name = ''
+ hierarchy_lookup[node.name] = node
+ root_list = [node]
+ flat_hierarchy = hierarchy_utils.flatten_tree_structure(root_list)
+ # Store result
+ site.hierarchy_per_classification[taxonomy.classification_name][lang] = root_list
+ site.flat_hierarchy_per_classification[taxonomy.classification_name][lang] = flat_hierarchy
+ site.hierarchy_lookup_per_classification[taxonomy.classification_name][lang] = hierarchy_lookup
+ taxonomy.postprocess_posts_per_classification(site.posts_per_classification[taxonomy.classification_name],
+ site.flat_hierarchy_per_classification[taxonomy.classification_name],
+ site.hierarchy_lookup_per_classification[taxonomy.classification_name])
+ else:
+ taxonomy.postprocess_posts_per_classification(site.posts_per_classification[taxonomy.classification_name])
+
+ # Check for valid paths and for collisions
+ taxonomy_outputs = {lang: dict() for lang in site.config['TRANSLATIONS'].keys()}
+ quit = False
+ for taxonomy in taxonomies:
+ # Check for collisions (per language)
+ for lang in site.config['TRANSLATIONS'].keys():
+ if not taxonomy.is_enabled(lang):
+ continue
+ for classification, posts in site.posts_per_classification[taxonomy.classification_name][lang].items():
+ # Do we actually generate this classification page?
+ filtered_posts = [x for x in posts if self.site.config["SHOW_UNTRANSLATED_POSTS"] or x.is_translation_available(lang)]
+ generate_list = taxonomy.should_generate_classification_page(classification, filtered_posts, lang)
+ if not generate_list:
+ continue
+ # Obtain path as tuple
+ path = site.path_handlers[taxonomy.classification_name](classification, lang)
+ # Check that path is OK
+ for path_element in path:
+ if len(path_element) == 0:
+ utils.LOGGER.error("{0} {1} yields invalid path '{2}'!".format(taxonomy.classification_name.title(), classification, '/'.join(path)))
+ quit = True
+ # Combine path
+ path = os.path.join(*[os.path.normpath(p) for p in path if p != '.'])
+ # Determine collisions
+ if path in taxonomy_outputs[lang]:
+ other_classification_name, other_classification, other_posts = taxonomy_outputs[lang][path]
+ if other_classification_name == taxonomy.classification_name and other_classification == classification:
+ taxonomy_outputs[lang][path][2].extend(filtered_posts)
+ else:
+ utils.LOGGER.error('You have classifications that are too similar: {0} "{1}" and {2} "{3}" both result in output path {4} for language {5}.'.format(
+ taxonomy.classification_name, classification, other_classification_name, other_classification, path, lang))
+ utils.LOGGER.error('{0} "{1}" is used in: {2}'.format(
+ taxonomy.classification_name.title(), classification, ', '.join(sorted([p.source_path for p in filtered_posts]))))
+ utils.LOGGER.error('{0} "{1}" is used in: {2}'.format(
+ other_classification_name.title(), other_classification, ', '.join(sorted([p.source_path for p in other_posts]))))
+ quit = True
+ else:
+ taxonomy_outputs[lang][path] = (taxonomy.classification_name, classification, list(posts))
+ if quit:
+ sys.exit(1)
+ blinker.signal('taxonomies_classified').send(site)
+
+ def _get_filtered_list(self, taxonomy, classification, lang):
+ """Return the filtered list of posts for this classification and language."""
+ post_list = self.site.posts_per_classification[taxonomy.classification_name][lang].get(classification, [])
+ if self.site.config["SHOW_UNTRANSLATED_POSTS"]:
+ return post_list
+ else:
+ return [x for x in post_list if x.is_translation_available(lang)]
+
+ @staticmethod
+ def _compute_number_of_pages(filtered_posts, posts_count):
+ """Given a list of posts and the maximal number of posts per page, computes the number of pages needed."""
+ return min(1, (len(filtered_posts) + posts_count - 1) // posts_count)
+
+ def _postprocess_path(self, path, lang, append_index='auto', dest_type='page', page_info=None, alternative_path=False):
+ """Postprocess a generated path.
+
+ Takes the path `path` for language `lang`, and postprocesses it.
+
+ It appends `site.config['INDEX_FILE']` depending on `append_index`
+ (which can have the values `'always'`, `'never'` and `'auto'`) and
+ `site.config['PRETTY_URLS']`.
+
+ It also modifies/adds the extension of the last path element resp.
+ `site.config['INDEX_FILE']` depending on `dest_type`, which can be
+ `'feed'`, `'rss'` or `'page'`.
+
+ If `dest_type` is `'page'`, `page_info` can be `None` or a tuple
+ of two integers: the page number and the number of pages. This will
+ be used to append the correct page number by calling
+ `utils.adjust_name_for_index_path_list` and
+ `utils.get_displayed_page_number`.
+
+ If `alternative_path` is set to `True`, `utils.adjust_name_for_index_path_list`
+ is called with `force_addition=True`, resulting in an alternative path for the
+ first page of an index or Atom feed by including the page number into the path.
+ """
+ # Forcing extension for Atom feeds and RSS feeds
+ force_extension = None
+ if dest_type == 'feed':
+ force_extension = self.site.config['ATOM_EXTENSION']
+ elif dest_type == 'rss':
+ force_extension = self.site.config['RSS_EXTENSION']
+ # Determine how to extend path
+ path = [_f for _f in path if _f]
+ if force_extension is not None:
+ if len(path) == 0 and dest_type == 'rss':
+ path = [self.site.config['RSS_FILENAME_BASE'](lang)]
+ elif len(path) == 0 and dest_type == 'feed':
+ path = [self.site.config['ATOM_FILENAME_BASE'](lang)]
+ elif len(path) == 0 or append_index == 'always':
+ path = path + [os.path.splitext(self.site.config['INDEX_FILE'])[0]]
+ elif len(path) > 0 and append_index == 'never':
+ path[-1] = os.path.splitext(path[-1])[0]
+ path[-1] += force_extension
+ elif (self.site.config['PRETTY_URLS'] and append_index != 'never') or len(path) == 0 or append_index == 'always':
+ path = path + [self.site.config['INDEX_FILE']]
+ elif append_index != 'never':
+ path[-1] += '.html'
+ # Create path
+ result = [_f for _f in [self.site.config['TRANSLATIONS'][lang]] + path if _f]
+ if page_info is not None and dest_type in ('page', 'feed'):
+ result = utils.adjust_name_for_index_path_list(result,
+ page_info[0],
+ utils.get_displayed_page_number(page_info[0], page_info[1], self.site),
+ lang,
+ self.site, force_addition=alternative_path, extension=force_extension)
+ return result
+
+ @staticmethod
+ def _parse_path_result(result):
+ """Interpret the return values of taxonomy.get_path() and taxonomy.get_overview_path() as if all three return values were given."""
+ if not isinstance(result[0], (list, tuple)):
+ # The result must be a list or tuple of strings. Wrap into a tuple
+ result = (result, )
+ path = result[0]
+ append_index = result[1] if len(result) > 1 else 'auto'
+ page_info = result[2] if len(result) > 2 else None
+ return path, append_index, page_info
+
+ def _taxonomy_index_path(self, name, lang, taxonomy):
+ """Return path to the classification overview."""
+ result = taxonomy.get_overview_path(lang)
+ path, append_index, _ = self._parse_path_result(result)
+ return self._postprocess_path(path, lang, append_index=append_index, dest_type='list')
+
+ def _taxonomy_path(self, name, lang, taxonomy, dest_type='page', page=None, alternative_path=False):
+ """Return path to a classification."""
+ if taxonomy.has_hierarchy:
+ result = taxonomy.get_path(taxonomy.extract_hierarchy(name), lang, dest_type=dest_type)
+ else:
+ result = taxonomy.get_path(name, lang, dest_type=dest_type)
+ path, append_index, page_ = self._parse_path_result(result)
+
+ if page is not None:
+ page = int(page)
+ else:
+ page = page_
+
+ page_info = None
+ if taxonomy.show_list_as_index and page is not None:
+ number_of_pages = self.site.page_count_per_classification[taxonomy.classification_name][lang].get(name)
+ if number_of_pages is None:
+ number_of_pages = self._compute_number_of_pages(self._get_filtered_list(taxonomy, name, lang), self.site.config['INDEX_DISPLAY_POST_COUNT'])
+ self.site.page_count_per_classification[taxonomy.classification_name][lang][name] = number_of_pages
+ page_info = (page, number_of_pages)
+ return self._postprocess_path(path, lang, append_index=append_index, dest_type=dest_type, page_info=page_info)
+
+ def _taxonomy_atom_path(self, name, lang, taxonomy, page=None, alternative_path=False):
+ """Return path to a classification Atom feed."""
+ return self._taxonomy_path(name, lang, taxonomy, dest_type='feed', page=page, alternative_path=alternative_path)
+
+ def _taxonomy_rss_path(self, name, lang, taxonomy):
+ """Return path to a classification RSS feed."""
+ return self._taxonomy_path(name, lang, taxonomy, dest_type='rss')
+
+ def _register_path_handlers(self, taxonomy):
+ functions = (
+ ('{0}_index', self._taxonomy_index_path),
+ ('{0}', self._taxonomy_path),
+ ('{0}_atom', self._taxonomy_atom_path),
+ ('{0}_rss', self._taxonomy_rss_path),
+ )
+
+ for name, function in functions:
+ name = name.format(taxonomy.classification_name)
+ p = functools.partial(function, taxonomy=taxonomy)
+ doc = taxonomy.path_handler_docstrings[name]
+ if doc is not False:
+ p.__doc__ = doc
+ self.site.register_path_handler(name, p)
+
+ def set_site(self, site):
+ """Set site, which is a Nikola instance."""
+ super().set_site(site)
+ # Add hook for after post scanning
+ blinker.signal("scanned").connect(self._do_classification)
+ # Register path handlers
+ for taxonomy in site.taxonomy_plugins.values():
+ self._register_path_handlers(taxonomy)
diff --git a/nikola/plugins/shortcode/chart.plugin b/nikola/plugins/shortcode/chart.plugin
new file mode 100644
index 0000000..edcbc13
--- /dev/null
+++ b/nikola/plugins/shortcode/chart.plugin
@@ -0,0 +1,13 @@
+[Core]
+name = chart
+module = chart
+
+[Nikola]
+PluginCategory = Shortcode
+
+[Documentation]
+author = Roberto Alsina
+version = 0.1
+website = https://getnikola.com/
+description = Chart directive based in PyGal
+
diff --git a/nikola/plugins/shortcode/chart.py b/nikola/plugins/shortcode/chart.py
new file mode 100644
index 0000000..64341e8
--- /dev/null
+++ b/nikola/plugins/shortcode/chart.py
@@ -0,0 +1,90 @@
+# -*- 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.
+"""Chart shortcode."""
+
+from ast import literal_eval
+
+from nikola.plugin_categories import ShortcodePlugin
+from nikola.utils import req_missing, load_data
+
+try:
+ import pygal
+except ImportError:
+ pygal = None
+
+_site = None
+
+
+class ChartShortcode(ShortcodePlugin):
+ """Plugin for chart shortcode."""
+
+ name = "chart"
+
+ def handler(self, chart_type, **_options):
+ """Generate chart using Pygal."""
+ if pygal is None:
+ msg = req_missing(
+ ['pygal'], 'use the Chart directive', optional=True)
+ return '<div class="text-error">{0}</div>'.format(msg)
+ options = {}
+ chart_data = []
+ _options.pop('post', None)
+ _options.pop('site')
+ data = _options.pop('data')
+
+ for line in data.splitlines():
+ line = line.strip()
+ if line:
+ chart_data.append(literal_eval('({0})'.format(line)))
+ if 'data_file' in _options:
+ options = load_data(_options['data_file'])
+ _options.pop('data_file')
+ if not chart_data: # If there is data in the document, it wins
+ for k, v in options.pop('data', {}).items():
+ chart_data.append((k, v))
+
+ options.update(_options)
+
+ style_name = options.pop('style', 'BlueStyle')
+ if '(' in style_name: # Parametric style
+ style = eval('pygal.style.' + style_name)
+ else:
+ style = getattr(pygal.style, style_name)
+ for k, v in options.items():
+ try:
+ options[k] = literal_eval(v)
+ except Exception:
+ options[k] = v
+ chart = pygal
+ for o in chart_type.split('.'):
+ chart = getattr(chart, o)
+ chart = chart(style=style)
+ if _site and _site.invariant:
+ chart.no_prefix = True
+ chart.config(**options)
+ for label, series in chart_data:
+ chart.add(label, series)
+ return chart.render().decode('utf8')
diff --git a/nikola/plugins/shortcode/emoji.plugin b/nikola/plugins/shortcode/emoji.plugin
new file mode 100644
index 0000000..c9a272c
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji.plugin
@@ -0,0 +1,13 @@
+[Core]
+name = emoji
+module = emoji
+
+[Nikola]
+PluginCategory = Shortcode
+
+[Documentation]
+author = Roberto Alsina
+version = 0.1
+website = https://getnikola.com/
+description = emoji shortcode
+
diff --git a/nikola/plugins/shortcode/emoji/__init__.py b/nikola/plugins/shortcode/emoji/__init__.py
new file mode 100644
index 0000000..9ae2228
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji/__init__.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# This file is public domain according to its author, Roberto Alsina
+
+"""Emoji directive for reStructuredText."""
+
+import glob
+import json
+import os
+
+from nikola.plugin_categories import ShortcodePlugin
+from nikola import utils
+
+TABLE = {}
+
+LOGGER = utils.get_logger('scan_posts')
+
+
+def _populate():
+ for fname in glob.glob(os.path.join(os.path.dirname(__file__), 'data', '*.json')):
+ with open(fname, encoding="utf-8-sig") as inf:
+ data = json.load(inf)
+ data = data[list(data.keys())[0]]
+ data = data[list(data.keys())[0]]
+ for item in data:
+ if item['key'] in TABLE:
+ LOGGER.warning('Repeated emoji {}'.format(item['key']))
+ else:
+ TABLE[item['key']] = item['value']
+
+
+class Plugin(ShortcodePlugin):
+ """Plugin for gist directive."""
+
+ name = "emoji"
+
+ def handler(self, name, filename=None, site=None, data=None, lang=None, post=None):
+ """Create HTML for emoji."""
+ if not TABLE:
+ _populate()
+ try:
+ output = u'''<span class="emoji">{}</span>'''.format(TABLE[name])
+ except KeyError:
+ LOGGER.warning('Unknown emoji {}'.format(name))
+ output = u'''<span class="emoji error">{}</span>'''.format(name)
+
+ return output, []
diff --git a/nikola/plugins/shortcode/emoji/data/Activity.json b/nikola/plugins/shortcode/emoji/data/Activity.json
new file mode 100644
index 0000000..1461f19
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji/data/Activity.json
@@ -0,0 +1,418 @@
+{
+ "activities": {
+ "activity": [
+ {
+ "key": "soccer_ball",
+ "value": "⚽"
+ },
+ {
+ "key": "basket_ball",
+ "value": "🏀"
+ },
+ {
+ "key": "american_football",
+ "value": "🏈"
+ },
+ {
+ "key": "baseball",
+ "value": "⚾"
+ },
+ {
+ "key": "tennis_racquet_ball",
+ "value": "🎾"
+ },
+ {
+ "key": "volley_ball",
+ "value": "🏐"
+ },
+ {
+ "key": "rugby_football",
+ "value": "🏉"
+ },
+ {
+ "key": "billiards",
+ "value": "🎱"
+ },
+ {
+ "key": "activity_in_hole",
+ "value": "⛳"
+ },
+ {
+ "key": "golfer",
+ "value": "🏌"
+ },
+ {
+ "key": "table_tennis_paddle_ball",
+ "value": "🏓"
+ },
+ {
+ "key": "badminton_racquet_shuttle_cock",
+ "value": "🏸"
+ },
+ {
+ "key": "ice_hockey_stick_puck",
+ "value": "🏒"
+ },
+ {
+ "key": "field_hockey_stick_ball",
+ "value": "🏑"
+ },
+ {
+ "key": "cricket_bat_ball",
+ "value": "🏏"
+ },
+ {
+ "key": "ski_and_ski_boot",
+ "value": "🎿"
+ },
+ {
+ "key": "skier",
+ "value": "⛷"
+ },
+ {
+ "key": "snow_boarder",
+ "value": "🏂"
+ },
+ {
+ "key": "ice_skate",
+ "value": "⛸"
+ },
+ {
+ "key": "bow_and_arrow",
+ "value": "🏹"
+ },
+ {
+ "key": "fishing_pole_and_fish",
+ "value": "🎣"
+ },
+ {
+ "key": "row_boat",
+ "value": "🚣"
+ },
+ {
+ "key": "row_boat_type_1_2",
+ "value": "🚣🏻"
+ },
+ {
+ "key": "row_boat_type_3",
+ "value": "🚣🏼"
+ },
+ {
+ "key": "row_boat_type_4",
+ "value": "🚣🏽"
+ },
+ {
+ "key": "row_boat_type_5",
+ "value": "🚣🏾"
+ },
+ {
+ "key": "row_boat_type_6",
+ "value": "🚣🏿"
+ },
+ {
+ "key": "swimmer",
+ "value": "🏊"
+ },
+ {
+ "key": "swimmer_type_1_2",
+ "value": "🏊🏻"
+ },
+ {
+ "key": "swimmer_type_3",
+ "value": "🏊🏼"
+ },
+ {
+ "key": "swimmer_type_4",
+ "value": "🏊🏽"
+ },
+ {
+ "key": "swimmer_type_5",
+ "value": "🏊🏾"
+ },
+ {
+ "key": "swimmer_type_6",
+ "value": "🏊🏿"
+ },
+ {
+ "key": "surfer",
+ "value": "🏄"
+ },
+ {
+ "key": "surfer_type_1_2",
+ "value": "🏄🏻"
+ },
+ {
+ "key": "surfer_type_3",
+ "value": "🏄🏼"
+ },
+ {
+ "key": "surfer_type_4",
+ "value": "🏄🏽"
+ },
+ {
+ "key": "surfer_type_5",
+ "value": "🏄🏾"
+ },
+ {
+ "key": "surfer_type_6",
+ "value": "🏄🏿"
+ },
+ {
+ "key": "bath",
+ "value": "🛀"
+ },
+ {
+ "key": "bath_type_1_2",
+ "value": "🛀🏻"
+ },
+ {
+ "key": "bath_type_3",
+ "value": "🛀🏼"
+ },
+ {
+ "key": "bath_type_4",
+ "value": "🛀🏽"
+ },
+ {
+ "key": "bath_type_5",
+ "value": "🛀🏾"
+ },
+ {
+ "key": "bath_type_6",
+ "value": "🛀🏿"
+ },
+ {
+ "key": "person_with_ball",
+ "value": "⛹"
+ },
+ {
+ "key": "person_with_ball_type_1_2",
+ "value": "⛹🏻"
+ },
+ {
+ "key": "person_with_ball_type_3",
+ "value": "⛹🏼"
+ },
+ {
+ "key": "person_with_ball_type_4",
+ "value": "⛹🏽"
+ },
+ {
+ "key": "person_with_ball_type_5",
+ "value": "⛹🏾"
+ },
+ {
+ "key": "person_with_ball_type_6",
+ "value": "⛹🏿"
+ },
+ {
+ "key": "weight_lifter",
+ "value": "🏋"
+ },
+ {
+ "key": "weight_lifter_type_1_2",
+ "value": "🏋🏻"
+ },
+ {
+ "key": "weight_lifter_type_3",
+ "value": "🏋🏼"
+ },
+ {
+ "key": "weight_lifter_type_4",
+ "value": "🏋🏽"
+ },
+ {
+ "key": "weight_lifter_type_5",
+ "value": "🏋🏾"
+ },
+ {
+ "key": "weight_lifter_type_6",
+ "value": "🏋🏿"
+ },
+ {
+ "key": "bicyclist",
+ "value": "🚴"
+ },
+ {
+ "key": "bicyclist_type_1_2",
+ "value": "🚴🏻"
+ },
+ {
+ "key": "bicyclist_type_3",
+ "value": "🚴🏼"
+ },
+ {
+ "key": "bicyclist_type_4",
+ "value": "🚴🏽"
+ },
+ {
+ "key": "bicyclist_type_5",
+ "value": "🚴🏾"
+ },
+ {
+ "key": "bicyclist_type_6",
+ "value": "🚴🏿"
+ },
+ {
+ "key": "mountain_bicyclist",
+ "value": "🚵"
+ },
+ {
+ "key": "mountain_bicyclist_type_1_2",
+ "value": "🚵🏻"
+ },
+ {
+ "key": "mountain_bicyclist_type_3",
+ "value": "🚵🏼"
+ },
+ {
+ "key": "mountain_bicyclist_type_4",
+ "value": "🚵🏽"
+ },
+ {
+ "key": "mountain_bicyclist_type_5",
+ "value": "🚵🏾"
+ },
+ {
+ "key": "mountain_bicyclist_type_6",
+ "value": "🚵🏿"
+ },
+ {
+ "key": "horse_racing",
+ "value": "🏇"
+ },
+ {
+ "key": "horse_racing_type_1_2",
+ "value": "🏇🏻"
+ },
+ {
+ "key": "horse_racing_type_3",
+ "value": "🏇🏻"
+ },
+ {
+ "key": "horse_racing_type_4",
+ "value": "🏇🏽"
+ },
+ {
+ "key": "horse_racing_type_5",
+ "value": "🏇🏾"
+ },
+ {
+ "key": "horse_racing_type_6",
+ "value": "🏇🏿"
+ },
+ {
+ "key": "main_business_suit_levitating",
+ "value": "🕴"
+ },
+ {
+ "key": "trophy",
+ "value": "🏆"
+ },
+ {
+ "key": "running_shirt_with_sash",
+ "value": "🎽"
+ },
+ {
+ "key": "sports_medal",
+ "value": "🏅"
+ },
+ {
+ "key": "military_medal",
+ "value": "🎖"
+ },
+ {
+ "key": "reminder_ribbon",
+ "value": "🎗"
+ },
+ {
+ "key": "rosette",
+ "value": "🏵"
+ },
+ {
+ "key": "ticket",
+ "value": "🎫"
+ },
+ {
+ "key": "admission_tickets",
+ "value": "🎟"
+ },
+ {
+ "key": "performing_arts",
+ "value": "🎭"
+ },
+ {
+ "key": "artist_palette",
+ "value": "🎨"
+ },
+ {
+ "key": "circus_tent",
+ "value": "🎪"
+ },
+ {
+ "key": "microphone",
+ "value": "🎤"
+ },
+ {
+ "key": "headphone",
+ "value": "🎧"
+ },
+ {
+ "key": "musical_score",
+ "value": "🎼"
+ },
+ {
+ "key": "musical_keyboard",
+ "value": "🎹"
+ },
+ {
+ "key": "saxophone",
+ "value": "🎷"
+ },
+ {
+ "key": "trumpet",
+ "value": "🎺"
+ },
+ {
+ "key": "guitar",
+ "value": "🎸"
+ },
+ {
+ "key": "violin",
+ "value": "🎻"
+ },
+ {
+ "key": "clapper_board",
+ "value": "🎬"
+ },
+ {
+ "key": "video_game",
+ "value": "🎮"
+ },
+ {
+ "key": "alien_monster",
+ "value": "👾"
+ },
+ {
+ "key": "direct_hit",
+ "value": "🎯"
+ },
+ {
+ "key": "game_die",
+ "value": "🎲"
+ },
+ {
+ "key": "slot_machine",
+ "value": "🎰"
+ },
+ {
+ "key": "bowling",
+ "value": "🎳"
+ },
+ {
+ "key": "olympic_rings",
+ "value": "◯‍◯‍◯‍◯‍◯"
+ }
+ ]
+ }
+} \ No newline at end of file
diff --git a/nikola/plugins/shortcode/emoji/data/Flags.json b/nikola/plugins/shortcode/emoji/data/Flags.json
new file mode 100644
index 0000000..d1d4bdc
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji/data/Flags.json
@@ -0,0 +1,998 @@
+{
+ "flags": {
+ "flag": [
+ {
+ "key": "afghanistan",
+ "value": "🇦🇫"
+ },
+ {
+ "key": "land_island",
+ "value": "🇦🇽"
+ },
+ {
+ "key": "albania",
+ "value": "🇦🇱"
+ },
+ {
+ "key": "algeria",
+ "value": "🇩🇿"
+ },
+ {
+ "key": "american_samoa",
+ "value": "🇦🇸"
+ },
+ {
+ "key": "andorra",
+ "value": "🇦🇩"
+ },
+ {
+ "key": "angola",
+ "value": "🇦🇴"
+ },
+ {
+ "key": "anguilla",
+ "value": "🇦🇮"
+ },
+ {
+ "key": "antarctica",
+ "value": "🇦🇶"
+ },
+ {
+ "key": "antigua_and_barbuda",
+ "value": "🇦🇬"
+ },
+ {
+ "key": "argentina",
+ "value": "🇦🇷"
+ },
+ {
+ "key": "armenia",
+ "value": "🇦🇲"
+ },
+ {
+ "key": "aruba",
+ "value": "🇦🇼"
+ },
+ {
+ "key": "australia",
+ "value": "🇦🇺"
+ },
+ {
+ "key": "austria",
+ "value": "🇦🇹"
+ },
+ {
+ "key": "azerbaijan",
+ "value": "🇦🇿"
+ },
+ {
+ "key": "bahamas",
+ "value": "🇧🇸"
+ },
+ {
+ "key": "bahrain",
+ "value": "🇧🇭"
+ },
+ {
+ "key": "bangladesh",
+ "value": "🇧🇩"
+ },
+ {
+ "key": "barbados",
+ "value": "🇧🇧"
+ },
+ {
+ "key": "belarus",
+ "value": "🇧🇾"
+ },
+ {
+ "key": "belgium",
+ "value": "🇧🇪"
+ },
+ {
+ "key": "belize",
+ "value": "🇧🇿"
+ },
+ {
+ "key": "benin",
+ "value": "🇧🇯"
+ },
+ {
+ "key": "bermuda",
+ "value": "🇧🇲"
+ },
+ {
+ "key": "bhutan",
+ "value": "🇧🇹"
+ },
+ {
+ "key": "bolivia",
+ "value": "🇧🇴"
+ },
+ {
+ "key": "caribbean_netherlands",
+ "value": "🇧🇶"
+ },
+ {
+ "key": "bosnia_and_herzegovina",
+ "value": "🇧🇦"
+ },
+ {
+ "key": "botswana",
+ "value": "🇧🇼"
+ },
+ {
+ "key": "brazil",
+ "value": "🇧🇷"
+ },
+ {
+ "key": "british_indian_ocean_territory",
+ "value": "🇮🇴"
+ },
+ {
+ "key": "british_virgin_islands",
+ "value": "🇻🇬"
+ },
+ {
+ "key": "brunei",
+ "value": "🇧🇳"
+ },
+ {
+ "key": "bulgaria",
+ "value": "🇧🇬"
+ },
+ {
+ "key": "burkina_faso",
+ "value": "🇧🇫"
+ },
+ {
+ "key": "burundi",
+ "value": "🇧🇮"
+ },
+ {
+ "key": "cape_verde",
+ "value": "🇨🇻"
+ },
+ {
+ "key": "cambodia",
+ "value": "🇰🇭"
+ },
+ {
+ "key": "cameroon",
+ "value": "🇨🇲"
+ },
+ {
+ "key": "canada",
+ "value": "🇨🇦"
+ },
+ {
+ "key": "canary_islands",
+ "value": "🇮🇨"
+ },
+ {
+ "key": "cayman_islands",
+ "value": "🇰🇾"
+ },
+ {
+ "key": "central_african_republic",
+ "value": "🇨🇫"
+ },
+ {
+ "key": "chad",
+ "value": "🇹🇩"
+ },
+ {
+ "key": "chile",
+ "value": "🇨🇱"
+ },
+ {
+ "key": "china",
+ "value": "🇨🇳"
+ },
+ {
+ "key": "christmas_island",
+ "value": "🇨🇽"
+ },
+ {
+ "key": "cocos_keeling_island",
+ "value": "🇨🇨"
+ },
+ {
+ "key": "colombia",
+ "value": "🇨🇴"
+ },
+ {
+ "key": "comoros",
+ "value": "🇰🇲"
+ },
+ {
+ "key": "congo_brazzaville",
+ "value": "🇨🇬"
+ },
+ {
+ "key": "congo_kingshasa",
+ "value": "🇨🇩"
+ },
+ {
+ "key": "cook_islands",
+ "value": "🇨🇰"
+ },
+ {
+ "key": "costa_rica",
+ "value": "🇨🇷"
+ },
+ {
+ "key": "croatia",
+ "value": "🇭🇷"
+ },
+ {
+ "key": "cuba",
+ "value": "🇨🇺"
+ },
+ {
+ "key": "curaao",
+ "value": "🇨🇼"
+ },
+ {
+ "key": "cyprus",
+ "value": "🇨🇾"
+ },
+ {
+ "key": "czech_republic",
+ "value": "🇨🇿"
+ },
+ {
+ "key": "denmark",
+ "value": "🇩🇰"
+ },
+ {
+ "key": "djibouti",
+ "value": "🇩🇯"
+ },
+ {
+ "key": "dominica",
+ "value": "🇩🇲"
+ },
+ {
+ "key": "dominican_republic",
+ "value": "🇩🇴"
+ },
+ {
+ "key": "ecuador",
+ "value": "🇪🇨"
+ },
+ {
+ "key": "egypt",
+ "value": "🇪🇬"
+ },
+ {
+ "key": "el_salvador",
+ "value": "🇸🇻"
+ },
+ {
+ "key": "equatorial_guinea",
+ "value": "🇬🇶"
+ },
+ {
+ "key": "eritrea",
+ "value": "🇪🇷"
+ },
+ {
+ "key": "estonia",
+ "value": "🇪🇪"
+ },
+ {
+ "key": "ethiopia",
+ "value": "🇪🇹"
+ },
+ {
+ "key": "european_union",
+ "value": "🇪🇺"
+ },
+ {
+ "key": "falkland_islands",
+ "value": "🇫🇰"
+ },
+ {
+ "key": "faroe_islands",
+ "value": "🇫🇴"
+ },
+ {
+ "key": "fiji",
+ "value": "🇫🇯"
+ },
+ {
+ "key": "finland",
+ "value": "🇫🇮"
+ },
+ {
+ "key": "france",
+ "value": "🇫🇷"
+ },
+ {
+ "key": "french_guiana",
+ "value": "🇬🇫"
+ },
+ {
+ "key": "french_polynesia",
+ "value": "🇵🇫"
+ },
+ {
+ "key": "french_southern_territories",
+ "value": "🇹🇫"
+ },
+ {
+ "key": "gabon",
+ "value": "🇬🇦"
+ },
+ {
+ "key": "gambia",
+ "value": "🇬🇲"
+ },
+ {
+ "key": "georgia",
+ "value": "🇬🇪"
+ },
+ {
+ "key": "germany",
+ "value": "🇩🇪"
+ },
+ {
+ "key": "ghana",
+ "value": "🇬🇭"
+ },
+ {
+ "key": "gibraltar",
+ "value": "🇬🇮"
+ },
+ {
+ "key": "greece",
+ "value": "🇬🇷"
+ },
+ {
+ "key": "greenland",
+ "value": "🇬🇱"
+ },
+ {
+ "key": "grenada",
+ "value": "🇬🇩"
+ },
+ {
+ "key": "guadeloupe",
+ "value": "🇬🇵"
+ },
+ {
+ "key": "guam",
+ "value": "🇬🇺"
+ },
+ {
+ "key": "guatemala",
+ "value": "🇬🇹"
+ },
+ {
+ "key": "guernsey",
+ "value": "🇬🇬"
+ },
+ {
+ "key": "guinea",
+ "value": "🇬🇳"
+ },
+ {
+ "key": "guinea_bissau",
+ "value": "🇬🇼"
+ },
+ {
+ "key": "guyana",
+ "value": "🇬🇾"
+ },
+ {
+ "key": "haiti",
+ "value": "🇭🇹"
+ },
+ {
+ "key": "honduras",
+ "value": "🇭🇳"
+ },
+ {
+ "key": "hong_kong",
+ "value": "🇭🇰"
+ },
+ {
+ "key": "hungary",
+ "value": "🇭🇺"
+ },
+ {
+ "key": "iceland",
+ "value": "🇮🇸"
+ },
+ {
+ "key": "india",
+ "value": "🇮🇳"
+ },
+ {
+ "key": "indonesia",
+ "value": "🇮🇩"
+ },
+ {
+ "key": "iran",
+ "value": "🇮🇷"
+ },
+ {
+ "key": "iraq",
+ "value": "🇮🇶"
+ },
+ {
+ "key": "ireland",
+ "value": "🇮🇪"
+ },
+ {
+ "key": "isle_of_man",
+ "value": "🇮🇲"
+ },
+ {
+ "key": "israel",
+ "value": "🇮🇱"
+ },
+ {
+ "key": "italy",
+ "value": "🇮🇹"
+ },
+ {
+ "key": "ctedivoire",
+ "value": "🇨🇮"
+ },
+ {
+ "key": "jamaica",
+ "value": "🇯🇲"
+ },
+ {
+ "key": "japan",
+ "value": "🇯🇵"
+ },
+ {
+ "key": "jersey",
+ "value": "🇯🇪"
+ },
+ {
+ "key": "jordan",
+ "value": "🇯🇴"
+ },
+ {
+ "key": "kazakhstan",
+ "value": "🇰🇿"
+ },
+ {
+ "key": "kenya",
+ "value": "🇰🇪"
+ },
+ {
+ "key": "kiribati",
+ "value": "🇰🇮"
+ },
+ {
+ "key": "kosovo",
+ "value": "🇽🇰"
+ },
+ {
+ "key": "kuwait",
+ "value": "🇰🇼"
+ },
+ {
+ "key": "kyrgyzstan",
+ "value": "🇰🇬"
+ },
+ {
+ "key": "laos",
+ "value": "🇱🇦"
+ },
+ {
+ "key": "latvia",
+ "value": "🇱🇻"
+ },
+ {
+ "key": "lebanon",
+ "value": "🇱🇧"
+ },
+ {
+ "key": "lesotho",
+ "value": "🇱🇸"
+ },
+ {
+ "key": "liberia",
+ "value": "🇱🇷"
+ },
+ {
+ "key": "libya",
+ "value": "🇱🇾"
+ },
+ {
+ "key": "liechtenstein",
+ "value": "🇱🇮"
+ },
+ {
+ "key": "lithuania",
+ "value": "🇱🇹"
+ },
+ {
+ "key": "luxembourg",
+ "value": "🇱🇺"
+ },
+ {
+ "key": "macau",
+ "value": "🇲🇴"
+ },
+ {
+ "key": "macedonia",
+ "value": "🇲🇰"
+ },
+ {
+ "key": "madagascar",
+ "value": "🇲🇬"
+ },
+ {
+ "key": "malawi",
+ "value": "🇲🇼"
+ },
+ {
+ "key": "malaysia",
+ "value": "🇲🇾"
+ },
+ {
+ "key": "maldives",
+ "value": "🇲🇻"
+ },
+ {
+ "key": "mali",
+ "value": "🇲🇱"
+ },
+ {
+ "key": "malta",
+ "value": "🇲🇹"
+ },
+ {
+ "key": "marshall_islands",
+ "value": "🇲🇭"
+ },
+ {
+ "key": "martinique",
+ "value": "🇲🇶"
+ },
+ {
+ "key": "mauritania",
+ "value": "🇲🇷"
+ },
+ {
+ "key": "mauritius",
+ "value": "🇲🇺"
+ },
+ {
+ "key": "mayotte",
+ "value": "🇾🇹"
+ },
+ {
+ "key": "mexico",
+ "value": "🇲🇽"
+ },
+ {
+ "key": "micronesia",
+ "value": "🇫🇲"
+ },
+ {
+ "key": "moldova",
+ "value": "🇲🇩"
+ },
+ {
+ "key": "monaco",
+ "value": "🇲🇨"
+ },
+ {
+ "key": "mongolia",
+ "value": "🇲🇳"
+ },
+ {
+ "key": "montenegro",
+ "value": "🇲🇪"
+ },
+ {
+ "key": "montserrat",
+ "value": "🇲🇸"
+ },
+ {
+ "key": "morocco",
+ "value": "🇲🇦"
+ },
+ {
+ "key": "mozambique",
+ "value": "🇲🇿"
+ },
+ {
+ "key": "myanmar_burma",
+ "value": "🇲🇲"
+ },
+ {
+ "key": "namibia",
+ "value": "🇳🇦"
+ },
+ {
+ "key": "nauru",
+ "value": "🇳🇷"
+ },
+ {
+ "key": "nepal",
+ "value": "🇳🇵"
+ },
+ {
+ "key": "netherlands",
+ "value": "🇳🇱"
+ },
+ {
+ "key": "new_caledonia",
+ "value": "🇳🇨"
+ },
+ {
+ "key": "new_zealand",
+ "value": "🇳🇿"
+ },
+ {
+ "key": "nicaragua",
+ "value": "🇳🇮"
+ },
+ {
+ "key": "niger",
+ "value": "🇳🇪"
+ },
+ {
+ "key": "nigeria",
+ "value": "🇳🇬"
+ },
+ {
+ "key": "niue",
+ "value": "🇳🇺"
+ },
+ {
+ "key": "norfolk_island",
+ "value": "🇳🇫"
+ },
+ {
+ "key": "northern_mariana_islands",
+ "value": "🇲🇵"
+ },
+ {
+ "key": "north_korea",
+ "value": "🇰🇵"
+ },
+ {
+ "key": "norway",
+ "value": "🇳🇴"
+ },
+ {
+ "key": "oman",
+ "value": "🇴🇲"
+ },
+ {
+ "key": "pakistan",
+ "value": "🇵🇰"
+ },
+ {
+ "key": "palau",
+ "value": "🇵🇼"
+ },
+ {
+ "key": "palestinian_territories",
+ "value": "🇵🇸"
+ },
+ {
+ "key": "panama",
+ "value": "🇵🇦"
+ },
+ {
+ "key": "papua_new_guinea",
+ "value": "🇵🇬"
+ },
+ {
+ "key": "paraguay",
+ "value": "🇵🇾"
+ },
+ {
+ "key": "peru",
+ "value": "🇵🇪"
+ },
+ {
+ "key": "philippines",
+ "value": "🇵🇭"
+ },
+ {
+ "key": "pitcairn_islands",
+ "value": "🇵🇳"
+ },
+ {
+ "key": "poland",
+ "value": "🇵🇱"
+ },
+ {
+ "key": "portugal",
+ "value": "🇵🇹"
+ },
+ {
+ "key": "puerto_rico",
+ "value": "🇵🇷"
+ },
+ {
+ "key": "qatar",
+ "value": "🇶🇦"
+ },
+ {
+ "key": "reunion",
+ "value": "🇷🇪"
+ },
+ {
+ "key": "romania",
+ "value": "🇷🇴"
+ },
+ {
+ "key": "russia",
+ "value": "🇷🇺"
+ },
+ {
+ "key": "rwanda",
+ "value": "🇷🇼"
+ },
+ {
+ "key": "saint_barthlemy",
+ "value": "🇧🇱"
+ },
+ {
+ "key": "saint_helena",
+ "value": "🇸🇭"
+ },
+ {
+ "key": "saint_kitts_and_nevis",
+ "value": "🇰🇳"
+ },
+ {
+ "key": "saint_lucia",
+ "value": "🇱🇨"
+ },
+ {
+ "key": "saint_pierre_and_miquelon",
+ "value": "🇵🇲"
+ },
+ {
+ "key": "st_vincent_grenadines",
+ "value": "🇻🇨"
+ },
+ {
+ "key": "samoa",
+ "value": "🇼🇸"
+ },
+ {
+ "key": "san_marino",
+ "value": "🇸🇲"
+ },
+ {
+ "key": "sotom_and_prncipe",
+ "value": "🇸🇹"
+ },
+ {
+ "key": "saudi_arabia",
+ "value": "🇸🇦"
+ },
+ {
+ "key": "senegal",
+ "value": "🇸🇳"
+ },
+ {
+ "key": "serbia",
+ "value": "🇷🇸"
+ },
+ {
+ "key": "seychelles",
+ "value": "🇸🇨"
+ },
+ {
+ "key": "sierra_leone",
+ "value": "🇸🇱"
+ },
+ {
+ "key": "singapore",
+ "value": "🇸🇬"
+ },
+ {
+ "key": "sint_maarten",
+ "value": "🇸🇽"
+ },
+ {
+ "key": "slovakia",
+ "value": "🇸🇰"
+ },
+ {
+ "key": "slovenia",
+ "value": "🇸🇮"
+ },
+ {
+ "key": "solomon_islands",
+ "value": "🇸🇧"
+ },
+ {
+ "key": "somalia",
+ "value": "🇸🇴"
+ },
+ {
+ "key": "south_africa",
+ "value": "🇿🇦"
+ },
+ {
+ "key": "south_georgia_south_sandwich_islands",
+ "value": "🇬🇸"
+ },
+ {
+ "key": "south_korea",
+ "value": "🇰🇷"
+ },
+ {
+ "key": "south_sudan",
+ "value": "🇸🇸"
+ },
+ {
+ "key": "spain",
+ "value": "🇪🇸"
+ },
+ {
+ "key": "sri_lanka",
+ "value": "🇱🇰"
+ },
+ {
+ "key": "sudan",
+ "value": "🇸🇩"
+ },
+ {
+ "key": "suriname",
+ "value": "🇸🇷"
+ },
+ {
+ "key": "swaziland",
+ "value": "🇸🇿"
+ },
+ {
+ "key": "sweden",
+ "value": "🇸🇪"
+ },
+ {
+ "key": "switzerland",
+ "value": "🇨🇭"
+ },
+ {
+ "key": "syria",
+ "value": "🇸🇾"
+ },
+ {
+ "key": "taiwan",
+ "value": "🇹🇼"
+ },
+ {
+ "key": "tajikistan",
+ "value": "🇹🇯"
+ },
+ {
+ "key": "tanzania",
+ "value": "🇹🇿"
+ },
+ {
+ "key": "thailand",
+ "value": "🇹🇭"
+ },
+ {
+ "key": "timorleste",
+ "value": "🇹🇱"
+ },
+ {
+ "key": "togo",
+ "value": "🇹🇬"
+ },
+ {
+ "key": "tokelau",
+ "value": "🇹🇰"
+ },
+ {
+ "key": "tonga",
+ "value": "🇹🇴"
+ },
+ {
+ "key": "trinidad_and_tobago",
+ "value": "🇹🇹"
+ },
+ {
+ "key": "tunisia",
+ "value": "🇹🇳"
+ },
+ {
+ "key": "turkey",
+ "value": "🇹🇷"
+ },
+ {
+ "key": "turkmenistan",
+ "value": "🇹🇲"
+ },
+ {
+ "key": "turks_and_caicos_islands",
+ "value": "🇹🇨"
+ },
+ {
+ "key": "tuvalu",
+ "value": "🇹🇻"
+ },
+ {
+ "key": "uganda",
+ "value": "🇺🇬"
+ },
+ {
+ "key": "ukraine",
+ "value": "🇺🇦"
+ },
+ {
+ "key": "united_arab_emirates",
+ "value": "🇦🇪"
+ },
+ {
+ "key": "united_kingdom",
+ "value": "🇬🇧"
+ },
+ {
+ "key": "united_states",
+ "value": "🇺🇸"
+ },
+ {
+ "key": "us_virgin_islands",
+ "value": "🇻🇮"
+ },
+ {
+ "key": "uruguay",
+ "value": "🇺🇾"
+ },
+ {
+ "key": "uzbekistan",
+ "value": "🇺🇿"
+ },
+ {
+ "key": "vanuatu",
+ "value": "🇻🇺"
+ },
+ {
+ "key": "vatican_city",
+ "value": "🇻🇦"
+ },
+ {
+ "key": "venezuela",
+ "value": "🇻🇪"
+ },
+ {
+ "key": "vietnam",
+ "value": "🇻🇳"
+ },
+ {
+ "key": "wallis_and_futuna",
+ "value": "🇼🇫"
+ },
+ {
+ "key": "western_sahara",
+ "value": "🇪🇭"
+ },
+ {
+ "key": "yemen",
+ "value": "🇾🇪"
+ },
+ {
+ "key": "zambia",
+ "value": "🇿🇲"
+ },
+ {
+ "key": "zimbabwe",
+ "value": "🇿🇼"
+ },
+ {
+ "key": "england",
+ "value": "🇽🇪"
+ }
+ ]
+ }
+} \ No newline at end of file
diff --git a/nikola/plugins/shortcode/emoji/data/Food.json b/nikola/plugins/shortcode/emoji/data/Food.json
new file mode 100644
index 0000000..c755a20
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji/data/Food.json
@@ -0,0 +1,274 @@
+{
+ "foods": {
+ "food": [
+ {
+ "key": "green_apple",
+ "value": "🍏"
+ },
+ {
+ "key": "red_apple",
+ "value": "🍎"
+ },
+ {
+ "key": "pear",
+ "value": "🍐"
+ },
+ {
+ "key": "tangerine",
+ "value": "🍊"
+ },
+ {
+ "key": "lemon",
+ "value": "🍋"
+ },
+ {
+ "key": "banana",
+ "value": "🍌"
+ },
+ {
+ "key": "watermelon",
+ "value": "🍉"
+ },
+ {
+ "key": "grapes",
+ "value": "🍇"
+ },
+ {
+ "key": "strawberry",
+ "value": "🍓"
+ },
+ {
+ "key": "melon",
+ "value": "🍈"
+ },
+ {
+ "key": "cherry",
+ "value": "🍒"
+ },
+ {
+ "key": "peach",
+ "value": "🍑"
+ },
+ {
+ "key": "pineapple",
+ "value": "🍍"
+ },
+ {
+ "key": "tomato",
+ "value": "🍅"
+ },
+ {
+ "key": "egg_plant",
+ "value": "🍆"
+ },
+ {
+ "key": "hot_pepper",
+ "value": "🌶"
+ },
+ {
+ "key": "ear_of_maize",
+ "value": "🌽"
+ },
+ {
+ "key": "roasted_sweet_potato",
+ "value": "🍠"
+ },
+ {
+ "key": "honey_pot",
+ "value": "🍯"
+ },
+ {
+ "key": "bread",
+ "value": "🍞"
+ },
+ {
+ "key": "cheese",
+ "value": "🧀"
+ },
+ {
+ "key": "poultry_leg",
+ "value": "🍗"
+ },
+ {
+ "key": "meat_on_bone",
+ "value": "🍖"
+ },
+ {
+ "key": "fried_shrimp",
+ "value": "🍤"
+ },
+ {
+ "key": "cooking",
+ "value": "🍳"
+ },
+ {
+ "key": "hamburger",
+ "value": "🍔"
+ },
+ {
+ "key": "french_fries",
+ "value": "🍟"
+ },
+ {
+ "key": "hot_dog",
+ "value": "🌭"
+ },
+ {
+ "key": "slice_of_pizza",
+ "value": "🍕"
+ },
+ {
+ "key": "spaghetti",
+ "value": "🍝"
+ },
+ {
+ "key": "taco",
+ "value": "🌮"
+ },
+ {
+ "key": "burrito",
+ "value": "🌯"
+ },
+ {
+ "key": "steaming_bowl",
+ "value": "🍜"
+ },
+ {
+ "key": "pot_of_food",
+ "value": "🍲"
+ },
+ {
+ "key": "fish_cake",
+ "value": "🍥"
+ },
+ {
+ "key": "sushi",
+ "value": "🍣"
+ },
+ {
+ "key": "bento_box",
+ "value": "🍱"
+ },
+ {
+ "key": "curry_and_rice",
+ "value": "🍛"
+ },
+ {
+ "key": "rice_ball",
+ "value": "🍙"
+ },
+ {
+ "key": "cooked_rice",
+ "value": "🍚"
+ },
+ {
+ "key": "rice_cracker",
+ "value": "🍘"
+ },
+ {
+ "key": "oden",
+ "value": "🍢"
+ },
+ {
+ "key": "dango",
+ "value": "🍡"
+ },
+ {
+ "key": "shaved_ice",
+ "value": "🍧"
+ },
+ {
+ "key": "ice_cream",
+ "value": "🍨"
+ },
+ {
+ "key": "soft_ice_cream",
+ "value": "🍦"
+ },
+ {
+ "key": "short_cake",
+ "value": "🍰"
+ },
+ {
+ "key": "birthday_cake",
+ "value": "🎂"
+ },
+ {
+ "key": "custard",
+ "value": "🍮"
+ },
+ {
+ "key": "candy",
+ "value": "🍬"
+ },
+ {
+ "key": "lollipop",
+ "value": "🍭"
+ },
+ {
+ "key": "chocolate_bar",
+ "value": "🍫"
+ },
+ {
+ "key": "popcorn",
+ "value": "🍿"
+ },
+ {
+ "key": "doughnut",
+ "value": "🍩"
+ },
+ {
+ "key": "cookie",
+ "value": "🍪"
+ },
+ {
+ "key": "bear_mug",
+ "value": "🍺"
+ },
+ {
+ "key": "clinking_beer_mugs",
+ "value": "🍻"
+ },
+ {
+ "key": "wine_glass",
+ "value": "🍷"
+ },
+ {
+ "key": "cocktail_glass",
+ "value": "🍸"
+ },
+ {
+ "key": "tropical_drink",
+ "value": "🍹"
+ },
+ {
+ "key": "bottle_with_popping_cork",
+ "value": "🍾"
+ },
+ {
+ "key": "sake_bottle_and_cup",
+ "value": "🍶"
+ },
+ {
+ "key": "tea_cup_without_handle",
+ "value": "🍵"
+ },
+ {
+ "key": "hot_beverage",
+ "value": "☕"
+ },
+ {
+ "key": "baby_bottle",
+ "value": "🍼"
+ },
+ {
+ "key": "fork_and_knife",
+ "value": "🍴"
+ },
+ {
+ "key": "fork_and_knife_with_plate",
+ "value": "🍽"
+ }
+ ]
+ }
+} \ No newline at end of file
diff --git a/nikola/plugins/shortcode/emoji/data/LICENSE b/nikola/plugins/shortcode/emoji/data/LICENSE
new file mode 100644
index 0000000..c7bf1f4
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji/data/LICENSE
@@ -0,0 +1,25 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 -2017 Shayan Rais
+
+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.
+
+------------
+
+Copied from https://github.com/shanraisshan/EmojiCodeSheet
diff --git a/nikola/plugins/shortcode/emoji/data/Nature.json b/nikola/plugins/shortcode/emoji/data/Nature.json
new file mode 100644
index 0000000..f845a64
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji/data/Nature.json
@@ -0,0 +1,594 @@
+{
+ "natures": {
+ "nature": [
+ {
+ "key": "dog_face",
+ "value": "🐶"
+ },
+ {
+ "key": "cat_face",
+ "value": "🐱"
+ },
+ {
+ "key": "mouse_face",
+ "value": "🐭"
+ },
+ {
+ "key": "hamster_face",
+ "value": "🐹"
+ },
+ {
+ "key": "rabbit_face",
+ "value": "🐰"
+ },
+ {
+ "key": "bear_face",
+ "value": "🐻"
+ },
+ {
+ "key": "panda_face",
+ "value": "🐼"
+ },
+ {
+ "key": "koala_face",
+ "value": "🐨"
+ },
+ {
+ "key": "lion_face",
+ "value": "🦁"
+ },
+ {
+ "key": "cow_face",
+ "value": "🐮"
+ },
+ {
+ "key": "pig_face",
+ "value": "🐷"
+ },
+ {
+ "key": "pig_nose",
+ "value": "🐽"
+ },
+ {
+ "key": "frog_face",
+ "value": "🐸"
+ },
+ {
+ "key": "octopus",
+ "value": "🐙"
+ },
+ {
+ "key": "monkey_face",
+ "value": "🐵"
+ },
+ {
+ "key": "tiger_face",
+ "value": "🐯"
+ },
+ {
+ "key": "see_no_evil_monkey",
+ "value": "🙈"
+ },
+ {
+ "key": "hear_no_evil_monkey",
+ "value": "🙉"
+ },
+ {
+ "key": "speak_no_evil_monkey",
+ "value": "🙊"
+ },
+ {
+ "key": "monkey",
+ "value": "🐒"
+ },
+ {
+ "key": "chicken",
+ "value": "🐔"
+ },
+ {
+ "key": "penguin",
+ "value": "🐧"
+ },
+ {
+ "key": "bird",
+ "value": "🐦"
+ },
+ {
+ "key": "baby_chick",
+ "value": "🐤"
+ },
+ {
+ "key": "hatching_chick",
+ "value": "🐣"
+ },
+ {
+ "key": "front_face_chick",
+ "value": "🐥"
+ },
+ {
+ "key": "wolf_face",
+ "value": "🐺"
+ },
+ {
+ "key": "boar",
+ "value": "🐗"
+ },
+ {
+ "key": "horse_face",
+ "value": "🐴"
+ },
+ {
+ "key": "unicorn_face",
+ "value": "🦄"
+ },
+ {
+ "key": "honey_bee",
+ "value": "🐝"
+ },
+ {
+ "key": "bug",
+ "value": "🐛"
+ },
+ {
+ "key": "snail",
+ "value": "🐌"
+ },
+ {
+ "key": "lady_beetle",
+ "value": "🐞"
+ },
+ {
+ "key": "ant",
+ "value": "🐜"
+ },
+ {
+ "key": "spider",
+ "value": "🕷"
+ },
+ {
+ "key": "scorpion",
+ "value": "🦂"
+ },
+ {
+ "key": "crab",
+ "value": "🦀"
+ },
+ {
+ "key": "snake",
+ "value": "🐍"
+ },
+ {
+ "key": "turtle",
+ "value": "🐢"
+ },
+ {
+ "key": "tropical_fish",
+ "value": "🐠"
+ },
+ {
+ "key": "fish",
+ "value": "🐟"
+ },
+ {
+ "key": "blow_fish",
+ "value": "🐡"
+ },
+ {
+ "key": "dolphin",
+ "value": "🐬"
+ },
+ {
+ "key": "spouting_whale",
+ "value": "🐳"
+ },
+ {
+ "key": "whale",
+ "value": "🐋"
+ },
+ {
+ "key": "crocodile",
+ "value": "🐊"
+ },
+ {
+ "key": "leopard",
+ "value": "🐆"
+ },
+ {
+ "key": "tiger",
+ "value": "🐅"
+ },
+ {
+ "key": "water_buffalo",
+ "value": "🐃"
+ },
+ {
+ "key": "ox",
+ "value": "🐂"
+ },
+ {
+ "key": "cow",
+ "value": "🐄"
+ },
+ {
+ "key": "dromedary_camel",
+ "value": "🐪"
+ },
+ {
+ "key": "bactrian_camel",
+ "value": "🐫"
+ },
+ {
+ "key": "elephant",
+ "value": "🐘"
+ },
+ {
+ "key": "goat",
+ "value": "🐐"
+ },
+ {
+ "key": "ram",
+ "value": "🐏"
+ },
+ {
+ "key": "sheep",
+ "value": "🐑"
+ },
+ {
+ "key": "horse",
+ "value": "🐎"
+ },
+ {
+ "key": "pig",
+ "value": "🐖"
+ },
+ {
+ "key": "rat",
+ "value": "🐀"
+ },
+ {
+ "key": "mouse",
+ "value": "🐁"
+ },
+ {
+ "key": "rooster",
+ "value": "🐓"
+ },
+ {
+ "key": "turkey",
+ "value": "🦃"
+ },
+ {
+ "key": "dove",
+ "value": "🕊"
+ },
+ {
+ "key": "dog",
+ "value": "🐕"
+ },
+ {
+ "key": "poodle",
+ "value": "🐩"
+ },
+ {
+ "key": "cat",
+ "value": "🐈"
+ },
+ {
+ "key": "rabbit",
+ "value": "🐇"
+ },
+ {
+ "key": "chipmunk",
+ "value": "🐿"
+ },
+ {
+ "key": "paw_prints",
+ "value": "🐾"
+ },
+ {
+ "key": "dragon",
+ "value": "🐉"
+ },
+ {
+ "key": "dragon_face",
+ "value": "🐲"
+ },
+ {
+ "key": "cactus",
+ "value": "🌵"
+ },
+ {
+ "key": "christmas_tree",
+ "value": "🎄"
+ },
+ {
+ "key": "ever_green_tree",
+ "value": "🌲"
+ },
+ {
+ "key": "deciduous_tree",
+ "value": "🌳"
+ },
+ {
+ "key": "palm_tree",
+ "value": "🌴"
+ },
+ {
+ "key": "seedling",
+ "value": "🌱"
+ },
+ {
+ "key": "herb",
+ "value": "🌿"
+ },
+ {
+ "key": "shamrock",
+ "value": "☘"
+ },
+ {
+ "key": "four_leaf",
+ "value": "🍀"
+ },
+ {
+ "key": "pine_decoration",
+ "value": "🎍"
+ },
+ {
+ "key": "tanabata_tree",
+ "value": "🎋"
+ },
+ {
+ "key": "leaf_wind",
+ "value": "🍃"
+ },
+ {
+ "key": "fallen_leaf",
+ "value": "🍂"
+ },
+ {
+ "key": "maple_leaf",
+ "value": "🍁"
+ },
+ {
+ "key": "ear_of_rice",
+ "value": "🌾"
+ },
+ {
+ "key": "hibiscus",
+ "value": "🌺"
+ },
+ {
+ "key": "sunflower",
+ "value": "🌻"
+ },
+ {
+ "key": "rose",
+ "value": "🌹"
+ },
+ {
+ "key": "tulip",
+ "value": "🌷"
+ },
+ {
+ "key": "blossom",
+ "value": "🌼"
+ },
+ {
+ "key": "cherry_blossom",
+ "value": "🌸"
+ },
+ {
+ "key": "bouquet",
+ "value": "💐"
+ },
+ {
+ "key": "mushroom",
+ "value": "🍄"
+ },
+ {
+ "key": "chestnut",
+ "value": "🌰"
+ },
+ {
+ "key": "jack_o_lantern",
+ "value": "🎃"
+ },
+ {
+ "key": "spiral_shell",
+ "value": "🐚"
+ },
+ {
+ "key": "spider_web",
+ "value": "🕸"
+ },
+ {
+ "key": "earth_america",
+ "value": "🌎"
+ },
+ {
+ "key": "earth_europe",
+ "value": "🌍"
+ },
+ {
+ "key": "earth_australia",
+ "value": "🌏"
+ },
+ {
+ "key": "full_moon",
+ "value": "🌕"
+ },
+ {
+ "key": "waning_gibbous_moon",
+ "value": "🌖"
+ },
+ {
+ "key": "last_quarter_moon",
+ "value": "🌗"
+ },
+ {
+ "key": "waning_crescent_moon",
+ "value": "🌘"
+ },
+ {
+ "key": "new_moon_symbol",
+ "value": "🌑"
+ },
+ {
+ "key": "waxing_crescent_moon",
+ "value": "🌒"
+ },
+ {
+ "key": "first_quarter_moon",
+ "value": "🌓"
+ },
+ {
+ "key": "waxing_gibbous_moon",
+ "value": "🌔"
+ },
+ {
+ "key": "new_moon_with_face",
+ "value": "🌚"
+ },
+ {
+ "key": "full_moon_face",
+ "value": "🌝"
+ },
+ {
+ "key": "first_quarter_moon_face",
+ "value": "🌛"
+ },
+ {
+ "key": "last_quarter_moon_face",
+ "value": "🌜"
+ },
+ {
+ "key": "sun_face",
+ "value": "🌞"
+ },
+ {
+ "key": "crescent_moon",
+ "value": "🌙"
+ },
+ {
+ "key": "white_star",
+ "value": "⭐"
+ },
+ {
+ "key": "glowing_star",
+ "value": "🌟"
+ },
+ {
+ "key": "dizzy_symbol",
+ "value": "💫"
+ },
+ {
+ "key": "sparkles",
+ "value": "✨"
+ },
+ {
+ "key": "comet",
+ "value": "☄"
+ },
+ {
+ "key": "black_sun_with_rays",
+ "value": "☀"
+ },
+ {
+ "key": "white_sun_small_cloud",
+ "value": "🌤"
+ },
+ {
+ "key": "sun_behind_cloud",
+ "value": "⛅"
+ },
+ {
+ "key": "white_sun_behind_cloud",
+ "value": "🌥"
+ },
+ {
+ "key": "white_sun_behind_cloud_rain",
+ "value": "🌦"
+ },
+ {
+ "key": "cloud",
+ "value": "☁"
+ },
+ {
+ "key": "cloud_with_rain",
+ "value": "🌧"
+ },
+ {
+ "key": "thunder_cloud_rain",
+ "value": "⛈"
+ },
+ {
+ "key": "cloud_lightening",
+ "value": "🌩"
+ },
+ {
+ "key": "high_voltage",
+ "value": "⚡"
+ },
+ {
+ "key": "fire",
+ "value": "🔥"
+ },
+ {
+ "key": "collision",
+ "value": "💥"
+ },
+ {
+ "key": "snow_flake",
+ "value": "❄"
+ },
+ {
+ "key": "cloud_with_snow",
+ "value": "🌨"
+ },
+ {
+ "key": "snowman",
+ "value": "☃"
+ },
+ {
+ "key": "snowman_without_snow",
+ "value": "⛄"
+ },
+ {
+ "key": "wind_blowing_face",
+ "value": "🌬"
+ },
+ {
+ "key": "dash_symbol",
+ "value": "💨"
+ },
+ {
+ "key": "cloud_with_tornado",
+ "value": "🌪"
+ },
+ {
+ "key": "fog",
+ "value": "🌫"
+ },
+ {
+ "key": "umbrella",
+ "value": "☂"
+ },
+ {
+ "key": "umbrella_with_rain_drops",
+ "value": "☔"
+ },
+ {
+ "key": "droplet",
+ "value": "💧"
+ },
+ {
+ "key": "splashing_sweat",
+ "value": "💦"
+ },
+ {
+ "key": "water_wave",
+ "value": "🌊"
+ }
+ ]
+ }
+} \ No newline at end of file
diff --git a/nikola/plugins/shortcode/emoji/data/Objects.json b/nikola/plugins/shortcode/emoji/data/Objects.json
new file mode 100644
index 0000000..5f13056
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji/data/Objects.json
@@ -0,0 +1,718 @@
+{
+ "objects": {
+ "object": [
+ {
+ "key": "watch",
+ "value": "⌚"
+ },
+ {
+ "key": "mobile_phone",
+ "value": "📱"
+ },
+ {
+ "key": "mobile_phone_with_right_arrow",
+ "value": "📲"
+ },
+ {
+ "key": "personal_computer",
+ "value": "💻"
+ },
+ {
+ "key": "keyboard",
+ "value": "⌨"
+ },
+ {
+ "key": "desktop_computer",
+ "value": "🖥"
+ },
+ {
+ "key": "printer",
+ "value": "🖨"
+ },
+ {
+ "key": "three_button_mouse",
+ "value": "🖱"
+ },
+ {
+ "key": "track_ball",
+ "value": "🖲"
+ },
+ {
+ "key": "joystick",
+ "value": "🕹"
+ },
+ {
+ "key": "compression",
+ "value": "🗜"
+ },
+ {
+ "key": "mini_disc",
+ "value": "💽"
+ },
+ {
+ "key": "floppy_disk",
+ "value": "💾"
+ },
+ {
+ "key": "optical_disc",
+ "value": "💿"
+ },
+ {
+ "key": "dvd",
+ "value": "📀"
+ },
+ {
+ "key": "video_cassette",
+ "value": "📼"
+ },
+ {
+ "key": "camera",
+ "value": "📷"
+ },
+ {
+ "key": "camera_with_flash",
+ "value": "📸"
+ },
+ {
+ "key": "video_camera",
+ "value": "📹"
+ },
+ {
+ "key": "movie_camera",
+ "value": "🎥"
+ },
+ {
+ "key": "film_projector",
+ "value": "📽"
+ },
+ {
+ "key": "film_frames",
+ "value": "🎞"
+ },
+ {
+ "key": "telephone_receiver",
+ "value": "📞"
+ },
+ {
+ "key": "black_telephone",
+ "value": "☎"
+ },
+ {
+ "key": "pager",
+ "value": "📟"
+ },
+ {
+ "key": "fax_machine",
+ "value": "📠"
+ },
+ {
+ "key": "television",
+ "value": "📺"
+ },
+ {
+ "key": "radio",
+ "value": "📻"
+ },
+ {
+ "key": "studio_microphone",
+ "value": "🎙"
+ },
+ {
+ "key": "level_slider",
+ "value": "🎚"
+ },
+ {
+ "key": "control_knobs",
+ "value": "🎛"
+ },
+ {
+ "key": "stop_watch",
+ "value": "⏱"
+ },
+ {
+ "key": "timer_clock",
+ "value": "⏲"
+ },
+ {
+ "key": "alarm_clock",
+ "value": "⏰"
+ },
+ {
+ "key": "mantel_piece_clock",
+ "value": "🕰"
+ },
+ {
+ "key": "hour_glass_with_flowing_stand",
+ "value": "⏳"
+ },
+ {
+ "key": "hour_glass",
+ "value": "⌛"
+ },
+ {
+ "key": "satellite_antenna",
+ "value": "📡"
+ },
+ {
+ "key": "battery",
+ "value": "🔋"
+ },
+ {
+ "key": "electric_plug",
+ "value": "🔌"
+ },
+ {
+ "key": "electric_light_bulb",
+ "value": "💡"
+ },
+ {
+ "key": "electric_torch",
+ "value": "🔦"
+ },
+ {
+ "key": "candle",
+ "value": "🕯"
+ },
+ {
+ "key": "waste_basket",
+ "value": "🗑"
+ },
+ {
+ "key": "oil_drum",
+ "value": "🛢"
+ },
+ {
+ "key": "money_with_wings",
+ "value": "💸"
+ },
+ {
+ "key": "bank_note_with_dollar_sign",
+ "value": "💵"
+ },
+ {
+ "key": "bank_note_with_yen_sign",
+ "value": "💴"
+ },
+ {
+ "key": "bank_note_with_euro_sign",
+ "value": "💶"
+ },
+ {
+ "key": "bank_note_with_pounds_sign",
+ "value": "💷"
+ },
+ {
+ "key": "money_bag",
+ "value": "💰"
+ },
+ {
+ "key": "credit_card",
+ "value": "💳"
+ },
+ {
+ "key": "gem_stone",
+ "value": "💎"
+ },
+ {
+ "key": "scales",
+ "value": "⚖"
+ },
+ {
+ "key": "wrench",
+ "value": "🔧"
+ },
+ {
+ "key": "hammer",
+ "value": "🔨"
+ },
+ {
+ "key": "hammer_and_pick",
+ "value": "⚒"
+ },
+ {
+ "key": "hammer_and_wrench",
+ "value": "🛠"
+ },
+ {
+ "key": "pick",
+ "value": "⛏"
+ },
+ {
+ "key": "nut_and_bolt",
+ "value": "🔩"
+ },
+ {
+ "key": "gear",
+ "value": "⚙"
+ },
+ {
+ "key": "chains",
+ "value": "⛓"
+ },
+ {
+ "key": "pistol",
+ "value": "🔫"
+ },
+ {
+ "key": "bomb",
+ "value": "💣"
+ },
+ {
+ "key": "hocho",
+ "value": "🔪"
+ },
+ {
+ "key": "dagger_knife",
+ "value": "🗡"
+ },
+ {
+ "key": "crossed_words",
+ "value": "⚔"
+ },
+ {
+ "key": "shield",
+ "value": "🛡"
+ },
+ {
+ "key": "smoking_symbol",
+ "value": "🚬"
+ },
+ {
+ "key": "skull_and_cross_bones",
+ "value": "☠"
+ },
+ {
+ "key": "coffin",
+ "value": "⚰"
+ },
+ {
+ "key": "funeral_urn",
+ "value": "⚱"
+ },
+ {
+ "key": "amphora",
+ "value": "🏺"
+ },
+ {
+ "key": "crystal_ball",
+ "value": "🔮"
+ },
+ {
+ "key": "prayer_beads",
+ "value": "📿"
+ },
+ {
+ "key": "barber_pole",
+ "value": "💈"
+ },
+ {
+ "key": "alembic",
+ "value": "⚗"
+ },
+ {
+ "key": "telescope",
+ "value": "🔭"
+ },
+ {
+ "key": "microscope",
+ "value": "🔬"
+ },
+ {
+ "key": "hole",
+ "value": "🕳"
+ },
+ {
+ "key": "pill",
+ "value": "💊"
+ },
+ {
+ "key": "syringe",
+ "value": "💉"
+ },
+ {
+ "key": "thermometer",
+ "value": "🌡"
+ },
+ {
+ "key": "label",
+ "value": "🏷"
+ },
+ {
+ "key": "bookmark",
+ "value": "🔖"
+ },
+ {
+ "key": "toilet",
+ "value": "🚽"
+ },
+ {
+ "key": "shower",
+ "value": "🚿"
+ },
+ {
+ "key": "bath_tub",
+ "value": "🛁"
+ },
+ {
+ "key": "key",
+ "value": "🔑"
+ },
+ {
+ "key": "old_key",
+ "value": "🗝"
+ },
+ {
+ "key": "couch_and_lamp",
+ "value": "🛋"
+ },
+ {
+ "key": "sleeping_accommodation",
+ "value": "🛌"
+ },
+ {
+ "key": "bed",
+ "value": "🛏"
+ },
+ {
+ "key": "door",
+ "value": "🚪"
+ },
+ {
+ "key": "bell_hop_bell",
+ "value": "🛎"
+ },
+ {
+ "key": "frame_with_picture",
+ "value": "🖼"
+ },
+ {
+ "key": "world_map",
+ "value": "🗺"
+ },
+ {
+ "key": "umbrella_on_ground",
+ "value": "⛱"
+ },
+ {
+ "key": "moyai",
+ "value": "🗿"
+ },
+ {
+ "key": "shopping_bags",
+ "value": "🛍"
+ },
+ {
+ "key": "balloon",
+ "value": "🎈"
+ },
+ {
+ "key": "carp_streamer",
+ "value": "🎏"
+ },
+ {
+ "key": "ribbon",
+ "value": "🎀"
+ },
+ {
+ "key": "wrapped_present",
+ "value": "🎁"
+ },
+ {
+ "key": "confetti_ball",
+ "value": "🎊"
+ },
+ {
+ "key": "party_popper",
+ "value": "🎉"
+ },
+ {
+ "key": "japanese_dolls",
+ "value": "🎎"
+ },
+ {
+ "key": "wind_chime",
+ "value": "🎐"
+ },
+ {
+ "key": "crossed_flags",
+ "value": "🎌"
+ },
+ {
+ "key": "izakaya_lantern",
+ "value": "🏮"
+ },
+ {
+ "key": "envelope",
+ "value": "✉"
+ },
+ {
+ "key": "envelope_with_down_arrow",
+ "value": "📩"
+ },
+ {
+ "key": "incoming_envelope",
+ "value": "📨"
+ },
+ {
+ "key": "email_symbol",
+ "value": "📧"
+ },
+ {
+ "key": "love_letter",
+ "value": "💌"
+ },
+ {
+ "key": "post_box",
+ "value": "📮"
+ },
+ {
+ "key": "closed_mail_box_with_lowered_flag",
+ "value": "📪"
+ },
+ {
+ "key": "closed_mail_box_with_raised_flag",
+ "value": "📫"
+ },
+ {
+ "key": "open_mail_box_with_raised_flag",
+ "value": "📬"
+ },
+ {
+ "key": "open_mail_box_with_lowered_flag",
+ "value": "📭"
+ },
+ {
+ "key": "package",
+ "value": "📦"
+ },
+ {
+ "key": "postal_horn",
+ "value": "📯"
+ },
+ {
+ "key": "inbox_tray",
+ "value": "📥"
+ },
+ {
+ "key": "outbox_tray",
+ "value": "📤"
+ },
+ {
+ "key": "scroll",
+ "value": "📜"
+ },
+ {
+ "key": "page_with_curl",
+ "value": "📃"
+ },
+ {
+ "key": "bookmark_tabs",
+ "value": "📑"
+ },
+ {
+ "key": "bar_chart",
+ "value": "📊"
+ },
+ {
+ "key": "chart_with_upwards_trend",
+ "value": "📈"
+ },
+ {
+ "key": "chart_with_downwards_trend",
+ "value": "📉"
+ },
+ {
+ "key": "page_facing_up",
+ "value": "📄"
+ },
+ {
+ "key": "calender",
+ "value": "📅"
+ },
+ {
+ "key": "tear_off_calendar",
+ "value": "📆"
+ },
+ {
+ "key": "spiral_calendar_pad",
+ "value": "🗓"
+ },
+ {
+ "key": "card_index",
+ "value": "📇"
+ },
+ {
+ "key": "card_file_box",
+ "value": "🗃"
+ },
+ {
+ "key": "ballot_box_with_ballot",
+ "value": "🗳"
+ },
+ {
+ "key": "file_cabinet",
+ "value": "🗄"
+ },
+ {
+ "key": "clip_board",
+ "value": "📋"
+ },
+ {
+ "key": "spiral_notepad",
+ "value": "🗒"
+ },
+ {
+ "key": "file_folder",
+ "value": "📁"
+ },
+ {
+ "key": "open_file_folder",
+ "value": "📂"
+ },
+ {
+ "key": "card_index_dividers",
+ "value": "🗂"
+ },
+ {
+ "key": "rolled_up_newspaper",
+ "value": "🗞"
+ },
+ {
+ "key": "newspaper",
+ "value": "📰"
+ },
+ {
+ "key": "notebook",
+ "value": "📓"
+ },
+ {
+ "key": "closed_book",
+ "value": "📕"
+ },
+ {
+ "key": "green_book",
+ "value": "📗"
+ },
+ {
+ "key": "blue_book",
+ "value": "📘"
+ },
+ {
+ "key": "orange_book",
+ "value": "📙"
+ },
+ {
+ "key": "notebook_with_decorative_cover",
+ "value": "📔"
+ },
+ {
+ "key": "ledger",
+ "value": "📒"
+ },
+ {
+ "key": "books",
+ "value": "📚"
+ },
+ {
+ "key": "open_book",
+ "value": "📖"
+ },
+ {
+ "key": "link_symbol",
+ "value": "🔗"
+ },
+ {
+ "key": "paper_clip",
+ "value": "📎"
+ },
+ {
+ "key": "linked_paper_clips",
+ "value": "🖇"
+ },
+ {
+ "key": "black_scissors",
+ "value": "✂"
+ },
+ {
+ "key": "triangular_ruler",
+ "value": "📐"
+ },
+ {
+ "key": "straight_ruler",
+ "value": "📏"
+ },
+ {
+ "key": "pushpin",
+ "value": "📌"
+ },
+ {
+ "key": "round_pushpin",
+ "value": "📍"
+ },
+ {
+ "key": "triangular_flag_post",
+ "value": "🚩"
+ },
+ {
+ "key": "waving_white_flag",
+ "value": "🏳"
+ },
+ {
+ "key": "waving_black_flag",
+ "value": "🏴"
+ },
+ {
+ "key": "closed_lock_with_key",
+ "value": "🔐"
+ },
+ {
+ "key": "lock",
+ "value": "🔒"
+ },
+ {
+ "key": "open_lock",
+ "value": "🔓"
+ },
+ {
+ "key": "lock_with_ink_pen",
+ "value": "🔏"
+ },
+ {
+ "key": "lower_left_ball_point_pen",
+ "value": "🖊"
+ },
+ {
+ "key": "lower_left_fountain_pen",
+ "value": "🖋"
+ },
+ {
+ "key": "black_nib",
+ "value": "✒"
+ },
+ {
+ "key": "memo",
+ "value": "📝"
+ },
+ {
+ "key": "pencil",
+ "value": "✏"
+ },
+ {
+ "key": "lower_left_crayon",
+ "value": "🖍"
+ },
+ {
+ "key": "lower_left_paint_brush",
+ "value": "🖌"
+ },
+ {
+ "key": "left_pointing_magnifying_glass",
+ "value": "🔍"
+ },
+ {
+ "key": "right_pointing_magnifying_glass",
+ "value": "🔎"
+ }
+ ]
+ }
+} \ No newline at end of file
diff --git a/nikola/plugins/shortcode/emoji/data/People.json b/nikola/plugins/shortcode/emoji/data/People.json
new file mode 100644
index 0000000..a5fb88f
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji/data/People.json
@@ -0,0 +1,1922 @@
+{
+ "peoples": {
+ "people": [
+ {
+ "key": "grinning_face",
+ "value": "😀"
+ },
+ {
+ "key": "grimacing_face",
+ "value": "😬"
+ },
+ {
+ "key": "grimacing_face_with_smile_eyes",
+ "value": "😁"
+ },
+ {
+ "key": "face_with_tear_of_joy",
+ "value": "😂"
+ },
+ {
+ "key": "smiling_face_with_open_mouth",
+ "value": "😃"
+ },
+ {
+ "key": "smiling_face_with_open_mouth_eyes",
+ "value": "😄"
+ },
+ {
+ "key": "smiling_face_with_open_mouth_cold_sweat",
+ "value": "😅"
+ },
+ {
+ "key": "smiling_face_with_open_mouth_hand_tight",
+ "value": "😆"
+ },
+ {
+ "key": "smiling_face_with_halo",
+ "value": "😇"
+ },
+ {
+ "key": "winking_face",
+ "value": "😉"
+ },
+ {
+ "key": "black_smiling_face",
+ "value": "😊"
+ },
+ {
+ "key": "slightly_smiling_face",
+ "value": "🙂"
+ },
+ {
+ "key": "upside_down_face",
+ "value": "🙃"
+ },
+ {
+ "key": "white_smiling_face",
+ "value": "☺"
+ },
+ {
+ "key": "face_savouring_delicious_food",
+ "value": "😋"
+ },
+ {
+ "key": "relieved_face",
+ "value": "😌"
+ },
+ {
+ "key": "smiling_face_heart_eyes",
+ "value": "😍"
+ },
+ {
+ "key": "face_throwing_kiss",
+ "value": "😘"
+ },
+ {
+ "key": "kissing_face",
+ "value": "😗"
+ },
+ {
+ "key": "kissing_face_with_smile_eyes",
+ "value": "😙"
+ },
+ {
+ "key": "kissing_face_with_closed_eyes",
+ "value": "😚"
+ },
+ {
+ "key": "face_with_tongue_wink_eye",
+ "value": "😜"
+ },
+ {
+ "key": "face_with_tongue_closed_eye",
+ "value": "😝"
+ },
+ {
+ "key": "face_with_stuck_out_tongue",
+ "value": "😛"
+ },
+ {
+ "key": "money_mouth_face",
+ "value": "🤑"
+ },
+ {
+ "key": "nerd_face",
+ "value": "🤓"
+ },
+ {
+ "key": "smiling_face_with_sun_glass",
+ "value": "😎"
+ },
+ {
+ "key": "hugging_face",
+ "value": "🤗"
+ },
+ {
+ "key": "smirking_face",
+ "value": "😏"
+ },
+ {
+ "key": "face_without_mouth",
+ "value": "😶"
+ },
+ {
+ "key": "neutral_face",
+ "value": "😐"
+ },
+ {
+ "key": "expressionless_face",
+ "value": "😑"
+ },
+ {
+ "key": "unamused_face",
+ "value": "😒"
+ },
+ {
+ "key": "face_with_rolling_eyes",
+ "value": "🙄"
+ },
+ {
+ "key": "thinking_face",
+ "value": "🤔"
+ },
+ {
+ "key": "flushed_face",
+ "value": "😳"
+ },
+ {
+ "key": "disappointed_face",
+ "value": "😞"
+ },
+ {
+ "key": "worried_face",
+ "value": "😟"
+ },
+ {
+ "key": "angry_face",
+ "value": "😠"
+ },
+ {
+ "key": "pouting_face",
+ "value": "😡"
+ },
+ {
+ "key": "pensive_face",
+ "value": "😔"
+ },
+ {
+ "key": "confused_face",
+ "value": "😕"
+ },
+ {
+ "key": "slightly_frowning_face",
+ "value": "🙁"
+ },
+ {
+ "key": "white_frowning_face",
+ "value": "☹"
+ },
+ {
+ "key": "persevering_face",
+ "value": "😣"
+ },
+ {
+ "key": "confounded_face",
+ "value": "😖"
+ },
+ {
+ "key": "tired_face",
+ "value": "😫"
+ },
+ {
+ "key": "weary_face",
+ "value": "😩"
+ },
+ {
+ "key": "face_with_look_of_triumph",
+ "value": "😤"
+ },
+ {
+ "key": "face_with_open_mouth",
+ "value": "😮"
+ },
+ {
+ "key": "face_screaming_in_fear",
+ "value": "😱"
+ },
+ {
+ "key": "fearful_face",
+ "value": "😨"
+ },
+ {
+ "key": "face_with_open_mouth_cold_sweat",
+ "value": "😰"
+ },
+ {
+ "key": "hushed_face",
+ "value": "😯"
+ },
+ {
+ "key": "frowning_face_with_open_mouth",
+ "value": "😦"
+ },
+ {
+ "key": "anguished_face",
+ "value": "😧"
+ },
+ {
+ "key": "crying_face",
+ "value": "😢"
+ },
+ {
+ "key": "disappointed_but_relieved_face",
+ "value": "😥"
+ },
+ {
+ "key": "sleepy_face",
+ "value": "😪"
+ },
+ {
+ "key": "face_with_cold_sweat",
+ "value": "😓"
+ },
+ {
+ "key": "loudly_crying_face",
+ "value": "😭"
+ },
+ {
+ "key": "dizzy_face",
+ "value": "😵"
+ },
+ {
+ "key": "astonished_face",
+ "value": "😲"
+ },
+ {
+ "key": "zipper_mouth_face",
+ "value": "🤐"
+ },
+ {
+ "key": "face_with_medical_mask",
+ "value": "😷"
+ },
+ {
+ "key": "face_with_thermometer",
+ "value": "🤒"
+ },
+ {
+ "key": "face_with_head_bandage",
+ "value": "🤕"
+ },
+ {
+ "key": "sleeping_face",
+ "value": "😴"
+ },
+ {
+ "key": "sleeping_symbol",
+ "value": "💤"
+ },
+ {
+ "key": "pile_of_poo",
+ "value": "💩"
+ },
+ {
+ "key": "smiling_face_with_horns",
+ "value": "😈"
+ },
+ {
+ "key": "imp",
+ "value": "👿"
+ },
+ {
+ "key": "japanese_ogre",
+ "value": "👹"
+ },
+ {
+ "key": "japanese_goblin",
+ "value": "👺"
+ },
+ {
+ "key": "skull",
+ "value": "💀"
+ },
+ {
+ "key": "ghost",
+ "value": "👻"
+ },
+ {
+ "key": "extra_terrestrial_alien",
+ "value": "👽"
+ },
+ {
+ "key": "robot_face",
+ "value": "🤖"
+ },
+ {
+ "key": "smiling_cat_face_open_mouth",
+ "value": "😺"
+ },
+ {
+ "key": "grinning_cat_face_smile_eyes",
+ "value": "😸"
+ },
+ {
+ "key": "cat_face_tears_of_joy",
+ "value": "😹"
+ },
+ {
+ "key": "smiling_cat_face_heart_shaped_eyes",
+ "value": "😻"
+ },
+ {
+ "key": "cat_face_wry_smile",
+ "value": "😼"
+ },
+ {
+ "key": "kissing_cat_face_closed_eyes",
+ "value": "😽"
+ },
+ {
+ "key": "weary_cat_face",
+ "value": "🙀"
+ },
+ {
+ "key": "crying_cat_face",
+ "value": "😿"
+ },
+ {
+ "key": "pouting_cat_face",
+ "value": "😾"
+ },
+ {
+ "key": "person_both_hand_celebration",
+ "value": "🙌"
+ },
+ {
+ "key": "person_both_hand_celebration_type_1_2",
+ "value": "🙌🏻"
+ },
+ {
+ "key": "person_both_hand_celebration_type_3",
+ "value": "🙌🏼"
+ },
+ {
+ "key": "person_both_hand_celebration_type_4",
+ "value": "🙌🏽"
+ },
+ {
+ "key": "person_both_hand_celebration_type_5",
+ "value": "🙌🏾"
+ },
+ {
+ "key": "person_both_hand_celebration_type_6",
+ "value": "🙌🏿"
+ },
+ {
+ "key": "clapping_hand",
+ "value": "👏"
+ },
+ {
+ "key": "clapping_hand_type_1_2",
+ "value": "👏🏼"
+ },
+ {
+ "key": "clapping_hand_type_3",
+ "value": "👏🏼"
+ },
+ {
+ "key": "clapping_hand_type_4",
+ "value": "👏🏽"
+ },
+ {
+ "key": "clapping_hand_type_5",
+ "value": "👏🏾"
+ },
+ {
+ "key": "clapping_hand_type_6",
+ "value": "👏🏿"
+ },
+ {
+ "key": "waving_hands",
+ "value": "👋"
+ },
+ {
+ "key": "waving_hands_type_1_2",
+ "value": "👋🏻"
+ },
+ {
+ "key": "waving_hands_type_3",
+ "value": "👋🏼"
+ },
+ {
+ "key": "waving_hands_type_4",
+ "value": "👋🏽"
+ },
+ {
+ "key": "waving_hands_type_5",
+ "value": "👋🏾"
+ },
+ {
+ "key": "waving_hands_type_6",
+ "value": "👋🏿"
+ },
+ {
+ "key": "thumbs_up",
+ "value": "👍"
+ },
+ {
+ "key": "thumbs_up_type_1_2",
+ "value": "👍🏻"
+ },
+ {
+ "key": "thumbs_up_type_3",
+ "value": "👍🏼"
+ },
+ {
+ "key": "thumbs_up_type_4",
+ "value": "👍🏽"
+ },
+ {
+ "key": "thumbs_up_type_5",
+ "value": "👍🏾"
+ },
+ {
+ "key": "thumbs_up_type_6",
+ "value": "👍🏿"
+ },
+ {
+ "key": "thumbs_down",
+ "value": "👎"
+ },
+ {
+ "key": "thumbs_down_type_1_2",
+ "value": "👎🏻"
+ },
+ {
+ "key": "thumbs_down_type_3",
+ "value": "👎🏼"
+ },
+ {
+ "key": "thumbs_down_type_4",
+ "value": "👎🏽"
+ },
+ {
+ "key": "thumbs_down_type_5",
+ "value": "👎🏾"
+ },
+ {
+ "key": "thumbs_down_type_6",
+ "value": "👎🏿"
+ },
+ {
+ "key": "fist_hand",
+ "value": "👊"
+ },
+ {
+ "key": "fist_hand_type_1_2",
+ "value": "👊🏻"
+ },
+ {
+ "key": "fist_hand_type_3",
+ "value": "👊🏼"
+ },
+ {
+ "key": "fist_hand_type_4",
+ "value": "👊🏽"
+ },
+ {
+ "key": "fist_hand_type_5",
+ "value": "👊🏾"
+ },
+ {
+ "key": "fist_hand_type_6",
+ "value": "👊🏿"
+ },
+ {
+ "key": "raised_fist",
+ "value": "✊"
+ },
+ {
+ "key": "raised_fist_type_1_2",
+ "value": "✊🏻"
+ },
+ {
+ "key": "raised_fist_type_3",
+ "value": "✊🏼"
+ },
+ {
+ "key": "raised_fist_type_4",
+ "value": "✊🏽"
+ },
+ {
+ "key": "raised_fist_type_5",
+ "value": "✊🏾"
+ },
+ {
+ "key": "raised_fist_type_6",
+ "value": "✊🏿"
+ },
+ {
+ "key": "victory_hand",
+ "value": "✌"
+ },
+ {
+ "key": "victory_hand_type_1_2",
+ "value": "✌🏻"
+ },
+ {
+ "key": "victory_hand_type_3",
+ "value": "✌🏼"
+ },
+ {
+ "key": "victory_hand_type_4",
+ "value": "✌🏽"
+ },
+ {
+ "key": "victory_hand_type_5",
+ "value": "✌🏾"
+ },
+ {
+ "key": "victory_hand_type_6",
+ "value": "✌🏿"
+ },
+ {
+ "key": "ok_hand",
+ "value": "👌"
+ },
+ {
+ "key": "ok_hand_type_1_2",
+ "value": "👌🏻"
+ },
+ {
+ "key": "ok_hand_type_3",
+ "value": "👌🏼"
+ },
+ {
+ "key": "ok_hand_type_4",
+ "value": "👌🏽"
+ },
+ {
+ "key": "ok_hand_type_5",
+ "value": "👌🏾"
+ },
+ {
+ "key": "ok_hand_type_6",
+ "value": "👌🏿"
+ },
+ {
+ "key": "raised_hand",
+ "value": "✋"
+ },
+ {
+ "key": "raised_hand_type_1_2",
+ "value": "✋🏻"
+ },
+ {
+ "key": "raised_hand_type_3",
+ "value": "✋🏼"
+ },
+ {
+ "key": "raised_hand_type_4",
+ "value": "✋🏽"
+ },
+ {
+ "key": "raised_hand_type_5",
+ "value": "✋🏾"
+ },
+ {
+ "key": "raised_hand_type_6",
+ "value": "✋🏿"
+ },
+ {
+ "key": "open_hand",
+ "value": "👐"
+ },
+ {
+ "key": "open_hand_type_1_2",
+ "value": "👐🏻"
+ },
+ {
+ "key": "open_hand_type_3",
+ "value": "👐🏼"
+ },
+ {
+ "key": "open_hand_type_4",
+ "value": "👐🏽"
+ },
+ {
+ "key": "open_hand_type_5",
+ "value": "👐🏾"
+ },
+ {
+ "key": "open_hand_type_6",
+ "value": "👐🏿"
+ },
+ {
+ "key": "flexed_biceps",
+ "value": "💪"
+ },
+ {
+ "key": "flexed_biceps_type_1_2",
+ "value": "💪🏻"
+ },
+ {
+ "key": "flexed_biceps_type_3",
+ "value": "💪🏼"
+ },
+ {
+ "key": "flexed_biceps_type_4",
+ "value": "💪🏽"
+ },
+ {
+ "key": "flexed_biceps_type_5",
+ "value": "💪🏾"
+ },
+ {
+ "key": "flexed_biceps_type_6",
+ "value": "💪🏿"
+ },
+ {
+ "key": "folded_hands",
+ "value": "🙏"
+ },
+ {
+ "key": "folded_hands_type_1_2",
+ "value": "🙏🏻"
+ },
+ {
+ "key": "folded_hands_type_3",
+ "value": "🙏🏼"
+ },
+ {
+ "key": "folded_hands_type_4",
+ "value": "🙏🏽"
+ },
+ {
+ "key": "folded_hands_type_5",
+ "value": "🙏🏾"
+ },
+ {
+ "key": "folded_hands_type_6",
+ "value": "🙏🏿"
+ },
+ {
+ "key": "up_pointing_index",
+ "value": "☝"
+ },
+ {
+ "key": "up_pointing_index_type_1_2",
+ "value": "☝🏻"
+ },
+ {
+ "key": "up_pointing_index_type_3",
+ "value": "☝🏼"
+ },
+ {
+ "key": "up_pointing_index_type_4",
+ "value": "☝🏽"
+ },
+ {
+ "key": "up_pointing_index_type_5",
+ "value": "☝🏾"
+ },
+ {
+ "key": "up_pointing_index_type_6",
+ "value": "☝🏿"
+ },
+ {
+ "key": "up_pointing_backhand_index",
+ "value": "👆"
+ },
+ {
+ "key": "up_pointing_backhand_index_type_1_2",
+ "value": "👆🏻"
+ },
+ {
+ "key": "up_pointing_backhand_index_type_3",
+ "value": "👆🏼"
+ },
+ {
+ "key": "up_pointing_backhand_index_type_4",
+ "value": "👆🏽"
+ },
+ {
+ "key": "up_pointing_backhand_index_type_5",
+ "value": "👆🏾"
+ },
+ {
+ "key": "up_pointing_backhand_index_type_6",
+ "value": "👆🏿"
+ },
+ {
+ "key": "down_pointing_backhand_index",
+ "value": "👇"
+ },
+ {
+ "key": "down_pointing_backhand_index_type_1_2",
+ "value": "👇🏻"
+ },
+ {
+ "key": "down_pointing_backhand_index_type_3",
+ "value": "👇🏼"
+ },
+ {
+ "key": "down_pointing_backhand_index_type_4",
+ "value": "👇🏽"
+ },
+ {
+ "key": "down_pointing_backhand_index_type_5",
+ "value": "👇🏾"
+ },
+ {
+ "key": "down_pointing_backhand_index_type_6",
+ "value": "👇🏿"
+ },
+ {
+ "key": "left_pointing_backhand_index",
+ "value": "👈"
+ },
+ {
+ "key": "left_pointing_backhand_index_type_1_2",
+ "value": "👈🏻"
+ },
+ {
+ "key": "left_pointing_backhand_index_type_3",
+ "value": "👈🏼"
+ },
+ {
+ "key": "left_pointing_backhand_index_type_4",
+ "value": "👈🏽"
+ },
+ {
+ "key": "left_pointing_backhand_index_type_5",
+ "value": "👈🏾"
+ },
+ {
+ "key": "left_pointing_backhand_index_type_6",
+ "value": "👈🏿"
+ },
+ {
+ "key": "right_pointing_backhand_index",
+ "value": "👉"
+ },
+ {
+ "key": "right_pointing_backhand_index_type_1_2",
+ "value": "👉🏻"
+ },
+ {
+ "key": "right_pointing_backhand_index_type_3",
+ "value": "👉🏼"
+ },
+ {
+ "key": "right_pointing_backhand_index_type_4",
+ "value": "👉🏽"
+ },
+ {
+ "key": "right_pointing_backhand_index_type_5",
+ "value": "👉🏾"
+ },
+ {
+ "key": "right_pointing_backhand_index_type_6",
+ "value": "👉🏿"
+ },
+ {
+ "key": "reverse_middle_finger",
+ "value": "🖕"
+ },
+ {
+ "key": "reverse_middle_finger_type_1_2",
+ "value": "🖕🏻"
+ },
+ {
+ "key": "reverse_middle_finger_type_3",
+ "value": "🖕🏼"
+ },
+ {
+ "key": "reverse_middle_finger_type_4",
+ "value": "🖕🏽"
+ },
+ {
+ "key": "reverse_middle_finger_type_5",
+ "value": "🖕🏾"
+ },
+ {
+ "key": "reverse_middle_finger_type_6",
+ "value": "🖕🏿"
+ },
+ {
+ "key": "raised_hand_fingers_splayed",
+ "value": "🖐"
+ },
+ {
+ "key": "raised_hand_fingers_splayed_type_1_2",
+ "value": "🖐🏻"
+ },
+ {
+ "key": "raised_hand_fingers_splayed_type_3",
+ "value": "🖐🏼"
+ },
+ {
+ "key": "raised_hand_fingers_splayed_type_4",
+ "value": "🖐🏽"
+ },
+ {
+ "key": "raised_hand_fingers_splayed_type_5",
+ "value": "🖐🏾"
+ },
+ {
+ "key": "raised_hand_fingers_splayed_type_6",
+ "value": "🖐🏿"
+ },
+ {
+ "key": "sign_of_horn",
+ "value": "🤘"
+ },
+ {
+ "key": "sign_of_horn_type_1_2",
+ "value": "🤘🏻"
+ },
+ {
+ "key": "sign_of_horn_type_3",
+ "value": "🤘🏼"
+ },
+ {
+ "key": "sign_of_horn_type_4",
+ "value": "🤘🏽"
+ },
+ {
+ "key": "sign_of_horn_type_5",
+ "value": "🤘🏾"
+ },
+ {
+ "key": "sign_of_horn_type_6",
+ "value": "🤘🏿"
+ },
+ {
+ "key": "raised_hand_part_between_middle_ring",
+ "value": "🖖"
+ },
+ {
+ "key": "raised_hand_part_between_middle_ring_type_1_2",
+ "value": "🖖🏻"
+ },
+ {
+ "key": "raised_hand_part_between_middle_ring_type_3",
+ "value": "🖖🏼"
+ },
+ {
+ "key": "raised_hand_part_between_middle_ring_type_4",
+ "value": "🖖🏽"
+ },
+ {
+ "key": "raised_hand_part_between_middle_ring_type_5",
+ "value": "🖖🏾"
+ },
+ {
+ "key": "raised_hand_part_between_middle_ring_type_6",
+ "value": "🖖🏿"
+ },
+ {
+ "key": "writing_hand",
+ "value": "✍"
+ },
+ {
+ "key": "writing_hand_type_1_2",
+ "value": "✍🏻"
+ },
+ {
+ "key": "writing_hand_type_3",
+ "value": "✍🏼"
+ },
+ {
+ "key": "writing_hand_type_4",
+ "value": "✍🏽"
+ },
+ {
+ "key": "writing_hand_type_5",
+ "value": "✍🏾"
+ },
+ {
+ "key": "writing_hand_type_6",
+ "value": "✍🏿"
+ },
+ {
+ "key": "nail_polish",
+ "value": "💅"
+ },
+ {
+ "key": "nail_polish_type_1_2",
+ "value": "💅🏻"
+ },
+ {
+ "key": "nail_polish_type_3",
+ "value": "💅🏼"
+ },
+ {
+ "key": "nail_polish_type_4",
+ "value": "💅🏽"
+ },
+ {
+ "key": "nail_polish_type_5",
+ "value": "💅🏾"
+ },
+ {
+ "key": "nail_polish_type_6",
+ "value": "💅🏿"
+ },
+ {
+ "key": "mouth",
+ "value": "👄"
+ },
+ {
+ "key": "tongue",
+ "value": "👅"
+ },
+ {
+ "key": "ear",
+ "value": "👂"
+ },
+ {
+ "key": "ear_type_1_2",
+ "value": "👂🏻"
+ },
+ {
+ "key": "ear_type_3",
+ "value": "👂🏼"
+ },
+ {
+ "key": "ear_type_4",
+ "value": "👂🏽"
+ },
+ {
+ "key": "ear_type_5",
+ "value": "👂🏾"
+ },
+ {
+ "key": "ear_type_6",
+ "value": "👂🏿"
+ },
+ {
+ "key": "nose",
+ "value": "👃"
+ },
+ {
+ "key": "nose_type_1_2",
+ "value": "👃🏻"
+ },
+ {
+ "key": "nose_type_3",
+ "value": "👃🏼"
+ },
+ {
+ "key": "nose_type_4",
+ "value": "👃🏽"
+ },
+ {
+ "key": "nose_type_5",
+ "value": "👃🏾"
+ },
+ {
+ "key": "nose_type_6",
+ "value": "👃🏿"
+ },
+ {
+ "key": "eye",
+ "value": "👁"
+ },
+ {
+ "key": "eyes",
+ "value": "👀"
+ },
+ {
+ "key": "bust_in_silhouette",
+ "value": "👤"
+ },
+ {
+ "key": "busts_in_silhouette",
+ "value": "👥"
+ },
+ {
+ "key": "speaking_head_in_silhouette",
+ "value": "🗣"
+ },
+ {
+ "key": "baby",
+ "value": "👶"
+ },
+ {
+ "key": "baby_type_1_2",
+ "value": "👶🏻"
+ },
+ {
+ "key": "baby_type_3",
+ "value": "👶🏼"
+ },
+ {
+ "key": "baby_type_4",
+ "value": "👶🏽"
+ },
+ {
+ "key": "baby_type_5",
+ "value": "👶🏾"
+ },
+ {
+ "key": "baby_type_6",
+ "value": "👶🏿"
+ },
+ {
+ "key": "boy",
+ "value": "👦"
+ },
+ {
+ "key": "boy_type_1_2",
+ "value": "👦🏻"
+ },
+ {
+ "key": "boy_type_3",
+ "value": "👦🏼"
+ },
+ {
+ "key": "boy_type_4",
+ "value": "👦🏽"
+ },
+ {
+ "key": "boy_type_5",
+ "value": "👦🏾"
+ },
+ {
+ "key": "boy_type_6",
+ "value": "👦🏿"
+ },
+ {
+ "key": "girl",
+ "value": "👧"
+ },
+ {
+ "key": "girl_type_1_2",
+ "value": "👧🏻"
+ },
+ {
+ "key": "girl_type_3",
+ "value": "👧🏼"
+ },
+ {
+ "key": "girl_type_4",
+ "value": "👧🏽"
+ },
+ {
+ "key": "girl_type_5",
+ "value": "👧🏾"
+ },
+ {
+ "key": "girl_type_6",
+ "value": "👧🏿"
+ },
+ {
+ "key": "man",
+ "value": "👨"
+ },
+ {
+ "key": "man_type_1_2",
+ "value": "👨🏻"
+ },
+ {
+ "key": "man_type_3",
+ "value": "👨🏼"
+ },
+ {
+ "key": "man_type_4",
+ "value": "👨🏽"
+ },
+ {
+ "key": "man_type_5",
+ "value": "👨🏾"
+ },
+ {
+ "key": "man_type_6",
+ "value": "👨🏿"
+ },
+ {
+ "key": "women",
+ "value": "👩"
+ },
+ {
+ "key": "women_type_1_2",
+ "value": "👩🏻"
+ },
+ {
+ "key": "women_type_3",
+ "value": "👩🏼"
+ },
+ {
+ "key": "women_type_4",
+ "value": "👩🏽"
+ },
+ {
+ "key": "women_type_5",
+ "value": "👩🏾"
+ },
+ {
+ "key": "women_type_6",
+ "value": "👩🏿"
+ },
+ {
+ "key": "person_with_blond_hair",
+ "value": "👱"
+ },
+ {
+ "key": "person_with_blond_hair_type_1_2",
+ "value": "👱🏻"
+ },
+ {
+ "key": "person_with_blond_hair_type_3",
+ "value": "👱🏼"
+ },
+ {
+ "key": "person_with_blond_hair_type_4",
+ "value": "👱🏽"
+ },
+ {
+ "key": "person_with_blond_hair_type_5",
+ "value": "👱🏾"
+ },
+ {
+ "key": "person_with_blond_hair_type_6",
+ "value": "👱🏿"
+ },
+ {
+ "key": "older_man",
+ "value": "👴"
+ },
+ {
+ "key": "older_man_type_1_2",
+ "value": "👴🏻"
+ },
+ {
+ "key": "older_man_type_3",
+ "value": "👴🏼"
+ },
+ {
+ "key": "older_man_type_4",
+ "value": "👴🏽"
+ },
+ {
+ "key": "older_man_type_5",
+ "value": "👴🏾"
+ },
+ {
+ "key": "older_man_type_6",
+ "value": "👴🏿"
+ },
+ {
+ "key": "older_women",
+ "value": "👵"
+ },
+ {
+ "key": "older_women_type_1_2",
+ "value": "👵🏻"
+ },
+ {
+ "key": "older_women_type_3",
+ "value": "👵🏼"
+ },
+ {
+ "key": "older_women_type_4",
+ "value": "👵🏽"
+ },
+ {
+ "key": "older_women_type_5",
+ "value": "👵🏾"
+ },
+ {
+ "key": "older_women_type_6",
+ "value": "👵🏿"
+ },
+ {
+ "key": "man_with_gua_pi_mao",
+ "value": "👲"
+ },
+ {
+ "key": "man_with_gua_pi_mao_type_1_2",
+ "value": "👲🏼"
+ },
+ {
+ "key": "man_with_gua_pi_mao_type_3",
+ "value": "👲🏼"
+ },
+ {
+ "key": "man_with_gua_pi_mao_type_4",
+ "value": "👲🏽"
+ },
+ {
+ "key": "man_with_gua_pi_mao_type_5",
+ "value": "👲🏾"
+ },
+ {
+ "key": "man_with_gua_pi_mao_type_6",
+ "value": "👲🏿"
+ },
+ {
+ "key": "man_with_turban",
+ "value": "👳"
+ },
+ {
+ "key": "man_with_turban_type_1_2",
+ "value": "👳🏻"
+ },
+ {
+ "key": "man_with_turban_type_3",
+ "value": "👳🏼"
+ },
+ {
+ "key": "man_with_turban_type_4",
+ "value": "👳🏽"
+ },
+ {
+ "key": "man_with_turban_type_5",
+ "value": "👳🏾"
+ },
+ {
+ "key": "man_with_turban_type_6",
+ "value": "👳🏿"
+ },
+ {
+ "key": "police_officer",
+ "value": "👮"
+ },
+ {
+ "key": "police_officer_type_1_2",
+ "value": "👮🏻"
+ },
+ {
+ "key": "police_officer_type_3",
+ "value": "👮🏼"
+ },
+ {
+ "key": "police_officer_type_4",
+ "value": "👮🏽"
+ },
+ {
+ "key": "police_officer_type_5",
+ "value": "👮🏾"
+ },
+ {
+ "key": "police_officer_type_6",
+ "value": "👮🏿"
+ },
+ {
+ "key": "construction_worker",
+ "value": "👷"
+ },
+ {
+ "key": "construction_worker_type_1_2",
+ "value": "👷🏻"
+ },
+ {
+ "key": "construction_worker_type_3",
+ "value": "👷🏼"
+ },
+ {
+ "key": "construction_worker_type_4",
+ "value": "👷🏽"
+ },
+ {
+ "key": "construction_worker_type_5",
+ "value": "👷🏾"
+ },
+ {
+ "key": "construction_worker_type_6",
+ "value": "👷🏿"
+ },
+ {
+ "key": "guards_man",
+ "value": "💂"
+ },
+ {
+ "key": "guards_man_type_1_2",
+ "value": "💂🏻"
+ },
+ {
+ "key": "guards_man_type_3",
+ "value": "💂🏼"
+ },
+ {
+ "key": "guards_man_type_4",
+ "value": "💂🏽"
+ },
+ {
+ "key": "guards_man_type_5",
+ "value": "💂🏾"
+ },
+ {
+ "key": "guards_man_type_6",
+ "value": "💂🏿"
+ },
+ {
+ "key": "spy",
+ "value": "🕵"
+ },
+ {
+ "key": "father_christmas",
+ "value": "🎅"
+ },
+ {
+ "key": "father_christmas_type_1_2",
+ "value": "🎅🏻"
+ },
+ {
+ "key": "father_christmas_type_3",
+ "value": "🎅🏼"
+ },
+ {
+ "key": "father_christmas_type_4",
+ "value": "🎅🏽"
+ },
+ {
+ "key": "father_christmas_type_5",
+ "value": "🎅🏾"
+ },
+ {
+ "key": "father_christmas_type_6",
+ "value": "🎅🏿"
+ },
+ {
+ "key": "baby_angel",
+ "value": "👼"
+ },
+ {
+ "key": "baby_angel_type_1_2",
+ "value": "👼🏻"
+ },
+ {
+ "key": "baby_angel_type_3",
+ "value": "👼🏼"
+ },
+ {
+ "key": "baby_angel_type_4",
+ "value": "👼🏽"
+ },
+ {
+ "key": "baby_angel_type_5",
+ "value": "👼🏾"
+ },
+ {
+ "key": "baby_angel_type_6",
+ "value": "👼🏿"
+ },
+ {
+ "key": "princess",
+ "value": "👸"
+ },
+ {
+ "key": "princess_type_1_2",
+ "value": "👸🏻"
+ },
+ {
+ "key": "princess_type_3",
+ "value": "👸🏼"
+ },
+ {
+ "key": "princess_type_4",
+ "value": "👸🏽"
+ },
+ {
+ "key": "princess_type_5",
+ "value": "👸🏾"
+ },
+ {
+ "key": "princess_type_6",
+ "value": "👸🏿"
+ },
+ {
+ "key": "bride_with_veil",
+ "value": "👰"
+ },
+ {
+ "key": "bride_with_veil_type_1_2",
+ "value": "👰🏻"
+ },
+ {
+ "key": "bride_with_veil_type_3",
+ "value": "👰🏼"
+ },
+ {
+ "key": "bride_with_veil_type_4",
+ "value": "👰🏽"
+ },
+ {
+ "key": "bride_with_veil_type_5",
+ "value": "👰🏾"
+ },
+ {
+ "key": "bride_with_veil_type_6",
+ "value": "👰🏿"
+ },
+ {
+ "key": "pedestrian",
+ "value": "🚶"
+ },
+ {
+ "key": "pedestrian_type_1_2",
+ "value": "🚶🏻"
+ },
+ {
+ "key": "pedestrian_type_3",
+ "value": "🚶🏼"
+ },
+ {
+ "key": "pedestrian_type_4",
+ "value": "🚶🏽"
+ },
+ {
+ "key": "pedestrian_type_5",
+ "value": "🚶🏾"
+ },
+ {
+ "key": "pedestrian_type_6",
+ "value": "🚶🏿"
+ },
+ {
+ "key": "runner",
+ "value": "🏃"
+ },
+ {
+ "key": "runner_type_1_2",
+ "value": "🏃🏻"
+ },
+ {
+ "key": "runner_type_3",
+ "value": "🏃🏼"
+ },
+ {
+ "key": "runner_type_4",
+ "value": "🏃🏽"
+ },
+ {
+ "key": "runner_type_5",
+ "value": "🏃🏾"
+ },
+ {
+ "key": "runner_type_6",
+ "value": "🏃🏿"
+ },
+ {
+ "key": "dancer",
+ "value": "💃"
+ },
+ {
+ "key": "dancer_type_1_2",
+ "value": "💃🏻"
+ },
+ {
+ "key": "dancer_type_3",
+ "value": "💃🏼"
+ },
+ {
+ "key": "dancer_type_4",
+ "value": "💃🏽"
+ },
+ {
+ "key": "dancer_type_5",
+ "value": "💃🏾"
+ },
+ {
+ "key": "dancer_type_6",
+ "value": "💃🏿"
+ },
+ {
+ "key": "women_with_bunny_years",
+ "value": "👯"
+ },
+ {
+ "key": "man_women_holding_hands",
+ "value": "👫"
+ },
+ {
+ "key": "two_man_holding_hands",
+ "value": "👬"
+ },
+ {
+ "key": "two_women_holding_hands",
+ "value": "👭"
+ },
+ {
+ "key": "person_bowing_deeply",
+ "value": "🙇"
+ },
+ {
+ "key": "person_bowing_deeply_type_1_2",
+ "value": "🙇🏻"
+ },
+ {
+ "key": "person_bowing_deeply_type_3",
+ "value": "🙇🏼"
+ },
+ {
+ "key": "person_bowing_deeply_type_4",
+ "value": "🙇🏽"
+ },
+ {
+ "key": "person_bowing_deeply_type_5",
+ "value": "🙇🏾"
+ },
+ {
+ "key": "person_bowing_deeply_type_6",
+ "value": "🙇🏿"
+ },
+ {
+ "key": "information_desk_person",
+ "value": "💁"
+ },
+ {
+ "key": "information_desk_person_type_1_2",
+ "value": "💁🏻"
+ },
+ {
+ "key": "information_desk_person_type_3",
+ "value": "💁🏼"
+ },
+ {
+ "key": "information_desk_person_type_4",
+ "value": "💁🏽"
+ },
+ {
+ "key": "information_desk_person_type_5",
+ "value": "💁🏾"
+ },
+ {
+ "key": "information_desk_person_type_6",
+ "value": "💁🏿"
+ },
+ {
+ "key": "face_with_no_good_gesture",
+ "value": "🙅"
+ },
+ {
+ "key": "face_with_no_good_gesture_type_1_2",
+ "value": "🙅🏻"
+ },
+ {
+ "key": "face_with_no_good_gesture_type_3",
+ "value": "🙅🏼"
+ },
+ {
+ "key": "face_with_no_good_gesture_type_4",
+ "value": "🙅🏽"
+ },
+ {
+ "key": "face_with_no_good_gesture_type_5",
+ "value": "🙅🏾"
+ },
+ {
+ "key": "face_with_no_good_gesture_type_6",
+ "value": "🙅🏿"
+ },
+ {
+ "key": "face_with_ok_gesture",
+ "value": "🙆"
+ },
+ {
+ "key": "face_with_ok_gesture_type_1_2",
+ "value": "🙆🏻"
+ },
+ {
+ "key": "face_with_ok_gesture_type_3",
+ "value": "🙆🏼"
+ },
+ {
+ "key": "face_with_ok_gesture_type_4",
+ "value": "🙆🏽"
+ },
+ {
+ "key": "face_with_ok_gesture_type_5",
+ "value": "🙆🏾"
+ },
+ {
+ "key": "face_with_ok_gesture_type_6",
+ "value": "🙆🏿"
+ },
+ {
+ "key": "happy_person_raise_one_hand",
+ "value": "🙋"
+ },
+ {
+ "key": "happy_person_raise_one_hand_type_1_2",
+ "value": "🙋🏻"
+ },
+ {
+ "key": "happy_person_raise_one_hand_type_3",
+ "value": "🙋🏼"
+ },
+ {
+ "key": "happy_person_raise_one_hand_type_4",
+ "value": "🙋🏽"
+ },
+ {
+ "key": "happy_person_raise_one_hand_type_5",
+ "value": "🙋🏾"
+ },
+ {
+ "key": "happy_person_raise_one_hand_type_6",
+ "value": "🙋🏿"
+ },
+ {
+ "key": "person_with_pouting_face",
+ "value": "🙎"
+ },
+ {
+ "key": "person_with_pouting_face_type_1_2",
+ "value": "🙎🏻"
+ },
+ {
+ "key": "person_with_pouting_face_type_3",
+ "value": "🙎🏼"
+ },
+ {
+ "key": "person_with_pouting_face_type_4",
+ "value": "🙎🏽"
+ },
+ {
+ "key": "person_with_pouting_face_type_5",
+ "value": "🙎🏾"
+ },
+ {
+ "key": "person_with_pouting_face_type_6",
+ "value": "🙎🏿"
+ },
+ {
+ "key": "person_frowning",
+ "value": "🙍"
+ },
+ {
+ "key": "person_frowning_type_1_2",
+ "value": "🙍🏻"
+ },
+ {
+ "key": "person_frowning_type_3",
+ "value": "🙍🏼"
+ },
+ {
+ "key": "person_frowning_type_4",
+ "value": "🙍🏽"
+ },
+ {
+ "key": "person_frowning_type_5",
+ "value": "🙍🏾"
+ },
+ {
+ "key": "person_frowning_type_6",
+ "value": "🙍🏿"
+ },
+ {
+ "key": "haircut",
+ "value": "💇"
+ },
+ {
+ "key": "haircut_type_1_2",
+ "value": "💇🏻"
+ },
+ {
+ "key": "haircut_type_3",
+ "value": "💇🏼"
+ },
+ {
+ "key": "haircut_type_4",
+ "value": "💇🏽"
+ },
+ {
+ "key": "haircut_type_5",
+ "value": "💇🏾"
+ },
+ {
+ "key": "haircut_type_6",
+ "value": "💇🏿"
+ },
+ {
+ "key": "face_massage",
+ "value": "💆"
+ },
+ {
+ "key": "face_massage_type_1_2",
+ "value": "💆🏻"
+ },
+ {
+ "key": "face_massage_type_3",
+ "value": "💆🏻"
+ },
+ {
+ "key": "face_massage_type_4",
+ "value": "💆🏽"
+ },
+ {
+ "key": "face_massage_type_5",
+ "value": "💆🏾"
+ },
+ {
+ "key": "face_massage_type_6",
+ "value": "💆🏿"
+ },
+ {
+ "key": "couple_with_heart",
+ "value": "💑"
+ },
+ {
+ "key": "couple_with_heart_woman",
+ "value": "👩‍❤️‍👩"
+ },
+ {
+ "key": "couple_with_heart_man",
+ "value": "👨‍❤️‍👨"
+ },
+ {
+ "key": "kiss",
+ "value": "💏"
+ },
+ {
+ "key": "kiss_woman",
+ "value": "👩‍❤️‍💋‍👩"
+ },
+ {
+ "key": "kiss_man",
+ "value": "👨‍❤️‍💋‍👨"
+ },
+ {
+ "key": "family",
+ "value": "👪"
+ },
+ {
+ "key": "family_man_women_girl",
+ "value": "👨‍👩‍👧"
+ },
+ {
+ "key": "family_man_women_girl_boy",
+ "value": "👨‍👩‍👧‍👦"
+ },
+ {
+ "key": "family_man_women_boy_boy",
+ "value": "👨‍👩‍👦‍👦"
+ },
+ {
+ "key": "family_man_women_girl_girl",
+ "value": "👨‍👩‍👧‍👧"
+ },
+ {
+ "key": "family_woman_women_boy",
+ "value": "👩‍👩‍👦"
+ },
+ {
+ "key": "family_woman_women_girl",
+ "value": "👩‍👩‍👧"
+ },
+ {
+ "key": "family_woman_women_girl_boy",
+ "value": "👩‍👩‍👧‍👦"
+ },
+ {
+ "key": "family_woman_women_boy_boy",
+ "value": "👩‍👩‍👦‍👦"
+ },
+ {
+ "key": "family_woman_women_girl_girl",
+ "value": "👩‍👩‍👧‍👧"
+ },
+ {
+ "key": "family_man_man_boy",
+ "value": "👨‍👨‍👦"
+ },
+ {
+ "key": "family_man_man_girl",
+ "value": "👨‍👨‍👧"
+ },
+ {
+ "key": "family_man_man_girl_boy",
+ "value": "👨‍👨‍👧‍👦"
+ },
+ {
+ "key": "family_man_man_boy_boy",
+ "value": "👨‍👨‍👦‍👦"
+ },
+ {
+ "key": "family_man_man_girl_girl",
+ "value": "👨‍👨‍👧‍👧"
+ },
+ {
+ "key": "woman_clothes",
+ "value": "👚"
+ },
+ {
+ "key": "t_shirt",
+ "value": "👕"
+ },
+ {
+ "key": "jeans",
+ "value": "👖"
+ },
+ {
+ "key": "necktie",
+ "value": "👔"
+ },
+ {
+ "key": "dress",
+ "value": "👗"
+ },
+ {
+ "key": "bikini",
+ "value": "👙"
+ },
+ {
+ "key": "kimono",
+ "value": "👘"
+ },
+ {
+ "key": "lipstick",
+ "value": "💄"
+ },
+ {
+ "key": "kiss_mark",
+ "value": "💋"
+ },
+ {
+ "key": "footprints",
+ "value": "👣"
+ },
+ {
+ "key": "high_heeled_shoe",
+ "value": "👠"
+ },
+ {
+ "key": "woman_sandal",
+ "value": "👡"
+ },
+ {
+ "key": "woman_boots",
+ "value": "👢"
+ },
+ {
+ "key": "man_shoe",
+ "value": "👞"
+ },
+ {
+ "key": "athletic_shoe",
+ "value": "👟"
+ },
+ {
+ "key": "woman_hat",
+ "value": "👒"
+ },
+ {
+ "key": "top_hat",
+ "value": "🎩"
+ },
+ {
+ "key": "graduation_cap",
+ "value": "🎓"
+ },
+ {
+ "key": "crown",
+ "value": "👑"
+ },
+ {
+ "key": "helmet_with_white_cross",
+ "value": "⛑"
+ },
+ {
+ "key": "school_satchel",
+ "value": "🎒"
+ },
+ {
+ "key": "pouch",
+ "value": "👝"
+ },
+ {
+ "key": "purse",
+ "value": "👛"
+ },
+ {
+ "key": "handbag",
+ "value": "👜"
+ },
+ {
+ "key": "briefcase",
+ "value": "💼"
+ },
+ {
+ "key": "eye_glasses",
+ "value": "👓"
+ },
+ {
+ "key": "dark_sun_glasses",
+ "value": "🕶"
+ },
+ {
+ "key": "ring",
+ "value": "💍"
+ },
+ {
+ "key": "closed_umbrella",
+ "value": "🌂"
+ }
+ ]
+ }
+} \ No newline at end of file
diff --git a/nikola/plugins/shortcode/emoji/data/Symbols.json b/nikola/plugins/shortcode/emoji/data/Symbols.json
new file mode 100644
index 0000000..2dd5454
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji/data/Symbols.json
@@ -0,0 +1,1082 @@
+{
+ "symbols": {
+ "symbol": [
+ {
+ "key": "heavy_black_heart",
+ "value": "❤"
+ },
+ {
+ "key": "yellow_heart",
+ "value": "💛"
+ },
+ {
+ "key": "green_heart",
+ "value": "💚"
+ },
+ {
+ "key": "blue_heart",
+ "value": "💙"
+ },
+ {
+ "key": "purple_heart",
+ "value": "💜"
+ },
+ {
+ "key": "broken_heart",
+ "value": "💔"
+ },
+ {
+ "key": "heavy_heart_exclamation_mark_ornament",
+ "value": "❣"
+ },
+ {
+ "key": "two_hearts",
+ "value": "💕"
+ },
+ {
+ "key": "revolving_hearts",
+ "value": "💞"
+ },
+ {
+ "key": "beating_heart",
+ "value": "💓"
+ },
+ {
+ "key": "growing_heart",
+ "value": "💗"
+ },
+ {
+ "key": "sparkling_heart",
+ "value": "💖"
+ },
+ {
+ "key": "heart_with_arrow",
+ "value": "💘"
+ },
+ {
+ "key": "heart_with_ribbon",
+ "value": "💝"
+ },
+ {
+ "key": "heart_decoration",
+ "value": "💟"
+ },
+ {
+ "key": "peace_symbol",
+ "value": "☮"
+ },
+ {
+ "key": "latin_cross",
+ "value": "✝"
+ },
+ {
+ "key": "star_and_crescent",
+ "value": "☪"
+ },
+ {
+ "key": "om_symbol",
+ "value": "🕉"
+ },
+ {
+ "key": "wheel_of_dharma",
+ "value": "☸"
+ },
+ {
+ "key": "star_of_david",
+ "value": "✡"
+ },
+ {
+ "key": "six_pointed_star_with_middle_dot",
+ "value": "🔯"
+ },
+ {
+ "key": "menorah_with_nine_branches",
+ "value": "🕎"
+ },
+ {
+ "key": "yin_yang",
+ "value": "☯"
+ },
+ {
+ "key": "orthodox_cross",
+ "value": "☦"
+ },
+ {
+ "key": "place_of_worship",
+ "value": "🛐"
+ },
+ {
+ "key": "ophiuchus",
+ "value": "⛎"
+ },
+ {
+ "key": "aries",
+ "value": "♈"
+ },
+ {
+ "key": "taurus",
+ "value": "♉"
+ },
+ {
+ "key": "gemini",
+ "value": "♊"
+ },
+ {
+ "key": "cancer",
+ "value": "♋"
+ },
+ {
+ "key": "leo",
+ "value": "♌"
+ },
+ {
+ "key": "virgo",
+ "value": "♍"
+ },
+ {
+ "key": "libra",
+ "value": "♎"
+ },
+ {
+ "key": "scorpius",
+ "value": "♏"
+ },
+ {
+ "key": "sagittarius",
+ "value": "♐"
+ },
+ {
+ "key": "capricorn",
+ "value": "♑"
+ },
+ {
+ "key": "aquarius",
+ "value": "♒"
+ },
+ {
+ "key": "pisces",
+ "value": "♓"
+ },
+ {
+ "key": "squared_id",
+ "value": "🆔"
+ },
+ {
+ "key": "atom_symbol",
+ "value": "⚛"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_7a7a",
+ "value": "🈳"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_5272",
+ "value": "🈹"
+ },
+ {
+ "key": "radioactive_sign",
+ "value": "☢"
+ },
+ {
+ "key": "biohazard_sign",
+ "value": "☣"
+ },
+ {
+ "key": "mobile_phone_off",
+ "value": "📴"
+ },
+ {
+ "key": "vibration_mode",
+ "value": "📳"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_6709",
+ "value": "🈶"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_7121",
+ "value": "🈚"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_7533",
+ "value": "🈸"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_55b6",
+ "value": "🈺"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_6708",
+ "value": "🈷"
+ },
+ {
+ "key": "eight_pointed_black_star",
+ "value": "✴"
+ },
+ {
+ "key": "squared_vs",
+ "value": "🆚"
+ },
+ {
+ "key": "circled_ideograph_accept",
+ "value": "🉑"
+ },
+ {
+ "key": "white_flower",
+ "value": "💮"
+ },
+ {
+ "key": "circled_ideograph_advantage",
+ "value": "🉐"
+ },
+ {
+ "key": "circled_ideograph_secret",
+ "value": "㊙"
+ },
+ {
+ "key": "circled_ideograph_congratulation",
+ "value": "㊗"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_5408",
+ "value": "🈴"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_6e80",
+ "value": "🈵"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_7981",
+ "value": "🈲"
+ },
+ {
+ "key": "negative_squared_latin_capital_letter_a",
+ "value": "🅰"
+ },
+ {
+ "key": "negative_squared_latin_capital_letter_b",
+ "value": "🅱"
+ },
+ {
+ "key": "negative_squared_ab",
+ "value": "🆎"
+ },
+ {
+ "key": "squared_cl",
+ "value": "🆑"
+ },
+ {
+ "key": "negative_squared_latin_capital_letter_o",
+ "value": "🅾"
+ },
+ {
+ "key": "squared_sos",
+ "value": "🆘"
+ },
+ {
+ "key": "no_entry",
+ "value": "⛔"
+ },
+ {
+ "key": "name_badge",
+ "value": "📛"
+ },
+ {
+ "key": "no_entry_sign",
+ "value": "🚫"
+ },
+ {
+ "key": "cross_mark",
+ "value": "❌"
+ },
+ {
+ "key": "heavy_large_circle",
+ "value": "⭕"
+ },
+ {
+ "key": "anger_symbol",
+ "value": "💢"
+ },
+ {
+ "key": "hot_springs",
+ "value": "♨"
+ },
+ {
+ "key": "no_pedestrians",
+ "value": "🚷"
+ },
+ {
+ "key": "do_not_litter_symbol",
+ "value": "🚯"
+ },
+ {
+ "key": "no_bi_cycles",
+ "value": "🚳"
+ },
+ {
+ "key": "non_potable_water_symbol",
+ "value": "🚱"
+ },
+ {
+ "key": "no_one_under_eighteen_symbol",
+ "value": "🔞"
+ },
+ {
+ "key": "no_mobile_phones",
+ "value": "📵"
+ },
+ {
+ "key": "heavy_exclamation_mark_symbol",
+ "value": "❗"
+ },
+ {
+ "key": "white_exclamation_mark_ornament",
+ "value": "❕"
+ },
+ {
+ "key": "black_question_mark_ornament",
+ "value": "❓"
+ },
+ {
+ "key": "white_question_mark_ornament",
+ "value": "❔"
+ },
+ {
+ "key": "double_exclamation_mark",
+ "value": "‼"
+ },
+ {
+ "key": "exclamation_question_mark",
+ "value": "⁉"
+ },
+ {
+ "key": "hundred_points_symbol",
+ "value": "💯"
+ },
+ {
+ "key": "low_brightness_symbol",
+ "value": "🔅"
+ },
+ {
+ "key": "high_brightness_symbol",
+ "value": "🔆"
+ },
+ {
+ "key": "trident_emblem",
+ "value": "🔱"
+ },
+ {
+ "key": "fleur_de_lis",
+ "value": "⚜"
+ },
+ {
+ "key": "part_alternation_mark",
+ "value": "〽"
+ },
+ {
+ "key": "warning_sign",
+ "value": "⚠"
+ },
+ {
+ "key": "children_crossing",
+ "value": "🚸"
+ },
+ {
+ "key": "japanese_symbol_for_beginner",
+ "value": "🔰"
+ },
+ {
+ "key": "black_universal_recycling_symbol",
+ "value": "♻"
+ },
+ {
+ "key": "squared_cjk_unified_ideograph_6307",
+ "value": "🈯"
+ },
+ {
+ "key": "chart_with_upwards_trend_and_yen_sign",
+ "value": "💹"
+ },
+ {
+ "key": "sparkle",
+ "value": "❇"
+ },
+ {
+ "key": "eight_spoked_asterisk",
+ "value": "✳"
+ },
+ {
+ "key": "negative_squared_crossmark",
+ "value": "❎"
+ },
+ {
+ "key": "white_heavy_checkmark",
+ "value": "✅"
+ },
+ {
+ "key": "diamond_shape_with_a_dot_inside",
+ "value": "💠"
+ },
+ {
+ "key": "cyclone",
+ "value": "🌀"
+ },
+ {
+ "key": "double_curly_loop",
+ "value": "➿"
+ },
+ {
+ "key": "globe_with_meridians",
+ "value": "🌐"
+ },
+ {
+ "key": "circled_latin_capital_letter_m",
+ "value": "ⓜ"
+ },
+ {
+ "key": "automated_teller_machine",
+ "value": "🏧"
+ },
+ {
+ "key": "squared_katakanasa",
+ "value": "🈂"
+ },
+ {
+ "key": "passport_control",
+ "value": "🛂"
+ },
+ {
+ "key": "customs",
+ "value": "🛃"
+ },
+ {
+ "key": "baggage_claim",
+ "value": "🛄"
+ },
+ {
+ "key": "left_luggage",
+ "value": "🛅"
+ },
+ {
+ "key": "wheel_chair_symbol",
+ "value": "♿"
+ },
+ {
+ "key": "no_smoking_symbol",
+ "value": "🚭"
+ },
+ {
+ "key": "water_closet",
+ "value": "🚾"
+ },
+ {
+ "key": "negative_squared_letter_p",
+ "value": "🅿"
+ },
+ {
+ "key": "potable_water_symbol",
+ "value": "🚰"
+ },
+ {
+ "key": "mens_symbol",
+ "value": "🚹"
+ },
+ {
+ "key": "womens_symbol",
+ "value": "🚺"
+ },
+ {
+ "key": "baby_symbol",
+ "value": "🚼"
+ },
+ {
+ "key": "restroom",
+ "value": "🚻"
+ },
+ {
+ "key": "put_litter_in_its_place",
+ "value": "🚮"
+ },
+ {
+ "key": "cinema",
+ "value": "🎦"
+ },
+ {
+ "key": "antenna_with_bars",
+ "value": "📶"
+ },
+ {
+ "key": "squared_katakana_koko",
+ "value": "🈁"
+ },
+ {
+ "key": "squared_ng",
+ "value": "🆖"
+ },
+ {
+ "key": "squared_ok",
+ "value": "🆗"
+ },
+ {
+ "key": "squared_exclamation_mark",
+ "value": "🆙"
+ },
+ {
+ "key": "squared_cool",
+ "value": "🆒"
+ },
+ {
+ "key": "squared_new",
+ "value": "🆕"
+ },
+ {
+ "key": "squared_free",
+ "value": "🆓"
+ },
+ {
+ "key": "keycap_digit_zero",
+ "value": "0⃣"
+ },
+ {
+ "key": "keycap_digit_one",
+ "value": "1⃣"
+ },
+ {
+ "key": "keycap_digit_two",
+ "value": "2⃣"
+ },
+ {
+ "key": "keycap_digit_three",
+ "value": "3⃣"
+ },
+ {
+ "key": "keycap_digit_four",
+ "value": "4⃣"
+ },
+ {
+ "key": "keycap_digit_five",
+ "value": "5⃣"
+ },
+ {
+ "key": "keycap_digit_six",
+ "value": "6⃣"
+ },
+ {
+ "key": "keycap_digit_seven",
+ "value": "7⃣"
+ },
+ {
+ "key": "keycap_digit_eight",
+ "value": "8⃣"
+ },
+ {
+ "key": "keycap_digit_nine",
+ "value": "9⃣"
+ },
+ {
+ "key": "keycap_ten",
+ "value": "🔟"
+ },
+ {
+ "key": "input_symbol_for_numbers",
+ "value": "🔢"
+ },
+ {
+ "key": "black_right_pointing_triangle",
+ "value": "▶"
+ },
+ {
+ "key": "double_vertical_bar",
+ "value": "⏸"
+ },
+ {
+ "key": "blk_rgt_point_triangle_dbl_vertical_bar",
+ "value": "⏯"
+ },
+ {
+ "key": "black_square_for_stop",
+ "value": "⏹"
+ },
+ {
+ "key": "black_circle_for_record",
+ "value": "⏺"
+ },
+ {
+ "key": "blk_rgt_point_dbl_triangle_vertical_bar",
+ "value": "⏭"
+ },
+ {
+ "key": "blk_lft_point_dbl_triangle_vertical_bar",
+ "value": "⏮"
+ },
+ {
+ "key": "blk_rgt_point_dbl_triangle",
+ "value": "⏩"
+ },
+ {
+ "key": "blk_lft_point_dbl_triangle",
+ "value": "⏪"
+ },
+ {
+ "key": "twisted_rightwards_arrows",
+ "value": "🔀"
+ },
+ {
+ "key": "cwise_rgt_lft_open_circle_arrow",
+ "value": "🔁"
+ },
+ {
+ "key": "cwise_rgt_lft_open_circle_arrow_overlay",
+ "value": "🔂"
+ },
+ {
+ "key": "blk_lft_point_triangle",
+ "value": "◀"
+ },
+ {
+ "key": "up_point_small_red_triangle",
+ "value": "🔼"
+ },
+ {
+ "key": "down_point_small_red_triangle",
+ "value": "🔽"
+ },
+ {
+ "key": "blk_up_point_double_triangle",
+ "value": "⏫"
+ },
+ {
+ "key": "blk_down_point_double_triangle",
+ "value": "⏬"
+ },
+ {
+ "key": "black_rightwards_arrow",
+ "value": "➡"
+ },
+ {
+ "key": "leftwards_black_arrow",
+ "value": "⬅"
+ },
+ {
+ "key": "upwards_black_arrow",
+ "value": "⬆"
+ },
+ {
+ "key": "downwards_black_arrow",
+ "value": "⬇"
+ },
+ {
+ "key": "northeast_arrow",
+ "value": "↗"
+ },
+ {
+ "key": "southeast_arrow",
+ "value": "↘"
+ },
+ {
+ "key": "south_west_arrow",
+ "value": "↙"
+ },
+ {
+ "key": "north_west_arrow",
+ "value": "↖"
+ },
+ {
+ "key": "up_down_arrow",
+ "value": "↕"
+ },
+ {
+ "key": "left_right_arrow",
+ "value": "↔"
+ },
+ {
+ "key": "acwise_down_up_open_circle_arrow",
+ "value": "🔄"
+ },
+ {
+ "key": "rightwards_arrow_with_hook",
+ "value": "↪"
+ },
+ {
+ "key": "leftwards_arrow_with_hook",
+ "value": "↩"
+ },
+ {
+ "key": "arrow_point_rgt_then_curving_up",
+ "value": "⤴"
+ },
+ {
+ "key": "arrow_point_rgt_then_curving_down",
+ "value": "⤵"
+ },
+ {
+ "key": "keycap_number_sign",
+ "value": "#⃣"
+ },
+ {
+ "key": "keycap_asterisk",
+ "value": "*⃣"
+ },
+ {
+ "key": "information_source",
+ "value": "ℹ"
+ },
+ {
+ "key": "input_symbol_for_latin_letters",
+ "value": "🔤"
+ },
+ {
+ "key": "input_symbol_latin_small_letters",
+ "value": "🔡"
+ },
+ {
+ "key": "input_symbol_latin_capital_letters",
+ "value": "🔠"
+ },
+ {
+ "key": "input_symbol_symbols",
+ "value": "🔣"
+ },
+ {
+ "key": "musical_note",
+ "value": "🎵"
+ },
+ {
+ "key": "multiple_musical_notes",
+ "value": "🎶"
+ },
+ {
+ "key": "wavy_dash",
+ "value": "〰"
+ },
+ {
+ "key": "curly_loop",
+ "value": "➰"
+ },
+ {
+ "key": "heavy_check_mark",
+ "value": "✔"
+ },
+ {
+ "key": "cwise_down_up_open_circle_arrows",
+ "value": "🔃"
+ },
+ {
+ "key": "heavy_plus_sign",
+ "value": "➕"
+ },
+ {
+ "key": "heavy_minus_sign",
+ "value": "➖"
+ },
+ {
+ "key": "heavy_division_sign",
+ "value": "➗"
+ },
+ {
+ "key": "heavy_multiplication_x",
+ "value": "✖"
+ },
+ {
+ "key": "heavy_dollar_sign",
+ "value": "💲"
+ },
+ {
+ "key": "currency_exchange",
+ "value": "💱"
+ },
+ {
+ "key": "copyright_sign",
+ "value": "©"
+ },
+ {
+ "key": "registered_sign",
+ "value": "®"
+ },
+ {
+ "key": "trademark_sign",
+ "value": "™"
+ },
+ {
+ "key": "end_with_lft_arrow_above",
+ "value": "🔚"
+ },
+ {
+ "key": "back_with_lft_arrow_above",
+ "value": "🔙"
+ },
+ {
+ "key": "on_exclamation_lft_rgt_arrow",
+ "value": "🔛"
+ },
+ {
+ "key": "top_with_up_arrow_above",
+ "value": "🔝"
+ },
+ {
+ "key": "soon_right_arrow_above",
+ "value": "🔜"
+ },
+ {
+ "key": "ballot_box_with_check",
+ "value": "☑"
+ },
+ {
+ "key": "radio_button",
+ "value": "🔘"
+ },
+ {
+ "key": "medium_white_circle",
+ "value": "⚪"
+ },
+ {
+ "key": "medium_black_circle",
+ "value": "⚫"
+ },
+ {
+ "key": "large_red_circle",
+ "value": "🔴"
+ },
+ {
+ "key": "large_blue_circle",
+ "value": "🔵"
+ },
+ {
+ "key": "small_orange_diamond",
+ "value": "🔸"
+ },
+ {
+ "key": "small_blue_diamond",
+ "value": "🔹"
+ },
+ {
+ "key": "large_orange_diamond",
+ "value": "🔶"
+ },
+ {
+ "key": "large_blue_diamond",
+ "value": "🔷"
+ },
+ {
+ "key": "up_point_red_triangle",
+ "value": "🔺"
+ },
+ {
+ "key": "black_small_square",
+ "value": "▪"
+ },
+ {
+ "key": "white_small_square",
+ "value": "▫"
+ },
+ {
+ "key": "black_large_square",
+ "value": "⬛"
+ },
+ {
+ "key": "white_large_square",
+ "value": "⬜"
+ },
+ {
+ "key": "down_point_red_triangle",
+ "value": "🔻"
+ },
+ {
+ "key": "black_medium_square",
+ "value": "◼"
+ },
+ {
+ "key": "white_medium_square",
+ "value": "◻"
+ },
+ {
+ "key": "black_medium_small_square",
+ "value": "◾"
+ },
+ {
+ "key": "white_medium_small_square",
+ "value": "◽"
+ },
+ {
+ "key": "black_square_button",
+ "value": "🔲"
+ },
+ {
+ "key": "white_square_button",
+ "value": "🔳"
+ },
+ {
+ "key": "speaker",
+ "value": "🔈"
+ },
+ {
+ "key": "speaker_one_sound_wave",
+ "value": "🔉"
+ },
+ {
+ "key": "speaker_three_sound_waves",
+ "value": "🔊"
+ },
+ {
+ "key": "speaker_cancellation_stroke",
+ "value": "🔇"
+ },
+ {
+ "key": "cheering_megaphone",
+ "value": "📣"
+ },
+ {
+ "key": "public_address_loudspeaker",
+ "value": "📢"
+ },
+ {
+ "key": "bell",
+ "value": "🔔"
+ },
+ {
+ "key": "bell_with_cancellation_stroke",
+ "value": "🔕"
+ },
+ {
+ "key": "playing_card_black_joker",
+ "value": "🃏"
+ },
+ {
+ "key": "mahjong_tile_red_dragon",
+ "value": "🀄"
+ },
+ {
+ "key": "black_spade_suit",
+ "value": "♠"
+ },
+ {
+ "key": "black_club_suit",
+ "value": "♣"
+ },
+ {
+ "key": "black_heart_suit",
+ "value": "♥"
+ },
+ {
+ "key": "black_diamond_suit",
+ "value": "♦"
+ },
+ {
+ "key": "flower_playing_cards",
+ "value": "🎴"
+ },
+ {
+ "key": "eye_in_speech_bubble",
+ "value": "👁‍🗨"
+ },
+ {
+ "key": "thought_balloon",
+ "value": "💭"
+ },
+ {
+ "key": "right_anger_bubble",
+ "value": "🗯"
+ },
+ {
+ "key": "speech_balloon",
+ "value": "💬"
+ },
+ {
+ "key": "clock_face_one_o_clock",
+ "value": "🕐"
+ },
+ {
+ "key": "clock_face_two_o_clock",
+ "value": "🕑"
+ },
+ {
+ "key": "clock_face_three_o_clock",
+ "value": "🕒"
+ },
+ {
+ "key": "clock_face_four_o_clock",
+ "value": "🕓"
+ },
+ {
+ "key": "clock_face_five_o_clock",
+ "value": "🕔"
+ },
+ {
+ "key": "clock_face_six_o_clock",
+ "value": "🕕"
+ },
+ {
+ "key": "clock_face_seven_o_clock",
+ "value": "🕖"
+ },
+ {
+ "key": "clock_face_eight_o_clock",
+ "value": "🕗"
+ },
+ {
+ "key": "clock_face_nine_o_clock",
+ "value": "🕘"
+ },
+ {
+ "key": "clock_face_ten_o_clock",
+ "value": "🕙"
+ },
+ {
+ "key": "clock_face_eleven_o_clock",
+ "value": "🕚"
+ },
+ {
+ "key": "clock_face_twelve_o_clock",
+ "value": "🕛"
+ },
+ {
+ "key": "clock_face_one_thirty",
+ "value": "🕜"
+ },
+ {
+ "key": "clock_face_two_thirty",
+ "value": "🕝"
+ },
+ {
+ "key": "clock_face_three_thirty",
+ "value": "🕞"
+ },
+ {
+ "key": "clock_face_four_thirty",
+ "value": "🕟"
+ },
+ {
+ "key": "clock_face_five_thirty",
+ "value": "🕠"
+ },
+ {
+ "key": "clock_face_six_thirty",
+ "value": "🕡"
+ },
+ {
+ "key": "clock_face_seven_thirty",
+ "value": "🕢"
+ },
+ {
+ "key": "clock_face_eight_thirty",
+ "value": "🕣"
+ },
+ {
+ "key": "clock_face_nine_thirty",
+ "value": "🕤"
+ },
+ {
+ "key": "clock_face_ten_thirty",
+ "value": "🕥"
+ },
+ {
+ "key": "clock_face_eleven_thirty",
+ "value": "🕦"
+ },
+ {
+ "key": "clock_face_twelve_thirty",
+ "value": "🕧"
+ }
+ ]
+ }
+} \ No newline at end of file
diff --git a/nikola/plugins/shortcode/emoji/data/Travel.json b/nikola/plugins/shortcode/emoji/data/Travel.json
new file mode 100644
index 0000000..e38b84f
--- /dev/null
+++ b/nikola/plugins/shortcode/emoji/data/Travel.json
@@ -0,0 +1,466 @@
+{
+ "travels": {
+ "travel": [
+ {
+ "key": "automobile",
+ "value": "🚗"
+ },
+ {
+ "key": "taxi",
+ "value": "🚕"
+ },
+ {
+ "key": "recreational_vehicle",
+ "value": "🚙"
+ },
+ {
+ "key": "bus",
+ "value": "🚌"
+ },
+ {
+ "key": "trolley_bus",
+ "value": "🚎"
+ },
+ {
+ "key": "racing_car",
+ "value": "🏎"
+ },
+ {
+ "key": "police_car",
+ "value": "🚓"
+ },
+ {
+ "key": "ambulance",
+ "value": "🚑"
+ },
+ {
+ "key": "fire_engine",
+ "value": "🚒"
+ },
+ {
+ "key": "minibus",
+ "value": "🚐"
+ },
+ {
+ "key": "delivery_truck",
+ "value": "🚚"
+ },
+ {
+ "key": "articulated_lorry",
+ "value": "🚛"
+ },
+ {
+ "key": "tractor",
+ "value": "🚜"
+ },
+ {
+ "key": "racing_motorcycle",
+ "value": "🏍"
+ },
+ {
+ "key": "bicycle",
+ "value": "🚲"
+ },
+ {
+ "key": "police_light",
+ "value": "🚨"
+ },
+ {
+ "key": "on_coming_police_car",
+ "value": "🚔"
+ },
+ {
+ "key": "on_coming_bus",
+ "value": "🚍"
+ },
+ {
+ "key": "on_coming_automobile",
+ "value": "🚘"
+ },
+ {
+ "key": "on_coming_taxi",
+ "value": "🚖"
+ },
+ {
+ "key": "aerial_tramway",
+ "value": "🚡"
+ },
+ {
+ "key": "mountain_cableway",
+ "value": "🚠"
+ },
+ {
+ "key": "suspension_railway",
+ "value": "🚟"
+ },
+ {
+ "key": "railway_car",
+ "value": "🚃"
+ },
+ {
+ "key": "tramcar",
+ "value": "🚋"
+ },
+ {
+ "key": "monorail",
+ "value": "🚝"
+ },
+ {
+ "key": "high_speed_train",
+ "value": "🚄"
+ },
+ {
+ "key": "high_speed_train_bullet_nose",
+ "value": "🚅"
+ },
+ {
+ "key": "light_rail",
+ "value": "🚈"
+ },
+ {
+ "key": "mountain_railway",
+ "value": "🚞"
+ },
+ {
+ "key": "steam_locomotive",
+ "value": "🚂"
+ },
+ {
+ "key": "train",
+ "value": "🚆"
+ },
+ {
+ "key": "metro",
+ "value": "🚇"
+ },
+ {
+ "key": "tram",
+ "value": "🚊"
+ },
+ {
+ "key": "station",
+ "value": "🚉"
+ },
+ {
+ "key": "helicopter",
+ "value": "🚁"
+ },
+ {
+ "key": "small_airplane",
+ "value": "🛩"
+ },
+ {
+ "key": "airplane",
+ "value": "✈"
+ },
+ {
+ "key": "airplane_departure",
+ "value": "🛫"
+ },
+ {
+ "key": "airplane_arriving",
+ "value": "🛬"
+ },
+ {
+ "key": "sailboat",
+ "value": "⛵"
+ },
+ {
+ "key": "motorboat",
+ "value": "🛥"
+ },
+ {
+ "key": "speedboat",
+ "value": "🚤"
+ },
+ {
+ "key": "ferry",
+ "value": "⛴"
+ },
+ {
+ "key": "passenger_ship",
+ "value": "🛳"
+ },
+ {
+ "key": "rocket",
+ "value": "🚀"
+ },
+ {
+ "key": "satellite",
+ "value": "🛰"
+ },
+ {
+ "key": "seat",
+ "value": "💺"
+ },
+ {
+ "key": "anchor",
+ "value": "⚓"
+ },
+ {
+ "key": "construction_sign",
+ "value": "🚧"
+ },
+ {
+ "key": "fuel_pump",
+ "value": "⛽"
+ },
+ {
+ "key": "bus_stop",
+ "value": "🚏"
+ },
+ {
+ "key": "vertical_traffic_light",
+ "value": "🚦"
+ },
+ {
+ "key": "horizontal_traffic_light",
+ "value": "🚥"
+ },
+ {
+ "key": "chequered_flag",
+ "value": "🏁"
+ },
+ {
+ "key": "ship",
+ "value": "🚢"
+ },
+ {
+ "key": "ferris_wheel",
+ "value": "🎡"
+ },
+ {
+ "key": "roller_coaster",
+ "value": "🎢"
+ },
+ {
+ "key": "carousel_horse",
+ "value": "🎠"
+ },
+ {
+ "key": "building_construction",
+ "value": "🏗"
+ },
+ {
+ "key": "foggy",
+ "value": "🌁"
+ },
+ {
+ "key": "tokyo_tower",
+ "value": "🗼"
+ },
+ {
+ "key": "factory",
+ "value": "🏭"
+ },
+ {
+ "key": "fountain",
+ "value": "⛲"
+ },
+ {
+ "key": "moon_viewing_ceremony",
+ "value": "🎑"
+ },
+ {
+ "key": "mountain",
+ "value": "⛰"
+ },
+ {
+ "key": "snow_capped_mountain",
+ "value": "🏔"
+ },
+ {
+ "key": "mount_fuji",
+ "value": "🗻"
+ },
+ {
+ "key": "volcano",
+ "value": "🌋"
+ },
+ {
+ "key": "silhouette_of_japan",
+ "value": "🗾"
+ },
+ {
+ "key": "camping",
+ "value": "🏕"
+ },
+ {
+ "key": "tent",
+ "value": "⛺"
+ },
+ {
+ "key": "national_park",
+ "value": "🏞"
+ },
+ {
+ "key": "motorway",
+ "value": "🛣"
+ },
+ {
+ "key": "railway_track",
+ "value": "🛤"
+ },
+ {
+ "key": "sunrise",
+ "value": "🌅"
+ },
+ {
+ "key": "sunrise_over_mountain",
+ "value": "🌄"
+ },
+ {
+ "key": "desert",
+ "value": "🏜"
+ },
+ {
+ "key": "beach_with_umbrella",
+ "value": "🏖"
+ },
+ {
+ "key": "desert_island",
+ "value": "🏝"
+ },
+ {
+ "key": "sunset_over_buildings",
+ "value": "🌇"
+ },
+ {
+ "key": "city_scape_at_dusk",
+ "value": "🌆"
+ },
+ {
+ "key": "city_scape",
+ "value": "🏙"
+ },
+ {
+ "key": "night_with_stars",
+ "value": "🌃"
+ },
+ {
+ "key": "bridge_at_night",
+ "value": "🌉"
+ },
+ {
+ "key": "milky_way",
+ "value": "🌌"
+ },
+ {
+ "key": "shooting_star",
+ "value": "🌠"
+ },
+ {
+ "key": "fire_work_sparkler",
+ "value": "🎇"
+ },
+ {
+ "key": "fireworks",
+ "value": "🎆"
+ },
+ {
+ "key": "rainbow",
+ "value": "🌈"
+ },
+ {
+ "key": "house_buildings",
+ "value": "🏘"
+ },
+ {
+ "key": "european_castle",
+ "value": "🏰"
+ },
+ {
+ "key": "japanese_castle",
+ "value": "🏯"
+ },
+ {
+ "key": "stadium",
+ "value": "🏟"
+ },
+ {
+ "key": "statue_of_liberty",
+ "value": "🗽"
+ },
+ {
+ "key": "house_building",
+ "value": "🏠"
+ },
+ {
+ "key": "house_with_garden",
+ "value": "🏡"
+ },
+ {
+ "key": "derelict_house_building",
+ "value": "🏚"
+ },
+ {
+ "key": "office_building",
+ "value": "🏢"
+ },
+ {
+ "key": "department_store",
+ "value": "🏬"
+ },
+ {
+ "key": "japanese_post_office",
+ "value": "🏣"
+ },
+ {
+ "key": "european_post_office",
+ "value": "🏤"
+ },
+ {
+ "key": "hospital",
+ "value": "🏥"
+ },
+ {
+ "key": "bank",
+ "value": "🏦"
+ },
+ {
+ "key": "hotel",
+ "value": "🏨"
+ },
+ {
+ "key": "convenience_store",
+ "value": "🏪"
+ },
+ {
+ "key": "school",
+ "value": "🏫"
+ },
+ {
+ "key": "love_hotel",
+ "value": "🏩"
+ },
+ {
+ "key": "wedding",
+ "value": "💒"
+ },
+ {
+ "key": "classical_building",
+ "value": "🏛"
+ },
+ {
+ "key": "church",
+ "value": "⛪"
+ },
+ {
+ "key": "mosque",
+ "value": "🕌"
+ },
+ {
+ "key": "synagogue",
+ "value": "🕍"
+ },
+ {
+ "key": "kaaba",
+ "value": "🕋"
+ },
+ {
+ "key": "shinto_shrine",
+ "value": "⛩"
+ }
+ ]
+ }
+}
diff --git a/nikola/plugins/shortcode/gist.plugin b/nikola/plugins/shortcode/gist.plugin
new file mode 100644
index 0000000..b610763
--- /dev/null
+++ b/nikola/plugins/shortcode/gist.plugin
@@ -0,0 +1,13 @@
+[Core]
+name = gist
+module = gist
+
+[Nikola]
+PluginCategory = Shortcode
+
+[Documentation]
+author = Roberto Alsina
+version = 0.1
+website = https://getnikola.com/
+description = Gist shortcode
+
diff --git a/nikola/plugins/shortcode/gist.py b/nikola/plugins/shortcode/gist.py
new file mode 100644
index 0000000..eb9e976
--- /dev/null
+++ b/nikola/plugins/shortcode/gist.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# This file is public domain according to its author, Brian Hsu
+
+"""Gist directive for reStructuredText."""
+
+import requests
+
+from nikola.plugin_categories import ShortcodePlugin
+
+
+class Plugin(ShortcodePlugin):
+ """Plugin for gist directive."""
+
+ name = "gist"
+
+ def get_raw_gist_with_filename(self, gistID, filename):
+ """Get raw gist text for a filename."""
+ url = '/'.join(("https://gist.github.com/raw", gistID, filename))
+ return requests.get(url).text
+
+ def get_raw_gist(self, gistID):
+ """Get raw gist text."""
+ url = "https://gist.github.com/raw/{0}".format(gistID)
+ try:
+ return requests.get(url).text
+ except requests.exceptions.RequestException:
+ raise self.error('Cannot get gist for url={0}'.format(url))
+
+ def handler(self, gistID, filename=None, site=None, data=None, lang=None, post=None):
+ """Create HTML for gist."""
+ if 'https://' in gistID:
+ gistID = gistID.split('/')[-1].strip()
+ else:
+ gistID = gistID.strip()
+ embedHTML = ""
+ rawGist = ""
+
+ if filename is not None:
+ rawGist = (self.get_raw_gist_with_filename(gistID, filename))
+ embedHTML = ('<script src="https://gist.github.com/{0}.js'
+ '?file={1}"></script>').format(gistID, filename)
+ else:
+ rawGist = (self.get_raw_gist(gistID))
+ embedHTML = ('<script src="https://gist.github.com/{0}.js">'
+ '</script>').format(gistID)
+
+ output = '''{}
+ <noscript><pre>{}</pre></noscript>'''.format(embedHTML, rawGist)
+
+ return output, []
diff --git a/nikola/plugins/shortcode/listing.plugin b/nikola/plugins/shortcode/listing.plugin
new file mode 100644
index 0000000..90fb6eb
--- /dev/null
+++ b/nikola/plugins/shortcode/listing.plugin
@@ -0,0 +1,13 @@
+[Core]
+name = listing_shortcode
+module = listing
+
+[Nikola]
+PluginCategory = Shortcode
+
+[Documentation]
+author = Roberto Alsina
+version = 0.1
+website = https://getnikola.com/
+description = Listing shortcode
+
diff --git a/nikola/plugins/shortcode/listing.py b/nikola/plugins/shortcode/listing.py
new file mode 100644
index 0000000..b51365a
--- /dev/null
+++ b/nikola/plugins/shortcode/listing.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © 2017-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.
+
+"""Listing shortcode (equivalent to reST’s listing directive)."""
+
+import os
+from urllib.parse import urlunsplit
+
+import pygments
+
+from nikola.plugin_categories import ShortcodePlugin
+
+
+class Plugin(ShortcodePlugin):
+ """Plugin for listing shortcode."""
+
+ name = "listing"
+
+ def set_site(self, site):
+ """Set Nikola site."""
+ self.site = site
+ Plugin.folders = site.config['LISTINGS_FOLDERS']
+ return super().set_site(site)
+
+ def handler(self, fname, language='text', linenumbers=False, filename=None, site=None, data=None, lang=None, post=None):
+ """Create HTML for a listing."""
+ fname = fname.replace('/', os.sep)
+ if len(self.folders) == 1:
+ listings_folder = next(iter(self.folders.keys()))
+ if fname.startswith(listings_folder):
+ fpath = os.path.join(fname) # new syntax: specify folder name
+ else:
+ # old syntax: don't specify folder name
+ fpath = os.path.join(listings_folder, fname)
+ else:
+ # must be new syntax: specify folder name
+ fpath = os.path.join(fname)
+ linenumbers = 'table' if linenumbers else False
+ deps = [fpath]
+ with open(fpath, 'r') as inf:
+ target = urlunsplit(
+ ("link", 'listing', fpath.replace('\\', '/'), '', ''))
+ src_target = urlunsplit(
+ ("link", 'listing_source', fpath.replace('\\', '/'), '', ''))
+ src_label = self.site.MESSAGES('Source')
+
+ data = inf.read()
+ lexer = pygments.lexers.get_lexer_by_name(language)
+ formatter = pygments.formatters.get_formatter_by_name(
+ 'html', linenos=linenumbers)
+ output = '<a href="{1}">{0}</a> <a href="{3}">({2})</a>' .format(
+ fname, target, src_label, src_target) + pygments.highlight(data, lexer, formatter)
+
+ return output, deps
diff --git a/nikola/plugins/shortcode/post_list.plugin b/nikola/plugins/shortcode/post_list.plugin
new file mode 100644
index 0000000..494a1d8
--- /dev/null
+++ b/nikola/plugins/shortcode/post_list.plugin
@@ -0,0 +1,13 @@
+[Core]
+name = post_list
+module = post_list
+
+[Nikola]
+PluginCategory = Shortcode
+
+[Documentation]
+author = Udo Spallek
+version = 0.2
+website = https://getnikola.com/
+description = Includes a list of posts with tag and slice based filters.
+
diff --git a/nikola/plugins/shortcode/post_list.py b/nikola/plugins/shortcode/post_list.py
new file mode 100644
index 0000000..462984a
--- /dev/null
+++ b/nikola/plugins/shortcode/post_list.py
@@ -0,0 +1,245 @@
+# -*- coding: utf-8 -*-
+
+# 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
+# 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.
+
+"""Post list shortcode."""
+
+
+import operator
+import os
+import uuid
+
+import natsort
+
+from nikola import utils
+from nikola.packages.datecond import date_in_range
+from nikola.plugin_categories import ShortcodePlugin
+
+
+class PostListShortcode(ShortcodePlugin):
+ """Provide a shortcode to create a list of posts.
+
+ Post List
+ =========
+ :Directive Arguments: None.
+ :Directive Options: lang, start, stop, reverse, sort, date, tags, categories, sections, slugs, post_type, 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).
+
+ ``date`` : string
+ Show posts that match date range specified by this option. Format:
+
+ * comma-separated clauses (AND)
+ * clause: attribute comparison_operator value (spaces optional)
+ * attribute: year, month, day, hour, month, second, weekday, isoweekday; or empty for full datetime
+ * comparison_operator: == != <= >= < >
+ * value: integer, 'now', 'today', or dateutil-compatible date input
+
+ ``tags`` : string [, string...]
+ Filter posts to show only posts having at least one of the ``tags``.
+ Defaults to None.
+
+ ``require_all_tags`` : flag
+ Change tag filter behaviour to show only posts that have all specified ``tags``.
+ Defaults to False.
+
+ ``categories`` : string [, string...]
+ Filter posts to show only posts having one of the ``categories``.
+ Defaults to None.
+
+ ``sections`` : string [, string...]
+ Filter posts to show only posts having one of the ``sections``.
+ Defaults to None.
+
+ ``slugs`` : string [, string...]
+ Filter posts to show only posts having at least one of the ``slugs``.
+ Defaults to None.
+
+ ``post_type`` (or ``type``) : string
+ Show only ``posts``, ``pages`` or ``all``.
+ Replaces ``all``. Defaults to ``posts``.
+
+ ``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.
+ """
+
+ name = "post_list"
+
+ def set_site(self, site):
+ """Set the site."""
+ super().set_site(site)
+ site.register_shortcode('post-list', self.handler)
+
+ def handler(self, start=None, stop=None, reverse=False, tags=None, require_all_tags=False, categories=None,
+ sections=None, slugs=None, post_type='post', type=False,
+ lang=None, template='post_list_directive.tmpl', sort=None,
+ id=None, data=None, state=None, site=None, date=None, filename=None, post=None):
+ """Generate HTML for post-list."""
+ if lang is None:
+ lang = utils.LocaleBorg().current_lang
+ if site.invariant: # for testing purposes
+ post_list_id = id or 'post_list_' + 'fixedvaluethatisnotauuid'
+ else:
+ post_list_id = id or 'post_list_' + uuid.uuid4().hex
+
+ # Get post from filename if available
+ if filename:
+ self_post = site.post_per_input_file.get(filename)
+ else:
+ self_post = None
+
+ if self_post:
+ self_post.register_depfile("####MAGIC####TIMELINE", lang=lang)
+
+ # If we get strings for start/stop, make them integers
+ if start is not None:
+ start = int(start)
+ if stop is not None:
+ stop = int(stop)
+
+ # Parse tags/categories/sections/slugs (input is strings)
+ categories = [c.strip().lower() for c in categories.split(',')] if categories else []
+ sections = [s.strip().lower() for s in sections.split(',')] if sections else []
+ slugs = [s.strip() for s in slugs.split(',')] if slugs else []
+
+ filtered_timeline = []
+ posts = []
+ step = None if reverse is False else -1
+
+ if type is not False:
+ post_type = type
+
+ if post_type == 'page' or post_type == 'pages':
+ timeline = [p for p in site.timeline if not p.use_in_feeds]
+ elif post_type == 'all':
+ timeline = [p for p in site.timeline]
+ else: # post
+ timeline = [p for p in site.timeline if p.use_in_feeds]
+
+ # self_post should be removed from timeline because this is redundant
+ timeline = [p for p in timeline if p.source_path != filename]
+
+ if categories:
+ timeline = [p for p in timeline if p.meta('category', lang=lang).lower() in categories]
+
+ if sections:
+ timeline = [p for p in timeline if p.section_name(lang).lower() in sections]
+
+ if tags:
+ tags = {t.strip().lower() for t in tags.split(',')}
+ if require_all_tags:
+ compare = set.issubset
+ else:
+ compare = operator.and_
+ for post in timeline:
+ post_tags = {t.lower() for t in post.tags}
+ if compare(tags, post_tags):
+ filtered_timeline.append(post)
+ else:
+ filtered_timeline = timeline
+
+ if sort:
+ filtered_timeline = natsort.natsorted(filtered_timeline, key=lambda post: post.meta[lang][sort], alg=natsort.ns.F | natsort.ns.IC)
+
+ if date:
+ _now = utils.current_time()
+ filtered_timeline = [p for p in filtered_timeline if date_in_range(utils.html_unescape(date), p.date, now=_now)]
+
+ 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) and state:
+ state.document.settings.record_dependencies.add(bp)
+ elif os.path.exists(bp) and self_post:
+ self_post.register_depfile(bp, lang=lang)
+
+ posts += [post]
+
+ template_deps = site.template_system.template_deps(template)
+ if state:
+ # Register template as a dependency (Issue #2391)
+ for d in template_deps:
+ state.document.settings.record_dependencies.add(d)
+ elif self_post:
+ for d in template_deps:
+ self_post.register_depfile(d, lang=lang)
+
+ template_data = {
+ 'lang': lang,
+ 'posts': posts,
+ # Need to provide str, not TranslatableSetting (Issue #2104)
+ 'date_format': site.GLOBAL_CONTEXT.get('date_format')[lang],
+ 'post_list_id': post_list_id,
+ 'messages': site.MESSAGES,
+ '_link': site.link,
+ }
+ output = site.template_system.render_template(
+ template, None, template_data)
+ return output, template_deps
+
+
+# Request file name from shortcode (Issue #2412)
+PostListShortcode.handler.nikola_shortcode_pass_filename = True
diff --git a/nikola/plugins/shortcode/thumbnail.plugin b/nikola/plugins/shortcode/thumbnail.plugin
new file mode 100644
index 0000000..e55d34f
--- /dev/null
+++ b/nikola/plugins/shortcode/thumbnail.plugin
@@ -0,0 +1,12 @@
+[Core]
+name = thumbnail
+module = thumbnail
+
+[Nikola]
+PluginCategory = Shortcode
+
+[Documentation]
+author = Chris Warrick
+version = 0.1
+website = https://getnikola.com/
+description = Thumbnail shortcode
diff --git a/nikola/plugins/shortcode/thumbnail.py b/nikola/plugins/shortcode/thumbnail.py
new file mode 100644
index 0000000..feb731b
--- /dev/null
+++ b/nikola/plugins/shortcode/thumbnail.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © 2017-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.
+
+"""Thumbnail shortcode (equivalent to reST’s thumbnail directive)."""
+
+import os.path
+
+from nikola.plugin_categories import ShortcodePlugin
+
+
+class ThumbnailShortcode(ShortcodePlugin):
+ """Plugin for thumbnail directive."""
+
+ name = "thumbnail"
+
+ def handler(self, uri, alt=None, align=None, linktitle=None, title=None, imgclass=None, figclass=None, site=None, data=None, lang=None, post=None):
+ """Create HTML for thumbnail."""
+ if uri.endswith('.svg'):
+ # the ? at the end makes docutil output an <img> instead of an object for the svg, which lightboxes may require
+ src = '.thumbnail'.join(os.path.splitext(uri)) + '?'
+ else:
+ src = '.thumbnail'.join(os.path.splitext(uri))
+
+ if imgclass is None:
+ imgclass = ''
+ if figclass is None:
+ figclass = ''
+
+ if align and data:
+ figclass += ' align-{0}'.format(align)
+ elif align:
+ imgclass += ' align-{0}'.format(align)
+
+ output = '<a href="{0}" class="image-reference"'.format(uri)
+ if linktitle:
+ output += ' title="{0}"'.format(linktitle)
+ output += '><img src="{0}"'.format(src)
+ for item, name in ((alt, 'alt'), (title, 'title'), (imgclass, 'class')):
+ if item:
+ output += ' {0}="{1}"'.format(name, item)
+ output += '></a>'
+
+ if data:
+ output = '<div class="figure {0}">{1}{2}</div>'.format(figclass, output, data)
+
+ return output, []
diff --git a/nikola/plugins/task/__init__.py b/nikola/plugins/task/__init__.py
index fd9a48f..3e18cd5 100644
--- a/nikola/plugins/task/__init__.py
+++ b/nikola/plugins/task/__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/task/archive.plugin b/nikola/plugins/task/archive.plugin
index 25f1195..62e5fd9 100644
--- a/nikola/plugins/task/archive.plugin
+++ b/nikola/plugins/task/archive.plugin
@@ -1,13 +1,13 @@
[Core]
-name = render_archive
+name = classify_archive
module = archive
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Generates the blog's archive pages.
[Nikola]
-plugincategory = Task
+PluginCategory = Taxonomy
diff --git a/nikola/plugins/task/archive.py b/nikola/plugins/task/archive.py
index 126aed4..4cbf215 100644
--- a/nikola/plugins/task/archive.py
+++ b/nikola/plugins/task/archive.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,222 +24,216 @@
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""Render the post archives."""
+"""Classify the posts in archives."""
-import copy
-import os
-
-# for tearDown with _reload we cannot use 'import from' to access LocaleBorg
-import nikola.utils
import datetime
-from nikola.plugin_categories import Task
-from nikola.utils import config_changed, adjust_name_for_index_path, adjust_name_for_index_link
-
+from collections import defaultdict
-class Archive(Task):
+import natsort
- """Render the post archives."""
-
- name = "render_archive"
+import nikola.utils
+from nikola.plugin_categories import Taxonomy
+
+
+class Archive(Taxonomy):
+ """Classify the post archives."""
+
+ name = "classify_archive"
+
+ classification_name = "archive"
+ overview_page_variable_name = "archive"
+ more_than_one_classifications_per_post = False
+ has_hierarchy = True
+ include_posts_from_subhierarchies = True
+ include_posts_into_hierarchy_root = True
+ subcategories_list_template = "list.tmpl"
+ template_for_classification_overview = None
+ always_disable_rss = True
+ always_disable_atom = True
+ apply_to_posts = True
+ apply_to_pages = False
+ minimum_post_count_per_classification_in_overview = 1
+ omit_empty_classifications = False
+ add_other_languages_variable = True
+ path_handler_docstrings = {
+ 'archive_index': False,
+ 'archive': """Link to archive path, name is the year.
+
+ Example:
+
+ link://archive/2013 => /archives/2013/index.html""",
+ 'archive_atom': False,
+ 'archive_rss': False,
+ }
def set_site(self, site):
"""Set Nikola site."""
- site.register_path_handler('archive', self.archive_path)
- site.register_path_handler('archive_atom', self.archive_atom_path)
- return super(Archive, self).set_site(site)
-
- def _prepare_task(self, kw, name, lang, posts, items, template_name,
- title, deps_translatable=None):
- """Prepare an archive task."""
- # name: used to build permalink and destination
- # posts, items: posts or items; only one of them should be used,
- # the other be None
- # template_name: name of the template to use
- # title: the (translated) title for the generated page
- # deps_translatable: dependencies (None if not added)
- assert posts is not None or items is not None
- task_cfg = [copy.copy(kw)]
- context = {}
- context["lang"] = lang
- context["title"] = title
- context["permalink"] = self.site.link("archive", name, lang)
- context["pagekind"] = ["list", "archive_page"]
- if posts is not None:
- context["posts"] = posts
- # Depend on all post metadata because it can be used in templates (Issue #1931)
- task_cfg.append([repr(p) for p in posts])
+ # Sanity checks
+ if (site.config['CREATE_MONTHLY_ARCHIVE'] and site.config['CREATE_SINGLE_ARCHIVE']) and not site.config['CREATE_FULL_ARCHIVES']:
+ raise Exception('Cannot create monthly and single archives at the same time.')
+ # Finish setup
+ self.show_list_as_subcategories_list = not site.config['CREATE_FULL_ARCHIVES']
+ self.show_list_as_index = site.config['ARCHIVES_ARE_INDEXES']
+ self.template_for_single_list = "archiveindex.tmpl" if site.config['ARCHIVES_ARE_INDEXES'] else "archive.tmpl"
+ # Determine maximum hierarchy height
+ if site.config['CREATE_DAILY_ARCHIVE'] or site.config['CREATE_FULL_ARCHIVES']:
+ self.max_levels = 3
+ elif site.config['CREATE_MONTHLY_ARCHIVE']:
+ self.max_levels = 2
+ elif site.config['CREATE_SINGLE_ARCHIVE']:
+ self.max_levels = 0
+ else:
+ self.max_levels = 1
+ return super().set_site(site)
+
+ def get_implicit_classifications(self, lang):
+ """Return a list of classification strings which should always appear in posts_per_classification."""
+ return ['']
+
+ def classify(self, post, lang):
+ """Classify the given post for the given language."""
+ levels = [str(post.date.year).zfill(4), str(post.date.month).zfill(2), str(post.date.day).zfill(2)]
+ return ['/'.join(levels[:self.max_levels])]
+
+ def sort_classifications(self, classifications, lang, level=None):
+ """Sort the given list of classification strings."""
+ if level in (0, 1):
+ # Years or months: sort descending
+ classifications.sort()
+ classifications.reverse()
+
+ def get_classification_friendly_name(self, classification, lang, only_last_component=False):
+ """Extract a friendly name from the classification."""
+ classification = self.extract_hierarchy(classification)
+ if len(classification) == 0:
+ return self.site.MESSAGES[lang]['Archive']
+ elif len(classification) == 1:
+ return classification[0]
+ elif len(classification) == 2:
+ if only_last_component:
+ date_str = "{month}"
+ else:
+ date_str = "{month_year}"
+ return nikola.utils.LocaleBorg().format_date_in_string(
+ date_str,
+ datetime.date(int(classification[0]), int(classification[1]), 1),
+ lang)
else:
- # Depend on the content of items, to rebuild if links change (Issue #1931)
- context["items"] = items
- task_cfg.append(items)
- task = self.site.generic_post_list_renderer(
- lang,
- [],
- os.path.join(kw['output_folder'], self.site.path("archive", name, lang)),
- template_name,
- kw['filters'],
- context,
- )
-
- task_cfg = {i: x for i, x in enumerate(task_cfg)}
- if deps_translatable is not None:
- task_cfg[3] = deps_translatable
- task['uptodate'] = task['uptodate'] + [config_changed(task_cfg, 'nikola.plugins.task.archive')]
- task['basename'] = self.name
- return task
-
- def _generate_posts_task(self, kw, name, lang, posts, title, deps_translatable=None):
- """Genereate a task for an archive with posts."""
- posts = sorted(posts, key=lambda a: a.date)
- posts.reverse()
- if kw['archives_are_indexes']:
- def page_link(i, displayed_i, num_pages, force_addition, extension=None):
- feed = "_atom" if extension == ".atom" else ""
- return adjust_name_for_index_link(self.site.link("archive" + feed, name, lang), i, displayed_i,
- lang, self.site, force_addition, extension)
-
- def page_path(i, displayed_i, num_pages, force_addition, extension=None):
- feed = "_atom" if extension == ".atom" else ""
- return adjust_name_for_index_path(self.site.path("archive" + feed, name, lang), i, displayed_i,
- lang, self.site, force_addition, extension)
-
- uptodate = []
- if deps_translatable is not None:
- uptodate += [config_changed(deps_translatable, 'nikola.plugins.task.archive')]
- context = {"archive_name": name,
- "is_feed_stale": kw["is_feed_stale"],
- "pagekind": ["index", "archive_page"]}
- yield self.site.generic_index_renderer(
- lang,
- posts,
- title,
- "archiveindex.tmpl",
- context,
- kw,
- str(self.name),
- page_link,
- page_path,
- uptodate)
+ if only_last_component:
+ return str(classification[2])
+ return nikola.utils.LocaleBorg().format_date_in_string(
+ "{month_day_year}",
+ datetime.date(int(classification[0]), int(classification[1]), int(classification[2])),
+ lang)
+
+ def get_path(self, classification, lang, dest_type='page'):
+ """Return a path for the given classification."""
+ components = [self.site.config['ARCHIVE_PATH'](lang)]
+ if classification:
+ components.extend(classification)
+ add_index = 'always'
else:
- yield self._prepare_task(kw, name, lang, posts, None, "list_post.tmpl", title, deps_translatable)
+ components.append(self.site.config['ARCHIVE_FILENAME'](lang))
+ add_index = 'never'
+ return [_f for _f in components if _f], add_index
+
+ def extract_hierarchy(self, classification):
+ """Given a classification, return a list of parts in the hierarchy."""
+ return classification.split('/') if classification else []
- def gen_tasks(self):
- """Generate archive tasks."""
+ def recombine_classification_from_hierarchy(self, hierarchy):
+ """Given a list of parts in the hierarchy, return the classification string."""
+ return '/'.join(hierarchy)
+
+ def provide_context_and_uptodate(self, classification, lang, node=None):
+ """Provide data for the context and the uptodate list for the list of the given classifiation."""
+ hierarchy = self.extract_hierarchy(classification)
kw = {
"messages": self.site.MESSAGES,
- "translations": self.site.config['TRANSLATIONS'],
- "output_folder": self.site.config['OUTPUT_FOLDER'],
- "filters": self.site.config['FILTERS'],
- "archives_are_indexes": self.site.config['ARCHIVES_ARE_INDEXES'],
- "create_monthly_archive": self.site.config['CREATE_MONTHLY_ARCHIVE'],
- "create_single_archive": self.site.config['CREATE_SINGLE_ARCHIVE'],
- "show_untranslated_posts": self.site.config['SHOW_UNTRANSLATED_POSTS'],
- "create_full_archives": self.site.config['CREATE_FULL_ARCHIVES'],
- "create_daily_archive": self.site.config['CREATE_DAILY_ARCHIVE'],
- "pretty_urls": self.site.config['PRETTY_URLS'],
- "strip_indexes": self.site.config['STRIP_INDEXES'],
- "index_file": self.site.config['INDEX_FILE'],
- "generate_atom": self.site.config["GENERATE_ATOM"],
}
- self.site.scan_posts()
- yield self.group_task()
- # TODO add next/prev links for years
- if (kw['create_monthly_archive'] and kw['create_single_archive']) and not kw['create_full_archives']:
- raise Exception('Cannot create monthly and single archives at the same time.')
- for lang in kw["translations"]:
- if kw['create_single_archive'] and not kw['create_full_archives']:
- # if we are creating one single archive
- archdata = {}
- else:
- # if we are not creating one single archive, start with all years
- archdata = self.site.posts_per_year.copy()
- if kw['create_single_archive'] or kw['create_full_archives']:
- # if we are creating one single archive, or full archives
- archdata[None] = self.site.posts # for create_single_archive
-
- for year, posts in archdata.items():
- # Filter untranslated posts (Issue #1360)
- if not kw["show_untranslated_posts"]:
- posts = [p for p in posts if lang in p.translated_to]
-
- # Add archive per year or total archive
- if year:
- title = kw["messages"][lang]["Posts for year %s"] % year
- kw["is_feed_stale"] = (datetime.datetime.utcnow().strftime("%Y") != year)
- else:
- title = kw["messages"][lang]["Archive"]
- kw["is_feed_stale"] = False
- deps_translatable = {}
- for k in self.site._GLOBAL_CONTEXT_TRANSLATABLE:
- deps_translatable[k] = self.site.GLOBAL_CONTEXT[k](lang)
- if not kw["create_monthly_archive"] or kw["create_full_archives"]:
- yield self._generate_posts_task(kw, year, lang, posts, title, deps_translatable)
- else:
- months = set([(m.split('/')[1], self.site.link("archive", m, lang)) for m in self.site.posts_per_month.keys() if m.startswith(str(year))])
- months = sorted(list(months))
- months.reverse()
- items = [[nikola.utils.LocaleBorg().get_month_name(int(month), lang), link] for month, link in months]
- yield self._prepare_task(kw, year, lang, None, items, "list.tmpl", title, deps_translatable)
-
- if not kw["create_monthly_archive"] and not kw["create_full_archives"] and not kw["create_daily_archive"]:
- continue # Just to avoid nesting the other loop in this if
- for yearmonth, posts in self.site.posts_per_month.items():
- # Add archive per month
- year, month = yearmonth.split('/')
-
- kw["is_feed_stale"] = (datetime.datetime.utcnow().strftime("%Y/%m") != yearmonth)
-
- # Filter untranslated posts (via Issue #1360)
- if not kw["show_untranslated_posts"]:
- posts = [p for p in posts if lang in p.translated_to]
-
- if kw["create_monthly_archive"] or kw["create_full_archives"]:
- title = kw["messages"][lang]["Posts for {month} {year}"].format(
- year=year, month=nikola.utils.LocaleBorg().get_month_name(int(month), lang))
- yield self._generate_posts_task(kw, yearmonth, lang, posts, title)
-
- if not kw["create_full_archives"] and not kw["create_daily_archive"]:
- continue # Just to avoid nesting the other loop in this if
- # Add archive per day
- days = dict()
- for p in posts:
- if p.date.day not in days:
- days[p.date.day] = list()
- days[p.date.day].append(p)
- for day, posts in days.items():
- title = kw["messages"][lang]["Posts for {month} {day}, {year}"].format(
- year=year, month=nikola.utils.LocaleBorg().get_month_name(int(month), lang), day=day)
- yield self._generate_posts_task(kw, yearmonth + '/{0:02d}'.format(day), lang, posts, title)
-
- if not kw['create_single_archive'] and not kw['create_full_archives']:
- # And an "all your years" page for yearly and monthly archives
- if "is_feed_stale" in kw:
- del kw["is_feed_stale"]
- years = list(self.site.posts_per_year.keys())
- years.sort(reverse=True)
- kw['years'] = years
- for lang in kw["translations"]:
- items = [(y, self.site.link("archive", y, lang)) for y in years]
- yield self._prepare_task(kw, None, lang, None, items, "list.tmpl", kw["messages"][lang]["Archive"])
-
- def archive_path(self, name, lang, is_feed=False):
- """Return archive paths."""
- if is_feed:
- extension = ".atom"
- archive_file = os.path.splitext(self.site.config['ARCHIVE_FILENAME'])[0] + extension
- index_file = os.path.splitext(self.site.config['INDEX_FILE'])[0] + extension
- else:
- archive_file = self.site.config['ARCHIVE_FILENAME']
- index_file = self.site.config['INDEX_FILE']
- if name:
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['ARCHIVE_PATH'], name,
- index_file] if _f]
+ page_kind = "list"
+ if self.show_list_as_index:
+ if not self.show_list_as_subcategories_list or len(hierarchy) == self.max_levels:
+ page_kind = "index"
+ if len(hierarchy) == 0:
+ title = kw["messages"][lang]["Archive"]
+ elif len(hierarchy) == 1:
+ title = kw["messages"][lang]["Posts for year %s"] % hierarchy[0]
+ elif len(hierarchy) == 2:
+ title = nikola.utils.LocaleBorg().format_date_in_string(
+ kw["messages"][lang]["Posts for {month_year}"],
+ datetime.date(int(hierarchy[0]), int(hierarchy[1]), 1),
+ lang)
+ elif len(hierarchy) == 3:
+ title = nikola.utils.LocaleBorg().format_date_in_string(
+ kw["messages"][lang]["Posts for {month_day_year}"],
+ datetime.date(int(hierarchy[0]), int(hierarchy[1]), int(hierarchy[2])),
+ lang)
else:
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['ARCHIVE_PATH'],
- archive_file] if _f]
+ raise Exception("Cannot interpret classification {}!".format(repr(classification)))
- def archive_atom_path(self, name, lang):
- """Return Atom archive paths."""
- return self.archive_path(name, lang, is_feed=True)
+ context = {
+ "title": title,
+ "pagekind": [page_kind, "archive_page"],
+ "create_archive_navigation": self.site.config["CREATE_ARCHIVE_NAVIGATION"],
+ "archive_name": classification
+ }
+
+ # Generate links for hierarchies
+ if context["create_archive_navigation"]:
+ if hierarchy:
+ # Up level link makes sense only if this is not the top-level
+ # page (hierarchy is empty)
+ parent = '/'.join(hierarchy[:-1])
+ context["up_archive"] = self.site.link('archive', parent, lang)
+ context["up_archive_name"] = self.get_classification_friendly_name(parent, lang)
+ else:
+ context["up_archive"] = None
+ context["up_archive_name"] = None
+
+ nodelevel = len(hierarchy)
+ flat_samelevel = self.archive_navigation[lang][nodelevel]
+ idx = flat_samelevel.index(classification)
+ if idx == -1:
+ raise Exception("Cannot find classification {0} in flat hierarchy!".format(classification))
+ previdx, nextidx = idx - 1, idx + 1
+ # If the previous index is -1, or the next index is 1, the previous/next archive does not exist.
+ context["previous_archive"] = self.site.link('archive', flat_samelevel[previdx], lang) if previdx != -1 else None
+ context["previous_archive_name"] = self.get_classification_friendly_name(flat_samelevel[previdx], lang) if previdx != -1 else None
+ context["next_archive"] = self.site.link('archive', flat_samelevel[nextidx], lang) if nextidx != len(flat_samelevel) else None
+ context["next_archive_name"] = self.get_classification_friendly_name(flat_samelevel[nextidx], lang) if nextidx != len(flat_samelevel) else None
+ context["archive_nodelevel"] = nodelevel
+ context["has_archive_navigation"] = bool(context["previous_archive"] or context["up_archive"] or context["next_archive"])
+ else:
+ context["has_archive_navigation"] = False
+ kw.update(context)
+ return context, kw
+
+ def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None):
+ """Rearrange, modify or otherwise use the list of posts per classification and per language."""
+ # Build a lookup table for archive navigation, if we’ll need one.
+ if self.site.config['CREATE_ARCHIVE_NAVIGATION']:
+ if flat_hierarchy_per_lang is None:
+ raise ValueError('Archives need flat_hierarchy_per_lang')
+ self.archive_navigation = {}
+ for lang, flat_hierarchy in flat_hierarchy_per_lang.items():
+ self.archive_navigation[lang] = defaultdict(list)
+ for node in flat_hierarchy:
+ if not self.site.config["SHOW_UNTRANSLATED_POSTS"]:
+ if not [x for x in posts_per_classification_per_language[lang][node.classification_name] if x.is_translation_available(lang)]:
+ continue
+ self.archive_navigation[lang][len(node.classification_path)].append(node.classification_name)
+
+ # We need to sort it. Natsort means it’s year 10000 compatible!
+ for k, v in self.archive_navigation[lang].items():
+ self.archive_navigation[lang][k] = natsort.natsorted(v, alg=natsort.ns.F | natsort.ns.IC)
+
+ return super().postprocess_posts_per_classification(posts_per_classification_per_language, flat_hierarchy_per_lang, hierarchy_lookup_per_lang)
+
+ def should_generate_classification_page(self, classification, post_list, lang):
+ """Only generates list of posts for classification if this function returns True."""
+ return classification == '' or len(post_list) > 0
+
+ def get_other_language_variants(self, classification, lang, classifications_per_language):
+ """Return a list of variants of the same classification in other languages."""
+ return [(other_lang, classification) for other_lang, lookup in classifications_per_language.items() if classification in lookup and other_lang != lang]
diff --git a/nikola/plugins/task/authors.plugin b/nikola/plugins/task/authors.plugin
new file mode 100644
index 0000000..19e687c
--- /dev/null
+++ b/nikola/plugins/task/authors.plugin
@@ -0,0 +1,12 @@
+[Core]
+Name = classify_authors
+Module = authors
+
+[Documentation]
+Author = Juanjo Conti
+Version = 0.1
+Website = http://getnikola.com
+Description = Render the author pages and feeds.
+
+[Nikola]
+PluginCategory = Taxonomy
diff --git a/nikola/plugins/task/authors.py b/nikola/plugins/task/authors.py
new file mode 100644
index 0000000..24fe650
--- /dev/null
+++ b/nikola/plugins/task/authors.py
@@ -0,0 +1,159 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © 2015-2020 Juanjo Conti 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.
+
+"""Render the author pages and feeds."""
+
+
+from nikola.plugin_categories import Taxonomy
+from nikola import utils
+
+
+class ClassifyAuthors(Taxonomy):
+ """Classify the posts by authors."""
+
+ name = "classify_authors"
+
+ classification_name = "author"
+ overview_page_variable_name = "authors"
+ more_than_one_classifications_per_post = False
+ has_hierarchy = False
+ template_for_classification_overview = "authors.tmpl"
+ apply_to_posts = True
+ apply_to_pages = False
+ minimum_post_count_per_classification_in_overview = 1
+ omit_empty_classifications = False
+ add_other_languages_variable = True
+ path_handler_docstrings = {
+ 'author_index': """ Link to the authors index.
+
+ Example:
+
+ link://authors/ => /authors/index.html""",
+ 'author': """Link to an author's page.
+
+ Example:
+
+ link://author/joe => /authors/joe.html""",
+ 'author_atom': """Link to an author's Atom feed.
+
+Example:
+
+link://author_atom/joe => /authors/joe.atom""",
+ 'author_rss': """Link to an author's RSS feed.
+
+Example:
+
+link://author_rss/joe => /authors/joe.xml""",
+ }
+
+ def set_site(self, site):
+ """Set Nikola site."""
+ super().set_site(site)
+ self.show_list_as_index = site.config['AUTHOR_PAGES_ARE_INDEXES']
+ self.more_than_one_classifications_per_post = site.config.get('MULTIPLE_AUTHORS_PER_POST', False)
+ self.template_for_single_list = "authorindex.tmpl" if self.show_list_as_index else "author.tmpl"
+ self.translation_manager = utils.ClassificationTranslationManager()
+
+ def is_enabled(self, lang=None):
+ """Return True if this taxonomy is enabled, or False otherwise."""
+ if not self.site.config["ENABLE_AUTHOR_PAGES"]:
+ return False
+ if lang is not None:
+ return self.generate_author_pages
+ return True
+
+ def classify(self, post, lang):
+ """Classify the given post for the given language."""
+ if self.more_than_one_classifications_per_post:
+ return post.authors(lang=lang)
+ else:
+ return [post.author(lang=lang)]
+
+ def get_classification_friendly_name(self, classification, lang, only_last_component=False):
+ """Extract a friendly name from the classification."""
+ return classification
+
+ def get_overview_path(self, lang, dest_type='page'):
+ """Return a path for the list of all classifications."""
+ path = self.site.config['AUTHOR_PATH'](lang)
+ return [component for component in path.split('/') if component], 'always'
+
+ def get_path(self, classification, lang, dest_type='page'):
+ """Return a path for the given classification."""
+ if self.site.config['SLUG_AUTHOR_PATH']:
+ slug = utils.slugify(classification, lang)
+ else:
+ slug = classification
+ return [self.site.config['AUTHOR_PATH'](lang), slug], 'auto'
+
+ def provide_overview_context_and_uptodate(self, lang):
+ """Provide data for the context and the uptodate list for the list of all classifiations."""
+ kw = {
+ "messages": self.site.MESSAGES,
+ }
+ context = {
+ "title": kw["messages"][lang]["Authors"],
+ "description": kw["messages"][lang]["Authors"],
+ "permalink": self.site.link("author_index", None, lang),
+ "pagekind": ["list", "authors_page"],
+ }
+ kw.update(context)
+ return context, kw
+
+ def provide_context_and_uptodate(self, classification, lang, node=None):
+ """Provide data for the context and the uptodate list for the list of the given classifiation."""
+ descriptions = self.site.config['AUTHOR_PAGES_DESCRIPTIONS']
+ kw = {
+ "messages": self.site.MESSAGES,
+ }
+ context = {
+ "author": classification,
+ "title": kw["messages"][lang]["Posts by %s"] % classification,
+ "description": descriptions[lang][classification] if lang in descriptions and classification in descriptions[lang] else None,
+ "pagekind": ["index" if self.show_list_as_index else "list", "author_page"],
+ }
+ kw.update(context)
+ return context, kw
+
+ def get_other_language_variants(self, classification, lang, classifications_per_language):
+ """Return a list of variants of the same author in other languages."""
+ return self.translation_manager.get_translations_as_list(classification, lang, classifications_per_language)
+
+ def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None):
+ """Rearrange, modify or otherwise use the list of posts per classification and per language."""
+ more_than_one = False
+ for lang, posts_per_author in posts_per_classification_per_language.items():
+ authors = set()
+ for author, posts in posts_per_author.items():
+ for post in posts:
+ if not self.site.config["SHOW_UNTRANSLATED_POSTS"] and not post.is_translation_available(lang):
+ continue
+ authors.add(author)
+ if len(authors) > 1:
+ more_than_one = True
+ self.generate_author_pages = self.site.config["ENABLE_AUTHOR_PAGES"] and more_than_one
+ self.site.GLOBAL_CONTEXT["author_pages_generated"] = self.generate_author_pages
+ self.translation_manager.add_defaults(posts_per_classification_per_language)
diff --git a/nikola/plugins/task/bundles.plugin b/nikola/plugins/task/bundles.plugin
index ca997d0..939065b 100644
--- a/nikola/plugins/task/bundles.plugin
+++ b/nikola/plugins/task/bundles.plugin
@@ -5,9 +5,9 @@ module = bundles
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
-description = Theme bundles using WebAssets
+website = https://getnikola.com/
+description = Bundle assets
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/bundles.py b/nikola/plugins/task/bundles.py
index b9c57b9..aa4ce78 100644
--- a/nikola/plugins/task/bundles.py
+++ b/nikola/plugins/task/bundles.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,38 +24,26 @@
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""Bundle assets using WebAssets."""
+"""Bundle assets."""
-from __future__ import unicode_literals
+import configparser
+import io
+import itertools
import os
-
-try:
- import webassets
-except ImportError:
- webassets = None # NOQA
+import shutil
from nikola.plugin_categories import LateTask
from nikola import utils
class BuildBundles(LateTask):
-
- """Bundle assets using WebAssets."""
+ """Bundle assets."""
name = "create_bundles"
- def set_site(self, site):
- """Set Nikola site."""
- self.logger = utils.get_logger('bundles', utils.STDERR_HANDLER)
- if webassets is None and site.config['USE_BUNDLES']:
- utils.req_missing(['webassets'], 'USE_BUNDLES', optional=True)
- self.logger.warn('Setting USE_BUNDLES to False.')
- site.config['USE_BUNDLES'] = False
- super(BuildBundles, self).set_site(site)
-
def gen_tasks(self):
- """Bundle assets using WebAssets."""
+ """Bundle assets."""
kw = {
'filters': self.site.config['FILTERS'],
'output_folder': self.site.config['OUTPUT_FOLDER'],
@@ -69,28 +57,21 @@ class BuildBundles(LateTask):
def build_bundle(output, inputs):
out_dir = os.path.join(kw['output_folder'],
os.path.dirname(output))
- inputs = [os.path.relpath(i, out_dir) for i in inputs if os.path.isfile(i)]
- cache_dir = os.path.join(kw['cache_folder'], 'webassets')
- utils.makedirs(cache_dir)
- env = webassets.Environment(out_dir, os.path.dirname(output),
- cache=cache_dir)
- if inputs:
- bundle = webassets.Bundle(*inputs, output=os.path.basename(output))
- env.register(output, bundle)
- # This generates the file
- try:
- env[output].urls()
- except Exception as e:
- self.logger.error("Failed to build bundles.")
- self.logger.exception(e)
- self.logger.notice("Try running ``nikola clean`` and building again.")
- else:
- with open(os.path.join(out_dir, os.path.basename(output)), 'wb+'):
- pass # Create empty file
+ inputs = [
+ os.path.join(
+ out_dir,
+ os.path.relpath(i, out_dir))
+ for i in inputs if os.path.isfile(i)
+ ]
+ with open(os.path.join(out_dir, os.path.basename(output)), 'wb+') as out_fh:
+ for i in inputs:
+ with open(i, 'rb') as in_fh:
+ shutil.copyfileobj(in_fh, out_fh)
+ out_fh.write(b'\n')
yield self.group_task()
- if (webassets is not None and self.site.config['USE_BUNDLES'] is not
- False):
+
+ if self.site.config['USE_BUNDLES']:
for name, _files in kw['theme_bundles'].items():
output_path = os.path.join(kw['output_folder'], name)
dname = os.path.dirname(name)
@@ -100,7 +81,11 @@ class BuildBundles(LateTask):
files.append(os.path.join(dname, fname))
file_dep = [os.path.join(kw['output_folder'], fname)
for fname in files if
- utils.get_asset_path(fname, self.site.THEMES, self.site.config['FILES_FOLDERS']) or fname == os.path.join('assets', 'css', 'code.css')]
+ utils.get_asset_path(
+ fname,
+ self.site.THEMES,
+ self.site.config['FILES_FOLDERS'],
+ output_dir=kw['output_folder']) or fname == os.path.join('assets', 'css', 'code.css')]
# code.css will be generated by us if it does not exist in
# FILES_FOLDERS or theme assets. It is guaranteed that the
# generation will happen before this task.
@@ -123,19 +108,17 @@ class BuildBundles(LateTask):
def get_theme_bundles(themes):
"""Given a theme chain, return the bundle definitions."""
- bundles = {}
for theme_name in themes:
bundles_path = os.path.join(
utils.get_theme_path(theme_name), 'bundles')
if os.path.isfile(bundles_path):
- with open(bundles_path) as fd:
- for line in fd:
- try:
- name, files = line.split('=')
- files = [f.strip() for f in files.split(',')]
- bundles[name.strip().replace('/', os.sep)] = files
- except ValueError:
- # for empty lines
- pass
- break
- return bundles
+ config = configparser.ConfigParser()
+ header = io.StringIO('[bundles]\n')
+ with open(bundles_path, 'rt') as fd:
+ config.read_file(itertools.chain(header, fd))
+ bundles = {}
+ for name, files in config['bundles'].items():
+ name = name.strip().replace('/', os.sep)
+ files = [f.strip() for f in files.split(',') if f.strip()]
+ bundles[name] = files
+ return bundles
diff --git a/nikola/plugins/task/categories.plugin b/nikola/plugins/task/categories.plugin
new file mode 100644
index 0000000..be2bb79
--- /dev/null
+++ b/nikola/plugins/task/categories.plugin
@@ -0,0 +1,12 @@
+[Core]
+name = classify_categories
+module = categories
+
+[Documentation]
+author = Roberto Alsina
+version = 1.0
+website = https://getnikola.com/
+description = Render the category pages and feeds.
+
+[Nikola]
+PluginCategory = Taxonomy
diff --git a/nikola/plugins/task/categories.py b/nikola/plugins/task/categories.py
new file mode 100644
index 0000000..68f9caa
--- /dev/null
+++ b/nikola/plugins/task/categories.py
@@ -0,0 +1,248 @@
+# -*- 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.
+
+"""Render the category pages and feeds."""
+
+import os
+
+from nikola.plugin_categories import Taxonomy
+from nikola import utils, hierarchy_utils
+
+
+class ClassifyCategories(Taxonomy):
+ """Classify the posts by categories."""
+
+ name = "classify_categories"
+
+ classification_name = "category"
+ overview_page_variable_name = "categories"
+ overview_page_items_variable_name = "cat_items"
+ overview_page_hierarchy_variable_name = "cat_hierarchy"
+ more_than_one_classifications_per_post = False
+ has_hierarchy = True
+ include_posts_from_subhierarchies = True
+ include_posts_into_hierarchy_root = False
+ show_list_as_subcategories_list = False
+ template_for_classification_overview = "tags.tmpl"
+ always_disable_rss = False
+ always_disable_atom = False
+ apply_to_posts = True
+ apply_to_pages = False
+ minimum_post_count_per_classification_in_overview = 1
+ omit_empty_classifications = True
+ add_other_languages_variable = True
+ path_handler_docstrings = {
+ 'category_index': """A link to the category index.
+
+Example:
+
+link://category_index => /categories/index.html""",
+ 'category': """A link to a category. Takes page number as optional keyword argument.
+
+Example:
+
+link://category/dogs => /categories/dogs.html""",
+ 'category_atom': """A link to a category's Atom feed.
+
+Example:
+
+link://category_atom/dogs => /categories/dogs.atom""",
+ 'category_rss': """A link to a category's RSS feed.
+
+Example:
+
+link://category_rss/dogs => /categories/dogs.xml""",
+ }
+
+ def set_site(self, site):
+ """Set site, which is a Nikola instance."""
+ super().set_site(site)
+ self.show_list_as_index = self.site.config['CATEGORY_PAGES_ARE_INDEXES']
+ self.template_for_single_list = "tagindex.tmpl" if self.show_list_as_index else "tag.tmpl"
+ self.translation_manager = utils.ClassificationTranslationManager()
+
+ # Needed to undo names for CATEGORY_PAGES_FOLLOW_DESTPATH
+ self.destpath_names_reverse = {}
+ for lang in self.site.config['TRANSLATIONS']:
+ self.destpath_names_reverse[lang] = {}
+ for k, v in self.site.config['CATEGORY_DESTPATH_NAMES'](lang).items():
+ self.destpath_names_reverse[lang][v] = k
+ self.destpath_names_reverse = utils.TranslatableSetting(
+ '_CATEGORY_DESTPATH_NAMES_REVERSE', self.destpath_names_reverse,
+ self.site.config['TRANSLATIONS'])
+
+ def is_enabled(self, lang=None):
+ """Return True if this taxonomy is enabled, or False otherwise."""
+ return True
+
+ def classify(self, post, lang):
+ """Classify the given post for the given language."""
+ cat = post.meta('category', lang=lang).strip()
+ return [cat] if cat else []
+
+ def get_classification_friendly_name(self, classification, lang, only_last_component=False):
+ """Extract a friendly name from the classification."""
+ classification = self.extract_hierarchy(classification)
+ return classification[-1] if classification else ''
+
+ def get_overview_path(self, lang, dest_type='page'):
+ """Return a path for the list of all classifications."""
+ if self.site.config['CATEGORIES_INDEX_PATH'](lang):
+ path = self.site.config['CATEGORIES_INDEX_PATH'](lang)
+ append_index = 'never'
+ else:
+ path = self.site.config['CATEGORY_PATH'](lang)
+ append_index = 'always'
+ return [component for component in path.split('/') if component], append_index
+
+ def slugify_tag_name(self, name, lang):
+ """Slugify a tag name."""
+ if self.site.config['SLUG_TAG_PATH']:
+ name = utils.slugify(name, lang)
+ return name
+
+ def slugify_category_name(self, path, lang):
+ """Slugify a category name."""
+ if self.site.config['CATEGORY_OUTPUT_FLAT_HIERARCHY']:
+ path = path[-1:] # only the leaf
+ result = [self.slugify_tag_name(part, lang) for part in path]
+ result[0] = self.site.config['CATEGORY_PREFIX'] + result[0]
+ if not self.site.config['PRETTY_URLS']:
+ result = ['-'.join(result)]
+ return result
+
+ def get_path(self, classification, lang, dest_type='page'):
+ """Return a path for the given classification."""
+ cat_string = '/'.join(classification)
+ classification_raw = classification # needed to undo CATEGORY_DESTPATH_NAMES
+ destpath_names_reverse = self.destpath_names_reverse(lang)
+ if self.site.config['CATEGORY_PAGES_FOLLOW_DESTPATH']:
+ base_dir = None
+ for post in self.site.posts_per_category[cat_string]:
+ if post.category_from_destpath:
+ base_dir = post.folder_base(lang)
+ # Handle CATEGORY_DESTPATH_NAMES
+ if cat_string in destpath_names_reverse:
+ cat_string = destpath_names_reverse[cat_string]
+ classification_raw = cat_string.split('/')
+ break
+
+ if not self.site.config['CATEGORY_DESTPATH_TRIM_PREFIX']:
+ # If prefixes are not trimmed, we'll already have the base_dir in classification_raw
+ base_dir = ''
+
+ if base_dir is None:
+ # fallback: first POSTS entry + classification
+ base_dir = self.site.config['POSTS'][0][1]
+ base_dir_list = base_dir.split(os.sep)
+ sub_dir = [self.slugify_tag_name(part, lang) for part in classification_raw]
+ return [_f for _f in (base_dir_list + sub_dir) if _f], 'auto'
+ else:
+ return [_f for _f in [self.site.config['CATEGORY_PATH'](lang)] if _f] + self.slugify_category_name(
+ classification, lang), 'auto'
+
+ def extract_hierarchy(self, classification):
+ """Given a classification, return a list of parts in the hierarchy."""
+ return hierarchy_utils.parse_escaped_hierarchical_category_name(classification)
+
+ def recombine_classification_from_hierarchy(self, hierarchy):
+ """Given a list of parts in the hierarchy, return the classification string."""
+ return hierarchy_utils.join_hierarchical_category_path(hierarchy)
+
+ def provide_overview_context_and_uptodate(self, lang):
+ """Provide data for the context and the uptodate list for the list of all classifiations."""
+ kw = {
+ 'category_path': self.site.config['CATEGORY_PATH'],
+ 'category_prefix': self.site.config['CATEGORY_PREFIX'],
+ "category_pages_are_indexes": self.site.config['CATEGORY_PAGES_ARE_INDEXES'],
+ "tzinfo": self.site.tzinfo,
+ "category_descriptions": self.site.config['CATEGORY_DESCRIPTIONS'],
+ "category_titles": self.site.config['CATEGORY_TITLES'],
+ }
+ context = {
+ "title": self.site.MESSAGES[lang]["Categories"],
+ "description": self.site.MESSAGES[lang]["Categories"],
+ "pagekind": ["list", "tags_page"],
+ }
+ kw.update(context)
+ return context, kw
+
+ def provide_context_and_uptodate(self, classification, lang, node=None):
+ """Provide data for the context and the uptodate list for the list of the given classifiation."""
+ cat_path = self.extract_hierarchy(classification)
+ kw = {
+ 'category_path': self.site.config['CATEGORY_PATH'],
+ 'category_prefix': self.site.config['CATEGORY_PREFIX'],
+ "category_pages_are_indexes": self.site.config['CATEGORY_PAGES_ARE_INDEXES'],
+ "tzinfo": self.site.tzinfo,
+ "category_descriptions": self.site.config['CATEGORY_DESCRIPTIONS'],
+ "category_titles": self.site.config['CATEGORY_TITLES'],
+ }
+ posts = self.site.posts_per_classification[self.classification_name][lang]
+ if node is None:
+ children = []
+ else:
+ children = [child for child in node.children if len([post for post in posts.get(child.classification_name, []) if self.site.config['SHOW_UNTRANSLATED_POSTS'] or post.is_translation_available(lang)]) > 0]
+ subcats = [(child.name, self.site.link(self.classification_name, child.classification_name, lang)) for child in children]
+ friendly_name = self.get_classification_friendly_name(classification, lang)
+ context = {
+ "title": self.site.config['CATEGORY_TITLES'].get(lang, {}).get(classification, self.site.MESSAGES[lang]["Posts about %s"] % friendly_name),
+ "description": self.site.config['CATEGORY_DESCRIPTIONS'].get(lang, {}).get(classification),
+ "pagekind": ["tag_page", "index" if self.show_list_as_index else "list"],
+ "tag": friendly_name,
+ "category": classification,
+ "category_path": cat_path,
+ "subcategories": subcats,
+ }
+ kw.update(context)
+ return context, kw
+
+ def get_other_language_variants(self, classification, lang, classifications_per_language):
+ """Return a list of variants of the same category in other languages."""
+ return self.translation_manager.get_translations_as_list(classification, lang, classifications_per_language)
+
+ def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None):
+ """Rearrange, modify or otherwise use the list of posts per classification and per language."""
+ self.translation_manager.read_from_config(self.site, 'CATEGORY', posts_per_classification_per_language, False)
+
+ def should_generate_classification_page(self, classification, post_list, lang):
+ """Only generates list of posts for classification if this function returns True."""
+ if self.site.config["CATEGORY_PAGES_FOLLOW_DESTPATH"]:
+ # In destpath mode, allow users to replace the default category index with a custom page.
+ classification_hierarchy = self.extract_hierarchy(classification)
+ dest_list, _ = self.get_path(classification_hierarchy, lang)
+ short_destination = os.sep.join(dest_list + [self.site.config["INDEX_FILE"]])
+ if short_destination in self.site.post_per_file:
+ return False
+ return True
+
+ def should_generate_atom_for_classification_page(self, classification, post_list, lang):
+ """Only generates Atom feed for list of posts for classification if this function returns True."""
+ return True
+
+ def should_generate_rss_for_classification_page(self, classification, post_list, lang):
+ """Only generates RSS feed for list of posts for classification if this function returns True."""
+ return True
diff --git a/nikola/plugins/task/copy_assets.plugin b/nikola/plugins/task/copy_assets.plugin
index c182150..b63581d 100644
--- a/nikola/plugins/task/copy_assets.plugin
+++ b/nikola/plugins/task/copy_assets.plugin
@@ -5,9 +5,9 @@ module = copy_assets
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Copy theme assets into output.
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/copy_assets.py b/nikola/plugins/task/copy_assets.py
index 58521d4..c6d32c7 100644
--- a/nikola/plugins/task/copy_assets.py
+++ b/nikola/plugins/task/copy_assets.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,17 +26,16 @@
"""Copy theme assets into output."""
-from __future__ import unicode_literals
import io
import os
+from nikola.packages.pygments_better_html import BetterHtmlFormatter
from nikola.plugin_categories import Task
from nikola import utils
class CopyAssets(Task):
-
"""Copy theme assets into output."""
name = "copy_assets"
@@ -49,50 +48,64 @@ class CopyAssets(Task):
"""
kw = {
"themes": self.site.THEMES,
+ "translations": self.site.translations,
"files_folders": self.site.config['FILES_FOLDERS'],
"output_folder": self.site.config['OUTPUT_FOLDER'],
"filters": self.site.config['FILTERS'],
"code_color_scheme": self.site.config['CODE_COLOR_SCHEME'],
- "code.css_selectors": 'pre.code',
+ "code.css_selectors": ['pre.code', '.code .codetable', '.highlight pre'],
+ "code.css_wrappers": ['.highlight', '.code'],
"code.css_head": '/* code.css file generated by Nikola */\n',
- "code.css_close": "\ntable.codetable { width: 100%;} td.linenos {text-align: right; width: 4em;}\n",
+ "code.css_close": (
+ "\ntable.codetable, table.highlighttable { width: 100%;}\n"
+ ".codetable td.linenos, td.linenos { text-align: right; width: 3.5em; "
+ "padding-right: 0.5em; background: rgba(127, 127, 127, 0.2) }\n"
+ ".codetable td.code, td.code { padding-left: 0.5em; }\n"),
}
tasks = {}
code_css_path = os.path.join(kw['output_folder'], 'assets', 'css', 'code.css')
code_css_input = utils.get_asset_path('assets/css/code.css',
themes=kw['themes'],
- files_folders=kw['files_folders'])
-
- kw["code.css_input"] = code_css_input
-
+ files_folders=kw['files_folders'], output_dir=None)
yield self.group_task()
+ main_theme = utils.get_theme_path(kw['themes'][0])
+ theme_ini = utils.parse_theme_meta(main_theme)
+ if theme_ini:
+ ignored_assets = theme_ini.get("Nikola", "ignored_assets", fallback='').split(',')
+ ignored_assets = [os.path.normpath(asset_name.strip()) for asset_name in ignored_assets]
+ else:
+ ignored_assets = []
+
for theme_name in kw['themes']:
src = os.path.join(utils.get_theme_path(theme_name), 'assets')
dst = os.path.join(kw['output_folder'], 'assets')
for task in utils.copy_tree(src, dst):
- if task['name'] in tasks:
+ asset_name = os.path.relpath(task['name'], dst)
+ if task['name'] in tasks or asset_name in ignored_assets:
continue
tasks[task['name']] = task
task['uptodate'] = [utils.config_changed(kw, 'nikola.plugins.task.copy_assets')]
task['basename'] = self.name
if code_css_input:
- task['file_dep'] = [code_css_input]
+ if 'file_dep' not in task:
+ task['file_dep'] = []
+ task['file_dep'].append(code_css_input)
yield utils.apply_filters(task, kw['filters'])
# Check whether or not there is a code.css file around.
- if not code_css_input:
+ if not code_css_input and kw['code_color_scheme']:
def create_code_css():
- from pygments.formatters import get_formatter_by_name
- formatter = get_formatter_by_name('html', style=kw["code_color_scheme"])
+ formatter = BetterHtmlFormatter(style=kw["code_color_scheme"])
utils.makedirs(os.path.dirname(code_css_path))
- with io.open(code_css_path, 'w+', encoding='utf8') as outf:
+ with io.open(code_css_path, 'w+', encoding='utf-8') as outf:
outf.write(kw["code.css_head"])
- outf.write(formatter.get_style_defs(kw["code.css_selectors"]))
+ outf.write(formatter.get_style_defs(
+ kw["code.css_selectors"], kw["code.css_wrappers"]))
outf.write(kw["code.css_close"])
if os.path.exists(code_css_path):
- with io.open(code_css_path, 'r', encoding='utf-8') as fh:
+ with io.open(code_css_path, 'r', encoding='utf-8-sig') as fh:
testcontents = fh.read(len(kw["code.css_head"])) == kw["code.css_head"]
else:
testcontents = False
diff --git a/nikola/plugins/task/copy_files.plugin b/nikola/plugins/task/copy_files.plugin
index ce8f5d0..45c2253 100644
--- a/nikola/plugins/task/copy_files.plugin
+++ b/nikola/plugins/task/copy_files.plugin
@@ -5,9 +5,9 @@ module = copy_files
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Copy static files into the output.
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/copy_files.py b/nikola/plugins/task/copy_files.py
index 1232248..26364d4 100644
--- a/nikola/plugins/task/copy_files.py
+++ b/nikola/plugins/task/copy_files.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
@@ -33,7 +33,6 @@ from nikola import utils
class CopyFiles(Task):
-
"""Copy static files into the output folder."""
name = "copy_files"
diff --git a/nikola/plugins/task/galleries.plugin b/nikola/plugins/task/galleries.plugin
index 9d3fa28..d06e117 100644
--- a/nikola/plugins/task/galleries.plugin
+++ b/nikola/plugins/task/galleries.plugin
@@ -5,9 +5,9 @@ module = galleries
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Create image galleries automatically.
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/galleries.py b/nikola/plugins/task/galleries.py
index c0df4a4..b8ac9ee 100644
--- a/nikola/plugins/task/galleries.py
+++ b/nikola/plugins/task/galleries.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2015 Roberto Alsina and others.
+# Copyright © 2012-2020 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -26,38 +26,33 @@
"""Render image galleries."""
-from __future__ import unicode_literals
import datetime
import glob
import io
import json
import mimetypes
import os
-import sys
-try:
- from urlparse import urljoin
-except ImportError:
- from urllib.parse import urljoin # NOQA
+from collections import OrderedDict
+from urllib.parse import urljoin
import natsort
-try:
- from PIL import Image # NOQA
-except ImportError:
- import Image as _Image
- Image = _Image
-
import PyRSS2Gen as rss
+from PIL import Image
from nikola.plugin_categories import Task
from nikola import utils
from nikola.image_processing import ImageProcessor
from nikola.post import Post
+try:
+ from ruamel.yaml import YAML
+except ImportError:
+ YAML = None
+
_image_size_cache = {}
class Galleries(Task, ImageProcessor):
-
"""Render image galleries."""
name = 'render_galleries'
@@ -65,12 +60,11 @@ class Galleries(Task, ImageProcessor):
def set_site(self, site):
"""Set Nikola site."""
+ super().set_site(site)
site.register_path_handler('gallery', self.gallery_path)
site.register_path_handler('gallery_global', self.gallery_global_path)
site.register_path_handler('gallery_rss', self.gallery_rss_path)
- self.logger = utils.get_logger('render_galleries', utils.STDERR_HANDLER)
-
self.kw = {
'thumbnail_size': site.config['THUMBNAIL_SIZE'],
'max_image_size': site.config['MAX_IMAGE_SIZE'],
@@ -87,6 +81,13 @@ class Galleries(Task, ImageProcessor):
'tzinfo': site.tzinfo,
'comments_in_galleries': site.config['COMMENTS_IN_GALLERIES'],
'generate_rss': site.config['GENERATE_RSS'],
+ 'preserve_exif_data': site.config['PRESERVE_EXIF_DATA'],
+ 'exif_whitelist': site.config['EXIF_WHITELIST'],
+ 'preserve_icc_profiles': site.config['PRESERVE_ICC_PROFILES'],
+ 'index_path': site.config['INDEX_PATH'],
+ 'disable_indexes': site.config['DISABLE_INDEXES'],
+ 'galleries_use_thumbnail': site.config['GALLERIES_USE_THUMBNAIL'],
+ 'galleries_default_thumbnail': site.config['GALLERIES_DEFAULT_THUMBNAIL'],
}
# Verify that no folder in GALLERY_FOLDERS appears twice
@@ -94,8 +95,8 @@ class Galleries(Task, ImageProcessor):
for source, dest in self.kw['gallery_folders'].items():
if source in appearing_paths or dest in appearing_paths:
problem = source if source in appearing_paths else dest
- utils.LOGGER.error("The gallery input or output folder '{0}' appears in more than one entry in GALLERY_FOLDERS, exiting.".format(problem))
- sys.exit(1)
+ utils.LOGGER.error("The gallery input or output folder '{0}' appears in more than one entry in GALLERY_FOLDERS, ignoring.".format(problem))
+ continue
appearing_paths.add(source)
appearing_paths.add(dest)
@@ -104,8 +105,6 @@ class Galleries(Task, ImageProcessor):
# Create self.gallery_links
self.create_galleries_paths()
- return super(Galleries, self).set_site(site)
-
def _find_gallery_path(self, name):
# The system using self.proper_gallery_links and self.improper_gallery_links
# is similar as in listings.py.
@@ -116,30 +115,56 @@ class Galleries(Task, ImageProcessor):
if len(candidates) == 1:
return candidates[0]
self.logger.error("Gallery name '{0}' is not unique! Possible output paths: {1}".format(name, candidates))
+ raise RuntimeError("Gallery name '{0}' is not unique! Possible output paths: {1}".format(name, candidates))
else:
self.logger.error("Unknown gallery '{0}'!".format(name))
self.logger.info("Known galleries: " + str(list(self.proper_gallery_links.keys())))
- sys.exit(1)
+ raise RuntimeError("Unknown gallery '{0}'!".format(name))
def gallery_path(self, name, lang):
- """Return a gallery path."""
+ """Link to an image gallery's path.
+
+ It will try to find a gallery with that name if it's not ambiguous
+ or with that path. For example:
+
+ link://gallery/london => /galleries/trips/london/index.html
+
+ link://gallery/trips/london => /galleries/trips/london/index.html
+ """
gallery_path = self._find_gallery_path(name)
return [_f for _f in [self.site.config['TRANSLATIONS'][lang]] +
gallery_path.split(os.sep) +
[self.site.config['INDEX_FILE']] if _f]
def gallery_global_path(self, name, lang):
- """Return the global gallery path, which contains images."""
+ """Link to the global gallery path, which contains all the images in galleries.
+
+ There is only one copy of an image on multilingual blogs, in the site root.
+
+ link://gallery_global/london => /galleries/trips/london/index.html
+
+ link://gallery_global/trips/london => /galleries/trips/london/index.html
+
+ (a ``gallery`` link could lead to eg. /en/galleries/trips/london/index.html)
+ """
gallery_path = self._find_gallery_path(name)
return [_f for _f in gallery_path.split(os.sep) +
[self.site.config['INDEX_FILE']] if _f]
def gallery_rss_path(self, name, lang):
- """Return path to the RSS file for a gallery."""
+ """Link to an image gallery's RSS feed.
+
+ It will try to find a gallery with that name if it's not ambiguous
+ or with that path. For example:
+
+ link://gallery_rss/london => /galleries/trips/london/rss.xml
+
+ link://gallery_rss/trips/london => /galleries/trips/london/rss.xml
+ """
gallery_path = self._find_gallery_path(name)
return [_f for _f in [self.site.config['TRANSLATIONS'][lang]] +
gallery_path.split(os.sep) +
- ['rss.xml'] if _f]
+ [self.site.config['RSS_FILENAME_BASE'](lang) + self.site.config['RSS_EXTENSION']] if _f]
def gen_tasks(self):
"""Render image galleries."""
@@ -147,8 +172,9 @@ class Galleries(Task, ImageProcessor):
self.image_ext_list.extend(self.site.config.get('EXTRA_IMAGE_EXTENSIONS', []))
for k, v in self.site.GLOBAL_CONTEXT['template_hooks'].items():
- self.kw['||template_hooks|{0}||'.format(k)] = v._items
+ self.kw['||template_hooks|{0}||'.format(k)] = v.calculate_deps()
+ self.site.scan_posts()
yield self.group_task()
template_name = "gallery.tmpl"
@@ -170,13 +196,6 @@ class Galleries(Task, ImageProcessor):
# Create image list, filter exclusions
image_list = self.get_image_list(gallery)
- # Sort as needed
- # Sort by date
- if self.kw['sort_by_date']:
- image_list.sort(key=lambda a: self.image_date(a))
- else: # Sort by name
- image_list.sort()
-
# Create thumbnails and large images in destination
for image in image_list:
for task in self.create_target_images(image, input_folder):
@@ -187,8 +206,6 @@ class Galleries(Task, ImageProcessor):
for task in self.remove_excluded_image(image, input_folder):
yield task
- crumbs = utils.get_crumbs(gallery, index_folder=self)
-
for lang in self.kw['translations']:
# save navigation links as dependencies
self.kw['navigation_links|{0}'.format(lang)] = self.kw['global_context']['navigation_links'](lang)
@@ -205,6 +222,12 @@ class Galleries(Task, ImageProcessor):
self.kw[k] = self.site.GLOBAL_CONTEXT[k](lang)
context = {}
+
+ # Do we have a metadata file?
+ meta_path, order, captions, img_metadata = self.find_metadata(gallery, lang)
+ context['meta_path'] = meta_path
+ context['order'] = order
+ context['captions'] = captions
context["lang"] = lang
if post:
context["title"] = post.title(lang)
@@ -214,11 +237,24 @@ class Galleries(Task, ImageProcessor):
image_name_list = [os.path.basename(p) for p in image_list]
- if self.kw['use_filename_as_title']:
+ if captions:
+ img_titles = []
+ for fn in image_name_list:
+ if fn in captions:
+ img_titles.append(captions[fn])
+ else:
+ if self.kw['use_filename_as_title']:
+ img_titles.append(fn)
+ else:
+ img_titles.append('')
+ self.logger.debug(
+ "Image {0} found in gallery but not listed in {1}".
+ format(fn, context['meta_path']))
+ elif self.kw['use_filename_as_title']:
img_titles = []
for fn in image_name_list:
name_without_ext = os.path.splitext(os.path.basename(fn))[0]
- img_titles.append(utils.unslugify(name_without_ext))
+ img_titles.append(utils.unslugify(name_without_ext, lang))
else:
img_titles = [''] * len(image_name_list)
@@ -230,6 +266,7 @@ class Galleries(Task, ImageProcessor):
folders = []
# Generate friendly gallery names
+ fpost_list = []
for path, folder in folder_list:
fpost = self.parse_index(path, input_folder, output_folder)
if fpost:
@@ -238,15 +275,25 @@ class Galleries(Task, ImageProcessor):
ft = folder
if not folder.endswith('/'):
folder += '/'
- folders.append((folder, ft))
+ # TODO: This is to keep compatibility with user's custom gallery.tmpl
+ # To be removed in v9 someday
+ if self.kw['galleries_use_thumbnail']:
+ folders.append((folder, ft, fpost))
+ if fpost:
+ fpost_list.append(fpost.source_path)
+ else:
+ folders.append((folder, ft))
+
+ context["gallery_path"] = gallery
context["folders"] = natsort.natsorted(
folders, alg=natsort.ns.F | natsort.ns.IC)
- context["crumbs"] = crumbs
+ context["crumbs"] = utils.get_crumbs(gallery, index_folder=self, lang=lang)
context["permalink"] = self.site.link("gallery", gallery, lang)
context["enable_comments"] = self.kw['comments_in_galleries']
context["thumbnail_size"] = self.kw["thumbnail_size"]
context["pagekind"] = ["gallery_front"]
+ context["galleries_use_thumbnail"] = self.kw['galleries_use_thumbnail']
if post:
yield {
@@ -273,7 +320,7 @@ class Galleries(Task, ImageProcessor):
yield utils.apply_filters({
'basename': self.name,
'name': dst,
- 'file_dep': file_dep,
+ 'file_dep': file_dep + dest_img_list + fpost_list,
'targets': [dst],
'actions': [
(self.render_gallery_index, (
@@ -283,7 +330,7 @@ class Galleries(Task, ImageProcessor):
dest_img_list,
img_titles,
thumbs,
- file_dep))],
+ img_metadata))],
'clean': True,
'uptodate': [utils.config_changed({
1: self.kw.copy(),
@@ -325,7 +372,14 @@ class Galleries(Task, ImageProcessor):
self.gallery_list = []
for input_folder, output_folder in self.kw['gallery_folders'].items():
for root, dirs, files in os.walk(input_folder, followlinks=True):
- self.gallery_list.append((root, input_folder, output_folder))
+ # If output folder is empty, the top-level gallery
+ # index will collide with the main page for the site.
+ # Don't generate the top-level gallery index in that
+ # case.
+ # FIXME: also ignore pages named index
+ if (output_folder or root != input_folder and
+ (not self.kw['disable_indexes'] and self.kw['index_path'] == '')):
+ self.gallery_list.append((root, input_folder, output_folder))
def create_galleries_paths(self):
"""Given a list of galleries, put their paths into self.gallery_links."""
@@ -377,12 +431,73 @@ class Galleries(Task, ImageProcessor):
'uptodate': [utils.config_changed(self.kw.copy(), 'nikola.plugins.task.galleries:mkdir')],
}
+ def find_metadata(self, gallery, lang):
+ """Search for a gallery metadata file.
+
+ If there is an metadata file for the gallery, use that to determine
+ captions and the order in which images shall be displayed in the
+ gallery. You only need to list the images if a specific ordering or
+ caption is required. The metadata file is YAML-formatted, with field
+ names of
+ #
+ name:
+ caption:
+ order:
+ #
+ If a numeric order value is specified, we use that directly, otherwise
+ we depend on how the library returns the information - which may or may not
+ be in the same order as in the file itself. Non-numeric ordering is not
+ supported. If no caption is specified, then we return an empty string.
+ Returns a string (l18n'd filename), list (ordering), dict (captions),
+ dict (image metadata).
+ """
+ base_meta_path = os.path.join(gallery, "metadata.yml")
+ localized_meta_path = utils.get_translation_candidate(self.site.config,
+ base_meta_path, lang)
+ order = []
+ captions = {}
+ custom_metadata = {}
+ used_path = ""
+
+ if os.path.isfile(localized_meta_path):
+ used_path = localized_meta_path
+ elif os.path.isfile(base_meta_path):
+ used_path = base_meta_path
+ else:
+ return "", [], {}, {}
+
+ self.logger.debug("Using {0} for gallery {1}".format(
+ used_path, gallery))
+ with open(used_path, "r", encoding='utf-8-sig') as meta_file:
+ if YAML is None:
+ utils.req_missing(['ruamel.yaml'], 'use metadata.yml files for galleries')
+ yaml = YAML(typ='safe')
+ meta = yaml.load_all(meta_file)
+ for img in meta:
+ # load_all and safe_load_all both return None as their
+ # final element, so skip it
+ if not img:
+ continue
+ if 'name' in img:
+ img_name = img.pop('name')
+ if 'caption' in img and img['caption']:
+ captions[img_name] = img.pop('caption')
+
+ if 'order' in img and img['order'] is not None:
+ order.insert(img.pop('order'), img_name)
+ else:
+ order.append(img_name)
+ custom_metadata[img_name] = img
+ else:
+ self.logger.error("no 'name:' for ({0}) in {1}".format(
+ img, used_path))
+ return used_path, order, captions, custom_metadata
+
def parse_index(self, gallery, input_folder, output_folder):
"""Return a Post object if there is an index.txt."""
index_path = os.path.join(gallery, "index.txt")
- destination = os.path.join(
- self.kw["output_folder"], output_folder,
- os.path.relpath(gallery, input_folder))
+ destination = os.path.join(output_folder,
+ os.path.relpath(gallery, input_folder))
if os.path.isfile(index_path):
post = Post(
index_path,
@@ -390,15 +505,20 @@ class Galleries(Task, ImageProcessor):
destination,
False,
self.site.MESSAGES,
- 'story.tmpl',
- self.site.get_compiler(index_path)
+ 'page.tmpl',
+ self.site.get_compiler(index_path),
+ None,
+ self.site.metadata_extractors_by
)
# If this did not exist, galleries without a title in the
# index.txt file would be errorneously named `index`
# (warning: galleries titled index and filenamed differently
# may break)
- if post.title == 'index':
- post.title = os.path.split(gallery)[1]
+ if post.title() == 'index':
+ for lang in post.meta.keys():
+ post.meta[lang]['title'] = os.path.split(gallery)[1]
+ # Register the post (via #2417)
+ self.site.post_per_input_file[index_path] = post
else:
post = None
return post
@@ -408,8 +528,8 @@ class Galleries(Task, ImageProcessor):
exclude_path = os.path.join(gallery_path, "exclude.meta")
try:
- f = open(exclude_path, 'r')
- excluded_image_name_list = f.read().split()
+ with open(exclude_path, 'r') as f:
+ excluded_image_name_list = f.read().split()
except IOError:
excluded_image_name_list = []
@@ -453,32 +573,26 @@ class Galleries(Task, ImageProcessor):
orig_dest_path = os.path.join(output_gallery, img_name)
yield utils.apply_filters({
'basename': self.name,
- 'name': thumb_path,
- 'file_dep': [img],
- 'targets': [thumb_path],
- 'actions': [
- (self.resize_image,
- (img, thumb_path, self.kw['thumbnail_size']))
- ],
- 'clean': True,
- 'uptodate': [utils.config_changed({
- 1: self.kw['thumbnail_size']
- }, 'nikola.plugins.task.galleries:resize_thumb')],
- }, self.kw['filters'])
-
- yield utils.apply_filters({
- 'basename': self.name,
'name': orig_dest_path,
'file_dep': [img],
- 'targets': [orig_dest_path],
+ 'targets': [thumb_path, orig_dest_path],
'actions': [
(self.resize_image,
- (img, orig_dest_path, self.kw['max_image_size']))
- ],
+ [img], {
+ 'dst_paths': [thumb_path, orig_dest_path],
+ 'max_sizes': [self.kw['thumbnail_size'], self.kw['max_image_size']],
+ 'bigger_panoramas': True,
+ 'preserve_exif_data': self.kw['preserve_exif_data'],
+ 'exif_whitelist': self.kw['exif_whitelist'],
+ 'preserve_icc_profiles': self.kw['preserve_icc_profiles']})],
'clean': True,
'uptodate': [utils.config_changed({
- 1: self.kw['max_image_size']
- }, 'nikola.plugins.task.galleries:resize_max')],
+ 1: self.kw['thumbnail_size'],
+ 2: self.kw['max_image_size'],
+ 3: self.kw['preserve_exif_data'],
+ 4: self.kw['exif_whitelist'],
+ 5: self.kw['preserve_icc_profiles'],
+ }, 'nikola.plugins.task.galleries:resize_thumb')],
}, self.kw['filters'])
def remove_excluded_image(self, img, input_folder):
@@ -524,7 +638,7 @@ class Galleries(Task, ImageProcessor):
img_list,
img_titles,
thumbs,
- file_dep):
+ img_metadata):
"""Build the gallery index."""
# The photo array needs to be created here, because
# it relies on thumbnails already being created on
@@ -534,15 +648,33 @@ class Galleries(Task, ImageProcessor):
url = '/'.join(os.path.relpath(p, os.path.dirname(output_name) + os.sep).split(os.sep))
return url
- photo_array = []
+ all_data = list(zip(img_list, thumbs, img_titles))
+
+ if self.kw['sort_by_date']:
+ all_data.sort(key=lambda a: self.image_date(a[0]))
+ else: # Sort by name
+ all_data.sort(key=lambda a: a[0])
+
+ if all_data:
+ img_list, thumbs, img_titles = zip(*all_data)
+ else:
+ img_list, thumbs, img_titles = [], [], []
+
+ photo_info = OrderedDict()
for img, thumb, title in zip(img_list, thumbs, img_titles):
w, h = _image_size_cache.get(thumb, (None, None))
if w is None:
- im = Image.open(thumb)
- w, h = im.size
- _image_size_cache[thumb] = w, h
- # Thumbs are files in output, we need URLs
- photo_array.append({
+ if os.path.splitext(thumb)[1] in ['.svg', '.svgz']:
+ w, h = 200, 200
+ else:
+ im = Image.open(thumb)
+ w, h = im.size
+ _image_size_cache[thumb] = w, h
+ im.close()
+ # Use basename to avoid issues with multilingual sites (Issue #3078)
+ img_basename = os.path.basename(img)
+ photo_info[img_basename] = {
+ # Thumbs are files in output, we need URLs
'url': url_from_path(img),
'url_thumb': url_from_path(thumb),
'title': title,
@@ -550,9 +682,27 @@ class Galleries(Task, ImageProcessor):
'w': w,
'h': h
},
- })
+ 'width': w,
+ 'height': h
+ }
+ if img_basename in img_metadata:
+ photo_info[img_basename].update(img_metadata[img_basename])
+ photo_array = []
+ if context['order']:
+ for entry in context['order']:
+ photo_array.append(photo_info.pop(entry))
+ # Do we have any orphan entries from metadata.yml, or
+ # are the files from the gallery not listed in metadata.yml?
+ if photo_info:
+ for entry in photo_info:
+ photo_array.append(photo_info[entry])
+ else:
+ for entry in photo_info:
+ photo_array.append(photo_info[entry])
+
context['photo_array'] = photo_array
context['photo_array_json'] = json.dumps(photo_array, sort_keys=True)
+
self.site.render_template(template_name, output_name, context)
def gallery_rss(self, img_list, dest_img_list, img_titles, lang, permalink, output_path, title):
@@ -564,6 +714,18 @@ class Galleries(Task, ImageProcessor):
def make_url(url):
return urljoin(self.site.config['BASE_URL'], url.lstrip('/'))
+ all_data = list(zip(img_list, dest_img_list, img_titles))
+
+ if self.kw['sort_by_date']:
+ all_data.sort(key=lambda a: self.image_date(a[0]))
+ else: # Sort by name
+ all_data.sort(key=lambda a: a[0])
+
+ if all_data:
+ img_list, dest_img_list, img_titles = zip(*all_data)
+ else:
+ img_list, dest_img_list, img_titles = [], [], []
+
items = []
for img, srcimg, title in list(zip(dest_img_list, img_list, img_titles))[:self.kw["feed_length"]]:
img_size = os.stat(
@@ -587,7 +749,7 @@ class Galleries(Task, ImageProcessor):
description='',
lastBuildDate=datetime.datetime.utcnow(),
items=items,
- generator='http://getnikola.com/',
+ generator='https://getnikola.com/',
language=lang
)
@@ -598,6 +760,6 @@ class Galleries(Task, ImageProcessor):
utils.makedirs(dst_dir)
with io.open(output_path, "w+", encoding="utf-8") as rss_file:
data = rss_obj.to_xml(encoding='utf-8')
- if isinstance(data, utils.bytes_str):
+ if isinstance(data, bytes):
data = data.decode('utf-8')
rss_file.write(data)
diff --git a/nikola/plugins/task/gzip.plugin b/nikola/plugins/task/gzip.plugin
index 7834d22..cc078b7 100644
--- a/nikola/plugins/task/gzip.plugin
+++ b/nikola/plugins/task/gzip.plugin
@@ -5,9 +5,9 @@ module = gzip
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Create gzipped copies of files
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/gzip.py b/nikola/plugins/task/gzip.py
index cf16f63..ebd427f 100644
--- a/nikola/plugins/task/gzip.py
+++ b/nikola/plugins/task/gzip.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
@@ -35,7 +35,6 @@ from nikola.plugin_categories import TaskMultiplier
class GzipFiles(TaskMultiplier):
-
"""If appropiate, create tasks to create gzipped versions of files."""
name = "gzip"
diff --git a/nikola/plugins/task/indexes.plugin b/nikola/plugins/task/indexes.plugin
index d9b0e5f..f4a8f05 100644
--- a/nikola/plugins/task/indexes.plugin
+++ b/nikola/plugins/task/indexes.plugin
@@ -1,13 +1,12 @@
[Core]
-name = render_indexes
+name = classify_indexes
module = indexes
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Generates the blog's index pages.
[Nikola]
-plugincategory = Task
-
+PluginCategory = Taxonomy
diff --git a/nikola/plugins/task/indexes.py b/nikola/plugins/task/indexes.py
index c02818e..20491fb 100644
--- a/nikola/plugins/task/indexes.py
+++ b/nikola/plugins/task/indexes.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,145 +24,114 @@
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""Render the blog indexes."""
+"""Render the blog's main index."""
-from __future__ import unicode_literals
-from collections import defaultdict
-import os
-from nikola.plugin_categories import Task
-from nikola import utils
+from nikola.plugin_categories import Taxonomy
-class Indexes(Task):
+class Indexes(Taxonomy):
+ """Classify for the blog's main index."""
- """Render the blog indexes."""
+ name = "classify_indexes"
- name = "render_indexes"
+ classification_name = "index"
+ overview_page_variable_name = None
+ more_than_one_classifications_per_post = False
+ has_hierarchy = False
+ show_list_as_index = True
+ template_for_single_list = "index.tmpl"
+ template_for_classification_overview = None
+ apply_to_posts = True
+ apply_to_pages = False
+ omit_empty_classifications = False
+ path_handler_docstrings = {
+ 'index_index': False,
+ 'index': """Link to a numbered index.
- 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)
- return super(Indexes, self).set_site(site)
+Example:
- def gen_tasks(self):
- """Render the blog indexes."""
- self.site.scan_posts()
- yield self.group_task()
+link://index/3 => /index-3.html""",
+ 'index_atom': """Link to a numbered Atom index.
- kw = {
- "translations": self.site.config['TRANSLATIONS'],
- "messages": self.site.MESSAGES,
- "output_folder": self.site.config['OUTPUT_FOLDER'],
- "filters": self.site.config['FILTERS'],
- "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'],
- "blog_title": self.site.config["BLOG_TITLE"],
- "generate_atom": self.site.config["GENERATE_ATOM"],
- }
+Example:
- template_name = "index.tmpl"
- posts = self.site.posts
- self.number_of_pages = dict()
- for lang in kw["translations"]:
- def page_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("index" + feed, None, lang), i, displayed_i,
- lang, self.site, force_addition, extension)
+link://index_atom/3 => /index-3.atom""",
+ 'index_rss': """A link to the RSS feed path.
- def page_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("index" + feed, None, lang), i, displayed_i,
- lang, self.site, force_addition, extension)
+Example:
- if kw["show_untranslated_posts"]:
- filtered_posts = posts
- else:
- filtered_posts = [x for x in posts if x.is_translation_available(lang)]
+link://rss => /blog/rss.xml""",
+ }
- indexes_title = kw['indexes_title'](lang) or kw['blog_title'](lang)
- self.number_of_pages[lang] = (len(filtered_posts) + kw['index_display_post_count'] - 1) // kw['index_display_post_count']
+ def set_site(self, site):
+ """Set Nikola site."""
+ # Redirect automatically generated 'index_rss' path handler to 'rss' for compatibility with old rss plugin
+ site.register_path_handler('rss', lambda name, lang: site.path_handlers['index_rss'](name, lang))
+ site.path_handlers['rss'].__doc__ = """A link to the RSS feed path.
+
+Example:
+
+ link://rss => /blog/rss.xml
+ """.strip()
+ return super().set_site(site)
+
+ def get_implicit_classifications(self, lang):
+ """Return a list of classification strings which should always appear in posts_per_classification."""
+ return [""]
+
+ def classify(self, post, lang):
+ """Classify the given post for the given language."""
+ return [""]
+
+ def get_classification_friendly_name(self, classification, lang, only_last_component=False):
+ """Extract a friendly name from the classification."""
+ return self.site.config["BLOG_TITLE"](lang)
+
+ def get_path(self, classification, lang, dest_type='page'):
+ """Return a path for the given classification."""
+ if dest_type == 'rss':
+ return [
+ self.site.config['RSS_PATH'](lang),
+ self.site.config['RSS_FILENAME_BASE'](lang)
+ ], 'auto'
+ if dest_type == 'feed':
+ return [
+ self.site.config['ATOM_PATH'](lang),
+ self.site.config['ATOM_FILENAME_BASE'](lang)
+ ], 'auto'
+ page_number = None
+ if dest_type == 'page':
+ # Interpret argument as page number
+ try:
+ page_number = int(classification)
+ except (ValueError, TypeError):
+ pass
+ return [self.site.config['INDEX_PATH'](lang)], 'always', page_number
+
+ def provide_context_and_uptodate(self, classification, lang, node=None):
+ """Provide data for the context and the uptodate list for the list of the given classifiation."""
+ kw = {
+ "show_untranslated_posts": self.site.config["SHOW_UNTRANSLATED_POSTS"],
+ }
+ context = {
+ "title": self.site.config["INDEXES_TITLE"](lang) or self.site.config["BLOG_TITLE"](lang),
+ "description": self.site.config["BLOG_DESCRIPTION"](lang),
+ "pagekind": ["main_index", "index"],
+ "featured": [p for p in self.site.posts if p.post_status == 'featured' and
+ (lang in p.translated_to or kw["show_untranslated_posts"])],
+ }
+ kw.update(context)
+ return context, kw
- context = {}
- context["pagekind"] = ["index"]
+ def should_generate_classification_page(self, classification, post_list, lang):
+ """Only generates list of posts for classification if this function returns True."""
+ return not self.site.config["DISABLE_INDEXES"]
- yield self.site.generic_index_renderer(lang, filtered_posts, indexes_title, template_name, context, kw, 'render_indexes', page_link, page_path)
+ def should_generate_atom_for_classification_page(self, classification, post_list, lang):
+ """Only generates Atom feed for list of posts for classification if this function returns True."""
+ return not self.site.config["DISABLE_MAIN_ATOM_FEED"]
- if not self.site.config["STORY_INDEX"]:
- return
- kw = {
- "translations": self.site.config['TRANSLATIONS'],
- "post_pages": self.site.config["post_pages"],
- "output_folder": self.site.config['OUTPUT_FOLDER'],
- "filters": self.site.config['FILTERS'],
- "index_file": self.site.config['INDEX_FILE'],
- "strip_indexes": self.site.config['STRIP_INDEXES'],
- }
- template_name = "list.tmpl"
- index_len = len(kw['index_file'])
- for lang in kw["translations"]:
- # Need to group by folder to avoid duplicated tasks (Issue #758)
- # Group all pages by path prefix
- groups = defaultdict(list)
- for p in self.site.timeline:
- if not p.is_post:
- destpath = p.destination_path(lang)
- if destpath[-(1 + index_len):] == '/' + kw['index_file']:
- destpath = destpath[:-(1 + index_len)]
- dirname = os.path.dirname(destpath)
- groups[dirname].append(p)
- for dirname, post_list in groups.items():
- context = {}
- context["items"] = []
- should_render = True
- output_name = os.path.join(kw['output_folder'], dirname, kw['index_file'])
- 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"] = ["list"]
- if dirname == "/":
- context["pagekind"].append("front_page")
-
- for post in post_list:
- # If there is an index.html pending to be created from
- # a story, do not generate the STORY_INDEX
- if post.destination_path(lang) == short_destination:
- should_render = False
- else:
- context["items"].append((post.title(lang),
- post.permalink(lang)))
-
- if should_render:
- task = self.site.generic_post_list_renderer(lang, post_list,
- output_name,
- template_name,
- kw['filters'],
- context)
- task['uptodate'] = task['uptodate'] + [utils.config_changed(kw, 'nikola.plugins.task.indexes')]
- task['basename'] = self.name
- yield task
-
- def index_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],
- self.site.config['INDEX_PATH'],
- index_file] if _f],
- name,
- utils.get_displayed_page_number(name, self.number_of_pages[lang], 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 should_generate_rss_for_classification_page(self, classification, post_list, lang):
+ """Only generates RSS feed for list of posts for classification if this function returns True."""
+ return not self.site.config["DISABLE_MAIN_RSS_FEED"]
diff --git a/nikola/plugins/task/listings.plugin b/nikola/plugins/task/listings.plugin
index 435234b..03b67d2 100644
--- a/nikola/plugins/task/listings.plugin
+++ b/nikola/plugins/task/listings.plugin
@@ -5,9 +5,9 @@ module = listings
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Render code listings into output
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/listings.py b/nikola/plugins/task/listings.py
index 5f79724..c946313 100644
--- a/nikola/plugins/task/listings.py
+++ b/nikola/plugins/task/listings.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,37 +26,32 @@
"""Render code listings."""
-from __future__ import unicode_literals, print_function
-
-import sys
import os
-import lxml.html
+from collections import defaultdict
-from pygments import highlight
-from pygments.lexers import get_lexer_for_filename, TextLexer
import natsort
+from pygments import highlight
+from pygments.lexers import get_lexer_for_filename, guess_lexer, TextLexer
from nikola.plugin_categories import Task
from nikola import utils
class Listings(Task):
-
"""Render code listings."""
name = "render_listings"
def register_output_name(self, input_folder, rel_name, rel_output_name):
"""Register proper and improper file mappings."""
- if rel_name not in self.improper_input_file_mapping:
- self.improper_input_file_mapping[rel_name] = []
- self.improper_input_file_mapping[rel_name].append(rel_output_name)
+ self.improper_input_file_mapping[rel_name].add(rel_output_name)
self.proper_input_file_mapping[os.path.join(input_folder, rel_name)] = rel_output_name
self.proper_input_file_mapping[rel_output_name] = rel_output_name
def set_site(self, site):
"""Set Nikola site."""
site.register_path_handler('listing', self.listing_path)
+ site.register_path_handler('listing_source', self.listing_source_path)
# We need to prepare some things for the listings path handler to work.
@@ -75,7 +70,7 @@ class Listings(Task):
if source in appearing_paths or dest in appearing_paths:
problem = source if source in appearing_paths else dest
utils.LOGGER.error("The listings input or output folder '{0}' appears in more than one entry in LISTINGS_FOLDERS, exiting.".format(problem))
- sys.exit(1)
+ continue
appearing_paths.add(source)
appearing_paths.add(dest)
@@ -85,7 +80,7 @@ class Listings(Task):
# a list is needed. This is needed for compatibility to previous Nikola
# versions, where there was no need to specify the input directory name
# when asking for a link via site.link('listing', ...).
- self.improper_input_file_mapping = {}
+ self.improper_input_file_mapping = defaultdict(set)
# proper_input_file_mapping maps relative input file (relative to CWD)
# to a generated output file. Since we don't allow an input directory
@@ -94,7 +89,7 @@ class Listings(Task):
self.proper_input_file_mapping = {}
for input_folder, output_folder in self.kw['listings_folders'].items():
- for root, dirs, files in os.walk(input_folder, followlinks=True):
+ for root, _, files in os.walk(input_folder, followlinks=True):
# Compute relative path; can't use os.path.relpath() here as it returns "." instead of ""
rel_path = root[len(input_folder):]
if rel_path[:1] == os.sep:
@@ -106,7 +101,7 @@ class Listings(Task):
# Register file names in the mapping.
self.register_output_name(input_folder, rel_name, rel_output_name)
- return super(Listings, self).set_site(site)
+ return super().set_site(site)
def gen_tasks(self):
"""Render pretty code listings."""
@@ -117,20 +112,31 @@ class Listings(Task):
needs_ipython_css = False
if in_name and in_name.endswith('.ipynb'):
# Special handling: render ipynbs in listings (Issue #1900)
- ipynb_compiler = self.site.plugin_manager.getPluginByName("ipynb", "PageCompiler").plugin_object
- ipynb_raw = ipynb_compiler.compile_html_string(in_name, True)
- ipynb_html = lxml.html.fromstring(ipynb_raw)
- # The raw HTML contains garbage (scripts and styles), we can’t leave it in
- code = lxml.html.tostring(ipynb_html.xpath('//*[@id="notebook"]')[0], encoding='unicode')
+ ipynb_plugin = self.site.plugin_manager.getPluginByName("ipynb", "PageCompiler")
+ if ipynb_plugin is None:
+ msg = "To use .ipynb files as listings, you must set up the Jupyter compiler in COMPILERS and POSTS/PAGES."
+ utils.LOGGER.error(msg)
+ raise ValueError(msg)
+
+ ipynb_compiler = ipynb_plugin.plugin_object
+ with open(in_name, "r", encoding="utf-8-sig") as in_file:
+ nb_json = ipynb_compiler._nbformat_read(in_file)
+ code = ipynb_compiler._compile_string(nb_json)
title = os.path.basename(in_name)
needs_ipython_css = True
elif in_name:
- with open(in_name, 'r') as fd:
+ with open(in_name, 'r', encoding='utf-8-sig') as fd:
try:
lexer = get_lexer_for_filename(in_name)
- except:
- lexer = TextLexer()
- code = highlight(fd.read(), lexer, utils.NikolaPygmentsHTML(in_name))
+ except Exception:
+ try:
+ lexer = guess_lexer(fd.read())
+ except Exception:
+ lexer = TextLexer()
+ fd.seek(0)
+ code = highlight(
+ fd.read(), lexer,
+ utils.NikolaPygmentsHTML(in_name, linenos='table'))
title = os.path.basename(in_name)
else:
code = ''
@@ -147,7 +153,7 @@ class Listings(Task):
os.path.join(
self.kw['output_folder'],
output_folder))))
- if self.site.config['COPY_SOURCES'] and in_name:
+ if in_name:
source_link = permalink[:-5] # remove '.html'
else:
source_link = None
@@ -182,7 +188,7 @@ class Listings(Task):
uptodate = {'c': self.site.GLOBAL_CONTEXT}
for k, v in self.site.GLOBAL_CONTEXT['template_hooks'].items():
- uptodate['||template_hooks|{0}||'.format(k)] = v._items
+ uptodate['||template_hooks|{0}||'.format(k)] = v.calculate_deps()
for k in self.site._GLOBAL_CONTEXT_TRANSLATABLE:
uptodate[k] = self.site.GLOBAL_CONTEXT[k](self.kw['default_lang'])
@@ -218,6 +224,8 @@ class Listings(Task):
'clean': True,
}, self.kw["filters"])
for f in files:
+ if f == '.DS_Store':
+ continue
ext = os.path.splitext(f)[-1]
if ext in ignored_extensions:
continue
@@ -240,22 +248,47 @@ class Listings(Task):
'uptodate': [utils.config_changed(uptodate, 'nikola.plugins.task.listings:source')],
'clean': True,
}, self.kw["filters"])
- if self.site.config['COPY_SOURCES']:
- rel_name = os.path.join(rel_path, f)
- rel_output_name = os.path.join(output_folder, rel_path, f)
- self.register_output_name(input_folder, rel_name, rel_output_name)
- out_name = os.path.join(self.kw['output_folder'], rel_output_name)
- yield utils.apply_filters({
- 'basename': self.name,
- 'name': out_name,
- 'file_dep': [in_name],
- 'targets': [out_name],
- 'actions': [(utils.copy_file, [in_name, out_name])],
- 'clean': True,
- }, self.kw["filters"])
+
+ rel_name = os.path.join(rel_path, f)
+ rel_output_name = os.path.join(output_folder, rel_path, f)
+ self.register_output_name(input_folder, rel_name, rel_output_name)
+ out_name = os.path.join(self.kw['output_folder'], rel_output_name)
+ yield utils.apply_filters({
+ 'basename': self.name,
+ 'name': out_name,
+ 'file_dep': [in_name],
+ 'targets': [out_name],
+ 'actions': [(utils.copy_file, [in_name, out_name])],
+ 'clean': True,
+ }, self.kw["filters"])
+
+ def listing_source_path(self, name, lang):
+ """Return a link to the source code for a listing.
+
+ It will try to use the file name if it's not ambiguous, or the file path.
+
+ Example:
+
+ link://listing_source/hello.py => /listings/tutorial/hello.py
+
+ link://listing_source/tutorial/hello.py => /listings/tutorial/hello.py
+ """
+ result = self.listing_path(name, lang)
+ if result[-1].endswith('.html'):
+ result[-1] = result[-1][:-5]
+ return result
def listing_path(self, namep, lang):
- """Return path to a listing."""
+ """Return a link to a listing.
+
+ It will try to use the file name if it's not ambiguous, or the file path.
+
+ Example:
+
+ link://listing/hello.py => /listings/tutorial/hello.py.html
+
+ link://listing/tutorial/hello.py => /listings/tutorial/hello.py.html
+ """
namep = namep.replace('/', os.sep)
nameh = namep + '.html'
for name in (namep, nameh):
@@ -268,14 +301,14 @@ class Listings(Task):
# ambiguities.
if len(self.improper_input_file_mapping[name]) > 1:
utils.LOGGER.error("Using non-unique listing name '{0}', which maps to more than one listing name ({1})!".format(name, str(self.improper_input_file_mapping[name])))
- sys.exit(1)
+ return ["ERROR"]
if len(self.site.config['LISTINGS_FOLDERS']) > 1:
- utils.LOGGER.notice("Using listings names in site.link() without input directory prefix while configuration's LISTINGS_FOLDERS has more than one entry.")
- name = self.improper_input_file_mapping[name][0]
+ utils.LOGGER.warning("Using listings names in site.link() without input directory prefix while configuration's LISTINGS_FOLDERS has more than one entry.")
+ name = list(self.improper_input_file_mapping[name])[0]
break
else:
utils.LOGGER.error("Unknown listing name {0}!".format(namep))
- sys.exit(1)
+ return ["ERROR"]
if not name.endswith(os.sep + self.site.config["INDEX_FILE"]):
name += '.html'
path_parts = name.split(os.sep)
diff --git a/nikola/plugins/task/page_index.plugin b/nikola/plugins/task/page_index.plugin
new file mode 100644
index 0000000..42c9288
--- /dev/null
+++ b/nikola/plugins/task/page_index.plugin
@@ -0,0 +1,12 @@
+[Core]
+name = classify_page_index
+module = page_index
+
+[Documentation]
+author = Roberto Alsina
+version = 1.0
+website = https://getnikola.com/
+description = Generates the blog's index pages.
+
+[Nikola]
+PluginCategory = Taxonomy
diff --git a/nikola/plugins/task/page_index.py b/nikola/plugins/task/page_index.py
new file mode 100644
index 0000000..e7b33cf
--- /dev/null
+++ b/nikola/plugins/task/page_index.py
@@ -0,0 +1,111 @@
+# -*- 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.
+
+"""Render the page index."""
+
+
+from nikola.plugin_categories import Taxonomy
+
+
+class PageIndex(Taxonomy):
+ """Classify for the page index."""
+
+ name = "classify_page_index"
+
+ classification_name = "page_index_folder"
+ overview_page_variable_name = "page_folder"
+ more_than_one_classifications_per_post = False
+ has_hierarchy = True
+ include_posts_from_subhierarchies = False
+ show_list_as_index = False
+ template_for_single_list = "list.tmpl"
+ template_for_classification_overview = None
+ always_disable_rss = True
+ always_disable_atom = True
+ apply_to_posts = False
+ apply_to_pages = True
+ omit_empty_classifications = True
+ path_handler_docstrings = {
+ 'page_index_folder_index': None,
+ 'page_index_folder': None,
+ 'page_index_folder_atom': None,
+ 'page_index_folder_rss': None,
+ }
+
+ def is_enabled(self, lang=None):
+ """Return True if this taxonomy is enabled, or False otherwise."""
+ return self.site.config["PAGE_INDEX"]
+
+ def classify(self, post, lang):
+ """Classify the given post for the given language."""
+ destpath = post.destination_path(lang, sep='/')
+ if post.has_pretty_url(lang):
+ idx = '/index.html'
+ if destpath.endswith(idx):
+ destpath = destpath[:-len(idx)]
+ i = destpath.rfind('/')
+ return [destpath[:i] if i >= 0 else '']
+
+ def get_classification_friendly_name(self, dirname, lang, only_last_component=False):
+ """Extract a friendly name from the classification."""
+ return dirname
+
+ def get_path(self, hierarchy, lang, dest_type='page'):
+ """Return a path for the given classification."""
+ return hierarchy, 'always'
+
+ def extract_hierarchy(self, dirname):
+ """Given a classification, return a list of parts in the hierarchy."""
+ return dirname.split('/') if dirname else []
+
+ def recombine_classification_from_hierarchy(self, hierarchy):
+ """Given a list of parts in the hierarchy, return the classification string."""
+ return '/'.join(hierarchy)
+
+ def provide_context_and_uptodate(self, dirname, lang, node=None):
+ """Provide data for the context and the uptodate list for the list of the given classifiation."""
+ kw = {
+ "translations": self.site.config['TRANSLATIONS'],
+ "filters": self.site.config['FILTERS'],
+ }
+ context = {
+ "title": self.site.config['BLOG_TITLE'](lang),
+ "pagekind": ["list", "front_page", "page_index"] if dirname == '' else ["list", "page_index"],
+ "kind": "page_index_folder",
+ "classification": dirname,
+ "has_no_feeds": True,
+ }
+ kw.update(context)
+ return context, kw
+
+ def should_generate_classification_page(self, dirname, post_list, lang):
+ """Only generates list of posts for classification if this function returns True."""
+ short_destination = dirname + '/' + self.site.config['INDEX_FILE']
+ for post in post_list:
+ # If there is an index.html pending to be created from a page, do not generate the page index.
+ if post.destination_path(lang, sep='/') == short_destination:
+ return False
+ return True
diff --git a/nikola/plugins/task/pages.plugin b/nikola/plugins/task/pages.plugin
index 023d41b..a04cd05 100644
--- a/nikola/plugins/task/pages.plugin
+++ b/nikola/plugins/task/pages.plugin
@@ -5,9 +5,9 @@ module = pages
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Create pages in the output.
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/pages.py b/nikola/plugins/task/pages.py
index e6a8a82..0c0bdd2 100644
--- a/nikola/plugins/task/pages.py
+++ b/nikola/plugins/task/pages.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,13 +26,13 @@
"""Render pages into output."""
-from __future__ import unicode_literals
+import os
+
from nikola.plugin_categories import Task
-from nikola.utils import config_changed
+from nikola.utils import config_changed, LOGGER
class RenderPages(Task):
-
"""Render pages into output."""
name = "render_pages"
@@ -48,6 +48,13 @@ class RenderPages(Task):
}
self.site.scan_posts()
yield self.group_task()
+ index_paths = {}
+ for lang in kw["translations"]:
+ index_paths[lang] = False
+ if not self.site.config["DISABLE_INDEXES"]:
+ index_paths[lang] = os.path.normpath(os.path.join(self.site.config['OUTPUT_FOLDER'],
+ self.site.path('index', '', lang=lang)))
+
for lang in kw["translations"]:
for post in self.site.timeline:
if not kw["show_untranslated_posts"] and not post.is_translation_available(lang):
@@ -55,8 +62,14 @@ class RenderPages(Task):
if post.is_post:
context = {'pagekind': ['post_page']}
else:
- context = {'pagekind': ['story_page']}
+ context = {'pagekind': ['story_page', 'page_page']}
for task in self.site.generic_page_renderer(lang, post, kw["filters"], context):
+ if task['name'] == index_paths[lang]:
+ # Issue 3022
+ LOGGER.error(
+ "Post {0!r}: output path ({1}) conflicts with the blog index ({2}). "
+ "Please change INDEX_PATH or disable index generation.".format(
+ post.source_path, task['name'], index_paths[lang]))
task['uptodate'] = task['uptodate'] + [config_changed(kw, 'nikola.plugins.task.pages')]
task['basename'] = self.name
task['task_dep'] = ['render_posts']
diff --git a/nikola/plugins/task/posts.plugin b/nikola/plugins/task/posts.plugin
index 79b7c51..6893472 100644
--- a/nikola/plugins/task/posts.plugin
+++ b/nikola/plugins/task/posts.plugin
@@ -5,9 +5,9 @@ module = posts
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Create HTML fragments out of posts.
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/posts.py b/nikola/plugins/task/posts.py
index a3a8375..5f48165 100644
--- a/nikola/plugins/task/posts.py
+++ b/nikola/plugins/task/posts.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,11 +26,11 @@
"""Build HTML fragments from metadata and text."""
-from copy import copy
import os
+from copy import copy
from nikola.plugin_categories import Task
-from nikola import filters, utils
+from nikola import utils
def update_deps(post, lang, task):
@@ -44,7 +44,6 @@ def update_deps(post, lang, task):
class RenderPosts(Task):
-
"""Build HTML fragments from metadata and text."""
name = "render_posts"
@@ -77,6 +76,8 @@ class RenderPosts(Task):
deps_dict = copy(kw)
deps_dict.pop('timeline')
for post in kw['timeline']:
+ if not post.is_translation_available(lang) and not self.site.config['SHOW_UNTRANSLATED_POSTS']:
+ continue
# Extra config dependencies picked from config
for p in post.fragment_deps(lang):
if p.startswith('####MAGIC####CONFIG:'):
@@ -84,11 +85,12 @@ class RenderPosts(Task):
deps_dict[k] = self.site.config.get(k)
dest = post.translated_base_path(lang)
file_dep = [p for p in post.fragment_deps(lang) if not p.startswith("####MAGIC####")]
+ extra_targets = post.compiler.get_extra_targets(post, lang, dest)
task = {
'basename': self.name,
'name': dest,
'file_dep': file_dep,
- 'targets': [dest],
+ 'targets': [dest] + extra_targets,
'actions': [(post.compile, (lang, )),
(update_deps, (post, lang, )),
],
@@ -106,15 +108,12 @@ class RenderPosts(Task):
for i, f in enumerate(ff):
if not f:
continue
- if f.startswith('filters.'): # A function from the filters module
- f = f[8:]
- try:
- flist.append(getattr(filters, f))
- except AttributeError:
- pass
+ _f = self.site.filters.get(f)
+ if _f is not None: # A registered filter
+ flist.append(_f)
else:
flist.append(f)
- yield utils.apply_filters(task, {os.path.splitext(dest): flist})
+ yield utils.apply_filters(task, {os.path.splitext(dest)[-1]: flist})
def dependence_on_timeline(self, post, lang):
"""Check if a post depends on the timeline."""
diff --git a/nikola/plugins/task/redirect.plugin b/nikola/plugins/task/redirect.plugin
index c3137b9..57bd0c0 100644
--- a/nikola/plugins/task/redirect.plugin
+++ b/nikola/plugins/task/redirect.plugin
@@ -5,9 +5,9 @@ module = redirect
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Create redirect pages.
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/redirect.py b/nikola/plugins/task/redirect.py
index 8530f5e..a89fbd0 100644
--- a/nikola/plugins/task/redirect.py
+++ b/nikola/plugins/task/redirect.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,7 +26,6 @@
"""Generate redirections."""
-from __future__ import unicode_literals
import os
@@ -35,7 +34,6 @@ from nikola import utils
class Redirect(Task):
-
"""Generate redirections."""
name = "redirect"
@@ -46,12 +44,15 @@ class Redirect(Task):
'redirections': self.site.config['REDIRECTIONS'],
'output_folder': self.site.config['OUTPUT_FOLDER'],
'filters': self.site.config['FILTERS'],
+ 'index_file': self.site.config['INDEX_FILE'],
}
yield self.group_task()
if kw['redirections']:
for src, dst in kw["redirections"]:
- src_path = os.path.join(kw["output_folder"], src)
+ src_path = os.path.join(kw["output_folder"], src.lstrip('/'))
+ if src_path.endswith("/"):
+ src_path += kw['index_file']
yield utils.apply_filters({
'basename': self.name,
'name': src_path,
diff --git a/nikola/plugins/task/robots.plugin b/nikola/plugins/task/robots.plugin
index 72ce31f..51f7781 100644
--- a/nikola/plugins/task/robots.plugin
+++ b/nikola/plugins/task/robots.plugin
@@ -5,9 +5,9 @@ module = robots
[Documentation]
author = Daniel Aleksandersen
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Generate /robots.txt exclusion file and promote sitemap.
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/robots.py b/nikola/plugins/task/robots.py
index 65254b6..627d436 100644
--- a/nikola/plugins/task/robots.py
+++ b/nikola/plugins/task/robots.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,20 +26,15 @@
"""Generate a robots.txt file."""
-from __future__ import print_function, absolute_import, unicode_literals
import io
import os
-try:
- from urlparse import urljoin, urlparse
-except ImportError:
- from urllib.parse import urljoin, urlparse # NOQA
+from urllib.parse import urljoin, urlparse
from nikola.plugin_categories import LateTask
from nikola import utils
class RobotsFile(LateTask):
-
"""Generate a robots.txt file."""
name = "robots_file"
@@ -60,18 +55,20 @@ class RobotsFile(LateTask):
def write_robots():
if kw["site_url"] != urljoin(kw["site_url"], "/"):
- utils.LOGGER.warn('robots.txt not ending up in server root, will be useless')
+ utils.LOGGER.warning('robots.txt not ending up in server root, will be useless')
+ utils.LOGGER.info('Add "robots" to DISABLED_PLUGINS to disable this warning and robots.txt generation.')
with io.open(robots_path, 'w+', encoding='utf8') as outf:
outf.write("Sitemap: {0}\n\n".format(sitemapindex_url))
+ outf.write("User-Agent: *\n")
if kw["robots_exclusions"]:
- outf.write("User-Agent: *\n")
for loc in kw["robots_exclusions"]:
outf.write("Disallow: {0}\n".format(loc))
+ outf.write("Host: {0}\n".format(urlparse(kw["base_url"]).netloc))
yield self.group_task()
- if not utils.get_asset_path("robots.txt", [], files_folders=kw["files_folders"]):
+ if not utils.get_asset_path("robots.txt", [], files_folders=kw["files_folders"], output_dir=False):
yield utils.apply_filters({
"basename": self.name,
"name": robots_path,
@@ -82,6 +79,6 @@ class RobotsFile(LateTask):
"task_dep": ["sitemap"]
}, kw["filters"])
elif kw["robots_exclusions"]:
- utils.LOGGER.warn('Did not generate robots.txt as one already exists in FILES_FOLDERS. ROBOTS_EXCLUSIONS will not have any affect on the copied fie.')
+ utils.LOGGER.warning('Did not generate robots.txt as one already exists in FILES_FOLDERS. ROBOTS_EXCLUSIONS will not have any affect on the copied file.')
else:
utils.LOGGER.debug('Did not generate robots.txt as one already exists in FILES_FOLDERS.')
diff --git a/nikola/plugins/task/rss.plugin b/nikola/plugins/task/rss.plugin
deleted file mode 100644
index cf9b7a7..0000000
--- a/nikola/plugins/task/rss.plugin
+++ /dev/null
@@ -1,13 +0,0 @@
-[Core]
-name = generate_rss
-module = rss
-
-[Documentation]
-author = Roberto Alsina
-version = 1.0
-website = http://getnikola.com
-description = Generate RSS feeds.
-
-[Nikola]
-plugincategory = Task
-
diff --git a/nikola/plugins/task/rss.py b/nikola/plugins/task/rss.py
deleted file mode 100644
index 9020a06..0000000
--- a/nikola/plugins/task/rss.py
+++ /dev/null
@@ -1,111 +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.
-
-"""Generate RSS feeds."""
-
-from __future__ import unicode_literals, print_function
-import os
-try:
- from urlparse import urljoin
-except ImportError:
- from urllib.parse import urljoin # NOQA
-
-from nikola import utils
-from nikola.plugin_categories import Task
-
-
-class GenerateRSS(Task):
-
- """Generate RSS feeds."""
-
- name = "generate_rss"
-
- def set_site(self, site):
- """Set Nikola site."""
- site.register_path_handler('rss', self.rss_path)
- return super(GenerateRSS, self).set_site(site)
-
- def gen_tasks(self):
- """Generate RSS feeds."""
- kw = {
- "translations": self.site.config["TRANSLATIONS"],
- "filters": self.site.config["FILTERS"],
- "blog_title": self.site.config["BLOG_TITLE"],
- "site_url": self.site.config["SITE_URL"],
- "base_url": self.site.config["BASE_URL"],
- "blog_description": self.site.config["BLOG_DESCRIPTION"],
- "output_folder": self.site.config["OUTPUT_FOLDER"],
- "rss_teasers": self.site.config["RSS_TEASERS"],
- "rss_plain": self.site.config["RSS_PLAIN"],
- "show_untranslated_posts": self.site.config['SHOW_UNTRANSLATED_POSTS'],
- "feed_length": self.site.config['FEED_LENGTH'],
- "tzinfo": self.site.tzinfo,
- "rss_read_more_link": self.site.config["RSS_READ_MORE_LINK"],
- "rss_links_append_query": self.site.config["RSS_LINKS_APPEND_QUERY"],
- }
- self.site.scan_posts()
- # Check for any changes in the state of use_in_feeds for any post.
- # Issue #934
- kw['use_in_feeds_status'] = ''.join(
- ['T' if x.use_in_feeds else 'F' for x in self.site.timeline]
- )
- yield self.group_task()
- for lang in kw["translations"]:
- output_name = os.path.join(kw['output_folder'],
- self.site.path("rss", None, lang))
- deps = []
- deps_uptodate = []
- if kw["show_untranslated_posts"]:
- posts = self.site.posts[:kw['feed_length']]
- else:
- posts = [x for x in self.site.posts if x.is_translation_available(lang)][:kw['feed_length']]
- for post in posts:
- deps += post.deps(lang)
- deps_uptodate += post.deps_uptodate(lang)
-
- feed_url = urljoin(self.site.config['BASE_URL'], self.site.link("rss", None, lang).lstrip('/'))
-
- task = {
- 'basename': 'generate_rss',
- 'name': os.path.normpath(output_name),
- 'file_dep': deps,
- 'targets': [output_name],
- 'actions': [(utils.generic_rss_renderer,
- (lang, kw["blog_title"](lang), kw["site_url"],
- kw["blog_description"](lang), posts, output_name,
- kw["rss_teasers"], kw["rss_plain"], kw['feed_length'], feed_url,
- None, kw["rss_links_append_query"]))],
-
- 'task_dep': ['render_posts'],
- 'clean': True,
- 'uptodate': [utils.config_changed(kw, 'nikola.plugins.task.rss')] + deps_uptodate,
- }
- yield utils.apply_filters(task, kw['filters'])
-
- def rss_path(self, name, lang):
- """Return RSS path."""
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['RSS_PATH'], 'rss.xml'] if _f]
diff --git a/nikola/plugins/task/scale_images.plugin b/nikola/plugins/task/scale_images.plugin
index d906b8c..332f583 100644
--- a/nikola/plugins/task/scale_images.plugin
+++ b/nikola/plugins/task/scale_images.plugin
@@ -5,9 +5,9 @@ module = scale_images
[Documentation]
author = Pelle Nilsson
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Create down-scaled images and thumbnails.
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/scale_images.py b/nikola/plugins/task/scale_images.py
index 22ed2ab..fa3a67b 100644
--- a/nikola/plugins/task/scale_images.py
+++ b/nikola/plugins/task/scale_images.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2014-2015 Pelle Nilsson and others.
+# Copyright © 2014-2020 Pelle Nilsson and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -34,34 +34,28 @@ from nikola import utils
class ScaleImage(Task, ImageProcessor):
-
"""Resize images and create thumbnails for them."""
name = "scale_images"
- def set_site(self, site):
- """Set Nikola site."""
- self.logger = utils.get_logger('scale_images', utils.STDERR_HANDLER)
- return super(ScaleImage, self).set_site(site)
-
def process_tree(self, src, dst):
"""Process all images in a src tree and put the (possibly) rescaled images in the dst folder."""
- ignore = set(['.svn'])
+ thumb_fmt = self.kw['image_thumbnail_format']
base_len = len(src.split(os.sep))
for root, dirs, files in os.walk(src, followlinks=True):
root_parts = root.split(os.sep)
- if set(root_parts) & ignore:
- continue
dst_dir = os.path.join(dst, *root_parts[base_len:])
utils.makedirs(dst_dir)
for src_name in files:
- if src_name in ('.DS_Store', 'Thumbs.db'):
- continue
if (not src_name.lower().endswith(tuple(self.image_ext_list)) and not src_name.upper().endswith(tuple(self.image_ext_list))):
continue
dst_file = os.path.join(dst_dir, src_name)
src_file = os.path.join(root, src_name)
- thumb_file = '.thumbnail'.join(os.path.splitext(dst_file))
+ thumb_name, thumb_ext = os.path.splitext(src_name)
+ thumb_file = os.path.join(dst_dir, thumb_fmt.format(
+ name=thumb_name,
+ ext=thumb_ext,
+ ))
yield {
'name': dst_file,
'file_dep': [src_file],
@@ -72,17 +66,28 @@ class ScaleImage(Task, ImageProcessor):
def process_image(self, src, dst, thumb):
"""Resize an image."""
- self.resize_image(src, dst, self.kw['max_image_size'], False)
- self.resize_image(src, thumb, self.kw['image_thumbnail_size'], False)
+ self.resize_image(
+ src,
+ dst_paths=[dst, thumb],
+ max_sizes=[self.kw['max_image_size'], self.kw['image_thumbnail_size']],
+ bigger_panoramas=True,
+ preserve_exif_data=self.kw['preserve_exif_data'],
+ exif_whitelist=self.kw['exif_whitelist'],
+ preserve_icc_profiles=self.kw['preserve_icc_profiles']
+ )
def gen_tasks(self):
"""Copy static files into the output folder."""
self.kw = {
'image_thumbnail_size': self.site.config['IMAGE_THUMBNAIL_SIZE'],
+ 'image_thumbnail_format': self.site.config['IMAGE_THUMBNAIL_FORMAT'],
'max_image_size': self.site.config['MAX_IMAGE_SIZE'],
'image_folders': self.site.config['IMAGE_FOLDERS'],
'output_folder': self.site.config['OUTPUT_FOLDER'],
'filters': self.site.config['FILTERS'],
+ 'preserve_exif_data': self.site.config['PRESERVE_EXIF_DATA'],
+ 'exif_whitelist': self.site.config['EXIF_WHITELIST'],
+ 'preserve_icc_profiles': self.site.config['PRESERVE_ICC_PROFILES'],
}
self.image_ext_list = self.image_ext_list_builtin
diff --git a/nikola/plugins/task/sitemap.plugin b/nikola/plugins/task/sitemap.plugin
index e3c991f..c8aa832 100644
--- a/nikola/plugins/task/sitemap.plugin
+++ b/nikola/plugins/task/sitemap.plugin
@@ -5,9 +5,9 @@ module = sitemap
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Generate google sitemap.
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/sitemap/__init__.py b/nikola/plugins/task/sitemap.py
index fd781d6..8bbaa63 100644
--- a/nikola/plugins/task/sitemap/__init__.py
+++ b/nikola/plugins/task/sitemap.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,20 +26,16 @@
"""Generate a sitemap."""
-from __future__ import print_function, absolute_import, unicode_literals
-import io
import datetime
-import dateutil.tz
+import io
import os
-try:
- from urlparse import urljoin, urlparse
- import robotparser as robotparser
-except ImportError:
- from urllib.parse import urljoin, urlparse # NOQA
- import urllib.robotparser as robotparser # NOQA
+import urllib.robotparser as robotparser
+from urllib.parse import urljoin, urlparse
+
+import dateutil.tz
from nikola.plugin_categories import LateTask
-from nikola.utils import config_changed, apply_filters
+from nikola.utils import apply_filters, config_changed, encodelink
urlset_header = """<?xml version="1.0" encoding="UTF-8"?>
@@ -106,7 +102,6 @@ def get_base_path(base):
class Sitemap(LateTask):
-
"""Generate a sitemap."""
name = "sitemap"
@@ -119,7 +114,6 @@ class Sitemap(LateTask):
"output_folder": self.site.config["OUTPUT_FOLDER"],
"strip_indexes": self.site.config["STRIP_INDEXES"],
"index_file": self.site.config["INDEX_FILE"],
- "sitemap_include_fileless_dirs": self.site.config["SITEMAP_INCLUDE_FILELESS_DIRS"],
"mapped_extensions": self.site.config.get('MAPPED_EXTENSIONS', ['.atom', '.html', '.htm', '.php', '.xml', '.rss']),
"robots_exclusions": self.site.config["ROBOTS_EXCLUSIONS"],
"filters": self.site.config["FILTERS"],
@@ -142,31 +136,35 @@ class Sitemap(LateTask):
def scan_locs():
"""Scan site locations."""
for root, dirs, files in os.walk(output, followlinks=True):
- if not dirs and not files and not kw['sitemap_include_fileless_dirs']:
+ if not dirs and not files:
continue # Totally empty, not on sitemap
path = os.path.relpath(root, output)
# ignore the current directory.
- path = (path.replace(os.sep, '/') + '/').replace('./', '')
+ if path == '.':
+ path = syspath = ''
+ else:
+ syspath = path + os.sep
+ path = path.replace(os.sep, '/') + '/'
lastmod = self.get_lastmod(root)
loc = urljoin(base_url, base_path + path)
if kw['index_file'] in files and kw['strip_indexes']: # ignore folders when not stripping urls
- post = self.site.post_per_file.get(path + kw['index_file'])
+ post = self.site.post_per_file.get(syspath + kw['index_file'])
if post and (post.is_draft or post.is_private or post.publish_later):
continue
alternates = []
if post:
- for lang in kw['translations']:
+ for lang in post.translated_to:
alt_url = post.permalink(lang=lang, absolute=True)
- if loc == alt_url:
+ if encodelink(loc) == alt_url:
continue
alternates.append(alternates_format.format(lang, alt_url))
- urlset[loc] = loc_format.format(loc, lastmod, ''.join(alternates))
+ urlset[loc] = loc_format.format(encodelink(loc), lastmod, ''.join(alternates))
for fname in files:
if kw['strip_indexes'] and fname == kw['index_file']:
continue # We already mapped the folder
if os.path.splitext(fname)[-1] in mapped_exts:
real_path = os.path.join(root, fname)
- path = os.path.relpath(real_path, output)
+ path = syspath = os.path.relpath(real_path, output)
if path.endswith(kw['index_file']) and kw['strip_indexes']:
# ignore index files when stripping urls
continue
@@ -174,16 +172,15 @@ class Sitemap(LateTask):
continue
# read in binary mode to make ancient files work
- fh = open(real_path, 'rb')
- filehead = fh.read(1024)
- fh.close()
+ with open(real_path, 'rb') as fh:
+ filehead = fh.read(1024)
if path.endswith('.html') or path.endswith('.htm') or path.endswith('.php'):
- """ ignores "html" files without doctype """
+ # Ignores "html" files without doctype
if b'<!doctype html' not in filehead.lower():
continue
- """ ignores "html" files with noindex robot directives """
+ # Ignores "html" files with noindex robot directives
robots_directives = [b'<meta content=noindex name=robots',
b'<meta content=none name=robots',
b'<meta name=robots content=noindex',
@@ -200,11 +197,11 @@ class Sitemap(LateTask):
path = path.replace(os.sep, '/')
lastmod = self.get_lastmod(real_path)
loc = urljoin(base_url, base_path + path)
- sitemapindex[loc] = sitemap_format.format(loc, lastmod)
+ sitemapindex[loc] = sitemap_format.format(encodelink(loc), lastmod)
continue
else:
continue # ignores all XML files except those presumed to be RSS
- post = self.site.post_per_file.get(path)
+ post = self.site.post_per_file.get(syspath)
if post and (post.is_draft or post.is_private or post.publish_later):
continue
path = path.replace(os.sep, '/')
@@ -212,12 +209,12 @@ class Sitemap(LateTask):
loc = urljoin(base_url, base_path + path)
alternates = []
if post:
- for lang in kw['translations']:
+ for lang in post.translated_to:
alt_url = post.permalink(lang=lang, absolute=True)
- if loc == alt_url:
+ if encodelink(loc) == alt_url:
continue
alternates.append(alternates_format.format(lang, alt_url))
- urlset[loc] = loc_format.format(loc, lastmod, '\n'.join(alternates))
+ urlset[loc] = loc_format.format(encodelink(loc), lastmod, '\n'.join(alternates))
def robot_fetch(path):
"""Check if robots can fetch a file."""
@@ -315,6 +312,7 @@ class Sitemap(LateTask):
lastmod = datetime.datetime.utcfromtimestamp(os.stat(p).st_mtime).replace(tzinfo=dateutil.tz.gettz('UTC'), second=0, microsecond=0).isoformat().replace('+00:00', 'Z')
return lastmod
+
if __name__ == '__main__':
import doctest
doctest.testmod()
diff --git a/nikola/plugins/task/sources.plugin b/nikola/plugins/task/sources.plugin
index d232c2b..1ab1a3c 100644
--- a/nikola/plugins/task/sources.plugin
+++ b/nikola/plugins/task/sources.plugin
@@ -5,9 +5,9 @@ module = sources
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Copy page sources into the output.
[Nikola]
-plugincategory = Task
+PluginCategory = Task
diff --git a/nikola/plugins/task/sources.py b/nikola/plugins/task/sources.py
index 87b4ae7..1d36429 100644
--- a/nikola/plugins/task/sources.py
+++ b/nikola/plugins/task/sources.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
@@ -33,7 +33,6 @@ from nikola import utils
class Sources(Task):
-
"""Copy page sources into the output."""
name = "render_sources"
@@ -62,12 +61,8 @@ class Sources(Task):
# do not publish PHP sources
if post.source_ext(True) == post.compiler.extension():
continue
- source = post.source_path
- if lang != kw["default_lang"]:
- source_lang = utils.get_translation_candidate(self.site.config, source, lang)
- if os.path.exists(source_lang):
- source = source_lang
- if os.path.isfile(source):
+ source = post.translated_source_path(lang)
+ if source is not None and os.path.isfile(source):
yield {
'basename': 'render_sources',
'name': os.path.normpath(output_name),
diff --git a/nikola/plugins/task/tags.plugin b/nikola/plugins/task/tags.plugin
index 283a16a..c17b7b3 100644
--- a/nikola/plugins/task/tags.plugin
+++ b/nikola/plugins/task/tags.plugin
@@ -1,13 +1,12 @@
[Core]
-name = render_tags
+name = classify_tags
module = tags
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Render the tag pages and feeds.
[Nikola]
-plugincategory = Task
-
+PluginCategory = Taxonomy
diff --git a/nikola/plugins/task/tags.py b/nikola/plugins/task/tags.py
index 3186636..aecf8f5 100644
--- a/nikola/plugins/task/tags.py
+++ b/nikola/plugins/task/tags.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,417 +24,137 @@
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""Render the tag/category pages and feeds."""
+"""Render the tag pages and feeds."""
-from __future__ import unicode_literals
-import json
-import os
-import sys
-import natsort
-try:
- from urlparse import urljoin
-except ImportError:
- from urllib.parse import urljoin # NOQA
-from nikola.plugin_categories import Task
+from nikola.plugin_categories import Taxonomy
from nikola import utils
-class RenderTags(Task):
+class ClassifyTags(Taxonomy):
+ """Classify the posts by tags."""
- """Render the tag/category pages and feeds."""
+ name = "classify_tags"
- name = "render_tags"
+ classification_name = "tag"
+ overview_page_variable_name = "tags"
+ overview_page_items_variable_name = "items"
+ more_than_one_classifications_per_post = True
+ has_hierarchy = False
+ show_list_as_subcategories_list = False
+ template_for_classification_overview = "tags.tmpl"
+ always_disable_rss = False
+ always_disable_atom = False
+ apply_to_posts = True
+ apply_to_pages = False
+ omit_empty_classifications = True
+ add_other_languages_variable = True
+ path_handler_docstrings = {
+ 'tag_index': """A link to the tag index.
- def set_site(self, site):
- """Set Nikola site."""
- site.register_path_handler('tag_index', self.tag_index_path)
- site.register_path_handler('category_index', self.category_index_path)
- site.register_path_handler('tag', self.tag_path)
- site.register_path_handler('tag_atom', self.tag_atom_path)
- site.register_path_handler('tag_rss', self.tag_rss_path)
- site.register_path_handler('category', self.category_path)
- site.register_path_handler('category_atom', self.category_atom_path)
- site.register_path_handler('category_rss', self.category_rss_path)
- return super(RenderTags, self).set_site(site)
-
- def gen_tasks(self):
- """Render the tag pages and feeds."""
- kw = {
- "translations": self.site.config["TRANSLATIONS"],
- "blog_title": self.site.config["BLOG_TITLE"],
- "site_url": self.site.config["SITE_URL"],
- "base_url": self.site.config["BASE_URL"],
- "messages": self.site.MESSAGES,
- "output_folder": self.site.config['OUTPUT_FOLDER'],
- "filters": self.site.config['FILTERS'],
- 'tag_path': self.site.config['TAG_PATH'],
- "tag_pages_are_indexes": self.site.config['TAG_PAGES_ARE_INDEXES'],
- 'category_path': self.site.config['CATEGORY_PATH'],
- 'category_prefix': self.site.config['CATEGORY_PREFIX'],
- "category_pages_are_indexes": self.site.config['CATEGORY_PAGES_ARE_INDEXES'],
- "generate_rss": self.site.config['GENERATE_RSS'],
- "rss_teasers": self.site.config["RSS_TEASERS"],
- "rss_plain": self.site.config["RSS_PLAIN"],
- "rss_link_append_query": self.site.config["RSS_LINKS_APPEND_QUERY"],
- "show_untranslated_posts": self.site.config['SHOW_UNTRANSLATED_POSTS'],
- "feed_length": self.site.config['FEED_LENGTH'],
- "taglist_minimum_post_count": self.site.config['TAGLIST_MINIMUM_POSTS'],
- "tzinfo": self.site.tzinfo,
- "pretty_urls": self.site.config['PRETTY_URLS'],
- "strip_indexes": self.site.config['STRIP_INDEXES'],
- "index_file": self.site.config['INDEX_FILE'],
- }
-
- self.site.scan_posts()
- yield self.group_task()
-
- yield self.list_tags_page(kw)
-
- if not self.site.posts_per_tag and not self.site.posts_per_category:
- return
-
- if kw['category_path'] == kw['tag_path']:
- tags = {self.slugify_tag_name(tag): tag for tag in self.site.posts_per_tag.keys()}
- cats = {tuple(self.slugify_category_name(category)): category for category in self.site.posts_per_category.keys()}
- categories = {k[0]: v for k, v in cats.items() if len(k) == 1}
- intersect = set(tags.keys()) & set(categories.keys())
- if len(intersect) > 0:
- for slug in intersect:
- utils.LOGGER.error("Category '{0}' and tag '{1}' both have the same slug '{2}'!".format('/'.join(categories[slug]), tags[slug], slug))
- sys.exit(1)
-
- # Test for category slug clashes
- categories = {}
- for category in self.site.posts_per_category.keys():
- slug = tuple(self.slugify_category_name(category))
- for part in slug:
- if len(part) == 0:
- utils.LOGGER.error("Category '{0}' yields invalid slug '{1}'!".format(category, '/'.join(slug)))
- sys.exit(1)
- if slug in categories:
- other_category = categories[slug]
- utils.LOGGER.error('You have categories that are too similar: {0} and {1}'.format(category, other_category))
- utils.LOGGER.error('Category {0} is used in: {1}'.format(category, ', '.join([p.source_path for p in self.site.posts_per_category[category]])))
- utils.LOGGER.error('Category {0} is used in: {1}'.format(other_category, ', '.join([p.source_path for p in self.site.posts_per_category[other_category]])))
- sys.exit(1)
- categories[slug] = category
-
- tag_list = list(self.site.posts_per_tag.items())
- cat_list = list(self.site.posts_per_category.items())
-
- def render_lists(tag, posts, is_category=True):
- """Render tag pages as RSS files and lists/indexes."""
- post_list = sorted(posts, key=lambda a: a.date)
- post_list.reverse()
- for lang in kw["translations"]:
- if kw["show_untranslated_posts"]:
- filtered_posts = post_list
- else:
- filtered_posts = [x for x in post_list if x.is_translation_available(lang)]
- if kw["generate_rss"]:
- yield self.tag_rss(tag, lang, filtered_posts, kw, is_category)
- # Render HTML
- if kw['category_pages_are_indexes'] if is_category else kw['tag_pages_are_indexes']:
- yield self.tag_page_as_index(tag, lang, filtered_posts, kw, is_category)
- else:
- yield self.tag_page_as_list(tag, lang, filtered_posts, kw, is_category)
-
- for tag, posts in tag_list:
- for task in render_lists(tag, posts, False):
- yield task
-
- for path, posts in cat_list:
- for task in render_lists(path, posts, True):
- yield task
-
- # Tag cloud json file
- tag_cloud_data = {}
- for tag, posts in self.site.posts_per_tag.items():
- if tag in self.site.config['HIDDEN_TAGS']:
- continue
- tag_posts = dict(posts=[{'title': post.meta[post.default_lang]['title'],
- 'date': post.date.strftime('%m/%d/%Y'),
- 'isodate': post.date.isoformat(),
- 'url': post.permalink(post.default_lang)}
- for post in reversed(sorted(self.site.timeline, key=lambda post: post.date))
- if tag in post.alltags])
- tag_cloud_data[tag] = [len(posts), self.site.link(
- 'tag', tag, self.site.config['DEFAULT_LANG']), tag_posts]
- output_name = os.path.join(kw['output_folder'],
- 'assets', 'js', 'tag_cloud_data.json')
-
- def write_tag_data(data):
- """Write tag data into JSON file, for use in tag clouds."""
- utils.makedirs(os.path.dirname(output_name))
- with open(output_name, 'w+') as fd:
- json.dump(data, fd)
-
- if self.site.config['WRITE_TAG_CLOUD']:
- task = {
- 'basename': str(self.name),
- 'name': str(output_name)
- }
-
- task['uptodate'] = [utils.config_changed(tag_cloud_data, 'nikola.plugins.task.tags:tagdata')]
- task['targets'] = [output_name]
- task['actions'] = [(write_tag_data, [tag_cloud_data])]
- task['clean'] = True
- yield utils.apply_filters(task, kw['filters'])
-
- def _create_tags_page(self, kw, include_tags=True, include_categories=True):
- """Create a global "all your tags/categories" page for each language."""
- categories = [cat.category_name for cat in self.site.category_hierarchy]
- has_categories = (categories != []) and include_categories
- template_name = "tags.tmpl"
- kw = kw.copy()
- if include_categories:
- kw['categories'] = categories
- for lang in kw["translations"]:
- tags = natsort.natsorted([tag for tag in self.site.tags_per_language[lang]
- if len(self.site.posts_per_tag[tag]) >= kw["taglist_minimum_post_count"]],
- alg=natsort.ns.F | natsort.ns.IC)
- has_tags = (tags != []) and include_tags
- if include_tags:
- kw['tags'] = tags
- output_name = os.path.join(
- kw['output_folder'], self.site.path('tag_index' if has_tags else 'category_index', None, lang))
- output_name = output_name
- context = {}
- if has_categories and has_tags:
- context["title"] = kw["messages"][lang]["Tags and Categories"]
- elif has_categories:
- context["title"] = kw["messages"][lang]["Categories"]
- else:
- context["title"] = kw["messages"][lang]["Tags"]
- if has_tags:
- context["items"] = [(tag, self.site.link("tag", tag, lang)) for tag
- in tags]
- else:
- context["items"] = None
- if has_categories:
- context["cat_items"] = [(tag, self.site.link("category", tag, lang)) for tag
- in categories]
- context['cat_hierarchy'] = [(node.name, node.category_name, node.category_path,
- self.site.link("category", node.category_name),
- node.indent_levels, node.indent_change_before,
- node.indent_change_after)
- for node in self.site.category_hierarchy]
- else:
- context["cat_items"] = None
- context["permalink"] = self.site.link("tag_index" if has_tags else "category_index", None, lang)
- context["description"] = context["title"]
- context["pagekind"] = ["list", "tags_page"]
- task = self.site.generic_post_list_renderer(
- lang,
- [],
- output_name,
- template_name,
- kw['filters'],
- context,
- )
- task['uptodate'] = task['uptodate'] + [utils.config_changed(kw, 'nikola.plugins.task.tags:page')]
- task['basename'] = str(self.name)
- yield task
-
- def list_tags_page(self, kw):
- """Create a global "all your tags/categories" page for each language."""
- if self.site.config['TAG_PATH'] == self.site.config['CATEGORY_PATH']:
- yield self._create_tags_page(kw, True, True)
- else:
- yield self._create_tags_page(kw, False, True)
- yield self._create_tags_page(kw, True, False)
-
- def _get_title(self, tag, is_category):
- if is_category:
- return self.site.parse_category_name(tag)[-1]
- else:
- return tag
-
- def _get_description(self, tag, is_category, lang):
- descriptions = self.site.config['CATEGORY_PAGES_DESCRIPTIONS'] if is_category else self.site.config['TAG_PAGES_DESCRIPTIONS']
- return descriptions[lang][tag] if lang in descriptions and tag in descriptions[lang] else None
-
- def _get_subcategories(self, category):
- node = self.site.category_hierarchy_lookup[category]
- return [(child.name, self.site.link("category", child.category_name)) for child in node.children]
+Example:
- def tag_page_as_index(self, tag, lang, post_list, kw, is_category):
- """Render a sort of index page collection using only this tag's posts."""
- kind = "category" if is_category else "tag"
+link://tag_index => /tags/index.html""",
+ 'tag': """A link to a tag's page. Takes page number as optional keyword argument.
- def page_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(kind + feed, tag, lang), i, displayed_i, lang, self.site, force_addition, extension)
+Example:
- def page_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(kind + feed, tag, lang), i, displayed_i, lang, self.site, force_addition, extension)
+link://tag/cats => /tags/cats.html""",
+ 'tag_atom': """A link to a tag's Atom feed.
- context_source = {}
- title = self._get_title(tag, is_category)
- if kw["generate_rss"]:
- # On a tag page, the feeds include the tag's feeds
- rss_link = ("""<link rel="alternate" type="application/rss+xml" """
- """type="application/rss+xml" title="RSS for tag """
- """{0} ({1})" href="{2}">""".format(
- title, lang, self.site.link(kind + "_rss", tag, lang)))
- context_source['rss_link'] = rss_link
- if is_category:
- context_source["category"] = tag
- context_source["category_path"] = self.site.parse_category_name(tag)
- context_source["tag"] = title
- indexes_title = kw["messages"][lang]["Posts about %s"] % title
- context_source["description"] = self._get_description(tag, is_category, lang)
- if is_category:
- context_source["subcategories"] = self._get_subcategories(tag)
- context_source["pagekind"] = ["index", "tag_page"]
- template_name = "tagindex.tmpl"
+Example:
- yield self.site.generic_index_renderer(lang, post_list, indexes_title, template_name, context_source, kw, str(self.name), page_link, page_path)
+link://tag_atom/cats => /tags/cats.atom""",
+ 'tag_rss': """A link to a tag's RSS feed.
- def tag_page_as_list(self, tag, lang, post_list, kw, is_category):
- """Render a single flat link list with this tag's posts."""
- kind = "category" if is_category else "tag"
- template_name = "tag.tmpl"
- output_name = os.path.join(kw['output_folder'], self.site.path(
- kind, tag, lang))
- context = {}
- context["lang"] = lang
- title = self._get_title(tag, is_category)
- if is_category:
- context["category"] = tag
- context["category_path"] = self.site.parse_category_name(tag)
- context["tag"] = title
- context["title"] = kw["messages"][lang]["Posts about %s"] % title
- context["posts"] = post_list
- context["permalink"] = self.site.link(kind, tag, lang)
- context["kind"] = kind
- context["description"] = self._get_description(tag, is_category, lang)
- if is_category:
- context["subcategories"] = self._get_subcategories(tag)
- context["pagekind"] = ["list", "tag_page"]
- task = self.site.generic_post_list_renderer(
- lang,
- post_list,
- output_name,
- template_name,
- kw['filters'],
- context,
- )
- task['uptodate'] = task['uptodate'] + [utils.config_changed(kw, 'nikola.plugins.task.tags:list')]
- task['basename'] = str(self.name)
- yield task
+Example:
- def tag_rss(self, tag, lang, posts, kw, is_category):
- """Create a RSS feed for a single tag in a given language."""
- kind = "category" if is_category else "tag"
- # Render RSS
- output_name = os.path.normpath(
- os.path.join(kw['output_folder'],
- self.site.path(kind + "_rss", tag, lang)))
- feed_url = urljoin(self.site.config['BASE_URL'], self.site.link(kind + "_rss", tag, lang).lstrip('/'))
- deps = []
- deps_uptodate = []
- post_list = sorted(posts, key=lambda a: a.date)
- post_list.reverse()
- for post in post_list:
- deps += post.deps(lang)
- deps_uptodate += post.deps_uptodate(lang)
- task = {
- 'basename': str(self.name),
- 'name': output_name,
- 'file_dep': deps,
- 'targets': [output_name],
- 'actions': [(utils.generic_rss_renderer,
- (lang, "{0} ({1})".format(kw["blog_title"](lang), self._get_title(tag, is_category)),
- kw["site_url"], None, post_list,
- output_name, kw["rss_teasers"], kw["rss_plain"], kw['feed_length'],
- feed_url, None, kw["rss_link_append_query"]))],
- 'clean': True,
- 'uptodate': [utils.config_changed(kw, 'nikola.plugins.task.tags:rss')] + deps_uptodate,
- 'task_dep': ['render_posts'],
- }
- return utils.apply_filters(task, kw['filters'])
+link://tag_rss/cats => /tags/cats.xml""",
+ }
- def slugify_tag_name(self, name):
+ def set_site(self, site):
+ """Set site, which is a Nikola instance."""
+ super().set_site(site)
+ self.show_list_as_index = self.site.config['TAG_PAGES_ARE_INDEXES']
+ self.template_for_single_list = "tagindex.tmpl" if self.show_list_as_index else "tag.tmpl"
+ self.minimum_post_count_per_classification_in_overview = self.site.config['TAGLIST_MINIMUM_POSTS']
+ self.translation_manager = utils.ClassificationTranslationManager()
+
+ def is_enabled(self, lang=None):
+ """Return True if this taxonomy is enabled, or False otherwise."""
+ return True
+
+ def classify(self, post, lang):
+ """Classify the given post for the given language."""
+ return post.tags_for_language(lang)
+
+ def get_classification_friendly_name(self, classification, lang, only_last_component=False):
+ """Extract a friendly name from the classification."""
+ return classification
+
+ def slugify_tag_name(self, name, lang):
"""Slugify a tag name."""
if self.site.config['SLUG_TAG_PATH']:
- name = utils.slugify(name)
+ name = utils.slugify(name, lang)
return name
- def tag_index_path(self, name, lang):
- """Return path to the tag index."""
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['TAG_PATH'],
- self.site.config['INDEX_FILE']] if _f]
-
- def category_index_path(self, name, lang):
- """Return path to the category index."""
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['CATEGORY_PATH'],
- self.site.config['INDEX_FILE']] if _f]
-
- def tag_path(self, name, lang):
- """Return path to a tag."""
- if self.site.config['PRETTY_URLS']:
- return [_f for _f in [
- self.site.config['TRANSLATIONS'][lang],
- self.site.config['TAG_PATH'],
- self.slugify_tag_name(name),
- self.site.config['INDEX_FILE']] if _f]
+ def get_overview_path(self, lang, dest_type='page'):
+ """Return a path for the list of all classifications."""
+ if self.site.config['TAGS_INDEX_PATH'](lang):
+ path = self.site.config['TAGS_INDEX_PATH'](lang)
+ append_index = 'never'
else:
- return [_f for _f in [
- self.site.config['TRANSLATIONS'][lang],
- self.site.config['TAG_PATH'],
- self.slugify_tag_name(name) + ".html"] if _f]
-
- def tag_atom_path(self, name, lang):
- """Return path to a tag Atom feed."""
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['TAG_PATH'], self.slugify_tag_name(name) + ".atom"] if
- _f]
-
- def tag_rss_path(self, name, lang):
- """Return path to a tag RSS feed."""
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['TAG_PATH'], self.slugify_tag_name(name) + ".xml"] if
- _f]
-
- def slugify_category_name(self, name):
- """Slugify a category name."""
- path = self.site.parse_category_name(name)
- if self.site.config['CATEGORY_OUTPUT_FLAT_HIERARCHY']:
- path = path[-1:] # only the leaf
- result = [self.slugify_tag_name(part) for part in path]
- result[0] = self.site.config['CATEGORY_PREFIX'] + result[0]
- if not self.site.config['PRETTY_URLS']:
- result = ['-'.join(result)]
- return result
-
- def _add_extension(self, path, extension):
- path[-1] += extension
- return path
+ path = self.site.config['TAG_PATH'](lang)
+ append_index = 'always'
+ return [component for component in path.split('/') if component], append_index
+
+ def get_path(self, classification, lang, dest_type='page'):
+ """Return a path for the given classification."""
+ return [_f for _f in [
+ self.site.config['TAG_PATH'](lang),
+ self.slugify_tag_name(classification, lang)] if _f], 'auto'
+
+ def provide_overview_context_and_uptodate(self, lang):
+ """Provide data for the context and the uptodate list for the list of all classifiations."""
+ kw = {
+ "tag_path": self.site.config['TAG_PATH'],
+ "tag_pages_are_indexes": self.site.config['TAG_PAGES_ARE_INDEXES'],
+ "taglist_minimum_post_count": self.site.config['TAGLIST_MINIMUM_POSTS'],
+ "tzinfo": self.site.tzinfo,
+ "tag_descriptions": self.site.config['TAG_DESCRIPTIONS'],
+ "tag_titles": self.site.config['TAG_TITLES'],
+ }
+ context = {
+ "title": self.site.MESSAGES[lang]["Tags"],
+ "description": self.site.MESSAGES[lang]["Tags"],
+ "pagekind": ["list", "tags_page"],
+ }
+ kw.update(context)
+ return context, kw
- def category_path(self, name, lang):
- """Return path to a category."""
- if self.site.config['PRETTY_URLS']:
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['CATEGORY_PATH']] if
- _f] + self.slugify_category_name(name) + [self.site.config['INDEX_FILE']]
- else:
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['CATEGORY_PATH']] if
- _f] + self._add_extension(self.slugify_category_name(name), ".html")
+ def provide_context_and_uptodate(self, classification, lang, node=None):
+ """Provide data for the context and the uptodate list for the list of the given classifiation."""
+ kw = {
+ "tag_path": self.site.config['TAG_PATH'],
+ "tag_pages_are_indexes": self.site.config['TAG_PAGES_ARE_INDEXES'],
+ "taglist_minimum_post_count": self.site.config['TAGLIST_MINIMUM_POSTS'],
+ "tzinfo": self.site.tzinfo,
+ "tag_descriptions": self.site.config['TAG_DESCRIPTIONS'],
+ "tag_titles": self.site.config['TAG_TITLES'],
+ }
+ context = {
+ "title": self.site.config['TAG_TITLES'].get(lang, {}).get(classification, self.site.MESSAGES[lang]["Posts about %s"] % classification),
+ "description": self.site.config['TAG_DESCRIPTIONS'].get(lang, {}).get(classification),
+ "pagekind": ["tag_page", "index" if self.show_list_as_index else "list"],
+ "tag": classification,
+ }
+ kw.update(context)
+ return context, kw
- def category_atom_path(self, name, lang):
- """Return path to a category Atom feed."""
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['CATEGORY_PATH']] if
- _f] + self._add_extension(self.slugify_category_name(name), ".atom")
+ def get_other_language_variants(self, classification, lang, classifications_per_language):
+ """Return a list of variants of the same tag in other languages."""
+ return self.translation_manager.get_translations_as_list(classification, lang, classifications_per_language)
- def category_rss_path(self, name, lang):
- """Return path to a category RSS feed."""
- return [_f for _f in [self.site.config['TRANSLATIONS'][lang],
- self.site.config['CATEGORY_PATH']] if
- _f] + self._add_extension(self.slugify_category_name(name), ".xml")
+ def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None):
+ """Rearrange, modify or otherwise use the list of posts per classification and per language."""
+ self.translation_manager.read_from_config(self.site, 'TAG', posts_per_classification_per_language, False)
diff --git a/nikola/plugins/task/taxonomies.plugin b/nikola/plugins/task/taxonomies.plugin
new file mode 100644
index 0000000..5bda812
--- /dev/null
+++ b/nikola/plugins/task/taxonomies.plugin
@@ -0,0 +1,12 @@
+[Core]
+name = render_taxonomies
+module = taxonomies
+
+[Documentation]
+author = Roberto Alsina
+version = 1.0
+website = https://getnikola.com/
+description = Render the taxonomy overviews, classification pages and feeds.
+
+[Nikola]
+PluginCategory = Task
diff --git a/nikola/plugins/task/taxonomies.py b/nikola/plugins/task/taxonomies.py
new file mode 100644
index 0000000..7dcf6ed
--- /dev/null
+++ b/nikola/plugins/task/taxonomies.py
@@ -0,0 +1,459 @@
+# -*- 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.
+
+"""Render the taxonomy overviews, classification pages and feeds."""
+
+import os
+from collections import defaultdict
+from copy import copy
+from urllib.parse import urljoin
+
+import blinker
+import natsort
+
+from nikola import utils, hierarchy_utils
+from nikola.nikola import _enclosure
+from nikola.plugin_categories import Task
+
+
+class RenderTaxonomies(Task):
+ """Render taxonomy pages and feeds."""
+
+ name = "render_taxonomies"
+
+ def _generate_classification_overview_kw_context(self, taxonomy, lang):
+ """Create context and kw for a classification overview page."""
+ context, kw = taxonomy.provide_overview_context_and_uptodate(lang)
+
+ context = copy(context)
+ context["kind"] = "{}_index".format(taxonomy.classification_name)
+ sorted_links = []
+ for other_lang in sorted(self.site.config['TRANSLATIONS'].keys()):
+ if other_lang != lang:
+ sorted_links.append((other_lang, None, None))
+ # Put the current language in front, so that it appears first in links
+ # (Issue #3248)
+ sorted_links_all = [(lang, None, None)] + sorted_links
+ context['has_other_languages'] = True
+ context['other_languages'] = sorted_links
+ context['all_languages'] = sorted_links_all
+
+ kw = copy(kw)
+ kw["messages"] = self.site.MESSAGES
+ kw["translations"] = self.site.config['TRANSLATIONS']
+ kw["filters"] = self.site.config['FILTERS']
+ kw["minimum_post_count"] = taxonomy.minimum_post_count_per_classification_in_overview
+ kw["output_folder"] = self.site.config['OUTPUT_FOLDER']
+ kw["pretty_urls"] = self.site.config['PRETTY_URLS']
+ kw["strip_indexes"] = self.site.config['STRIP_INDEXES']
+ kw["index_file"] = self.site.config['INDEX_FILE']
+
+ # Collect all relevant classifications
+ if taxonomy.has_hierarchy:
+ def acceptor(node):
+ return len(self._filter_list(self.site.posts_per_classification[taxonomy.classification_name][lang][node.classification_name], lang)) >= kw["minimum_post_count"]
+
+ clipped_root_list = [hierarchy_utils.clone_treenode(node, parent=None, acceptor=acceptor) for node in self.site.hierarchy_per_classification[taxonomy.classification_name][lang]]
+ clipped_root_list = [node for node in clipped_root_list if node]
+ clipped_flat_hierarchy = hierarchy_utils.flatten_tree_structure(clipped_root_list)
+
+ classifications = [cat.classification_name for cat in clipped_flat_hierarchy]
+ else:
+ classifications = natsort.natsorted([tag for tag, posts in self.site.posts_per_classification[taxonomy.classification_name][lang].items()
+ if len(self._filter_list(posts, lang)) >= kw["minimum_post_count"]],
+ alg=natsort.ns.F | natsort.ns.IC)
+ taxonomy.sort_classifications(classifications, lang)
+
+ # Set up classifications in context
+ context[taxonomy.overview_page_variable_name] = classifications
+ context["has_hierarchy"] = taxonomy.has_hierarchy
+ if taxonomy.overview_page_items_variable_name:
+ items = [(classification,
+ self.site.link(taxonomy.classification_name, classification, lang))
+ for classification in classifications]
+ items_with_postcount = [
+ (classification,
+ self.site.link(taxonomy.classification_name, classification, lang),
+ len(self._filter_list(self.site.posts_per_classification[taxonomy.classification_name][lang][classification], lang)))
+ for classification in classifications
+ ]
+ context[taxonomy.overview_page_items_variable_name] = items
+ context[taxonomy.overview_page_items_variable_name + "_with_postcount"] = items_with_postcount
+ if taxonomy.has_hierarchy and taxonomy.overview_page_hierarchy_variable_name:
+ hier_items = [
+ (node.name, node.classification_name, node.classification_path,
+ self.site.link(taxonomy.classification_name, node.classification_name, lang),
+ node.indent_levels, node.indent_change_before,
+ node.indent_change_after)
+ for node in clipped_flat_hierarchy
+ ]
+ hier_items_with_postcount = [
+ (node.name, node.classification_name, node.classification_path,
+ self.site.link(taxonomy.classification_name, node.classification_name, lang),
+ node.indent_levels, node.indent_change_before,
+ node.indent_change_after,
+ len(node.children),
+ len(self._filter_list(self.site.posts_per_classification[taxonomy.classification_name][lang][node.classification_name], lang)))
+ for node in clipped_flat_hierarchy
+ ]
+ context[taxonomy.overview_page_hierarchy_variable_name] = hier_items
+ context[taxonomy.overview_page_hierarchy_variable_name + '_with_postcount'] = hier_items_with_postcount
+ return context, kw
+
+ def _render_classification_overview(self, classification_name, template, lang, context, kw):
+ # Prepare rendering
+ context["permalink"] = self.site.link("{}_index".format(classification_name), None, lang)
+ if "pagekind" not in context:
+ context["pagekind"] = ["list", "tags_page"]
+ output_name = os.path.join(self.site.config['OUTPUT_FOLDER'], self.site.path('{}_index'.format(classification_name), None, lang))
+ blinker.signal('generate_classification_overview').send({
+ 'site': self.site,
+ 'classification_name': classification_name,
+ 'lang': lang,
+ 'context': context,
+ 'kw': kw,
+ 'output_name': output_name,
+ })
+ task = self.site.generic_post_list_renderer(
+ lang,
+ [],
+ output_name,
+ template,
+ kw['filters'],
+ context,
+ )
+ task['uptodate'] = task['uptodate'] + [utils.config_changed(kw, 'nikola.plugins.task.taxonomies:page')]
+ task['basename'] = str(self.name)
+ yield task
+
+ def _generate_classification_overview(self, taxonomy, lang):
+ """Create a global "all your tags/categories" page for a given language."""
+ context, kw = self._generate_classification_overview_kw_context(taxonomy, lang)
+ for task in self._render_classification_overview(taxonomy.classification_name, taxonomy.template_for_classification_overview, lang, context, kw):
+ yield task
+
+ def _generate_tag_and_category_overview(self, tag_taxonomy, category_taxonomy, lang):
+ """Create a global "all your tags/categories" page for a given language."""
+ # Create individual contexts and kw dicts
+ tag_context, tag_kw = self._generate_classification_overview_kw_context(tag_taxonomy, lang)
+ cat_context, cat_kw = self._generate_classification_overview_kw_context(category_taxonomy, lang)
+
+ # Combine resp. select dicts
+ if tag_context['items'] and cat_context['cat_items']:
+ # Combine contexts. We must merge the tag context into the category context
+ # so that tag_context['items'] makes it into the result.
+ context = cat_context
+ context.update(tag_context)
+ kw = cat_kw
+ kw.update(tag_kw)
+
+ # Update title
+ title = self.site.MESSAGES[lang]["Tags and Categories"]
+ context['title'] = title
+ context['description'] = title
+ kw['title'] = title
+ kw['description'] = title
+ elif cat_context['cat_items']:
+ # Use category overview page
+ context = cat_context
+ kw = cat_kw
+ else:
+ # Use tag overview page
+ context = tag_context
+ kw = tag_kw
+
+ # Render result
+ for task in self._render_classification_overview('tag', tag_taxonomy.template_for_classification_overview, lang, context, kw):
+ yield task
+
+ def _generate_classification_page_as_rss(self, taxonomy, classification, filtered_posts, title, description, kw, lang):
+ """Create a RSS feed for a single classification in a given language."""
+ kind = taxonomy.classification_name
+ # Render RSS
+ output_name = os.path.normpath(os.path.join(self.site.config['OUTPUT_FOLDER'], self.site.path(kind + "_rss", classification, lang)))
+ feed_url = urljoin(self.site.config['BASE_URL'], self.site.link(kind + "_rss", classification, lang).lstrip('/'))
+ deps = []
+ deps_uptodate = []
+ for post in filtered_posts:
+ deps += post.deps(lang)
+ deps_uptodate += post.deps_uptodate(lang)
+ blog_title = kw["blog_title"](lang)
+ task = {
+ 'basename': str(self.name),
+ 'name': output_name,
+ 'file_dep': deps,
+ 'targets': [output_name],
+ 'actions': [(utils.generic_rss_renderer,
+ (lang, "{0} ({1})".format(blog_title, title) if blog_title != title else blog_title,
+ kw["site_url"], description, filtered_posts,
+ output_name, kw["feed_teasers"], kw["feed_plain"], kw['feed_length'],
+ feed_url, _enclosure, kw["feed_links_append_query"]))],
+ 'clean': True,
+ 'uptodate': [utils.config_changed(kw, 'nikola.plugins.task.taxonomies:rss')] + deps_uptodate,
+ 'task_dep': ['render_posts'],
+ }
+ return utils.apply_filters(task, kw['filters'])
+
+ def _generate_classification_page_as_index(self, taxonomy, classification, filtered_posts, context, kw, lang):
+ """Render an index page collection using only this classification's posts."""
+ kind = taxonomy.classification_name
+
+ def page_link(i, displayed_i, num_pages, force_addition, extension=None):
+ return self.site.link(kind, classification, lang, alternative_path=force_addition, page=i)
+
+ def page_path(i, displayed_i, num_pages, force_addition, extension=None):
+ return self.site.path(kind, classification, lang, alternative_path=force_addition, page=i)
+
+ context = copy(context)
+ context["kind"] = kind
+ if "pagekind" not in context:
+ context["pagekind"] = ["index", "tag_page"]
+ template_name = taxonomy.template_for_single_list
+
+ yield self.site.generic_index_renderer(lang, filtered_posts, context['title'], template_name, context, kw, str(self.name), page_link, page_path)
+
+ def _generate_classification_page_as_atom(self, taxonomy, classification, filtered_posts, context, kw, lang):
+ """Generate atom feeds for classification lists."""
+ kind = taxonomy.classification_name
+
+ context = copy(context)
+ context["kind"] = kind
+
+ yield self.site.generic_atom_renderer(lang, filtered_posts, context, kw, str(self.name), classification, kind)
+
+ def _generate_classification_page_as_list(self, taxonomy, classification, filtered_posts, context, kw, lang):
+ """Render a single flat link list with this classification's posts."""
+ kind = taxonomy.classification_name
+ template_name = taxonomy.template_for_single_list
+ output_name = os.path.join(self.site.config['OUTPUT_FOLDER'], self.site.path(kind, classification, lang))
+ context["lang"] = lang
+ # list.tmpl expects a different format than list_post.tmpl (Issue #2701)
+ if template_name == 'list.tmpl':
+ context["items"] = [(post.title(lang), post.permalink(lang), None) for post in filtered_posts]
+ else:
+ context["posts"] = filtered_posts
+ if "pagekind" not in context:
+ context["pagekind"] = ["list", "tag_page"]
+ task = self.site.generic_post_list_renderer(lang, filtered_posts, output_name, template_name, kw['filters'], context)
+ task['uptodate'] = task['uptodate'] + [utils.config_changed(kw, 'nikola.plugins.task.taxonomies:list')]
+ task['basename'] = str(self.name)
+ yield task
+
+ def _filter_list(self, post_list, lang):
+ """Return only the posts which should be shown for this language."""
+ if self.site.config["SHOW_UNTRANSLATED_POSTS"]:
+ return post_list
+ else:
+ return [x for x in post_list if x.is_translation_available(lang)]
+
+ def _generate_subclassification_page(self, taxonomy, node, context, kw, lang):
+ """Render a list of subclassifications."""
+ def get_subnode_data(subnode):
+ return [
+ taxonomy.get_classification_friendly_name(subnode.classification_name, lang, only_last_component=True),
+ self.site.link(taxonomy.classification_name, subnode.classification_name, lang),
+ len(self._filter_list(self.site.posts_per_classification[taxonomy.classification_name][lang][subnode.classification_name], lang))
+ ]
+
+ items = [get_subnode_data(subnode) for subnode in node.children]
+ context = copy(context)
+ context["lang"] = lang
+ context["permalink"] = self.site.link(taxonomy.classification_name, node.classification_name, lang)
+ if "pagekind" not in context:
+ context["pagekind"] = ["list", "archive_page"]
+ context["items"] = items
+ task = self.site.generic_post_list_renderer(
+ lang,
+ [],
+ os.path.join(kw['output_folder'], self.site.path(taxonomy.classification_name, node.classification_name, lang)),
+ taxonomy.subcategories_list_template,
+ kw['filters'],
+ context,
+ )
+ task_cfg = {1: kw, 2: items}
+ task['uptodate'] = task['uptodate'] + [utils.config_changed(task_cfg, 'nikola.plugins.task.taxonomy')]
+ task['basename'] = self.name
+ return task
+
+ def _generate_classification_page(self, taxonomy, classification, filtered_posts, generate_list, generate_rss, generate_atom, lang, post_lists_per_lang, classification_set_per_lang=None):
+ """Render index or post list and associated feeds per classification."""
+ # Should we create this list?
+ if not any((generate_list, generate_rss, generate_atom)):
+ return
+ # Get data
+ node = None
+ if taxonomy.has_hierarchy:
+ node = self.site.hierarchy_lookup_per_classification[taxonomy.classification_name][lang].get(classification)
+ context, kw = taxonomy.provide_context_and_uptodate(classification, lang, node)
+ kw = copy(kw)
+ kw["messages"] = self.site.MESSAGES
+ kw["translations"] = self.site.config['TRANSLATIONS']
+ kw["filters"] = self.site.config['FILTERS']
+ kw["site_url"] = self.site.config['SITE_URL']
+ kw["blog_title"] = self.site.config['BLOG_TITLE']
+ kw["generate_rss"] = self.site.config['GENERATE_RSS']
+ kw["generate_atom"] = self.site.config['GENERATE_ATOM']
+ kw["feed_teasers"] = self.site.config["FEED_TEASERS"]
+ kw["feed_plain"] = self.site.config["FEED_PLAIN"]
+ kw["feed_links_append_query"] = self.site.config["FEED_LINKS_APPEND_QUERY"]
+ kw["feed_length"] = self.site.config['FEED_LENGTH']
+ kw["output_folder"] = self.site.config['OUTPUT_FOLDER']
+ kw["pretty_urls"] = self.site.config['PRETTY_URLS']
+ kw["strip_indexes"] = self.site.config['STRIP_INDEXES']
+ kw["index_file"] = self.site.config['INDEX_FILE']
+ context = copy(context)
+ context["permalink"] = self.site.link(taxonomy.classification_name, classification, lang)
+ context["kind"] = taxonomy.classification_name
+ # Get links to other language versions of this classification
+ if classification_set_per_lang is not None:
+ other_lang_links = taxonomy.get_other_language_variants(classification, lang, classification_set_per_lang)
+ # Collect by language
+ links_per_lang = defaultdict(list)
+ for other_lang, link in other_lang_links:
+ # Make sure we ignore the current language (in case the
+ # plugin accidentally returns links for it as well)
+ if other_lang != lang:
+ links_per_lang[other_lang].append(link)
+ # Sort first by language, then by classification
+ sorted_links = []
+ sorted_links_all = []
+ for other_lang in sorted(list(links_per_lang.keys()) + [lang]):
+ if other_lang == lang:
+ sorted_links_all.append((lang, classification, taxonomy.get_classification_friendly_name(classification, lang)))
+ else:
+ links = hierarchy_utils.sort_classifications(taxonomy, links_per_lang[other_lang], other_lang)
+ links = [(other_lang, other_classification,
+ taxonomy.get_classification_friendly_name(other_classification, other_lang))
+ for other_classification in links if post_lists_per_lang[other_lang].get(other_classification, ('', False, False))[1]]
+ sorted_links.extend(links)
+ sorted_links_all.extend(links)
+ # Store result in context and kw
+ context['has_other_languages'] = True
+ context['other_languages'] = sorted_links
+ context['all_languages'] = sorted_links_all
+ kw['other_languages'] = sorted_links
+ kw['all_languages'] = sorted_links_all
+ else:
+ context['has_other_languages'] = False
+ # Allow other plugins to modify the result
+ blinker.signal('generate_classification_page').send({
+ 'site': self.site,
+ 'taxonomy': taxonomy,
+ 'classification': classification,
+ 'lang': lang,
+ 'posts': filtered_posts,
+ 'context': context,
+ 'kw': kw,
+ })
+ # Decide what to do
+ if taxonomy.has_hierarchy and taxonomy.show_list_as_subcategories_list:
+ # Determine whether there are subcategories
+ node = self.site.hierarchy_lookup_per_classification[taxonomy.classification_name][lang][classification]
+ # Are there subclassifications?
+ if len(node.children) > 0:
+ # Yes: create list with subclassifications instead of list of posts
+ if generate_list:
+ yield self._generate_subclassification_page(taxonomy, node, context, kw, lang)
+ return
+ # Generate RSS feed
+ if generate_rss and kw["generate_rss"] and not taxonomy.always_disable_rss:
+ yield self._generate_classification_page_as_rss(taxonomy, classification, filtered_posts, context['title'], context.get("description"), kw, lang)
+
+ # Generate Atom feed
+ if generate_atom and kw["generate_atom"] and not taxonomy.always_disable_atom:
+ yield self._generate_classification_page_as_atom(taxonomy, classification, filtered_posts, context, kw, lang)
+
+ # Render HTML
+ if generate_list and taxonomy.show_list_as_index:
+ yield self._generate_classification_page_as_index(taxonomy, classification, filtered_posts, context, kw, lang)
+ elif generate_list:
+ yield self._generate_classification_page_as_list(taxonomy, classification, filtered_posts, context, kw, lang)
+
+ def gen_tasks(self):
+ """Render the tag pages and feeds."""
+ self.site.scan_posts()
+ yield self.group_task()
+
+ # Cache classification sets per language for taxonomies where
+ # add_other_languages_variable is True.
+ classification_set_per_lang = {}
+ for taxonomy in self.site.taxonomy_plugins.values():
+ if taxonomy.add_other_languages_variable:
+ lookup = self.site.posts_per_classification[taxonomy.classification_name]
+ cspl = {lang: set(lookup[lang].keys()) for lang in lookup}
+ classification_set_per_lang[taxonomy.classification_name] = cspl
+
+ # Collect post lists for classification pages and determine whether
+ # they should be generated.
+ post_lists_per_lang = {}
+ for taxonomy in self.site.taxonomy_plugins.values():
+ plpl = {}
+ for lang in self.site.config["TRANSLATIONS"]:
+ result = {}
+ for classification, posts in self.site.posts_per_classification[taxonomy.classification_name][lang].items():
+ # Filter list
+ filtered_posts = self._filter_list(posts, lang)
+ if len(filtered_posts) == 0 and taxonomy.omit_empty_classifications:
+ generate_list = generate_rss = generate_atom = False
+ else:
+ # Should we create this list?
+ generate_list = taxonomy.should_generate_classification_page(classification, filtered_posts, lang)
+ generate_rss = taxonomy.should_generate_rss_for_classification_page(classification, filtered_posts, lang)
+ generate_atom = taxonomy.should_generate_atom_for_classification_page(classification, filtered_posts, lang)
+ result[classification] = (filtered_posts, generate_list, generate_rss, generate_atom)
+ plpl[lang] = result
+ post_lists_per_lang[taxonomy.classification_name] = plpl
+
+ # Now generate pages
+ for lang in self.site.config["TRANSLATIONS"]:
+ # To support that tag and category classifications share the same overview,
+ # we explicitly detect this case:
+ ignore_plugins_for_overview = set()
+ if 'tag' in self.site.taxonomy_plugins and 'category' in self.site.taxonomy_plugins and self.site.link("tag_index", None, lang) == self.site.link("category_index", None, lang):
+ # Block both plugins from creating overviews
+ ignore_plugins_for_overview.add(self.site.taxonomy_plugins['tag'])
+ ignore_plugins_for_overview.add(self.site.taxonomy_plugins['category'])
+ for taxonomy in self.site.taxonomy_plugins.values():
+ if not taxonomy.is_enabled(lang):
+ continue
+ # Generate list of classifications (i.e. classification overview)
+ if taxonomy not in ignore_plugins_for_overview:
+ if taxonomy.template_for_classification_overview is not None:
+ for task in self._generate_classification_overview(taxonomy, lang):
+ yield task
+
+ # Process classifications
+ for classification, (filtered_posts, generate_list, generate_rss, generate_atom) in post_lists_per_lang[taxonomy.classification_name][lang].items():
+ for task in self._generate_classification_page(taxonomy, classification, filtered_posts,
+ generate_list, generate_rss, generate_atom, lang,
+ post_lists_per_lang[taxonomy.classification_name],
+ classification_set_per_lang.get(taxonomy.classification_name)):
+ yield task
+ # In case we are ignoring plugins for overview, we must have a collision for
+ # tags and categories. Handle this special case with extra code.
+ if ignore_plugins_for_overview:
+ for task in self._generate_tag_and_category_overview(self.site.taxonomy_plugins['tag'], self.site.taxonomy_plugins['category'], lang):
+ yield task
diff --git a/nikola/plugins/template/__init__.py b/nikola/plugins/template/__init__.py
index d416ad7..a530db4 100644
--- a/nikola/plugins/template/__init__.py
+++ b/nikola/plugins/template/__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/template/jinja.plugin b/nikola/plugins/template/jinja.plugin
index cfe9fa8..629b20e 100644
--- a/nikola/plugins/template/jinja.plugin
+++ b/nikola/plugins/template/jinja.plugin
@@ -5,9 +5,9 @@ module = jinja
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Support for Jinja2 templates.
[Nikola]
-plugincategory = Template
+PluginCategory = Template
diff --git a/nikola/plugins/template/jinja.py b/nikola/plugins/template/jinja.py
index b02d75c..7795739 100644
--- a/nikola/plugins/template/jinja.py
+++ b/nikola/plugins/template/jinja.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,47 +24,51 @@
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
"""Jinja template handler."""
-from __future__ import unicode_literals
-import os
+import io
import json
-from collections import deque
+import os
+
+from nikola.plugin_categories import TemplateSystem
+from nikola.utils import makedirs, req_missing, sort_posts, _smartjoin_filter
+
try:
import jinja2
from jinja2 import meta
except ImportError:
- jinja2 = None # NOQA
-
-from nikola.plugin_categories import TemplateSystem
-from nikola.utils import makedirs, req_missing
+ jinja2 = None
class JinjaTemplates(TemplateSystem):
-
"""Support for Jinja2 templates."""
name = "jinja"
lookup = None
dependency_cache = {}
+ per_file_cache = {}
def __init__(self):
"""Initialize Jinja2 environment with extended set of filters."""
if jinja2 is None:
return
- self.lookup = jinja2.Environment()
+
+ def set_directories(self, directories, cache_folder):
+ """Create a new template lookup with set directories."""
+ if jinja2 is None:
+ req_missing(['jinja2'], 'use this theme')
+ cache_folder = os.path.join(cache_folder, 'jinja')
+ makedirs(cache_folder)
+ cache = jinja2.FileSystemBytecodeCache(cache_folder)
+ self.lookup = jinja2.Environment(bytecode_cache=cache)
self.lookup.trim_blocks = True
self.lookup.lstrip_blocks = True
self.lookup.filters['tojson'] = json.dumps
+ self.lookup.filters['sort_posts'] = sort_posts
+ self.lookup.filters['smartjoin'] = _smartjoin_filter
self.lookup.globals['enumerate'] = enumerate
self.lookup.globals['isinstance'] = isinstance
self.lookup.globals['tuple'] = tuple
-
- def set_directories(self, directories, cache_folder):
- """Create a new template lookup with set directories."""
- if jinja2 is None:
- req_missing(['jinja2'], 'use this theme')
self.directories = directories
self.create_lookup()
@@ -89,36 +93,46 @@ class JinjaTemplates(TemplateSystem):
if jinja2 is None:
req_missing(['jinja2'], 'use this theme')
template = self.lookup.get_template(template_name)
- output = template.render(**context)
+ data = template.render(**context)
if output_name is not None:
makedirs(os.path.dirname(output_name))
- with open(output_name, 'w+') as output:
- output.write(output.encode('utf8'))
- return output
+ with io.open(output_name, 'w', encoding='utf-8') as output:
+ output.write(data)
+ return data
def render_template_to_string(self, template, context):
"""Render template to a string using context."""
return self.lookup.from_string(template).render(**context)
+ def get_string_deps(self, text):
+ """Find dependencies for a template string."""
+ deps = set([])
+ ast = self.lookup.parse(text)
+ dep_names = [d for d in meta.find_referenced_templates(ast) if d]
+ for dep_name in dep_names:
+ filename = self.lookup.loader.get_source(self.lookup, dep_name)[1]
+ sub_deps = [filename] + self.get_deps(filename)
+ self.dependency_cache[dep_name] = sub_deps
+ deps |= set(sub_deps)
+ return list(deps)
+
+ def get_deps(self, filename):
+ """Return paths to dependencies for the template loaded from filename."""
+ with io.open(filename, 'r', encoding='utf-8-sig') as fd:
+ text = fd.read()
+ return self.get_string_deps(text)
+
def template_deps(self, template_name):
"""Generate list of dependencies for a template."""
- # Cache the lists of dependencies for each template name.
if self.dependency_cache.get(template_name) is None:
- # Use a breadth-first search to find all templates this one
- # depends on.
- queue = deque([template_name])
- visited_templates = set([template_name])
- deps = []
- while len(queue) > 0:
- curr = queue.popleft()
- source, filename = self.lookup.loader.get_source(self.lookup,
- curr)[:2]
- deps.append(filename)
- ast = self.lookup.parse(source)
- dep_names = meta.find_referenced_templates(ast)
- for dep_name in dep_names:
- if (dep_name not in visited_templates and dep_name is not None):
- visited_templates.add(dep_name)
- queue.append(dep_name)
- self.dependency_cache[template_name] = deps
+ filename = self.lookup.loader.get_source(self.lookup, template_name)[1]
+ self.dependency_cache[template_name] = [filename] + self.get_deps(filename)
return self.dependency_cache[template_name]
+
+ def get_template_path(self, template_name):
+ """Get the path to a template or return None."""
+ try:
+ t = self.lookup.get_template(template_name)
+ return t.filename
+ except jinja2.TemplateNotFound:
+ return None
diff --git a/nikola/plugins/template/mako.plugin b/nikola/plugins/template/mako.plugin
index d256faf..2d353bf 100644
--- a/nikola/plugins/template/mako.plugin
+++ b/nikola/plugins/template/mako.plugin
@@ -5,9 +5,9 @@ module = mako
[Documentation]
author = Roberto Alsina
version = 1.0
-website = http://getnikola.com
+website = https://getnikola.com/
description = Support for Mako templates.
[Nikola]
-plugincategory = Template
+PluginCategory = Template
diff --git a/nikola/plugins/template/mako.py b/nikola/plugins/template/mako.py
index aed6596..30e2041 100644
--- a/nikola/plugins/template/mako.py
+++ b/nikola/plugins/template/mako.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,25 +26,22 @@
"""Mako template handler."""
-from __future__ import unicode_literals, print_function, absolute_import
+import io
import os
import shutil
-import sys
-import tempfile
-from mako import util, lexer, parsetree
+from mako import exceptions, util, lexer, parsetree
from mako.lookup import TemplateLookup
from mako.template import Template
from markupsafe import Markup # It's ok, Mako requires it
from nikola.plugin_categories import TemplateSystem
-from nikola.utils import makedirs, get_logger, STDERR_HANDLER
+from nikola.utils import makedirs, get_logger
-LOGGER = get_logger('mako', STDERR_HANDLER)
+LOGGER = get_logger('mako')
class MakoTemplates(TemplateSystem):
-
"""Support for Mako templates."""
name = "mako"
@@ -55,10 +52,9 @@ class MakoTemplates(TemplateSystem):
directories = []
cache_dir = None
- def get_deps(self, filename):
- """Get dependencies for a template (internal function)."""
- text = util.read_file(filename)
- lex = lexer.Lexer(text=text, filename=filename)
+ def get_string_deps(self, text, filename=None):
+ """Find dependencies for a template string."""
+ lex = lexer.Lexer(text=text, filename=filename, input_encoding='utf-8')
lex.parse()
deps = []
@@ -66,18 +62,25 @@ class MakoTemplates(TemplateSystem):
keyword = getattr(n, 'keyword', None)
if keyword in ["inherit", "namespace"] or isinstance(n, parsetree.IncludeTag):
deps.append(n.attributes['file'])
+ # Some templates will include "foo.tmpl" and we need paths, so normalize them
+ # using the template lookup
+ for i, d in enumerate(deps):
+ dep = self.get_template_path(d)
+ if dep:
+ deps[i] = dep
+ else:
+ LOGGER.error("Cannot find template {0} referenced in {1}",
+ d, filename)
return deps
+ def get_deps(self, filename):
+ """Get paths to dependencies for a template."""
+ text = util.read_file(filename)
+ return self.get_string_deps(text, filename)
+
def set_directories(self, directories, cache_folder):
"""Create a new template lookup with set directories."""
cache_dir = os.path.join(cache_folder, '.mako.tmp')
- # Workaround for a Mako bug, Issue #825
- if sys.version_info[0] == 2:
- try:
- os.path.abspath(cache_dir).decode('ascii')
- except UnicodeEncodeError:
- cache_dir = tempfile.mkdtemp()
- LOGGER.warning('Because of a Mako bug, setting cache_dir to {0}'.format(cache_dir))
if os.path.exists(cache_dir):
shutil.rmtree(cache_dir)
self.directories = directories
@@ -95,6 +98,7 @@ class MakoTemplates(TemplateSystem):
self.lookup = TemplateLookup(
directories=self.directories,
module_directory=self.cache_dir,
+ input_encoding='utf-8',
output_encoding='utf-8')
def set_site(self, site):
@@ -109,14 +113,14 @@ class MakoTemplates(TemplateSystem):
data = template.render_unicode(**context)
if output_name is not None:
makedirs(os.path.dirname(output_name))
- with open(output_name, 'w+') as output:
+ with io.open(output_name, 'w', encoding='utf-8') as output:
output.write(data)
return data
def render_template_to_string(self, template, context):
"""Render template to a string using context."""
context.update(self.filters)
- return Template(template).render(**context)
+ return Template(template, lookup=self.lookup).render(**context)
def template_deps(self, template_name):
"""Generate list of dependencies for a template."""
@@ -127,9 +131,18 @@ class MakoTemplates(TemplateSystem):
dep_filenames = self.get_deps(template.filename)
deps = [template.filename]
for fname in dep_filenames:
- deps += self.template_deps(fname)
- self.cache[template_name] = tuple(deps)
- return list(self.cache[template_name])
+ # yes, it uses forward slashes on Windows
+ deps += self.template_deps(fname.split('/')[-1])
+ self.cache[template_name] = list(set(deps))
+ return self.cache[template_name]
+
+ def get_template_path(self, template_name):
+ """Get the path to a template or return None."""
+ try:
+ t = self.lookup.get_template(template_name)
+ return t.filename
+ except exceptions.TopLevelLookupException:
+ return None
def striphtml(text):