Add Markdown support to blogs, comments, pages

This commit is contained in:
Noah 2014-04-18 21:55:37 -07:00
parent b58563b0d1
commit 978928c97e
13 changed files with 217 additions and 24 deletions

View File

@ -4,3 +4,5 @@ redis
bcrypt bcrypt
pillow pillow
requests requests
Markdown
Pygments

View File

@ -120,10 +120,21 @@ def catchall(path):
abspath = os.path.abspath("{}/{}".format(root, path)) abspath = os.path.abspath("{}/{}".format(root, path))
if os.path.isfile(abspath): if os.path.isfile(abspath):
return send_file(abspath) return send_file(abspath)
elif not "." in path and os.path.isfile(abspath + ".html"):
return rophako.utils.template(path + ".html") # The exact file wasn't found, look for some extensions and index pages.
elif not "." in path and os.path.isfile(abspath + "/index.html"): suffixes = [
return rophako.utils.template(path + "/index.html") ".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") return not_found("404")

View File

@ -73,11 +73,15 @@ def get_entry(post_id):
if len(db["fid"]) == 0: if len(db["fid"]) == 0:
db["fid"] = str(post_id) db["fid"] = str(post_id)
# If no "format" option, set it to HTML (legacy)
if db.get("format", "") == "":
db["format"] = "html"
return db return db
def post_entry(post_id, fid, epoch, author, subject, avatar, categories, 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.""" """Post (or update) a blog entry."""
# Fetch the index. # Fetch the index.
@ -139,6 +143,7 @@ def post_entry(post_id, fid, epoch, author, subject, avatar, categories,
privacy = privacy or "public", privacy = privacy or "public",
author = author, author = author,
subject = subject, subject = subject,
format = format,
body = body, body = body,
)) ))

View File

@ -13,7 +13,7 @@ import config
import rophako.jsondb as JsonDB import rophako.jsondb as JsonDB
import rophako.model.user as User import rophako.model.user as User
import rophako.model.emoticons as Emoticons 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 from rophako.log import logger
@ -162,15 +162,12 @@ def unsubscribe(thread, email):
def format_message(message): def format_message(message):
"""HTML sanitize the message and format it for display.""" """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 <br> tags. # Comments use Markdown formatting, and HTML tags are escaped by default.
message = re.sub(r'&', '&amp;', message) message = render_markdown(message)
message = re.sub(r'<', '&lt;', message)
message = re.sub(r'>', '&gt;', message) # Don't allow commenters to use images.
message = re.sub(r'"', '&quot;', message) message = re.sub(r'<img.+?/>', '', message)
message = re.sub(r"'", '&apos;', message)
message = re.sub(r'\n', '<br>', message)
message = re.sub(r'\r', '', message)
# Process emoticons. # Process emoticons.
message = Emoticons.render(message) message = Emoticons.render(message)

View File

@ -13,7 +13,7 @@ import rophako.model.user as User
import rophako.model.blog as Blog import rophako.model.blog as Blog
import rophako.model.comment as Comment import rophako.model.comment as Comment
import rophako.model.emoticons as Emoticons 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 rophako.log import logger
from config import * from config import *
@ -44,9 +44,15 @@ def entry(fid):
post = Blog.get_entry(post_id) post = Blog.get_entry(post_id)
post["post_id"] = 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. # Render emoticons.
if post["emoticons"]: if post["emoticons"]:
post["body"] = Emoticons.render(post["body"]) post["rendered_body"] = Emoticons.render(post["rendered_body"])
# Get the author's information. # Get the author's information.
post["profile"] = User.get_user(uid=post["author"]) post["profile"] = User.get_user(uid=post["author"])
@ -85,6 +91,7 @@ def update():
author=g.info["session"]["uid"], author=g.info["session"]["uid"],
subject="", subject="",
body="", body="",
format="markdown",
avatar="", avatar="",
categories="", categories="",
privacy=BLOG_DEFAULT_PRIVACY, privacy=BLOG_DEFAULT_PRIVACY,
@ -110,7 +117,7 @@ def update():
g.info["post"] = post g.info["post"] = post
# Copy fields. # Copy fields.
for field in ["author", "fid", "subject", "body", "avatar", for field in ["author", "fid", "subject", "format", "body", "avatar",
"categories", "privacy", "emoticons", "comments"]: "categories", "privacy", "emoticons", "comments"]:
g.info[field] = post[field] g.info[field] = post[field]
@ -141,6 +148,17 @@ def update():
# What action are they doing? # What action are they doing?
if action == "preview": if action == "preview":
g.info["preview"] = True 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": elif action == "publish":
# Publishing! Validate inputs first. # Publishing! Validate inputs first.
invalid = False invalid = False
@ -188,6 +206,7 @@ def update():
ip = request.remote_addr, ip = request.remote_addr,
emoticons = g.info["emoticons"], emoticons = g.info["emoticons"],
comments = g.info["comments"], comments = g.info["comments"],
format = g.info["format"],
body = g.info["body"], body = g.info["body"],
) )
@ -365,9 +384,15 @@ def partial_index():
post["post_id"] = 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. # Render emoticons.
if post["emoticons"]: if post["emoticons"]:
post["body"] = Emoticons.render(post["body"]) post["rendered_body"] = Emoticons.render(post["rendered_body"])
# Get the author's information. # Get the author's information.
post["profile"] = User.get_user(uid=post["author"]) post["profile"] = User.get_user(uid=post["author"])

