Skip to content
Browse files
Added static_comments plugin.
  • Loading branch information
felixfontein committed Jan 8, 2017
1 parent e87add9 commit b60949dd255429498dd09c175727839520da8b83
@@ -0,0 +1,66 @@
This plugin allows to add static comments to your theme. Static comments are taken from files `<path>/<name>.<id>.wpcomment`, where `<path>/<name>.<ext>` is the post's main file name. Such comments are for example written by the `import_wordpress` plugin when specifying the `--export-comments` argument on import.

You must use a theme which supports static comments for them to be visible (see below for instructions on how to adjust a theme).

Why use static comments?

Static comments allow you to avoid using a dynamic (JavaScript-based) comment system. If you want users to be able to still comment things, you need to provide a form which could send the comment as an email to you, so you can create the comment files manually (or with a script).

Static comments also allow you to import a legacy WordPress blog and convert it to a completely static Nikola blog, without having to use some external service for handling the comments.

Comment files

Comment files are of the following form::

.. id: 10
.. approved: True
.. author: felix
.. author_email:
.. author_IP:
.. author_url:
.. date_utc: 2017-01-06 11:23:55
.. parent_id: 8
.. wordpress_user_id: 1
.. compiler: rest

this is a test comment.

the content spans the rest of the file.

Most header fields are optional. `compiler` must specify a page compiler which allows to compile a content given as a string to a string; these are currently the restructured text compiler (`rest`), the `ipynb` compiler, and the [WordPress]( (`wordpress`) compiler. Comments can form a hierarchy; `parent_id` must be the comment ID of the parent comment, or left away if there's no parent.

Inclusion in theme

You need a static comments aware theme to be able to actually see the comments. To modify a theme accordingly, some helper functions are provided in `templates/*/static_comment_helpers.tmpl`. They can be used as follows.

The plugin defines a variable `site_has_static_comments` with value `True`, so themes can detect the presence of static comments in general.

In templates which show the post contents (`post.tmpl` and `index.tmpl`), you can get the comments shown as follows (with jinja2 templates; adjust accordingly for mako templates)::

{% import 'static_comments_helper.tmpl' as static_comments with context %}
{% if not post.meta('nocomments') and (site_has_comments or site_has_static_comments) %}
<div class="comments">
<h2>{{ messages("Comments", lang) }}</h2>
{{ static_comments.add_static_comments(post.comments, lang) }}
{% endif %}

In templates which list the posts (`list_post.tmpl`, `post_list_directive.tmpl` etc.), you can get the static comment count shown as follows::

{% import 'static_comments_helper.tmpl' as static_comments with context %}
{% if not post.meta('nocomments') and site_has_static_comments %}
<span class="comment-count">{{ static_comments.add_static_comment_count(post.comments, lang) }}</span>
{% endif %}
@@ -0,0 +1,12 @@
Name = static_comments
Module = static_comments

PluginCategory = SignalHandler

Author = Felix Fontein
Version = 1.0
Website =
Description = Static comments for Nikola
@@ -0,0 +1,246 @@
from __future__ import unicode_literals, print_function, absolute_import

from nikola.plugin_categories import SignalHandler
from nikola import utils

import blinker
import hashlib
import os
import re

__all__ = []

_LOGGER = utils.get_logger('static_comments', utils.STDERR_HANDLER)

class Comment(object):
"""Represents a comment for a post, story or gallery."""

# set by constructor
id = None
parent_id = None

# set by creator
content = '' # should be a properly escaped HTML fragment
author = None
author_email = None # should not be published by default
author_url = None
author_ip = None # should not be published by default
date_utc = None # should be set via set_utc_date()
date_localized = None # should be set via set_utc_date()

# set by _process_comments():
indent_levels = None # use for formatting comments as tree
indent_change_before = 0 # use for formatting comments as tree
indent_change_after = 0 # use for formatting comments as tree

# The meaning of indent_levels, indent_change_before and
# indent_change_after are the same as the values in utils.TreeNode.

def __init__(self, site, owner, id, parent_id=None):
"""Initialize comment.
site: Nikola site object;
owner: post which 'owns' this comment;
id: ID of comment;
parent_id: ID of comment's parent, or None if it has none.
self._owner = owner
self._config = site.config = id
self.parent_id = parent_id

def set_utc_date(self, date_utc):
"""Set the date (in UTC). Automatically updates the localized date."""
self.date_utc = utils.to_datetime(date_utc)
self.date_localized = utils.to_datetime(date_utc, self._config['__tzinfo__'])

def formatted_date(self, date_format):
"""Return the formatted localized date."""
return utils.LocaleBorg().formatted_date(date_format, self.date_localized)

def hash_values(self):
"""Return tuple of values whose hash to consider for computing the hash of this comment."""
return (, self.parent_id, self.content,, self.author_url, self.date_utc)

def __repr__(self):
"""Returns string representation for comment."""
return '<Comment: {0} for {1}; indent: {2}>'.format(, self._owner, self.indent_levels)

class StaticComments(SignalHandler):
"""Add static comments to posts."""

# Used to parse comment headers
_header_regex = re.compile('^\.\. (.*?): (.*)')

def _compile_content(self, compiler_name, content, filename):
"""Compile comment content with """
if compiler_name not in
_LOGGER.error("Cannot find page compiler '{0}' for comment {1}!".format(compiler_name, filename))
compiler =[compiler_name]
if compiler_name == 'rest':
content, error_level, _ = compiler.compile_string(content)
if error_level >= 3:
_LOGGER.error("Restructured text page compiler ({0}) failed to compile comment {1}!".format(compiler_name, filename))
return content
elif compiler_name == 'ipynb':
return compiler.compile_string(content)
return compiler.compile_to_string(content) # this is a non-standard function! must not be available with any page compiler!
except AttributeError:
return compiler.compile_string(content) # this is a non-standard function! must not be available with any page compiler!
except AttributeError:
_LOGGER.error("Page compiler plugin '{0}' has no compile_to_string or compile_string function (comment {1})!".format(compiler_name, filename))

def _read_comment(self, filename, owner, id):
"""Read a comment from a file."""
with open(filename, "r") as f:
lines = f.readlines()
start = 0
# create comment object
comment = Comment(, owner, id)
# parse headers
compiler_name = None
while start < len(lines):
# on empty line, header is definitely done
if len(lines[start].strip()) == 0:
# try to check if line fits header regex
result = self._header_regex.findall(lines[start].strip())
if not result:
# parse header line
header = result[0][0]
value = result[0][1]
if header == 'id': = value
elif header == 'status':
elif header == 'approved':
if value != 'True':
return None
elif header == 'author': = value
elif header == 'author_email':
comment.author_email = value
elif header == 'author_url':
comment.author_url = value
elif header == 'author_IP':
comment.author_ip = value
elif header == 'date_utc':
elif header == 'parent_id':
if value != 'None':
comment.parent_id = value
elif header == 'wordpress_user_id':
elif header == 'post_language':
elif header == 'compiler':
compiler_name = value
_LOGGER.error("Unknown comment header: '{0}' (in file {1})".format(header, filename))
# go to next line
start += 1
# skip empty lines and re-combine content
while start < len(lines) and len(lines[start]) == 0:
start += 1
content = '\n'.join(lines[start:])
# check compiler name
if compiler_name is None:
_LOGGER.warn("Comment file '{0}' doesn't specify compiler! Using default 'wordpress'.".format(filename))
compiler_name = 'wordpress'
# compile content
comment.content = self._compile_content(compiler_name, content, filename)
return comment

def _scan_comments(self, path, file, owner):
"""Scan comments for post."""
comments = {}
for dirpath, dirnames, filenames in os.walk(path, followlinks=True):
if dirpath != path:
for filename in filenames:
if not filename.startswith(file + '.'):
rest = filename[len(file):].split('.')
if len(rest) != 3:
if rest[0] != '' or rest[2] != 'wpcomment':
comment = self._read_comment(os.path.join(dirpath, filename), owner, rest[1])
if comment is not None:
#"Found comment '{0}' with ID {1}".format(os.path.join(dirpath, filename),
comments[] = comment
except ValueError as e:
_LOGGER.warn("Exception '{1}' while reading file '{0}'!".format(os.path.join(dirpath, filename), e))
return sorted(list(comments.values()), key=lambda c: c.date_utc)

def _hash_post_comments(self, post):
"""Compute hash of all comments for this post."""
# compute hash of comments
hash = hashlib.md5()
c = 0
for comment in post.comments:
c += 1
for part in comment.hash_values():
return hash.hexdigest()

def _process_comments(self, comments):
"""Given a list of comments, rearranges them according to hierarchy and returns ordered list with indentation information."""
# First, build tree structure out of TreeNode with comments attached
root_list = []
comment_nodes = dict()
for comment in comments:
node = utils.TreeNode(
node.comment = comment
comment_nodes[] = node
for comment in comments:
node = comment_nodes[]
parent_node = comment_nodes.get(node.comment.parent_id)
if parent_node is not None:
# Then flatten structure and add indent information
comment_nodes = utils.flatten_tree_structure(root_list)
for node in comment_nodes:
comment = node.comment
comment.indent_levels = node.indent_levels
comment.indent_change_before = node.indent_change_before
comment.indent_change_after = node.indent_change_after
return [node.comment for node in comment_nodes]

def _process_post_object(self, post):
"""Add comments to a post object."""
# Get all comments
path, ext = os.path.splitext(post.source_path)
path, file = os.path.split(path)
comments = self._scan_comments(path, file, post)
# Add ordered comment list to post
post.comments = self._process_comments(comments)
# Add dependency to post
digest = self._hash_post_comments(post)
post.add_dependency_uptodate(utils.config_changed({1: digest}, 'nikola.plugins.comments.static_comments:' + post.base_path), is_callable=False, add='page')

def _process_posts_and_stories(self, site):
"""Add comments to all posts."""
if site is
for post in site.timeline:

def set_site(self, site):
"""Set Nikola site object."""
super(StaticComments, self).set_site(site)
site._GLOBAL_CONTEXT['site_has_static_comments'] = True
@@ -0,0 +1,34 @@
{# -*- coding: utf-8 -*- #}

{% macro add_static_comments(static_comment_list, lang) %}
{%if static_comment_list|length == 0 %}
<div class="no-comments">{{ messages("No comments.", lang) }}</div>
{% else %}
{%for comment in static_comment_list %}
{%for i in range(comment.indent_change_before) %}
<div class="comment-level comment-level-{{ comment.indent_levels|length + i }}">
{% endfor %}
<div class="comment comment-{{ }}">
<div class="comment-header">
<a name="comment-{{ }}"></a>
{%if is not none %}
{{ messages("{0} wrote on {1}:", lang).format(
'<span class="author">' ~ ('<a href="{0}">{1}</a>'.format(comment.author_url|e,|e) if comment.author_url is not none else (|e)) ~ '</span>',
'<span class="date">' + comment.formatted_date(date_format) + '</span>'
) }}
{% endif %}
<div class="comment-content">
{{ comment.content }}
{%for i in range(-comment.indent_change_after) %}
{% endfor %}
{% endfor %}
{% endif %}
{% endmacro %}

{% macro add_static_comment_count(static_comment_list, lang) %}
{{ messages("No comments" if static_comment_list|length == 0 else ("{0} comments" if static_comment_list|length != 1 else "{0} comment"), lang).format(static_comment_list|length) }}
{% endmacro %}
@@ -0,0 +1,34 @@
## -*- coding: utf-8 -*-

<%def name="add_static_comments(static_comment_list, lang)">
% if len(static_comment_list) == 0:
<div class="no-comments">${ messages("No comments.", lang) }</div>
% else:
% for comment in static_comment_list:
% for i in range(comment.indent_change_before):
<div class="comment-level comment-level-${ len(comment.indent_levels) + i }">
% endfor
<div class="comment comment-${ }">
<div class="comment-header">
<a name="comment-${ }"></a>
% if is not none:
${ messages("{0} wrote on {1}:", lang).format(
'<span class="author">' + ('<a href="{0}">{1}</a>'.format(comment.author_url|h,|h) if comment.author_url is not none else (|h)) + '</span>',
'<span class="date">' + comment.formatted_date(date_format) + '</span>'
) }
% endif
<div class="comment-content">
${ comment.content }
% for i in range(-comment.indent_change_after):
% endfor
% endfor
% endif

<%def name="add_static_comment_count(static_comment_list, lang)">
${ messages("No comments" if len(static_comment_list) == 0 else ("{0} comments" if len(static_comment_list) != 1 else "{0} comment"), lang).format(len(static_comment_list)) }

0 comments on commit b60949d

Please sign in to comment.