First commit

This commit is contained in:
Fabio Manganiello 2022-01-11 20:16:27 +01:00
commit a91b564305
30 changed files with 1233 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__
build/
dist/
*.egg-info

22
LICENSE.txt Normal file
View File

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2021, 2022 Fabio Manganiello
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.

4
MANIFEST.in Normal file
View File

@ -0,0 +1,4 @@
recursive-include madblog/static/css *
recursive-include madblog/static/fonts *
recursive-include madblog/static/img *
recursive-include madblog/templates *

103
README.md Normal file
View File

@ -0,0 +1,103 @@
# mdblog
This project provides a minimal blogging platform based on Markdown files.
## Installation
```shell
$ python setup.py install
```
## Usage
```shell
# The application will listen on port 8000 and it will
# serve the current folder
$ madness
```
```
usage: madblog [-h] [--host HOST] [--port PORT] [--debug] [path]
Serve a Markdown folder as a web blog.
The folder should have the following structure:
.
-> markdown
-> article-1.md
-> article-2.md
-> ...
-> img [recommended]
-> favicon.ico
-> icon.png
-> image-1.png
-> image-2.png
-> ...
-> css [optional]
-> custom-1.css
-> custom-2.css
-> ...
-> fonts [optional]
-> custom-1.ttf
-> custom-1.css
-> ...
-> templates [optional]
-> index.html [for a custom index template]
-> article.html [for a custom article template]
positional arguments:
path Base path for the blog
options:
-h, --help show this help message and exit
--host HOST Bind host/address
--port PORT Bind port (default: 8000)
--debug Enable debug mode (default: False)
```
## Markdown files
Articles are Markdown files stored under `pages`. For an article to be correctly rendered,
you need to start the Markdown file with the following metadata header:
```markdown
[//]: # (title: Title of the article)
[//]: # (description: Short description of the content)
[//]: # (image: /img/some-header-image.png)
[//]: # (author: Author Name <email@author.me>)
[//]: # (published: 2022-01-01)
```
## Images
Images are stored under `img`. You can reference them in your articles through the following syntax:
```markdown
![image description](/img/image.png)
```
You can also drop your `favicon.ico` under this folder.
## LaTeX support
LaTeX support is built-in as long as you have the `latex` executable installed on your server.
Syntax for inline LaTeX:
```markdown
And we can therefore prove that \( c^2 = a^2 + b^2 \)
```
Syntax for LaTeX expression on a new line:
```markdown
$$
c^2 = a^2 + b^2
$$
```
## RSS syndacation
RSS feeds for the blog are provided under the `/rss` URL.

0
madblog/__init__.py Normal file
View File

4
madblog/__main__.py Normal file
View File

@ -0,0 +1,4 @@
from .cli import run
if __name__ == '__main__':
run()

103
madblog/app.py Normal file
View File

