Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #201 from getnikola/static_comments
Added static_comments plugin.
- Loading branch information
Showing
5 changed files
with
405 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
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: felix@fontein.de | ||
.. author_IP: 1.2.3.4 | ||
.. author_url: https://felix.fontein.de | ||
.. 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 MarkDown compiler (`markdown`; only allows this in Nikola v7.8.2 or newer), and the [WordPress](https://plugins.getnikola.com/#wordpress_compiler) (`wordpress`) compiler. You can also specify `html`, in which case the comment's content will be taken as HTML without any processing. | ||
|
||
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) }} | ||
[...] | ||
</div> | ||
{% 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 %} | ||
[...] | ||
|
||
Finally, you need to add support for some additional messages. | ||
|
||
* `"No comments."`; | ||
* `"{0} wrote on {1}:"` where `{0}` will be replaced by the author and `{1}` by the localized date; | ||
* `"No comments"`; | ||
* `"{0} comments"` where `{0}` will be replaced by a number larger than 1; | ||
* `"{0} comment"` where `{0}` will be replaced by `1`. | ||
|
||
Your theme might of course also print comments differently with other messages than these, by incorporating a modified version of `static_comment_helpers.tmpl`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
[Core] | ||
Name = static_comments | ||
Module = static_comments | ||
|
||
[Nikola] | ||
PluginCategory = SignalHandler | ||
|
||
[Documentation] | ||
Author = Felix Fontein | ||
Version = 1.0 | ||
Website = https://felix.fontein.de | ||
Description = Static comments for Nikola |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,247 @@ | ||
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 | ||
self.id = 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.id, self.parent_id, self.content, self.author, self.author_url, self.date_utc) | ||
|
||
def __repr__(self): | ||
"""Returns string representation for comment.""" | ||
return '<Comment: {0} for {1}; indent: {2}>'.format(self.id, 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 specified page compiler.""" | ||
if compiler_name == 'html': | ||
# Special case: just pass-through content. | ||
return content | ||
if compiler_name not in self.site.compilers: | ||
_LOGGER.error("Cannot find page compiler '{0}' for comment {1}!".format(compiler_name, filename)) | ||
exit(1) | ||
compiler = self.site.compilers[compiler_name] | ||
if compiler_name == 'rest': | ||
content, error_level, _ = compiler.compile_string(content) | ||
if error_level >= 3: | ||
_LOGGER.error("reStructuredText page compiler ({0}) failed to compile comment {1}!".format(compiler_name, filename)) | ||
exit(1) | ||
return content | ||
else: | ||
try: | ||
return compiler.compile_string(content)[0] | ||
except AttributeError: | ||
try: | ||
return compiler.compile_to_string(content) | ||
except AttributeError: | ||
_LOGGER.error("Page compiler plugin '{0}' provides no compile_string or compile_to_string function (comment {1})!".format(compiler_name, filename)) | ||
exit(1) | ||
|
||
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(self.site, owner, id) | ||
# parse headers | ||
compiler_name = None | ||
while start < len(lines): | ||
# on empty line, header is definitely done | ||
if len(lines[start].strip()) == 0: | ||
break | ||
# try to check if line fits header regex | ||
result = self._header_regex.findall(lines[start].strip()) | ||
if not result: | ||
break | ||
# parse header line | ||
header = result[0][0] | ||
value = result[0][1] | ||
if header == 'id': | ||
comment.id = value | ||
elif header == 'status': | ||
pass | ||
elif header == 'approved': | ||
if value != 'True': | ||
return None | ||
elif header == 'author': | ||
comment.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': | ||
comment.set_utc_date(value) | ||
elif header == 'parent_id': | ||
if value != 'None': | ||
comment.parent_id = value | ||
elif header == 'wordpress_user_id': | ||
pass | ||
elif header == 'post_language': | ||
pass | ||
elif header == 'compiler': | ||
compiler_name = value | ||
else: | ||
_LOGGER.error("Unknown comment header: '{0}' (in file {1})".format(header, filename)) | ||
exit(1) | ||
# 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: | ||
continue | ||
for filename in filenames: | ||
if not filename.startswith(file + '.'): | ||
continue | ||
rest = filename[len(file):].split('.') | ||
if len(rest) != 3: | ||
continue | ||
if rest[0] != '' or rest[2] != 'wpcomment': | ||
continue | ||
try: | ||
comment = self._read_comment(os.path.join(dirpath, filename), owner, rest[1]) | ||
if comment is not None: | ||
# _LOGGER.info("Found comment '{0}' with ID {1}".format(os.path.join(dirpath, filename), comment.id)) | ||
comments[comment.id] = comment | ||
except ValueError as e: | ||
_LOGGER.warn("Exception '{1}' while reading file '{0}'!".format(os.path.join(dirpath, filename), e)) | ||
pass | ||
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(): | ||
hash.update(str(part).encode('utf-8')) | ||
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(comment.id) | ||
node.comment = comment | ||
comment_nodes[comment.id] = node | ||
for comment in comments: | ||
node = comment_nodes[comment.id] | ||
parent_node = comment_nodes.get(node.comment.parent_id) | ||
if parent_node is not None: | ||
parent_node.children.append(node) | ||
else: | ||
root_list.append(node) | ||
# 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 self.site: | ||
for post in site.timeline: | ||
self._process_post_object(post) | ||
|
||
def set_site(self, site): | ||
"""Set Nikola site object.""" | ||
super(StaticComments, self).set_site(site) | ||
site._GLOBAL_CONTEXT['site_has_static_comments'] = True | ||
blinker.signal("scanned").connect(self._process_posts_and_stories) |
34 changes: 34 additions & 0 deletions
34
v7/static_comments/template/jinja/static_comments_helper.tmpl
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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-{{ comment.id }}"> | ||
<div class="comment-header"> | ||
<a name="comment-{{ comment.id }}"></a> | ||
{%if comment.author is not none %} | ||
{{ messages("{0} wrote on {1}:", lang).format( | ||
'<span class="author">' ~ ('<a href="{0}">{1}</a>'.format(comment.author_url|e, comment.author|e) if comment.author_url is not none else (comment.author|e)) ~ '</span>', | ||
'<span class="date">' + comment.formatted_date(date_format) + '</span>' | ||
) }} | ||
{% endif %} | ||
</div> | ||
<div class="comment-content"> | ||
{{ comment.content }} | ||
</div> | ||
</div> | ||
{%for i in range(-comment.indent_change_after) %} | ||
</div> | ||
{% 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 %} |
Oops, something went wrong.