From 978928c97ed748bbe40620d504671c1cea9c758f Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 18 Apr 2014 21:55:37 -0700 Subject: [PATCH] Add Markdown support to blogs, comments, pages --- requirements.txt | 2 + rophako/__init__.py | 19 ++++++-- rophako/model/blog.py | 7 ++- rophako/model/comment.py | 17 +++---- rophako/modules/blog.py | 33 ++++++++++++-- rophako/utils.py | 50 +++++++++++++++++++++ rophako/www/blog/entry.inc.html | 2 +- rophako/www/blog/update.html | 8 +++- rophako/www/comment/index.inc.html | 9 ++-- rophako/www/css/codehilite.css | 71 ++++++++++++++++++++++++++++++ rophako/www/layout.html | 1 + rophako/www/markdown.inc.html | 7 +++ rophako/www/smoke/style.css | 15 +++++++ 13 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 rophako/www/css/codehilite.css create mode 100644 rophako/www/markdown.inc.html diff --git a/requirements.txt b/requirements.txt index f4bb3fc..e95c9bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ redis bcrypt pillow requests +Markdown +Pygments \ No newline at end of file diff --git a/rophako/__init__.py b/rophako/__init__.py index 55fa81b..8e6ee35 100644 --- a/rophako/__init__.py +++ b/rophako/__init__.py @@ -120,10 +120,21 @@ def catchall(path): abspath = os.path.abspath("{}/{}".format(root, path)) if os.path.isfile(abspath): return send_file(abspath) - elif not "." in path and os.path.isfile(abspath + ".html"): - return rophako.utils.template(path + ".html") - elif not "." in path and os.path.isfile(abspath + "/index.html"): - return rophako.utils.template(path + "/index.html") + + # The exact file wasn't found, look for some extensions and index pages. + suffixes = [ + ".html", + "/index.html", + ".md", # Markdown formatted pages. + "/index.md", + ] + for suffix in suffixes: + if not "." in path and os.path.isfile(abspath + suffix): + # HTML, or Markdown? + if suffix.endswith(".html"): + return rophako.utils.template(path + suffix) + else: + return rophako.utils.markdown_template(abspath + suffix) return not_found("404") diff --git a/rophako/model/blog.py b/rophako/model/blog.py index adf6a6b..e7534cc 100644 --- a/rophako/model/blog.py +++ b/rophako/model/blog.py @@ -73,11 +73,15 @@ def get_entry(post_id): if len(db["fid"]) == 0: db["fid"] = str(post_id) + # If no "format" option, set it to HTML (legacy) + if db.get("format", "") == "": + db["format"] = "html" + return db def post_entry(post_id, fid, epoch, author, subject, avatar, categories, - privacy, ip, emoticons, comments, body): + privacy, ip, emoticons, comments, format, body): """Post (or update) a blog entry.""" # Fetch the index. @@ -139,6 +143,7 @@ def post_entry(post_id, fid, epoch, author, subject, avatar, categories, privacy = privacy or "public", author = author, subject = subject, + format = format, body = body, )) diff --git a/rophako/model/comment.py b/rophako/model/comment.py index 148cddd..de7124e 100644 --- a/rophako/model/comment.py +++ b/rophako/model/comment.py @@ -13,7 +13,7 @@ import config import rophako.jsondb as JsonDB import rophako.model.user as User import rophako.model.emoticons as Emoticons -from rophako.utils import send_email +from rophako.utils import send_email, render_markdown from rophako.log import logger @@ -162,15 +162,12 @@ def unsubscribe(thread, email): def format_message(message): """HTML sanitize the message and format it for display.""" - # We basically want to escape HTML symbols (like what Flask does for us - # automatically), but we want line breaks to translate to literal
tags. - message = re.sub(r'&', '&', message) - message = re.sub(r'<', '<', message) - message = re.sub(r'>', '>', message) - message = re.sub(r'"', '"', message) - message = re.sub(r"'", ''', message) - message = re.sub(r'\n', '
', message) - message = re.sub(r'\r', '', message) + + # Comments use Markdown formatting, and HTML tags are escaped by default. + message = render_markdown(message) + + # Don't allow commenters to use images. + message = re.sub(r'', '', message) # Process emoticons. message = Emoticons.render(message) diff --git a/rophako/modules/blog.py b/rophako/modules/blog.py index 6a7f8cf..59e2d62 100644 --- a/rophako/modules/blog.py +++ b/rophako/modules/blog.py @@ -13,7 +13,7 @@ import rophako.model.user as User import rophako.model.blog as Blog import rophako.model.comment as Comment import rophako.model.emoticons as Emoticons -from rophako.utils import template, pretty_time, login_required +from rophako.utils import template, render_markdown, pretty_time, login_required from rophako.log import logger from config import * @@ -44,9 +44,15 @@ def entry(fid): post = Blog.get_entry(post_id) post["post_id"] = post_id + # Render the body. + if post["format"] == "markdown": + post["rendered_body"] = render_markdown(post["body"]) + else: + post["rendered_body"] = post["body"] + # Render emoticons. if post["emoticons"]: - post["body"] = Emoticons.render(post["body"]) + post["rendered_body"] = Emoticons.render(post["rendered_body"]) # Get the author's information. post["profile"] = User.get_user(uid=post["author"]) @@ -85,6 +91,7 @@ def update(): author=g.info["session"]["uid"], subject="", body="", + format="markdown", avatar="", categories="", privacy=BLOG_DEFAULT_PRIVACY, @@ -110,7 +117,7 @@ def update(): g.info["post"] = post # Copy fields. - for field in ["author", "fid", "subject", "body", "avatar", + for field in ["author", "fid", "subject", "format", "body", "avatar", "categories", "privacy", "emoticons", "comments"]: g.info[field] = post[field] @@ -141,6 +148,17 @@ def update(): # What action are they doing? if action == "preview": g.info["preview"] = True + + # Render markdown? + if g.info["format"] == "markdown": + g.info["rendered_body"] = render_markdown(g.info["body"]) + else: + g.info["rendered_body"] = g.info["body"] + + # Render emoticons. + if g.info["emoticons"]: + g.info["rendered_body"] = Emoticons.render(g.info["rendered_body"]) + elif action == "publish": # Publishing! Validate inputs first. invalid = False @@ -188,6 +206,7 @@ def update(): ip = request.remote_addr, emoticons = g.info["emoticons"], comments = g.info["comments"], + format = g.info["format"], body = g.info["body"], ) @@ -365,9 +384,15 @@ def partial_index(): post["post_id"] = post_id + # Render the body. + if post["format"] == "markdown": + post["rendered_body"] = render_markdown(post["body"]) + else: + post["rendered_body"] = post["body"] + # Render emoticons. if post["emoticons"]: - post["body"] = Emoticons.render(post["body"]) + post["rendered_body"] = Emoticons.render(post["rendered_body"]) # Get the author's information. post["profile"] = User.get_user(uid=post["author"]) diff --git a/rophako/utils.py b/rophako/utils.py index 1c03e2c..22ebfee 100644 --- a/rophako/utils.py +++ b/rophako/utils.py @@ -2,12 +2,14 @@ from flask import g, session, request, render_template, flash, redirect, url_for from functools import wraps +import codecs import uuid import datetime import time import re import importlib import smtplib +import markdown from rophako.log import logger from config import * @@ -54,6 +56,54 @@ def template(name, **kwargs): return html +def markdown_template(path): + """Render a Markdown page to the browser.""" + + # The path is the absolute path to the Markdown file, so open it directly. + fh = codecs.open(path, "r", "utf-8") + body = fh.read() + fh.close() + + # Extract a title from the first line. + first = body.split("\n")[0] + if first.startswith("#"): + first = first[1:].strip() + + rendered = render_markdown(body) + return template("markdown.inc.html", + title=first, + markdown=rendered, + ) + + +def render_markdown(body, html_escape=True): + """Render a block of Markdown text. + + This will default to escaping literal HTML characters. Set + `html_escape=False` to trust HTML.""" + + args = dict( + lazy_ol=False, # If a numbered list starts at e.g. 4, show the
    there + extensions=[ + "fenced_code", # GitHub style code blocks + "tables", # http://michelf.ca/projects/php-markdown/extra/#table + "smart_strong", # Handles double__underscore better. + "codehilite", # Code highlighting with Pygment! + "nl2br", # Line breaks inside a paragraph become
    + "sane_lists", # Make lists less surprising + ], + extension_configs={ + "codehilite": { + "linenums": False, + } + } + ) + if html_escape: + args["safe_mode"] = "escape" + + return markdown.markdown(body, **args) + + def send_email(to, subject, message, sender=None): """Send an e-mail out.""" if sender is None: diff --git a/rophako/www/blog/entry.inc.html b/rophako/www/blog/entry.inc.html index 3de96d4..3778b38 100644 --- a/rophako/www/blog/entry.inc.html +++ b/rophako/www/blog/entry.inc.html @@ -26,7 +26,7 @@ on {{ post["pretty_time"] }} - {{ post["body"] | safe }} + {{ post["rendered_body"] | safe }}

    diff --git a/rophako/www/blog/update.html b/rophako/www/blog/update.html index 4dcccd0..556be5f 100644 --- a/rophako/www/blog/update.html +++ b/rophako/www/blog/update.html @@ -5,7 +5,7 @@ {% if preview %}

    Preview: {{ subject }}

    - {{ body|safe }} + {{ rendered_body|safe }}
    {% endif %} @@ -26,6 +26,12 @@

    Body:
    + +

    Emoticon reference (opens in new window)

    diff --git a/rophako/www/comment/index.inc.html b/rophako/www/comment/index.inc.html index c56aaf8..827b253 100644 --- a/rophako/www/comment/index.inc.html +++ b/rophako/www/comment/index.inc.html @@ -31,6 +31,7 @@ There {% if comments|length == 1 %}is{% else %}are{% endif %}

    {% endfor %} +

    Add a Comment

    @@ -56,7 +57,8 @@ There {% if comments|length == 1 %}is{% else %}are{% endif %} Your Email: - (optional) + + (optional) @@ -65,8 +67,9 @@ There {% if comments|length == 1 %}is{% else %}are{% endif %}
    - You can use emoticons - in your comment. (opens in a new window) + Comments can be formatted with Markdown, + and you can use
    emoticons + in your comment.
    diff --git a/rophako/www/css/codehilite.css b/rophako/www/css/codehilite.css new file mode 100644 index 0000000..99b7e05 --- /dev/null +++ b/rophako/www/css/codehilite.css @@ -0,0 +1,71 @@ +/* Syntax highlighting classes for markdown codehilite plugin, which uses + Pygments. This file was generated by doing this in the Python shell: + + >>> from pygments.formatters import HtmlFormatter + >>> fh = open("codehilite.css", "w") + >>> fh.write(HtmlFormatter().get_style_defs(".codehilite")) + >>> fh.close() +*/ + +.codehilite .hll { background-color: #ffffcc } +.codehilite { background: #f8f8f8; } +.codehilite .c { color: #408080; font-style: italic } /* Comment */ +.codehilite .err { border: 1px solid #FF0000 } /* Error */ +.codehilite .k { color: #008000; font-weight: bold } /* Keyword */ +.codehilite .o { color: #666666 } /* Operator */ +.codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */ +.codehilite .cp { color: #BC7A00 } /* Comment.Preproc */ +.codehilite .c1 { color: #408080; font-style: italic } /* Comment.Single */ +.codehilite .cs { color: #408080; font-style: italic } /* Comment.Special */ +.codehilite .gd { color: #A00000 } /* Generic.Deleted */ +.codehilite .ge { font-style: italic } /* Generic.Emph */ +.codehilite .gr { color: #FF0000 } /* Generic.Error */ +.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.codehilite .gi { color: #00A000 } /* Generic.Inserted */ +.codehilite .go { color: #888888 } /* Generic.Output */ +.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.codehilite .gs { font-weight: bold } /* Generic.Strong */ +.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.codehilite .gt { color: #0044DD } /* Generic.Traceback */ +.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.codehilite .kp { color: #008000 } /* Keyword.Pseudo */ +.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.codehilite .kt { color: #B00040 } /* Keyword.Type */ +.codehilite .m { color: #666666 } /* Literal.Number */ +.codehilite .s { color: #BA2121 } /* Literal.String */ +.codehilite .na { color: #7D9029 } /* Name.Attribute */ +.codehilite .nb { color: #008000 } /* Name.Builtin */ +.codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.codehilite .no { color: #880000 } /* Name.Constant */ +.codehilite .nd { color: #AA22FF } /* Name.Decorator */ +.codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */ +.codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ +.codehilite .nf { color: #0000FF } /* Name.Function */ +.codehilite .nl { color: #A0A000 } /* Name.Label */ +.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.codehilite .nv { color: #19177C } /* Name.Variable */ +.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.codehilite .w { color: #bbbbbb } /* Text.Whitespace */ +.codehilite .mf { color: #666666 } /* Literal.Number.Float */ +.codehilite .mh { color: #666666 } /* Literal.Number.Hex */ +.codehilite .mi { color: #666666 } /* Literal.Number.Integer */ +.codehilite .mo { color: #666666 } /* Literal.Number.Oct */ +.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */ +.codehilite .sc { color: #BA2121 } /* Literal.String.Char */ +.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */ +.codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ +.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */ +.codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ +.codehilite .sx { color: #008000 } /* Literal.String.Other */ +.codehilite .sr { color: #BB6688 } /* Literal.String.Regex */ +.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */ +.codehilite .ss { color: #19177C } /* Literal.String.Symbol */ +.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */ +.codehilite .vc { color: #19177C } /* Name.Variable.Class */ +.codehilite .vg { color: #19177C } /* Name.Variable.Global */ +.codehilite .vi { color: #19177C } /* Name.Variable.Instance */ +.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/rophako/www/layout.html b/rophako/www/layout.html index 539b0c1..0e1fb21 100644 --- a/rophako/www/layout.html +++ b/rophako/www/layout.html @@ -3,6 +3,7 @@ {% block title %}{% endblock %} + diff --git a/rophako/www/markdown.inc.html b/rophako/www/markdown.inc.html new file mode 100644 index 0000000..b75b91e --- /dev/null +++ b/rophako/www/markdown.inc.html @@ -0,0 +1,7 @@ +{% extends "layout.html" %} +{% block title %}{{ title }}{% endblock %} +{% block content %} + +{{ markdown|safe }} + +{% endblock %} \ No newline at end of file diff --git a/rophako/www/smoke/style.css b/rophako/www/smoke/style.css index 216c92f..3b50202 100644 --- a/rophako/www/smoke/style.css +++ b/rophako/www/smoke/style.css @@ -18,6 +18,10 @@ body { padding: 0; } +small { + font-size: 9pt; +} + a:link, a:visited { color: #FF4400; text-decoration: underline; @@ -62,6 +66,17 @@ fieldset legend { font-weight: bold; } +/* Markdown Code-Hilite class overrides. */ +.codehilite { + border: 1px dashed #000000; + padding: 0px 20px; + background-color: transparent; +} +pre, code { + font-family: "DejaVu Sans Mono","Ubuntu Mono","Lucida Console","Courier New","Courier",monospace; + font-size: 10pt; +} + /********************* * Classes used by core Rophako pages. You'll want to * implement these in your custom layout.