@ -0,0 +1,103 @@
import datetime
import os
import re
from glob import glob
from typing import Optional
from flask import Flask, abort, render_template
from markdown import markdown
from .config import config
from .latex import MarkdownLatex
class BlogApp(Flask):
def __init__(self, *args, **kwargs):
super().__init__(*args, template_folder=config.templates_dir, **kwargs)
self.pages_dir = os.path.join(config.content_dir, 'markdown')
self.img_dir = config.default_img_dir
self.css_dir = config.default_css_dir
self.fonts_dir = config.default_fonts_dir
if not os.path.isdir(self.pages_dir):
raise FileNotFoundError(self.pages_dir)
img_dir = os.path.join(config.content_dir, 'img')
if os.path.isdir(img_dir):
self.img_dir = img_dir
css_dir = os.path.join(config.content_dir, 'css')
if os.path.isdir(css_dir):
self.css_dir = css_dir
fonts_dir = os.path.join(config.content_dir, 'fonts')
if os.path.isdir(fonts_dir):
self.fonts_dir = fonts_dir
templates_dir = os.path.join(config.content_dir, 'templates')
if os.path.isdir(templates_dir):
self.template_folder = templates_dir
def get_page_metadata(self, page: str) -> dict:
if not page.endswith('.md'):
page = page + '.md'
if not os.path.isfile(os.path.join(self.pages_dir, page)):
abort(404)
metadata = {}
with open(os.path.join(self.pages_dir, page), 'r') as f:
metadata['uri'] = '/article/' + page[:-3]
for line in f.readlines():
if not line:
continue
if not (m := re.match(r'^\[//]: # \(([^:]+):\s*([^)]+)\)\s*$', line)):
break
if m.group(1) == 'published':
metadata[m.group(1)] = datetime.date.fromisoformat(m.group(2))
else:
metadata[m.group(1)] = m.group(2)
return metadata
def get_page(self, page: str, title: Optional[str] = None, skip_header: bool = False):
if not page.endswith('.md'):
page = page + '.md'
metadata = self.get_page_metadata(page)
with open(os.path.join(self.pages_dir, page), 'r') as f:
return render_template(
'article.html',
config=config,
title=title if title else metadata.get('title', config.title),
image=metadata.get('image'),
description=metadata.get('description'),
author=re.match(r'(.+?)\s+<([^>]+>)', metadata['author'])[1] if 'author' in metadata else None,
author_email=re.match(r'(.+?)\s+<([^>]+)>', metadata['author'])[2] if 'author' in metadata else None,
published=(metadata['published'].strftime('%b %d, %Y')
if metadata.get('published') else None),
content=markdown(f.read(), extensions=['fenced_code', 'codehilite', MarkdownLatex()]),
skip_header=skip_header
)
def get_pages(self, with_content: bool = False, skip_header: bool = False) -> list:
return sorted([
{
'path': path,
'content': self.get_page(path, skip_header=skip_header) if with_content else '',
**self.get_page_metadata(os.path.basename(path)),
}
for path in glob(os.path.join(app.pages_dir, '*.md'))
], key=lambda page: page.get('published'), reverse=True)
app = BlogApp(__name__)
from .routes import *
# vim:sw=4:ts=4:et:

55
madblog/cli.py Normal file
View File

