aboutsummaryrefslogtreecommitdiffstats
path: root/nikola/plugins/command
diff options
context:
space:
mode:
Diffstat (limited to 'nikola/plugins/command')
-rw-r--r--nikola/plugins/command/__init__.py2
-rw-r--r--nikola/plugins/command/auto.plugin2
-rw-r--r--nikola/plugins/command/auto/__init__.py690
l---------nikola/plugins/command/auto/livereload.js2
-rw-r--r--nikola/plugins/command/bootswatch_theme.py116
-rw-r--r--nikola/plugins/command/check.plugin2
-rw-r--r--nikola/plugins/command/check.py104
-rw-r--r--nikola/plugins/command/console.plugin2
-rw-r--r--nikola/plugins/command/console.py45
-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.plugin2
-rw-r--r--nikola/plugins/command/deploy.py54
-rw-r--r--nikola/plugins/command/github_deploy.plugin2
-rw-r--r--nikola/plugins/command/github_deploy.py43
-rw-r--r--nikola/plugins/command/import_wordpress.plugin2
-rw-r--r--nikola/plugins/command/import_wordpress.py283
-rw-r--r--nikola/plugins/command/init.plugin2
-rw-r--r--nikola/plugins/command/init.py88
-rw-r--r--nikola/plugins/command/install_theme.plugin13
-rw-r--r--nikola/plugins/command/install_theme.py91
-rw-r--r--nikola/plugins/command/new_page.plugin2
-rw-r--r--nikola/plugins/command/new_page.py4
-rw-r--r--nikola/plugins/command/new_post.plugin2
-rw-r--r--nikola/plugins/command/new_post.py105
-rw-r--r--nikola/plugins/command/orphans.plugin2
-rw-r--r--nikola/plugins/command/orphans.py3
-rw-r--r--nikola/plugins/command/plugin.plugin2
-rw-r--r--nikola/plugins/command/plugin.py109
-rw-r--r--nikola/plugins/command/rst2html.plugin2
-rw-r--r--nikola/plugins/command/rst2html/__init__.py11
-rw-r--r--nikola/plugins/command/serve.plugin2
-rw-r--r--nikola/plugins/command/serve.py87
-rw-r--r--nikola/plugins/command/status.plugin2
-rw-r--r--nikola/plugins/command/status.py3
-rw-r--r--nikola/plugins/command/subtheme.plugin (renamed from nikola/plugins/command/bootswatch_theme.plugin)10
-rw-r--r--nikola/plugins/command/subtheme.py150
-rw-r--r--nikola/plugins/command/theme.plugin2
-rw-r--r--nikola/plugins/command/theme.py102
-rw-r--r--nikola/plugins/command/version.plugin2
-rw-r--r--nikola/plugins/command/version.py17
41 files changed, 1263 insertions, 968 deletions
diff --git a/nikola/plugins/command/__init__.py b/nikola/plugins/command/__init__.py
index 62d7086..cdd1560 100644
--- a/nikola/plugins/command/__init__.py
+++ b/nikola/plugins/command/__init__.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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 1081c78..a847e14 100644
--- a/nikola/plugins/command/auto.plugin
+++ b/nikola/plugins/command/auto.plugin
@@ -9,5 +9,5 @@ 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 a82dc3e..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-2016 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,55 @@
"""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
+
import pkg_resources
-from blinker import signal
+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 dns_sd, 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 = [
{
@@ -94,7 +83,7 @@ class CommandAuto(Command):
'long': 'port',
'default': 8000,
'type': int,
- 'help': 'Port nummber (default: 8000)',
+ 'help': 'Port number',
},
{
'name': 'address',
@@ -102,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',
@@ -127,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.append('--conf=' + self.site.configuration_filename)
+ 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://'
@@ -155,7 +168,7 @@ 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/'
] + [get_theme_path(name) for name in self.site.THEMES])
@@ -167,12 +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:
@@ -181,289 +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)
-
- try:
- self.logger.info("Watching files for changes...")
- observer.start()
- except KeyboardInterrupt:
- pass
+ self.wd_observer.schedule(ConfigEventHandler(_conf_fn, self.queue_rebuild, loop), _conf_dn, recursive=False)
+ self.wd_observer.start()
- parent = self
+ 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())
- 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:
- self.dns_sd = dns_sd(port, (options['ipv6'] or '::' in host))
- ws.serve_forever()
- except KeyboardInterrupt:
- self.logger.info("Server is shutting down.")
- if self.dns_sd:
- self.dns_sd.Reset()
- # 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
+ 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')) or
- os.path.isdir(event_path)): # Skip on folders, these are usually duplicates
+ 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')
- def do_refresh(self, event):
- """Refresh the page."""
+ 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')
+
+ 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 p_uri.path.endswith('/'): # Redirect to avoid breakage
- start_response('301 Moved Permanently', [('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; charset=UTF-8')])
- return ['User-Agent: *\nDisallow: /\n'.encode('utf-8')]
- elif os.path.isfile(f_path):
- with open(f_path, 'rb') as fd:
- if mimetype.startswith('text/') or mimetype.endswith('+xml'):
- start_response('200 OK', [('Content-type', "{0}; charset=UTF-8".format(mimetype))])
- else:
- 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)
- 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)
+ if event:
+ event_path = event.dest_path if hasattr(event, 'dest_path') else event.src_path
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
+
+ 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
-class OurWatchHandler(FileSystemEventHandler):
- """A Nikola-specific handler for Watchdog."""
+# 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):
+ 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.py b/nikola/plugins/command/bootswatch_theme.py
deleted file mode 100644
index 4808fdb..0000000
--- a/nikola/plugins/command/bootswatch_theme.py
+++ /dev/null
@@ -1,116 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# Copyright © 2012-2016 Roberto Alsina and others.
-
-# Permission is hereby granted, free of charge, to any
-# person obtaining a copy of this software and associated
-# 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)
-
-
-def _check_for_theme(theme, themes):
- for t in themes:
- if t.endswith(os.sep + theme):
- return True
- return False
-
-
-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, self.site.themes_dirs)
- if not _check_for_theme('bootstrap3', themes) and not _check_for_theme('bootstrap3-jinja', themes):
- version = '2'
- elif not _check_for_theme('bootstrap', themes) and not _check_for_theme('bootstrap-jinja', themes):
- LOGGER.warn('"bootswatch_theme" only makes sense for themes that use bootstrap')
- elif _check_for_theme('bootstrap3-gradients', themes) or _check_for_theme('bootstrap3-gradients-jinja', 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 = '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)
- exit(1)
- data = r.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 6d2df82..bc6ede3 100644
--- a/nikola/plugins/command/check.plugin
+++ b/nikola/plugins/command/check.plugin
@@ -9,5 +9,5 @@ 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 0141a6b..cac6000 100644
--- a/nikola/plugins/command/check.py
+++ b/nikola/plugins/command/check.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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,19 @@
"""Check the generated site."""
-from __future__ import print_function
-from collections import defaultdict
+import logging
import os
import re
import sys
import time
-import logbook
-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, cache=None):
@@ -104,7 +98,6 @@ 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"
@@ -159,15 +152,13 @@ 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 = logbook.DEBUG
+ self.logger.level = logging.DEBUG
else:
- self.logger.level = logbook.NOTICE
+ self.logger.level = logging.WARNING
failure = False
if options['links']:
failure |= self.scan_links(options['find_sources'], options['remote'])
@@ -191,6 +182,7 @@ class CommandCheck(Command):
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:
@@ -205,7 +197,7 @@ 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):
@@ -213,7 +205,8 @@ class CommandCheck(Command):
return False
if '.html' == fname[-5:]:
- d = lxml.html.fromstring(open(filename, 'rb').read())
+ 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
@@ -223,7 +216,7 @@ class CommandCheck(Command):
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' == filename[-5:]:
+ 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'):
@@ -257,13 +250,13 @@ class CommandCheck(Command):
# 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.warn("Remote link moved PERMANENTLY to \"{0}\" and should be updated in {1}: {2} [HTTP: 301]".format(redir_target, filename, target))
+ 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.
@@ -273,7 +266,7 @@ class CommandCheck(Command):
continue
if target in self.checked_remote_targets: # already checked this exact target
if self.checked_remote_targets[target] in [301, 308]:
- self.logger.warn("Remote link PERMANENTLY redirected in {0}: {1} [Error {2}]".format(filename, target, self.checked_remote_targets[target]))
+ 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:
@@ -281,7 +274,7 @@ class CommandCheck(Command):
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
@@ -301,7 +294,7 @@ 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.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
@@ -315,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':
@@ -323,23 +316,44 @@ 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') if sys.version_info.major >= 3 else unquote(target).decode('utf-8')
+ unquoted_target = unquote(target).encode('utf-8')
target_filename = os.path.abspath(
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 isinstance(target_filename, str):
+ target_filename_str = target_filename
+ else:
+ target_filename_str = target_filename.decode("utf-8", errors="surrogateescape")
- if any(re.search(x, target_filename) for x in self.whitelist):
+ if any(pattern.search(target_filename_str) for pattern in self.whitelist):
continue
elif target_filename not in self.existing_targets:
@@ -348,11 +362,11 @@ class CommandCheck(Command):
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(u"Error with: {0} {1}".format(filename, exc))
return rv
@@ -363,6 +377,7 @@ class CommandCheck(Command):
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']
@@ -374,7 +389,7 @@ class CommandCheck(Command):
if '.html' == fname[-5:]:
if self.analyze(fname, find_sources, check_remote):
failure = True
- if '.atom' == fname[-5:]:
+ 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'):
@@ -397,15 +412,15 @@ 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.debug("All files checked.")
return failure
@@ -434,6 +449,7 @@ class CommandCheck(Command):
pass
if warn_flag:
- self.logger.warn('Some files or directories have been removed, your site may need rebuilding')
+ self.logger.warning('Some files or directories have been removed, your site may need rebuilding')
+ return True
- return True
+ return False
diff --git a/nikola/plugins/command/console.plugin b/nikola/plugins/command/console.plugin
index 9bcc909..35e3585 100644
--- a/nikola/plugins/command/console.plugin
+++ b/nikola/plugins/command/console.plugin
@@ -9,5 +9,5 @@ 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 c6a8376..b4342b4 100644
--- a/nikola/plugins/command/console.py
+++ b/nikola/plugins/command/console.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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,15 +26,14 @@
"""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):
@@ -44,9 +43,9 @@ class CommandConsole(Command):
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',
@@ -72,19 +71,35 @@ 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):
"""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['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'))
@@ -93,10 +108,10 @@ If there is no console to use specified (as -b, -i, -p) it tries IPython, then f
"""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)
@@ -134,7 +149,13 @@ If there is no console to use specified (as -b, -i, -p) it tries IPython, then f
'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 8bdc0e2..7cff28d 100644
--- a/nikola/plugins/command/deploy.plugin
+++ b/nikola/plugins/command/deploy.plugin
@@ -9,5 +9,5 @@ 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 c2289e8..5273b58 100644
--- a/nikola/plugins/command/deploy.py
+++ b/nikola/plugins/command/deploy.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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 @@
"""Deploy site."""
-from __future__ import print_function
-import io
-from datetime import datetime
-from dateutil.tz import gettz
-import dateutil
-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, clean_before_deployment, STDERR_HANDLER
+from nikola.utils import clean_before_deployment
class CommandDeploy(Command):
@@ -49,49 +46,28 @@ class CommandDeploy(Command):
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')
-
# Get last-deploy from persistent state
last_deploy = self.site.state.get('last_deploy')
- if last_deploy is None:
- # If there is a last-deploy saved, move it to the new state persistence thing
- # FIXME: remove in Nikola 8
- if os.path.isfile(timestamp_path):
- try:
- with io.open(timestamp_path, 'r', encoding='utf8') as inf:
- last_deploy = dateutil.parser.parse(inf.read())
- 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
- os.unlink(timestamp_path) # Remove because from now on it's in state
- else: # Just a default
- last_deploy = datetime(1970, 1, 1)
- clean = True
- else:
+ if last_deploy is not None:
last_deploy = dateutil.parser.parse(last_deploy)
clean = False
- 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"
- "(press Ctrl+C to abort)\n")
+ 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)
# Remove drafts and future posts if requested
undeployed_posts = clean_before_deployment(self.site)
if undeployed_posts:
- self.logger.notice("Deleted {0} posts due to DEPLOY_* settings".format(len(undeployed_posts)))
+ self.logger.warning("Deleted {0} posts due to DEPLOY_* settings".format(len(undeployed_posts)))
if args:
presets = args
@@ -102,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
diff --git a/nikola/plugins/command/github_deploy.plugin b/nikola/plugins/command/github_deploy.plugin
index 21e246c..fbdd3bf 100644
--- a/nikola/plugins/command/github_deploy.plugin
+++ b/nikola/plugins/command/github_deploy.plugin
@@ -9,5 +9,5 @@ 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 b5ad322..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-2016 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,14 +26,13 @@
"""Deploy site to GitHub Pages."""
-from __future__ import print_function
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, clean_before_deployment, STDERR_HANDLER
+from nikola.utils import req_missing, clean_before_deployment
from nikola.__main__ import main
from nikola import __version__
@@ -54,6 +53,12 @@ def check_ghp_import_installed():
req_missing(['ghp-import2'], 'deploy the site to GitHub Pages')
+class DeployFailedException(Exception):
+ """An internal exception for deployment errors."""
+
+ pass
+
+
class CommandGitHubDeploy(Command):
"""Deploy site to GitHub Pages."""
@@ -63,11 +68,9 @@ class CommandGitHubDeploy(Command):
doc_purpose = 'deploy the site to GitHub Pages'
doc_description = dedent(
"""\
- This command can be used to deploy your site to GitHub Pages.
-
- It uses ghp-import to do this task.
+ 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.
- """
+ Configuration help: https://getnikola.com/handbook.html#deploying-to-github"""
)
cmd_options = [
{
@@ -76,15 +79,12 @@ class CommandGitHubDeploy(Command):
'long': 'message',
'default': 'Nikola auto commit.',
'type': str,
- 'help': 'Commit message (default: Nikola auto commit.)',
+ 'help': 'Commit message',
},
]
- logger = None
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()
@@ -102,12 +102,10 @@ class CommandGitHubDeploy(Command):
# Remove drafts and future posts if requested (Issue #2406)
undeployed_posts = clean_before_deployment(self.site)
if undeployed_posts:
- self.logger.notice("Deleted {0} posts due to DEPLOY_* settings".format(len(undeployed_posts)))
+ self.logger.warning("Deleted {0} posts due to DEPLOY_* settings".format(len(undeployed_posts)))
# Commit and push
- self._commit_and_push(options['commit_message'])
-
- return
+ return self._commit_and_push(options['commit_message'])
def _run_command(self, command, xfail=False):
"""Run a command that may or may not fail."""
@@ -122,7 +120,7 @@ class CommandGitHubDeploy(Command):
'Failed GitHub deployment -- command {0} '
'returned {1}'.format(e.cmd, e.returncode)
)
- raise SystemError(e.returncode)
+ raise DeployFailedException(e.returncode)
def _commit_and_push(self, commit_first_line):
"""Commit all the files and push."""
@@ -145,9 +143,16 @@ class CommandGitHubDeploy(Command):
if e != 0:
self._run_command(['git', 'commit', '-am', commit_message])
else:
- self.logger.notice('Nothing to commit to source branch.')
+ 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 = '?'
- source_commit = uni_check_output(['git', 'rev-parse', source])
commit_message = (
'{0}\n\n'
'Source commit: {1}'
@@ -161,7 +166,7 @@ class CommandGitHubDeploy(Command):
if autocommit:
self._run_command(['git', 'push', '-u', remote, source])
- except SystemError as e:
+ 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 eab9d17..46df1ef 100644
--- a/nikola/plugins/command/import_wordpress.plugin
+++ b/nikola/plugins/command/import_wordpress.plugin
@@ -9,5 +9,5 @@ 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 0b48583..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-2016 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,46 +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
-try:
- import html2text
-except:
- html2text = None
+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, unicode_str
-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:
@@ -148,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",
},
@@ -259,9 +265,9 @@ 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)
@@ -307,7 +313,7 @@ class CommandImportWordpress(Command, ImportMixin):
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.warn("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.")
+ 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.")
@@ -339,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."""
@@ -373,17 +379,12 @@ 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(
@@ -397,7 +398,7 @@ class CommandImportWordpress(Command, ImportMixin):
# Add tag redirects
for tag in self.all_tags:
try:
- if isinstance(tag, utils.bytes_str):
+ if isinstance(tag, bytes):
tag_str = tag.decode('utf8', 'replace')
else:
tag_str = tag
@@ -420,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):
@@ -438,9 +439,16 @@ class CommandImportWordpress(Command, ImportMixin):
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
@@ -451,7 +459,10 @@ class CommandImportWordpress(Command, ImportMixin):
context = SAMPLE_CONF.copy()
self.lang = get_text_tag(channel, 'language', 'en')[:2]
context['DEFAULT_LANG'] = self.lang
- context['TRANSLATIONS_PATTERN'] = DEFAULT_TRANSLATIONS_PATTERN
+ # 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(
@@ -482,17 +493,17 @@ class CommandImportWordpress(Command, ImportMixin):
PAGES = '(\n'
for extension in extensions:
POSTS += ' ("posts/*.{0}", "posts", "post.tmpl"),\n'.format(extension)
- PAGES += ' ("pages/*.{0}", "pages", "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
@@ -503,12 +514,12 @@ class CommandImportWordpress(Command, ImportMixin):
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."""
@@ -549,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'
@@ -583,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
@@ -775,7 +782,7 @@ class CommandImportWordpress(Command, ImportMixin):
elif approved == 'spam' or approved == 'trash':
pass
else:
- LOGGER.warn("Unknown comment approved status: {0}".format(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
@@ -796,7 +803,7 @@ class CommandImportWordpress(Command, ImportMixin):
"""Write comment header line."""
if header_content is None:
return
- header_content = unicode_str(header_content).replace('\n', ' ')
+ header_content = str(header_content).replace('\n', ' ')
line = '.. ' + header_field + ': ' + header_content + '\n'
fd.write(line.encode('utf8'))
@@ -813,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}
@@ -824,16 +841,16 @@ 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: {}}
@@ -847,7 +864,7 @@ class CommandImportWordpress(Command, ImportMixin):
previous = self._tag_sanitize_map[is_category][tag.lower()]
if self.tag_saniziting_strategy == 'first':
if tag != previous[0]:
- LOGGER.warn("Changing spelling of {0} name '{1}' to {2}.".format('category' if is_category else 'tag', 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))
@@ -873,7 +890,7 @@ class CommandImportWordpress(Command, ImportMixin):
path = unquote(parsed.path.strip('/'))
try:
- if isinstance(path, utils.bytes_str):
+ if isinstance(path, bytes):
path = path.decode('utf8', 'replace')
else:
path = path
@@ -925,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
@@ -953,7 +972,7 @@ class CommandImportWordpress(Command, ImportMixin):
tags.append(text)
if '$latex' in content:
- tags.append('mathjax')
+ has_math = "yes"
for i, cat in enumerate(categories[:]):
cat = self._sanitize(cat, True)
@@ -974,52 +993,56 @@ 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))
-
+ current_title = title_translations.get(lang, default_title)
meta = {
- "title": title,
- "slug": meta_slug,
+ "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:
@@ -1033,7 +1056,7 @@ class CommandImportWordpress(Command, ImportMixin):
else:
self.write_metadata(os.path.join(self.output_folder, out_folder,
out_meta_filename),
- title, meta_slug, post_date, description, tags, **other_meta)
+ current_title, slug, post_date, description, tags, **other_meta)
self.write_content(
os.path.join(self.output_folder,
out_folder, out_content_filename),
@@ -1053,8 +1076,8 @@ class CommandImportWordpress(Command, ImportMixin):
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):
@@ -1080,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."""
@@ -1118,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):
@@ -1133,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 = []
@@ -1153,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:]
@@ -1176,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 a8b1523..6ee27d3 100644
--- a/nikola/plugins/command/init.plugin
+++ b/nikola/plugins/command/init.plugin
@@ -9,5 +9,5 @@ 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 3d6669c..0026edc 100644
--- a/nikola/plugins/command/init.py
+++ b/nikola/plugins/command/init.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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_FEED_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,50 +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,
'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': """(
- ("pages/*.rst", "pages", "story.tmpl"),
- ("pages/*.txt", "pages", "story.tmpl"),
- ("pages/*.html", "pages", "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: (
@@ -108,6 +109,7 @@ SAMPLE_CONF = {
),
}""",
'REDIRECTIONS': [],
+ '_METADATA_MAPPING_FORMATS': ', '.join(LEGAL_VALUES['METADATA_MAPPING'])
}
@@ -171,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"""\
@@ -212,7 +222,7 @@ 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', 'FEED_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['FEED_READ_MORE_LINK'] = "'" + p['FEED_READ_MORE_LINK'].replace("'", "\\'") + "'"
@@ -285,7 +295,7 @@ class CommandInit(Command):
@classmethod
def create_empty_site(cls, target):
"""Create an empty site with directories only."""
- for folder in ('files', 'galleries', 'listings', 'posts', 'pages'):
+ for folder in ('files', 'galleries', 'images', 'listings', 'posts', 'pages'):
makedirs(os.path.join(target, folder))
@staticmethod
@@ -323,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:
@@ -354,9 +363,8 @@ 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:
@@ -377,22 +385,22 @@ class CommandInit(Command):
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:
diff --git a/nikola/plugins/command/install_theme.plugin b/nikola/plugins/command/install_theme.plugin
deleted file mode 100644
index aa68773..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 = https://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 28f7aa3..0000000
--- a/nikola/plugins/command/install_theme.py
+++ /dev/null
@@ -1,91 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# Copyright © 2012-2016 Roberto Alsina and others.
-
-# Permission is hereby granted, free of charge, to any
-# person obtaining a copy of this software and associated
-# 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
-
-from nikola import utils
-from nikola.plugin_categories import Command
-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."""
- p = self.site.plugin_manager.getPluginByName('theme', 'Command').plugin_object
- listing = options['list']
- url = options['url']
- if args:
- name = args[0]
- else:
- name = None
-
- if options['getpath'] and name:
- return p.get_path(name)
-
- if name is None and not listing:
- LOGGER.error("This command needs either a theme name or the -l option.")
- return False
-
- if listing:
- p.list_available(url)
- else:
- p.do_install_deps(url, name)
diff --git a/nikola/plugins/command/new_page.plugin b/nikola/plugins/command/new_page.plugin
index 3eaecb4..8734805 100644
--- a/nikola/plugins/command/new_page.plugin
+++ b/nikola/plugins/command/new_page.plugin
@@ -9,5 +9,5 @@ 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 c09b4be..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-2016 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 @@
"""Create a new page."""
-from __future__ import unicode_literals, print_function
from nikola.plugin_categories import Command
@@ -107,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 e9c3af5..efdeb58 100644
--- a/nikola/plugins/command/new_post.plugin
+++ b/nikola/plugins/command/new_post.plugin
@@ -9,5 +9,5 @@ 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 36cc04f..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-2016 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 @@
"""Create a new post."""
-from __future__ import unicode_literals, print_function
import io
import datetime
import operator
@@ -35,15 +34,15 @@ import shutil
import subprocess
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
@@ -90,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)
@@ -111,7 +110,7 @@ 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):
@@ -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
@@ -296,26 +329,34 @@ class CommandNewPost(Command):
if not path:
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], lang=self.site.default_lang)
+ 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,17 +364,21 @@ class CommandNewPost(Command):
'tags': tags,
'link': '',
'description': '',
- 'type': 'text',
+ 'type': post_type,
}
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"
@@ -360,18 +405,18 @@ 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 onefile and import_file:
- with io.open(import_file, 'r', encoding='utf-8') as fh:
+ with io.open(import_file, 'r', encoding='utf-8-sig') as fh:
content = fh.read()
elif not import_file:
if is_page:
@@ -385,13 +430,13 @@ class CommandNewPost(Command):
else:
compiler_plugin.create_post(
txt_path, content=content, onefile=onefile, title=title,
- slug=slug, date=date, tags=tags, is_page=is_page, **metadata)
+ 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))
@@ -406,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.
@@ -523,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 d20c539..5107032 100644
--- a/nikola/plugins/command/orphans.plugin
+++ b/nikola/plugins/command/orphans.plugin
@@ -9,5 +9,5 @@ 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 5e2574d..0cf2e63 100644
--- a/nikola/plugins/command/orphans.py
+++ b/nikola/plugins/command/orphans.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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
diff --git a/nikola/plugins/command/plugin.plugin b/nikola/plugins/command/plugin.plugin
index 016bcaa..db99ceb 100644
--- a/nikola/plugins/command/plugin.plugin
+++ b/nikola/plugins/command/plugin.plugin
@@ -9,5 +9,5 @@ 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 364f343..33dee23 100644
--- a/nikola/plugins/command/plugin.py
+++ b/nikola/plugins/command/plugin.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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,8 @@
"""Manage plugins."""
-from __future__ import print_function
import io
+import json.decoder
import os
import sys
import shutil
@@ -42,7 +42,7 @@ 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):
@@ -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'
@@ -179,9 +178,18 @@ class CommandPlugin(Command):
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('\n\nAlso, you have disabled these plugins: {}'.format(self.site.config['DISABLED_PLUGINS']))
+ 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):
@@ -235,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((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 * ' '))
@@ -280,17 +277,36 @@ 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):
@@ -320,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 a095705..6f2fb25 100644
--- a/nikola/plugins/command/rst2html.plugin
+++ b/nikola/plugins/command/rst2html.plugin
@@ -9,5 +9,5 @@ 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 c877f63..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-2016 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
@@ -50,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')
diff --git a/nikola/plugins/command/serve.plugin b/nikola/plugins/command/serve.plugin
index a4a726f..aa40073 100644
--- a/nikola/plugins/command/serve.plugin
+++ b/nikola/plugins/command/serve.plugin
@@ -9,5 +9,5 @@ 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 c9702d5..ede5179 100644
--- a/nikola/plugins/command/serve.py
+++ b/nikola/plugins/command/serve.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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,26 +26,18 @@
"""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 dns_sd, get_logger, STDERR_HANDLER
+from nikola.utils import dns_sd
class IPv6Server(HTTPServer):
@@ -60,7 +52,6 @@ class CommandServe(Command):
name = "serve"
doc_usage = "[options]"
doc_purpose = "start the test webserver"
- logger = None
dns_sd = None
cmd_options = (
@@ -70,7 +61,7 @@ class CommandServe(Command):
'long': 'port',
'default': 8000,
'type': int,
- 'help': 'Port number (default: 8000)',
+ 'help': 'Port number',
},
{
'name': 'address',
@@ -78,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',
@@ -106,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(']')
@@ -128,35 +130,43 @@ 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.")
- if self.dns_sd:
- self.dns_sd.Reset()
+ self.shutdown()
return 130
@@ -172,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
@@ -185,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,
@@ -198,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,
@@ -227,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
@@ -235,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)
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 b3ffbb4..c96d13f 100644
--- a/nikola/plugins/command/status.py
+++ b/nikola/plugins/command/status.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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 @@
"""Display site status."""
-from __future__ import print_function
import os
from datetime import datetime
from dateutil.tz import gettz, tzlocal
diff --git a/nikola/plugins/command/bootswatch_theme.plugin b/nikola/plugins/command/subtheme.plugin
index 51e6718..d377e22 100644
--- a/nikola/plugins/command/bootswatch_theme.plugin
+++ b/nikola/plugins/command/subtheme.plugin
@@ -1,13 +1,13 @@
[Core]
-name = bootswatch_theme
-module = bootswatch_theme
+name = subtheme
+module = subtheme
[Documentation]
author = Roberto Alsina
-version = 1.0
+version = 1.1
website = https://getnikola.com/
-description = Given a swatch name and a parent theme, creates a custom theme.
+description = Given a swatch name and a parent theme, creates a custom subtheme.
[Nikola]
-plugincategory = Command
+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
index b0c1886..421d027 100644
--- a/nikola/plugins/command/theme.plugin
+++ b/nikola/plugins/command/theme.plugin
@@ -9,5 +9,5 @@ website = https://getnikola.com/
description = Manage Nikola themes
[Nikola]
-plugincategory = Command
+PluginCategory = Command
diff --git a/nikola/plugins/command/theme.py b/nikola/plugins/command/theme.py
index 7513491..6f4339a 100644
--- a/nikola/plugins/command/theme.py
+++ b/nikola/plugins/command/theme.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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,15 @@
"""Manage themes."""
-from __future__ import print_function
-import os
+import configparser
import io
+import json.decoder
+import os
import shutil
+import sys
import time
-import requests
+import requests
import pygments
from pygments.lexers import PythonLexer
from pygments.formatters import TerminalFormatter
@@ -41,7 +43,7 @@ from pkg_resources import resource_filename
from nikola.plugin_categories import Command
from nikola import utils
-LOGGER = utils.get_logger('theme', utils.STDERR_HANDLER)
+LOGGER = utils.get_logger('theme')
class CommandTheme(Command):
@@ -89,9 +91,8 @@ class CommandTheme(Command):
'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'
+ 'help': "URL for the theme repository",
+ 'default': 'https://themes.getnikola.com/v8/themes.json'
},
{
'name': 'getpath',
@@ -122,14 +123,21 @@ class CommandTheme(Command):
'long': 'engine',
'type': str,
'default': 'mako',
- 'help': 'Engine to use for new theme (mako or jinja -- 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 (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',
},
]
@@ -147,6 +155,7 @@ class CommandTheme(Command):
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,
@@ -172,7 +181,7 @@ class CommandTheme(Command):
elif copy_template:
return self.copy_template(copy_template)
elif new:
- return self.new_theme(new, new_engine, new_parent)
+ return self.new_theme(new, new_engine, new_parent, new_legacy_meta)
def do_install_deps(self, url, name):
"""Install themes and their dependencies."""
@@ -188,11 +197,11 @@ class CommandTheme(Command):
try:
utils.get_theme_path_real(parent_name, self.site.themes_dirs)
break
- except: # Not available
+ except Exception: # 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))
+ 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."""
@@ -225,15 +234,13 @@ class CommandTheme(Command):
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!')
+ 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') 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 True
def do_uninstall(self, name):
@@ -282,7 +289,9 @@ class CommandTheme(Command):
themes = []
themes_dirs = self.site.themes_dirs + [resource_filename('nikola', os.path.join('data', 'themes'))]
for tdir in themes_dirs:
- themes += [(i, os.path.join(tdir, i)) for i in os.listdir(tdir)]
+ 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))
@@ -316,7 +325,7 @@ class CommandTheme(Command):
LOGGER.error("This file already exists in your templates directory ({0}).".format(base))
return 3
- def new_theme(self, name, engine, parent):
+ def new_theme(self, name, engine, parent, create_legacy_meta=False):
"""Create a new theme."""
base = 'themes'
themedir = os.path.join(base, name)
@@ -326,9 +335,7 @@ class CommandTheme(Command):
LOGGER.info("Created directory {0}".format(base))
# Check if engine and parent match
- engine_file = utils.get_asset_path('engine', utils.get_theme_chain(parent, self.site.themes_dirs))
- with io.open(engine_file, 'r', encoding='utf-8') as fh:
- parent_engine = fh.read().strip()
+ 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))
@@ -342,24 +349,45 @@ class CommandTheme(Command):
LOGGER.error("Theme already exists")
return 2
- 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')))
+ 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.notice('Remember to set THEME="{0}" in conf.py to use this theme.'.format(name))
+ 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:
- 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://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 d78b79b..a172e28 100644
--- a/nikola/plugins/command/version.plugin
+++ b/nikola/plugins/command/version.plugin
@@ -9,5 +9,5 @@ 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 267837e..9b81343 100644
--- a/nikola/plugins/command/version.py
+++ b/nikola/plugins/command/version.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2016 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,15 +26,13 @@
"""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):
@@ -60,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))