View File

@ -2,12 +2,14 @@
from flask import g, session, request, render_template, flash, redirect, url_for from flask import g, session, request, render_template, flash, redirect, url_for
from functools import wraps from functools import wraps
import codecs
import uuid import uuid
import datetime import datetime
import time import time
import re import re
import importlib import importlib
import smtplib import smtplib
import markdown
from rophako.log import logger from rophako.log import logger
from config import * from config import *
@ -54,6 +56,54 @@ def template(name, **kwargs):
return html 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 <ol> 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 <br>
"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): def send_email(to, subject, message, sender=None):
"""Send an e-mail out.""" """Send an e-mail out."""
if sender is None: if sender is None:

View File

@ -26,7 +26,7 @@
on <span title="{{ post['time'] }}">{{ post["pretty_time"] }}</span> on <span title="{{ post['time'] }}">{{ post["pretty_time"] }}</span>
</div> </div>
{{ post["body"] | safe }} {{ post["rendered_body"] | safe }}
<p> <p>
<div class="clear"> <div class="clear">

View File

@ -5,7 +5,7 @@
{% if preview %} {% if preview %}
<h1>Preview: {{ subject }}</h1> <h1>Preview: {{ subject }}</h1>
{{ body|safe }} {{ rendered_body|safe }}
<hr> <hr>
{% endif %} {% endif %}
@ -26,6 +26,12 @@
<input type="text" size="80" name="fid" value="{{ fid }}"><p> <input type="text" size="80" name="fid" value="{{ fid }}"><p>
<strong>Body:</strong><br> <strong>Body:</strong><br>
<label>
<input type="radio" name="format" value="markdown"{% if format == "markdown" %} checked{% endif %}> Markdown
</label>
<label>
<input type="radio" name="format" value="html"{% if format == "html" %} checked{% endif %}> HTML
</label><br>
<textarea cols="80" rows="12" name="body">{{ body }}</textarea><br> <textarea cols="80" rows="12" name="body">{{ body }}</textarea><br>
<a href="{{ url_for('emoticons.index') }}" target="_blank">Emoticon reference</a> (opens in new window)<p> <a href="{{ url_for('emoticons.index') }}" target="_blank">Emoticon reference</a> (opens in new window)<p>

View File

@ -31,6 +31,7 @@ There {% if comments|length == 1 %}is{% else %}are{% endif %}
</div><p> </div><p>
{% endfor %} {% endfor %}
<a name="comments"></a>
<h2>Add a Comment</h2> <h2>Add a Comment</h2>
<form name="comment" action="{{ url_for('comment.preview') }}" method="POST"> <form name="comment" action="{{ url_for('comment.preview') }}" method="POST">
@ -56,7 +57,8 @@ There {% if comments|length == 1 %}is{% else %}are{% endif %}
Your Email: Your Email:
</td> </td>
<td align="left" valign="middle"> <td align="left" valign="middle">
<input type="text" size="40" name="contact"> <small>(optional)</small> <input type="text" size="40" name="contact">
<small>(optional)</small>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -65,8 +67,9 @@ There {% if comments|length == 1 %}is{% else %}are{% endif %}
</td> </td>
<td align="left" valign="top"> <td align="left" valign="top">
<textarea cols="40" rows="8" name="message" style="width: 100%"></textarea><br> <textarea cols="40" rows="8" name="message" style="width: 100%"></textarea><br>
<small>You can use <a href="{{ url_for('emoticons.index') }}" target="_blank">emoticons</a> <small>Comments can be formatted with <a href="https://daringfireball.net/projects/markdown/syntax" target="_blank">Markdown</a>,
in your comment. <em>(opens in a new window)</em></small> and you can use<br><a href="{{ url_for('emoticons.index') }}" target="_blank">emoticons</a>
in your comment.</small>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -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 */

View File

@ -3,6 +3,7 @@
<head> <head>
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" type="text/css" href="/css/codehilite.css">
<link rel="stylesheet" type="text/css" href="/smoke/style.css"> <link rel="stylesheet" type="text/css" href="/smoke/style.css">
</head> </head>
<body> <body>

View File

@ -0,0 +1,7 @@
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
{{ markdown|safe }}
{% endblock %}

View File

@ -18,6 +18,10 @@ body {
padding: 0; padding: 0;
} }
small {
font-size: 9pt;
}
a:link, a:visited { a:link, a:visited {
color: #FF4400; color: #FF4400;
text-decoration: underline; text-decoration: underline;
@ -62,6 +66,17 @@ fieldset legend {
font-weight: bold; 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 * Classes used by core Rophako pages. You'll want to
* implement these in your custom layout. * implement these in your custom layout.