@ -0,0 +1,55 @@
import argparse
import os
import sys
def get_args():
parser = argparse.ArgumentParser(description='''Serve a Markdown folder as a web blog.
The folder should have the following structure:
.
-> config.yaml [recommended]
-> markdown
-> article-1.md
-> article-2.md
-> ...
-> img [recommended]
-> favicon.ico
-> icon.png
-> image-1.png
-> image-2.png
-> ...
-> css [optional]
-> custom-1.css
-> custom-2.css
-> ...
-> fonts [optional]
-> custom-1.ttf
-> custom-1.css
-> ...
-> templates [optional]
-> index.html [for a custom index template]
-> article.html [for a custom article template]
''', formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument('dir', nargs='?', default='.', help='Base path for the blog (default: current directory)')
parser.add_argument('--config', dest='config', default='config.yaml', required=False, help='Path to a configuration file (default: config.yaml in the blog root directory)')
parser.add_argument('--host', dest='host', required=False, default='0.0.0.0', help='Bind host/address')
parser.add_argument('--port', dest='port', required=False, type=int, default=8000, help='Bind port (default: 8000)')
parser.add_argument('--debug', dest='debug', required=False, action='store_true', default=False,
help='Enable debug mode (default: False)')
return parser.parse_known_args(sys.argv[1:])
def run():
from .config import init_config
opts, _ = get_args()
config_file = os.path.join(opts.dir, 'config.yaml')
init_config(config_file=config_file, content_dir=opts.dir)
from .app import app
app.run(host=opts.host, port=opts.port, debug=opts.debug)
# vim:sw=4:ts=4:et:

54
madblog/config.py Normal file
View File

@ -0,0 +1,54 @@
import os
import yaml
from typing import Optional
from dataclasses import dataclass
@dataclass
class Config:
title = 'Blog'
description = ''
link = '/'
home_link = '/'
language = 'en-US'
logo = '/img/icon.png'
content_dir = None
categories = None
basedir = os.path.abspath(os.path.dirname(__file__))
templates_dir = os.path.join(basedir, 'templates')
static_dir = os.path.join(basedir, 'static')
default_css_dir = os.path.join(static_dir, 'css')
default_fonts_dir = os.path.join(static_dir, 'fonts')
default_img_dir = os.path.join(static_dir, 'img')
config = Config()
def init_config(content_dir='.', config_file='config.yaml'):
cfg = {}
config.content_dir = content_dir
if os.path.isfile(config_file):
with open(config_file, 'r') as f:
cfg = yaml.safe_load(f)
if cfg.get('title'):
config.title = cfg['title']
if cfg.get('description'):
config.description = cfg['description']
if cfg.get('link'):
config.link = cfg['link']
if cfg.get('home_link'):
config.home_link = cfg['home_link']
if cfg.get('logo'):
config.logo = cfg['logo']
if cfg.get('language'):
config.language = cfg['language']
config.categories = cfg.get('categories', [])
# vim:sw=4:ts=4:et:

248
madblog/latex.py Normal file
View File

@ -0,0 +1,248 @@
"""
Licensed under Public Domain Mark 1.0.
See https://creativecommons.org/publicdomain/mark/1.0/
Author: Justin Bruce Van Horne <justinvh@gmail.com>
Python-Markdown LaTeX Extension
Adds support for $math mode$ and %text mode%. This plugin supports
multiline equations/text.
The actual image generation is done via LaTeX/DVI output.
It encodes data as base64 so there is no need for images directly.
All the work is done in the preprocessor.
"""
import base64
import hashlib
import json
import os
import re
import tempfile
from subprocess import call as rawcall, PIPE
import markdown
def call(*args, **kwargs):
"""
Proxy to subprocess.call(), removes timeout argument in case of
Python2 because that was only implemented in Python3.
"""
return rawcall(*args, **kwargs)
# Defines our basic inline image
img_expr = '<img class="latex inline math-%s" alt="%s" id="%s" src="data:image/png;base64,%s">'
# Defines multiline expression image
multiline_img_expr = '''<div class="multiline-wrapper">
<img class="latex multiline math-%s" alt="%s" id="%s" src="data:image/png;base64,%s"></div>'''
# Base CSS template
img_css = """<style scoped>
.multiline-wrapper {
width: 100%;
text-align: center;
}
img.latex.multiline {
height: 65%;
}
img.latex.inline {
height: .9em;
vertical-align: middle;
}
</style>"""
# Cache and temp file paths
tmpdir = tempfile.gettempdir() + '/markdown-latex'
cache_file = tmpdir + '/latex.cache'
class LaTeXPreprocessor(markdown.preprocessors.Preprocessor):
# These are our cached expressions that are stored in latex.cache
cached = {}
# Basic LaTex Setup as well as our list of expressions to parse
tex_preamble = r"""\documentclass[14pt]{article}
\usepackage{amsmath}
\usepackage{amsthm}
\usepackage{amssymb}
\usepackage{bm}
\usepackage{graphicx}
\usepackage[usenames,dvipsnames]{color}
\pagestyle{empty}
"""
# Math TeX extraction regex
math_extract_regex = re.compile(r'(.+?)((\\\(.+?\\\))|(\$\$\n.+?\n\$\$\n))(.*)', re.MULTILINE | re.DOTALL)
# Math TeX matching regex
math_match_regex = re.compile(r'\s*(\\\(.+?\\\))|(\$\$\n.+?\n\$\$\n)\s*', re.MULTILINE | re.DOTALL)
def __init__(self, *_, **__):
if not os.path.isdir(tmpdir):
os.makedirs(tmpdir)
try:
with open(cache_file, 'r') as f:
self.cached = json.load(f)
except (IOError, json.JSONDecodeError):
self.cached = {}
self.config = {
("general", "preamble"): "",
("dvipng", "args"): "-q -T tight -bg Transparent -z 9 -D 200",
("delimiters", "text"): "%",
("delimiters", "math"): "$",
("delimiters", "preamble"): "%%"}
def _latex_to_base64(self, tex):
"""Generates a base64 representation of TeX string"""
# Generate the temporary file
tmp_file_fd, path = tempfile.mkstemp(dir=tmpdir)
with os.fdopen(tmp_file_fd, "w") as tmp_file:
tmp_file.write(self.tex_preamble)
tmp_file.write(tex)
tmp_file.write('\n\\end{document}')
# compile LaTeX document. A DVI file is created
status = call(('latex -halt-on-error -output-directory={:s} {:s}'
.format(tmpdir, path)).split(),
stdout=PIPE, timeout=10)
# clean up if the above failed
if status:
self._cleanup(path, err=True)
raise Exception("Couldn't compile LaTeX document." +
"Please read '%s.log' for more detail." % path)
# Run dvipng on the generated DVI file. Use tight bounding box.
# Magnification is set to 1200
dvi = "%s.dvi" % path
png = "%s.png" % path
# Extract the image
cmd = "dvipng %s %s -o %s" % (self.config[("dvipng", "args")], dvi, png)
status = call(cmd.split(), stdout=PIPE)
# clean up if we couldn't make the above work
if status:
self._cleanup(path, err=True)
raise Exception("Couldn't convert LaTeX to image." +
"Please read '%s.log' for more detail." % path)
# Read the png and encode the data
try:
with open(png, "rb") as png:
data = png.read()
return base64.b64encode(data)
finally:
self._cleanup(path)
@staticmethod
def _cleanup(path, err=False):
# don't clean up the log if there's an error
extensions = ["", ".aux", ".dvi", ".png", ".log"]
if err:
extensions.pop()
# now do the actual cleanup, passing on non-existent files
for extension in extensions:
try:
os.remove("%s%s" % (path, extension))
except (IOError, OSError):
pass
def run(self, lines):
"""Parses the actual page"""
# Checks for the LaTeX header
use_latex = any(line == '[//]: # (latex: 1)' for line in lines)
if not use_latex:
return lines
# Re-creates the entire page so we can parse in a multiline env.
page = "\n".join(lines)
# Adds a preamble mode
self.tex_preamble += self.config[("general", "preamble")] + "\n\\begin{document}\n"
# Figure out our text strings and math-mode strings
tex_expr = self.math_extract_regex.findall(page)
# No sense in doing the extra work
if not len(tex_expr):
return page.split("\n")
# Parse the expressions
new_cache = {}
new_page = ''
n_multiline_expressions = 0
while page:
m = self.math_extract_regex.match(page)
if not m:
new_page += page
break
new_page += m.group(1)
math_match = self.math_match_regex.match(m.group(2))
if not math_match:
new_page += m.group(2)
else:
expr = m.group(2)
is_multiline = math_match.group(2) is not None
tex_hash = self.hash(expr)
if tex_hash in self.cached:
data = self.cached[tex_hash]
else:
data = self._latex_to_base64(expr).decode()
new_cache[tex_hash] = data
if is_multiline and n_multiline_expressions > 0:
new_page += '</p>'
new_page += (multiline_img_expr if is_multiline else img_expr) % ('true', expr, tex_hash, data)
if is_multiline:
new_page += '<p>'
n_multiline_expressions += 1
page = m.group(5)
if n_multiline_expressions > 0:
new_page += '</p>'
# Cache our data
self.cached.update(new_cache)
with open(cache_file, 'w') as f:
json.dump(self.cached, f)
# Make sure to re-split the lines
return new_page.split("\n")
@staticmethod
def hash(tex: str) -> str:
return hashlib.sha1(tex.encode()).hexdigest()
class LaTeXPostprocessor(markdown.postprocessors.Postprocessor):
"""This post processor extension just allows us to further
refine, if necessary, the document after it has been parsed."""
# noinspection PyMethodMayBeStatic
def run(self, text):
# Inline a style for default behavior
text = img_css + text
return text
class MarkdownLatex(markdown.Extension):
"""Wrapper for LaTeXPreprocessor"""
def extendMarkdown(self, md):
# Our base LaTeX extension
md.preprocessors.add('latex',
LaTeXPreprocessor(self), ">html_block")
# Our cleanup postprocessing extension
md.postprocessors.add('latex',
LaTeXPostprocessor(self), ">amp_substitute")

96
madblog/routes.py Normal file
View File

@ -0,0 +1,96 @@
import os
from typing import Optional
from flask import Response, send_from_directory as send_from_directory_, render_template
from .app import app
from .config import config
def send_from_directory(path: str, file: str, alternative_path: Optional[str] = None, *args, **kwargs):
if not os.path.exists(os.path.join(path, file)) and alternative_path:
path = alternative_path
return send_from_directory_(path, file, *args, **kwargs)
@app.route('/', methods=['GET'])
def home_route():
return render_template('index.html', pages=app.get_pages(), config=config)
@app.route('/img/<img>', methods=['GET'])
def img_route(img: str):
return send_from_directory(app.img_dir, img, config.default_img_dir)
@app.route('/favicon.ico', methods=['GET'])
def favicon_route():
return img_route('favicon.ico')
@app.route('/css/<style>', methods=['GET'])
def css_route(style: str):
return send_from_directory(app.css_dir, style, config.default_css_dir)
@app.route('/fonts/<file>', methods=['GET'])
def fonts_route(file: str):
return send_from_directory(app.fonts_dir, file, config.default_fonts_dir)
@app.route('/article/<article>', methods=['GET'])
def article_route(article: str):
return app.get_page(article)
@app.route('/rss', methods=['GET'])
def rss_route():
pages = app.get_pages(with_content=True, skip_header=True)
return Response('''<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>{title}</title>
<link>{link}</link>
<description>{description}</description>
<category>{",".join(categories)}</category>
<image>
<url>{link}/img/icon.png</url>
<title>{title}</title>
<link>{link}</link>
</image>
<pubDate>{last_pub_date}</pubDate>
<language>{language}</language>
{items}
</channel>
</rss>'''.format(
title=config.title,
description=config.description,
link=config.link,
categories=config.categories,
language=config.language,
last_pub_date=pages[0]['published'].strftime('%a, %d %b %Y %H:%M:%S GMT'),
items='\n\n'.join([
'''
<item>
<title>{title}</title>
<link>{base_link}{link}</link>
<pubDate>{published}</pubDate>
<description><![CDATA[{content}]]></description>
<media:content medium="image" url="{base_link}{image}" width="200" height="150" />
</item>
'''.format(
base_link=config.link,
title=page.get('title', '[No Title]'),
link=page.get('uri', ''),
published=page['published'].strftime('%a, %d %b %Y %H:%M:%S GMT') if 'published' in page else '',
content=page.get('content', ''),
image=page.get('image', ''),
)
for page in pages
]),
), mimetype='application/rss+xml')
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,50 @@
main .content {
display: flex;
flex-direction: column;
line-height: 1.5em;
}
main .content p,
main .content ul {
font-family: Avenir, Palatino, charter, Georgia, Cambria, "Times New Roman", Times, serif;
text-align: justify;
overflow-wrap: break-word;
word-break: break-word;
}
main .content code, .codehilite {
font-size: .85em;
}
a:hover {
opacity: 0.7;
}
.description h3 {
font-weight: normal;
opacity: 0.6;
margin: -.5em auto .5em auto;
}
.published-date {
font-size: 0.75em;
opacity: .75;
margin-bottom: 2em;
}
@media screen and (max-width: 1024px) {
main .container {
width: 100%;
}
}
@media screen and (min-width: 1024px) {
main .container {
max-width: 768px;
}
}
.codehilite {
padding: 0 .5em;
overflow: auto;
}

View File

@ -0,0 +1,74 @@
pre { line-height: 125%; }
td.linenos pre { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; }
span.linenos { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; }
td.linenos pre.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.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 .ch { color: #408080; font-style: italic } /* Comment.Hashbang */
.codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */
.codehilite .cp { color: #BC7A00 } /* Comment.Preproc */
.codehilite .cpf { color: #408080; font-style: italic } /* Comment.PreprocFile */
.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 .mb { color: #666666 } /* Literal.Number.Bin */
.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 .sa { color: #BA2121 } /* Literal.String.Affix */
.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */
.codehilite .sc { color: #BA2121 } /* Literal.String.Char */
.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */
.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 .fm { color: #0000FF } /* Name.Function.Magic */
.codehilite .vc { color: #19177C } /* Name.Variable.Class */
.codehilite .vg { color: #19177C } /* Name.Variable.Global */
.codehilite .vi { color: #19177C } /* Name.Variable.Instance */
.codehilite .vm { color: #19177C } /* Name.Variable.Magic */
.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */

View File

@ -0,0 +1,107 @@
html {
height: 100%;
font-size: 20px;
font-family: BlinkMacSystemFont, -apple-system, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, Helvetica, Arial, sans-serif;
font-weight: 400;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
width: 100%;
height: 100%;
}
a, a:visited {
color: #555;
border-bottom: 1px dashed #999;
text-decoration: none;
}
header {
display: flex;
align-items: center;
height: 3em;
padding: 0 .5em;
font-size: .9em;
box-shadow: 1px 3px 3px 0 #bbb;
}
header a {
color: initial;
opacity: .8;
text-decoration: none;
border-bottom: 0;
}
header .left a {
display: flex;
align-items: center;
color: initial !important;
opacity: .9;
}
header a:hover {
opacity: .6;
}
header .title {
font-family: 'Poppins', sans-serif;
margin-left: .333em;
}
header .left,
header .right {
display: inline-flex;
align-items: center;
width: 50%;
}
header .right {
display: inline-flex;
justify-content: right;
text-align: right;
direction: rtl;
opacity: .8;
}
header .icon {
background-size: 40px !important;
width: 40px;
height: 40px;
display: inline-flex;
}
header .left .icon {
width: 2.5em;
height: 2.5em;
margin-right: .5em;
}
header .right .icon {
background: url(/img/rss.png);
}
header .title {
display: inline-flex;
}
main {
height: calc(100% - 3em);
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 2em;
}
h1 {
font-size: 2em;
line-height: 1.2em;
}
h2 {
font-size: 1.5em;
line-height: 1.1em;
}

112
madblog/static/css/home.css Normal file
View File

@ -0,0 +1,112 @@
main {
padding: 0;
}
.articles {
width: 100%;
height: 100%;
display: flex;
flex-wrap: wrap;
overflow: auto;
padding: 0;
}
.article {
display: block;
width: 100%;
max-height: 80%;
box-shadow: 0 1px 3px 1px #ccc;
overflow: hidden;
text-overflow: ellipsis;
color: black !important;
}
.article:hover {
box-shadow: 0 1px 4px 3px #989898;
opacity: 1;
border-radius: .5em;
}
@media screen and (min-width: 767px) {
.article {
max-height: 55%;
}
}
@media screen and (min-width: 640px) and (max-width: 767px) {
.article {
padding: 0 calc(1em + 7vw);
}
}
@media screen and (min-width: 768px) and (max-width: 979px) {
.article {
width: 50%;
}
}
@media screen and (min-width: 980px) and (max-width: 1279px) {
.article {
width: 33.33%;
}
}
@media screen and (min-width: 1280px) and (max-width: 1599px) {
.article {
width: 25%;
}
}
@media screen and (min-width: 1600px) {
.article {
width: 20%;
}
}
.article .container {
height: 100%;
padding: 2em;
}
.image {
height: 35%;
z-index: 1;
}
.image img {
width: 100%;
height: 100%;
}
.article .title {
font-size: 1.2em;
font-weight: bold;
margin-top: .4em;
text-align: center;
}
a {
border-bottom: 0;
}
a:hover {
opacity: 0.8;
}
.description {
color: rgba(0, 0, 0, 0.7);
font-size: .8em;
}
.published-date {
font-size: .8em;
color: rgba(0, 0, 0, 0.4);
margin: .5em 0 2em 0;
text-align: center;
}
h3 {
margin: 40px 0 0;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,14 @@
@font-face {
font-family: 'Fira Sans';
font-style: normal;
font-weight: 200;
font-display: swap;
src: url(./fira-sans-200.ttf) format('truetype');
}
@font-face {
font-family: 'Fira Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(./fira-sans-300.ttf) format('truetype');
}

View File

@ -0,0 +1,7 @@
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(./Poppins.ttf) format('truetype');
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
madblog/static/img/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
madblog/static/img/rss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,43 @@
{% with title=title or 'Platypush blog', skip_header=skip_header, styles=['/css/blog.css', '/css/code.css'] %}
{% include 'common-head.html' %}
{% endwith %}
<main>
<div class="container">
{% if not skip_header %}
<div class="title">
<h1>{{ title }}</h1>
</div>
{% if description %}
<div class="description">
<h3>{{ description }}</h3>
</div>
{% endif %}
{% if published %}
<div class="published-date">
Published
{% if author %} by
{% if author_email %}
<a href="mailto:{{ author_email }}">
{% endif %}
{{ author }}
{% if author_email %}
</a>
{% endif %}
{% endif %}
on {{ published }}
</div>
{% endif %}
{% endif %}
<div class="content">
{{ content | safe }}
</div>
</div>
</main>
{% include 'common-tail.html' %}

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<link rel="alternate" type="application/rss+xml" title="{{ config.title }}" href="/rss" />
<link rel="stylesheet" href="/fonts/poppins.css">
<link rel="stylesheet" href="/fonts/fira-sans.css">
<link rel="stylesheet" href="/css/common.css">
{% if styles %}
{% for style in styles %}
<link rel="stylesheet" type="text/css" href="{{ style }}">
{% endfor %}
{% endif %}
<title>{{ title }}</title>
</head>
<body>
{% if not skip_header %}
<header>
<div class="left">
<a href="{{ config.home_link }}" title="Home">
<img src="{{ config.logo }}" class="icon" alt=".">
</a>
<a href="{{ config.link }}" title="{{ config.title }}">
<span class="title">{{ config.title }}</span>
</a>
</div>
<div class="right">
<a href="/rss" target="_blank" title="RSS feeds">
<div class="icon"></div>
</a>
</div>
</header>
{% endif %}

View File

@ -0,0 +1,2 @@
</body>
</html>

View File

@ -0,0 +1,37 @@
{% with title=title or 'Platypush blog', skip_header=False, styles=['/css/home.css'] %}
{% include 'common-head.html' %}
{% endwith %}
<main>
<div class="articles">
{% for page in pages %}
<a class="article" href="{{ page['uri'] }}">
<div class="container">
{% if page['image'] %}
<div class="image">
<img src="{{ page['image'] }}" alt="">
</div>
{% endif %}
<div class="title">
{{ page['title'] }}
</div>
{% if page['published'] %}
<div class="published-date">
{{ page['published'].strftime('%b %d, %Y') }}
</div>
{% endif %}
{% if page['description'] %}
<div class="description">
{{ page['description'] }}
</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</main>
{% include 'common-tail.html' %}

11
madblog/uwsgi.py Normal file
View File

@ -0,0 +1,11 @@
from .config import init_config
init_config()
from .app import app
# For gunicorn/uWSGI compatibility
application = app
# vim:sw=4:ts=4:et:

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
flask
markdown
pygments
pyyaml

42
setup.py Executable file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env python
import os
from setuptools import setup, find_packages
def readfile(file):
with open(file, 'r') as f:
return f.read()
setup(
name='madblog',
version='0.1.0',
author='Fabio Manganiello',
author_email='info@fabiomanganiello.com',
description='A minimal platform for Markdown-based blogs',
license='MIT',
python_requires='>= 3.8',
keywords='blog markdown',
url='https://git.platypush.tech/blacklight/madblog',
packages=find_packages(include=['madblog']),
include_package_data=True,
entry_points={
'console_scripts': [
'madblog=madblog.cli:run',
],
},
long_description=readfile('README.md'),
long_description_content_type='text/markdown',
classifiers=[
"Topic :: Utilities",
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
],
install_requires=[
'flask',
'markdown',
'pygments',
'pyyaml',
],
)