72 Commits

Author SHA1 Message Date
15142054da tweak the appearance of footnotes 2021-02-12 19:37:25 -06:00
dc81ef35de float image left/right for inlining in an article 2021-02-12 12:51:43 -06:00
c292f33334 CSS for framing an image inline the article 2021-02-12 12:51:12 -06:00
1c052b8409 pin bandit in requirements-dev since 1.7.0 is weird in tox 2021-02-12 09:28:12 -06:00
7cf8a427ce add an .img-25 for 25% wide images 2021-02-12 09:26:10 -06:00
e8a749d9ba Revert "tweak the base text line height, again"
This reverts commit 1878d5951b.

the more I look at this, the more I like the old text spacing
2021-02-12 09:21:21 -06:00
ae72fe87b5 class to center an image as a block element
this is effectively a replacement for div.splash means of getting a
centered header image, and can be used anywhere
2021-02-12 09:19:35 -06:00
bb0e71e9e4 give *all* images max-width of the inner column
this was done for the giant splash logo but I should really just
restrain this everywhere
2021-02-12 09:18:54 -06:00
3bfdacdb6d add attr_list to markdown extensions
this will lead to me putting less HTML in the .md files, which is a good
thing
2021-02-12 09:15:41 -06:00
e6d2015de5 use smarty markdown extension for dashes, ellipses 2021-02-11 19:05:01 -06:00
56eb767e33 don't let sub/superscripts affect line height 2021-02-11 18:53:57 -06:00
07031fe667 enable footnotes extra for markdown 2021-02-11 18:36:48 -06:00
48c6e8495a provide some styling of footnotes 2021-02-11 18:20:42 -06:00
4f45943775 initialize markdown on a per-page basis
the footnote extra expects to only parse one document over the Markup's
lifetime, and writes the footnotes to the bottom of every page that is
rendered (again assuming only one) with links back to the reference

having one parser for the entire app, naturally, introduced
ever-increasing footnote links and every footnote on the site showing up
on every page. this was not intended

in some light testing, doing this per-request has a nominal effect on
performance
2021-02-11 18:17:26 -06:00
b26ea6a661 add html tag in order to specify lang="en" 2021-02-11 09:36:24 -06:00
1878d5951b tweak the base text line height, again 2021-02-11 09:35:21 -06:00
829165ad8c style link underline same color as the hover 2021-02-11 09:35:21 -06:00
7d982b96c9 tweak text colors; less normal, more bold 2021-02-11 09:35:21 -06:00
5e41cde52e use a flexbox for the header sections
this is better than a float because I have always kind of hated how
floating divs work, and this also orders and displays the navs better in
elinks
2021-02-11 00:23:19 -06:00
ad33cf2e83 replace section tags with div tags
syntactically incorrect usage, as picked up by a W3C validator
2021-02-11 00:08:19 -06:00
87ad48d8d2 add mdx-linkify to markdown extensions 2021-01-22 09:51:53 -06:00
8a6f4d6b45 test multi-line metadata entries 2021-01-17 23:58:57 -06:00
c25fefa9e3 add opengraph metadata to pages, via Markdown meta 2021-01-17 23:02:14 -06:00
b0795999fe make splash images look better on small devices 2020-12-14 16:26:08 -06:00
aaced9d0e1 add polycephaly-style figure support
this is really pushing my patience for CSS, but I've always thought this
looked nice, so I'm going to try to retain it
2020-12-14 16:25:35 -06:00
4042932240 tone down the line-height a bit
I think the color changes and using viewport magic has helped
readability a bit
2020-12-08 18:56:49 -06:00
49ab2befb6 disable browser resize magic, do viewport magic instead 2020-12-08 18:47:02 -06:00
fbf6a81e0b use my old "square" logo as favicon
also provide a backgroundless version as a possible splash page image
2020-12-08 18:46:04 -06:00
dabf9f7544 more corrections of the link colors 2020-12-08 18:27:55 -06:00
dcf173ab61 add a test to ensure style selection works 2020-12-08 17:12:35 -06:00
d2c1c2e3ce why did I make user styles a config setting???
this moves it into the code, where it's sensible, and leaves the default
to the config
2020-12-08 16:43:20 -06:00
3fcf916317 requirements bump 2020-12-08 16:33:19 -06:00
67e1890629 increase the line height for readability(?) 2020-12-07 21:55:56 -06:00
e1cb541ea5 highlight links in light theme as in dark theme 2020-12-07 21:55:32 -06:00
93e9c8dc24 tweaks to the dark theme 2020-12-07 21:54:43 -06:00
7cf11986c5 user-selectable light and dark themes
cookies, template rendering with different CSS files via default or
request param or cookie, etc.
2020-10-30 00:19:19 -05:00
5ca483a904 configurable markdown extensions
meta is always loaded, because the code expects it
2020-10-29 23:51:58 -05:00
fe7d61e1f7 actually style the white bg beyond the viewport scroll 2020-10-25 18:05:48 -05:00
1398cfe3db put some sidebars on the site for readability 2020-10-25 17:48:19 -05:00
f63de031f6 tox updates: run py38, combine coverage, dist-as-dir 2020-10-20 16:07:49 -05:00
46bce5a0a5 recompile all requirements, add flake8-mutable 2020-10-20 16:05:17 -05:00
0af0f4e8aa tox.ini updates, use requirements-dev.txt, fix pathing 2020-06-23 13:33:15 -05:00
08896a18c1 reorganize requirements-dev.in, add dlint and flake8-fixme, bandit 2020-06-23 13:30:49 -05:00
ea7c9a1e07 let TODOs through linting, but warn about them 2020-06-22 19:09:39 -05:00
63da59efd5 enable flake8-logging-format violations 2020-06-22 18:50:13 -05:00
c7d4a1c930 add any suppressed flake8-fixme messages in the fail-open run 2020-06-22 18:49:34 -05:00
421d0e6f8e properly create the symlink to dist/ across multiple runs of tox 2020-06-22 18:48:22 -05:00
5c1fc93ff9 combine tox deps in order to unconfuse flake8-isort
with pytest not being included in the lint environment, flake8-isort
didn't know how to treat it vs. incorporealcms imports, leading to false
positives only inside tox. this makes it so that certain packages
(defined in base deps) can be imported in any/all envs, because they
show up in analyzed/imported/etc code rather than being merely tools
2020-06-22 18:48:18 -05:00
7b5f7ff00b add dlint and flake8-fixme 2020-06-20 10:48:46 -05:00
9db5189c65 add flake8-isort, with a caveat 2020-06-19 20:23:23 -05:00
ab2d754e43 reorganize tox.ini a bit and use pytest-cov rather than coverage directly 2020-06-19 20:01:06 -05:00
0f7495bf2b add the ability to redirect a file-looking request to a dir
if the client has requested /foo, and foo is actually a directory,
this redirects the client to /foo/
2020-06-19 19:58:12 -05:00
cf8f0325a2 fix /most/ isort problems, but conftest.py is being weird 2020-06-19 19:54:01 -05:00
718b217868 add flake8 and many plugins to requirements-dev, for vim's sake 2020-06-19 19:40:01 -05:00
ebaccbd0ad organize tests a bit better between unit and functional tests 2020-06-18 23:36:51 -05:00
63f13398e0 versioneer.py doesn't need to be included in the package 2020-06-18 23:29:37 -05:00
605a82680d add bandit and flake8 plugins to tox, remove redundant deps 2020-06-18 17:39:34 -05:00
14f6125f4e use new-style tox.ini, add flake8-docstrings, add docstrings 2020-06-17 20:18:43 -05:00
21f65813fb properly run pytest + cov in the tox env 2020-06-17 16:34:50 -05:00
f77aebb097 replace CI tools with tox invocation 2020-06-16 23:00:49 -05:00
5994b73b2e give tables a lighter border 2020-06-14 10:56:57 -05:00
dadc902c49 put a bit of a background behind blockquote
closes #2
2020-06-14 10:55:24 -05:00
5c8251d01a explicitly set the footer margin-top 2020-06-14 10:01:07 -05:00
29498504cc get the actual pinned requirements in setup.py 2020-05-28 17:00:58 -05:00
ce06de78a8 tests misleadingly had a leading /, need to append it ourselves 2020-05-28 16:52:43 -05:00
beea0c80bf CSS: slightly tweak/specify the text size/height 2020-05-28 12:18:28 -05:00
ab977f7e81 header CSS tweaks 2020-05-28 12:18:04 -05:00
05f879ab80 display untitled-page paths as /path rather than path.md 2020-05-28 12:17:27 -05:00
059108c37b rewrite generate_parent_navs
* works on a path now, not a file location
* as such is sliiiiiightly easier to understand
* now also puts the current page in the nav
* fixed failing test where this caused an error (rather than 404) on
  non-existent paths
2020-05-28 12:09:59 -05:00
0993147dea give tables a bottom margin
otherwise they look bad, for instance, at the very end of the page, too
close to the "Last modified" text.
2020-05-28 08:20:24 -05:00
9e97cb097e requirements bump; tests pass 2020-05-28 08:13:55 -05:00
da2476bbda enable table support in the markdown parser 2020-04-05 10:25:46 -05:00
26 changed files with 962 additions and 154 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ __pycache__/
build/ build/
develop-eggs/ develop-eggs/
dist/ dist/
dist
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/

View File

@@ -1,5 +1,4 @@
graft incorporealcms/static graft incorporealcms/static
graft incorporealcms/templates graft incorporealcms/templates
include versioneer.py
global-exclude *.pyc global-exclude *.pyc
global-exclude *.swp global-exclude *.swp

View File

@@ -1,4 +1,4 @@
"""create_app application factory function and similar things.""" """An application for running my Markdown-based sites."""
import logging import logging
import os import os
from logging.config import dictConfig from logging.config import dictConfig
@@ -12,6 +12,7 @@ del get_versions
def create_app(instance_path=None, test_config=None): def create_app(instance_path=None, test_config=None):
"""Create the Flask app, with allowances for customizing path and test settings."""
app = Flask(__name__, instance_relative_config=True, instance_path=instance_path) app = Flask(__name__, instance_relative_config=True, instance_path=instance_path)
# if it doesn't already exist, create the instance folder # if it doesn't already exist, create the instance folder

View File

@@ -32,5 +32,23 @@ class Config(object):
}, },
} }
MARKDOWN_EXTENSIONS = ['extra', 'mdx_linkify', 'smarty', 'tables']
MARKDOWN_EXTENSION_CONFIGS = {
'extra': {
'attr_list': {},
'footnotes': {
'UNIQUE_IDS': True,
},
},
'smarty': {
'smart_dashes': True,
'smart_quotes': False,
'smart_angled_quotes': False,
'smart_ellipses': True,
},
}
DEFAULT_PAGE_STYLE = 'light'
TITLE_SUFFIX = 'incorporeal.org' TITLE_SUFFIX = 'incorporeal.org'
MEDIA_DIR = 'media' MEDIA_DIR = 'media'

21
incorporealcms/lib.py Normal file
View File

@@ -0,0 +1,21 @@
"""Miscellaneous helper functions and whatnot."""
import markdown
from flask import current_app as app
def get_meta_str(md, key):
"""Provide the page's (parsed in Markup obj md) metadata for the specified key, or '' if unset."""
return " ".join(md.Meta.get(key)) if md.Meta.get(key) else ""
def init_md():
"""Initialize the Markdown parser.
This used to done at the app level in __init__, but extensions like footnotes apparently
assume the parser to only live for the length of parsing one document, and create double
footnote ref links if the one parser sees the same document multiple times.
"""
# initialize markdown parser from config, but include
# extensions our app depends on, like the meta extension
return markdown.Markdown(extensions=app.config['MARKDOWN_EXTENSIONS'] + ['meta'],
extension_configs=app.config['MARKDOWN_EXTENSION_CONFIGS'])

View File

@@ -3,39 +3,70 @@ import datetime
import logging import logging
import os import os
import markdown from flask import Blueprint, Markup, abort
from flask import Blueprint, Markup, abort, current_app as app, render_template from flask import current_app as app
from flask import make_response, redirect, render_template, request
from tzlocal import get_localzone from tzlocal import get_localzone
from incorporealcms.lib import get_meta_str, init_md
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
bp = Blueprint('pages', __name__, url_prefix='/') bp = Blueprint('pages', __name__, url_prefix='/')
md = markdown.Markdown(extensions=['meta'])
@bp.route('/', defaults={'path': 'index'}) @bp.route('/', defaults={'path': 'index'})
@bp.route('/<path:path>') @bp.route('/<path:path>')
def display_page(path): def display_page(path):
"""Get the file contents of the requested path and render the file.""" """Get the file contents of the requested path and render the file."""
if is_file_path_actually_dir_path(path):
return redirect(f'{path}/', code=301)
resolved_path = resolve_page_file(path) resolved_path = resolve_page_file(path)
parent_navs = generate_parent_navs(resolved_path)
logger.debug("received request for path '%s', resolved to '%s'", path, resolved_path) logger.debug("received request for path '%s', resolved to '%s'", path, resolved_path)
try: try:
with app.open_instance_resource(resolved_path, 'r') as entry_file: with app.open_instance_resource(resolved_path, 'r') as entry_file:
logger.debug("file '%s' found", resolved_path) logger.debug("file '%s' found", resolved_path)
parent_navs = generate_parent_navs(path)
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(entry_file.name), get_localzone()) mtime = datetime.datetime.fromtimestamp(os.path.getmtime(entry_file.name), get_localzone())
entry = entry_file.read() entry = entry_file.read()
except FileNotFoundError: except FileNotFoundError:
logger.warning("requested path '%s' (resolved path '%s') not found!", path, resolved_path) logger.warning("requested path '%s' (resolved path '%s') not found!", path, resolved_path)
abort(404) abort(404)
else: else:
md = init_md()
content = Markup(md.convert(entry)) content = Markup(md.convert(entry))
logger.debug("file metadata: %s", md.Meta) logger.debug("file metadata: %s", md.Meta)
title = " ".join(md.Meta.get('title')) if md.Meta.get('title') else ""
return render_template('base.html', title=title, content=content, navs=parent_navs, return render('base.html', title=get_meta_str(md, 'title'), description=get_meta_str(md, 'description'),
image=get_meta_str(md, 'image'), base_url=request.base_url, content=content, navs=parent_navs,
mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z')) mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'))
def render(template_name_or_list, **context):
"""Wrap Flask's render_template.
* Determine the proper site theme to use in the template and provide it.
"""
PAGE_STYLES = {
'dark': 'css/dark.css',
'light': 'css/light.css',
}
selected_style = request.args.get('style', None)
if selected_style:
user_style = selected_style
else:
user_style = request.cookies.get('user-style')
logger.debug("user style cookie: %s", user_style)
context['user_style'] = PAGE_STYLES.get(user_style, PAGE_STYLES.get(app.config['DEFAULT_PAGE_STYLE']))
resp = make_response(render_template(template_name_or_list, **context))
if selected_style:
resp.set_cookie('user-style', selected_style)
return resp
def resolve_page_file(path): def resolve_page_file(path):
"""Manipulate the request path to find appropriate page file. """Manipulate the request path to find appropriate page file.
@@ -50,19 +81,39 @@ def resolve_page_file(path):
return path return path
def is_file_path_actually_dir_path(path):
"""Check if requested path which looks like a file is actually a directory.
If, for example, /foo used to be a file (foo.md) which later became a directory,
foo/, this returns True. Useful for when I make my structure more complicated
than it originally was, or if users are just weird.
"""
if not path.endswith('/'):
logger.debug("requested path '%s' looks like a file", path)
if os.path.isdir(f'{app.instance_path}/pages/{path}'):
logger.debug("...and it's actually a dir")
return True
return False
def generate_parent_navs(path): def generate_parent_navs(path):
"""Create a series of paths/links to navigate up from the given path.""" """Create a series of paths/links to navigate up from the given path."""
parent_dir = os.path.dirname(path) # derive additional path/location stuff based on path
if parent_dir == 'pages': resolved_path = resolve_page_file(path)
parent_dir = os.path.dirname(resolved_path)
parent_path = '/'.join(path[:-1].split('/')[:-1]) + '/'
logger.debug("path: '%s'; parent path: '%s'; resolved path: '%s'; parent dir: '%s'",
path, parent_path, resolved_path, parent_dir)
if path in ('index', '/'):
return [(app.config['TITLE_SUFFIX'], '/')] return [(app.config['TITLE_SUFFIX'], '/')]
elif path.endswith('index.md'):
# if we're on an index.md, don't link to ourselves as we're our own parent
return generate_parent_navs(parent_dir)
else: else:
parent_path = f'{parent_dir}/'.replace('pages/', '/', 1) with app.open_instance_resource(resolved_path, 'r') as entry_file:
resolved_parent_path = resolve_page_file(parent_path)
with app.open_instance_resource(resolved_parent_path, 'r') as entry_file:
entry = entry_file.read() entry = entry_file.read()
# for issues regarding parser reuse (see lib.init_md) we reinitialize the parser here
md = init_md()
_ = Markup(md.convert(entry)) _ = Markup(md.convert(entry))
parent_name = " ".join(md.Meta.get('title')) if md.Meta.get('title') else os.path.basename(parent_dir) page_name = " ".join(md.Meta.get('title')) if md.Meta.get('title') else f'/{path}'
return generate_parent_navs(parent_dir) + [(parent_name, parent_path)] return generate_parent_navs(parent_path) + [(page_name, f'/{path}')]

View File

@@ -0,0 +1,73 @@
html {
color: #CCC;
}
body {
background: black;
}
strong {
color: #EEE;
}
.site-wrap {
background: #111;
border: 1px solid #222;
border-top: none;
border-bottom: none;
}
h1, h2, h3, h4, h5, h6 {
color: #B31D15;
}
a:link, a:visited {
color: #EEE;
border-bottom: 1px dotted #EEE;
}
a:hover, a:active {
color: #B31D15;
border-bottom: 1px dotted #B31D15;
}
div.header {
background: #222;
border-bottom: 1px solid #222;
}
div.nav, div.styles {
color: #BBB;
}
div.nav a, div.styles a {
color: #BBB;
}
table, th, td {
border: 1px solid #333;
}
th {
background: #333;
}
blockquote {
background-color: rgba(120, 120, 120, 0.3);
border: 1px solid #222;
}
.img-frame {
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid #333;
}
figure {
background: #222;
border: 1px solid #333;
}
figcaption {
color: #BBB;
}

View File

@@ -0,0 +1,73 @@
html {
color: #222;
}
body {
background: #999;
}
strong {
color: #111;
}
.site-wrap {
background: white;
border: 1px solid #ddd;
border-top: none;
border-bottom: none;
}
h1, h2, h3, h4, h5, h6 {
color: #811610;
}
a:link, a:visited {
color: #111;
border-bottom: 1px dotted #111;
}
a:hover, a:active {
color: #811610;
border-bottom: 1px dotted #811610;
}
div.header {
background: #EEE;
border-bottom: 1px solid #CCC;
}
div.nav, div.styles {
color: #666;
}
div.nav a, div.styles a {
color: #666;
}
table, th, td {
border: 1px solid #ccc;
}
th {
background: #eee;
}
blockquote {
background-color: rgba(120, 120, 120, 0.1);
border: 1px solid #CCC;
}
.img-frame {
background-color: rgba(0, 0, 0, 0.1);
border: 1px solid #BBB;
}
figure {
background: #EFEFEF;
border: 1px solid #CCCCCC;
}
figcaption {
color: #777777;
}

View File

@@ -1,61 +1,96 @@
html { html {
font-family: sans-serif; font-family: sans-serif;
padding: 0; padding: 0;
padding-bottom: 16px;
color: #222;
} }
body { body {
margin: 0; margin: 0;
text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
} }
h1,h2,h3,h4,h5,h6 { .site-wrap {
color: #811610; max-width: 70pc;
min-height: 100vh;
margin: 0;
margin-left: auto;
margin-right: auto;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.5em;
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1.17em;
}
h5 {
font-size: 1em;
}
h6 {
font-size: .83em;
} }
a:link { a:link {
color: #222;
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
border-bottom: 1px dotted #222;
} }
a:visited { a:visited {
color: #222;
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
border-bottom: 1px dotted #222;
} }
a:hover { a:hover {
color: #811610;
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
border-bottom: 1px dotted #222;
} }
a:active { a:active {
color: #811610;
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
border-bottom: 1px dotted #222;
} }
section.nav { div.header {
color: #666; display: flex;
background: #eee; justify-content: space-between;
}
div.nav, div.styles {
font-size: 0.75em; font-size: 0.75em;
border-bottom: 1px solid #ccc;
padding: 0.25em 0.5em; padding: 0.25em 0.5em;
} }
section.nav a { div.nav a, div.styles a {
color: #666;
border-bottom: none; border-bottom: none;
} }
section.content { div.content {
font-size: 11pt;
padding: 0 1em; padding: 0 1em;
line-height: 1.5em;
}
sup, sub {
vertical-align: baseline;
position: relative;
top: -0.4em;
}
sub {
top: 0.4em;
} }
footer { footer {
@@ -63,4 +98,124 @@ footer {
font-size: 75%; font-size: 75%;
color: #999; color: #999;
padding: 0 1em; padding: 0 1em;
padding-bottom: 16px;
margin-top: 15px;
}
table {
border-collapse: collapse;
}
table, th, td {
padding: 5px;
border: 1px solid #ccc;
margin-bottom: 15px;
}
blockquote {
background-color: rgba(120, 120, 120, 0.3);
padding: 1px 10px;
}
.splash {
margin-top: 1em;
margin-bottom: 1em;
text-align: center;
}
img {
max-width: 100%;
}
.img-25 {
max-width: 25% !important;
}
.img-50 {
max-width: 50% !important;
}
.img-center {
display: block;
margin-left: auto;
margin-right: auto;
}
.img-left {
float: left;
margin-right: 1em;
}
.img-right {
float: right;
margin-left: 1em;
}
.img-frame {
padding: 5px;
}
/* For screens with width smaller than 400px */
.figure-left .figure-right {
max-width: 95%;
float: none;
margin-left: 10px;
margin-right: 10px;
}
/* For larger screens */
@media only screen and (min-width: 400px) {
.figure-left {
float: left;
margin-top: 0;
margin-left: 0;
}
.figure-right {
float: right;
margin-top: 0;
margin-right: 0;
}
}
figure {
max-width: 400px;
padding: 5px;
margin: 10px;
margin-top: 0;
margin-bottom: 5px;
}
figure img {
max-width: 100%;
height: auto;
}
figcaption {
font-family: "Times New Roman", serif;
color: #777777;
text-align: center;
font-style: italic;
line-height: 1.3em;
margin-top: 5px;
}
.thumbnail-image {
width: 150px;
height: auto;
margin: 5px;
display: inline;
}
.footnote {
font-size: 0.8em;
}
.footnote p {
margin: 0;
}
.footnote-ref:link, .footnote-ref:visited, .footnote-ref:hover, .footnote-ref:active {
border-bottom: none;
font-weight: normal;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,16 +1,34 @@
<!doctype html> <!doctype html>
<html lang="en">
<title>{{ title }}{% if title %} - {% endif %}{{ config.TITLE_SUFFIX }}</title> <title>{{ title }}{% if title %} - {% endif %}{{ config.TITLE_SUFFIX }}</title>
{% if title %}<meta property="og:title" content="{{ title }}">{% endif %}
{% if description %}<meta property="og:description" content="{{ description }}">{% endif %}
{% if image %}<meta property="og:image" content="{{ image }}">{% endif %}
<meta property="og:url" content="{{ base_url }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename=user_style) }}">
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}"> <link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}">
<section class="nav">
<div class="site-wrap">
<div class="header">
<div class="nav">
{% for nav in navs %} {% for nav in navs %}
<a href="{{ nav.1 }}">{{ nav.0 }}</a> <a href="{{ nav.1 }}">{{ nav.0 }}</a>
{% if not loop.last %} &raquo; {% endif %} {% if not loop.last %} &raquo; {% endif %}
{% endfor %} {% endfor %}
</section> </div>
<section class="content"> <div class="styles">
<a href="?style=dark">[dark]</a>
<a href="?style=light">[light]</a>
</div>
</div>
<div class="content">
{{ content }} {{ content }}
</section> </div>
<footer> <footer>
<i>Last modified: {{ mtime }}</i> <i>Last modified: {{ mtime }}</i>
</footer> </footer>
</div>
</html>

View File

@@ -1,7 +1,24 @@
-r requirements.in -r requirements.in
flake8 # python code quality stuff # testing runner, test reporting, packages used during testing (e.g. requests-mock), etc.
pytest
pytest-cov
# linting and other static code analysis
bandit==1.6.2 # pinned because 1.7.0 wasn't running right in tox
dlint
flake8
flake8-blind-except
flake8-builtins
flake8-docstrings
flake8-executable
flake8-fixme
flake8-isort
flake8-logging-format
flake8-mutable
# maintenance utilities and tox
pip-tools # pip-compile pip-tools # pip-compile
pytest # unit tests tox # CI stuff
pytest-cov # coverage in unit tests tox-wheel # build wheels in tox
versioneer # automatic version numbering versioneer # automatic version numbering

View File

@@ -4,35 +4,149 @@
# #
# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in # pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in
# #
attrs==19.3.0 # via pytest appdirs==1.4.4
click==7.0 # via flask, pip-tools # via virtualenv
coverage==5.0.3 # via pytest-cov attrs==20.3.0
entrypoints==0.3 # via flake8 # via pytest
flake8==3.7.9 # via -r requirements/requirements-dev.in bandit==1.6.2
flask==1.1.1 # via -r requirements/requirements.in # via -r requirements/requirements-dev.in
importlib-metadata==1.5.0 # via pluggy, pytest bleach==3.2.2
itsdangerous==1.1.0 # via flask # via mdx-linkify
jinja2==2.11.1 # via flask click==7.1.2
markdown==3.2.1 # via -r requirements/requirements.in # via
markupsafe==1.1.1 # via jinja2 # flask
mccabe==0.6.1 # via flake8 # pip-tools
more-itertools==8.2.0 # via pytest coverage==5.3.1
packaging==20.3 # via pytest # via pytest-cov
pip-tools==4.5.1 # via -r requirements/requirements-dev.in distlib==0.3.1
pluggy==0.13.1 # via pytest # via virtualenv
py==1.8.1 # via pytest dlint==0.11.0
pycodestyle==2.5.0 # via flake8 # via -r requirements/requirements-dev.in
pyflakes==2.1.1 # via flake8 filelock==3.0.12
pyparsing==2.4.6 # via packaging # via
pytest-cov==2.8.1 # via -r requirements/requirements-dev.in # tox
pytest==5.3.5 # via -r requirements/requirements-dev.in, pytest-cov # virtualenv
pytz==2019.3 # via tzlocal flake8-blind-except==0.2.0
six==1.14.0 # via packaging, pip-tools # via -r requirements/requirements-dev.in
tzlocal==2.0.0 # via -r requirements/requirements.in flake8-builtins==1.5.3
versioneer==0.18 # via -r requirements/requirements-dev.in # via -r requirements/requirements-dev.in
wcwidth==0.1.8 # via pytest flake8-docstrings==1.5.0
werkzeug==1.0.0 # via flask # via -r requirements/requirements-dev.in
zipp==3.1.0 # via importlib-metadata flake8-executable==2.1.1
# via -r requirements/requirements-dev.in
flake8-fixme==1.1.1
# via -r requirements/requirements-dev.in
flake8-isort==4.0.0
# via -r requirements/requirements-dev.in
flake8-logging-format==0.6.0
# via -r requirements/requirements-dev.in
flake8-mutable==1.2.0
# via -r requirements/requirements-dev.in
flake8==3.8.4
# via
# -r requirements/requirements-dev.in
# dlint
# flake8-builtins
# flake8-docstrings
# flake8-executable
# flake8-isort
# flake8-mutable
flask==1.1.2
# via -r requirements/requirements.in
gitdb==4.0.5
# via gitpython
gitpython==3.1.12
# via bandit
iniconfig==1.1.1
# via pytest
isort==5.7.0
# via flake8-isort
itsdangerous==1.1.0
# via flask
jinja2==2.11.2
# via flask
markdown==3.3.3
# via
# -r requirements/requirements.in
# mdx-linkify
markupsafe==1.1.1
# via jinja2
mccabe==0.6.1
# via flake8
mdx-linkify==2.1
# via -r requirements/requirements.in
packaging==20.8
# via
# bleach
# pytest
# tox
pbr==5.5.1
# via stevedore
pip-tools==5.5.0
# via -r requirements/requirements-dev.in
pluggy==0.13.1
# via
# pytest
# tox
py==1.10.0
# via
# pytest
# tox
pycodestyle==2.6.0
# via flake8
pydocstyle==5.1.1
# via flake8-docstrings
pyflakes==2.2.0
# via flake8
pyparsing==2.4.7
# via packaging
pytest-cov==2.11.1
# via -r requirements/requirements-dev.in
pytest==6.2.1
# via
# -r requirements/requirements-dev.in
# pytest-cov
pytz==2020.5
# via tzlocal
pyyaml==5.4.1
# via bandit
six==1.15.0
# via
# bandit
# bleach
# tox
# virtualenv
smmap==3.0.4
# via gitdb
snowballstemmer==2.1.0
# via pydocstyle
stevedore==3.3.0
# via bandit
testfixtures==6.17.1
# via flake8-isort
toml==0.10.2
# via
# pytest
# tox
tox-wheel==0.6.0
# via -r requirements/requirements-dev.in
tox==3.21.2
# via
# -r requirements/requirements-dev.in
# tox-wheel
tzlocal==2.1
# via -r requirements/requirements.in
versioneer==0.19
# via -r requirements/requirements-dev.in
virtualenv==20.4.0
# via tox
webencodings==0.5.1
# via bleach
werkzeug==1.0.1
# via flask
wheel==0.36.2
# via tox-wheel
# The following packages are considered to be unsafe in a requirements file: # The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools # setuptools

View File

@@ -1,3 +1,4 @@
Flask # general purpose web service and web server stuff Flask # general purpose web service and web server stuff
Markdown # markdown rendering in templates Markdown # markdown rendering in templates
mdx-linkify # convert URLs in the text to clickable links
tzlocal # identifying system's local timezone tzlocal # identifying system's local timezone

View File

@@ -4,15 +4,35 @@
# #
# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in # pip-compile --output-file=requirements/requirements.txt requirements/requirements.in
# #
click==7.0 # via flask bleach==3.2.2
flask==1.1.1 # via -r requirements/requirements.in # via mdx-linkify
itsdangerous==1.1.0 # via flask click==7.1.2
jinja2==2.11.1 # via flask # via flask
markdown==3.2.1 # via -r requirements/requirements.in flask==1.1.2
markupsafe==1.1.1 # via jinja2 # via -r requirements/requirements.in
pytz==2019.3 # via tzlocal itsdangerous==1.1.0
tzlocal==2.0.0 # via -r requirements/requirements.in # via flask
werkzeug==1.0.0 # via flask jinja2==2.11.2
# via flask
# The following packages are considered to be unsafe in a requirements file: markdown==3.3.3
# setuptools # via
# -r requirements/requirements.in
# mdx-linkify
markupsafe==1.1.1
# via jinja2
mdx-linkify==2.1
# via -r requirements/requirements.in
packaging==20.8
# via bleach
pyparsing==2.4.7
# via packaging
pytz==2020.5
# via tzlocal
six==1.15.0
# via bleach
tzlocal==2.1
# via -r requirements/requirements.in
webencodings==0.5.1
# via bleach
werkzeug==1.0.1
# via flask

View File

@@ -1,23 +1,3 @@
[coverage:run]
branch = True
omit =
.venv/*
incorporealcms/_version.py
setup.py
tests/*
versioneer.py
source =
incorporealcms
[flake8]
exclude = .git,.venv,__pycache__,versioneer.py,incorporealcms/_version.py
max-line-length = 120
[tool:pytest]
addopts = --cov=. --cov-report=term --cov-report=term-missing
log_cli = 1
log_cli_level = DEBUG
[versioneer] [versioneer]
VCS = git VCS = git
style = pep440-post style = pep440-post

View File

@@ -1,5 +1,6 @@
"""Setuptools configuration.""" """Setuptools configuration."""
import os import os
from setuptools import find_packages, setup from setuptools import find_packages, setup
import versioneer import versioneer
@@ -8,8 +9,8 @@ HERE = os.path.dirname(os.path.abspath(__file__))
def extract_requires(): def extract_requires():
"""Get pinned requirements from requirements.in.""" """Get pinned requirements from requirements.txt."""
with open(os.path.join(HERE, 'requirements/requirements.in'), 'r') as reqs: with open(os.path.join(HERE, 'requirements/requirements.txt'), 'r') as reqs:
return [line.split(' ')[0] for line in reqs if not line[0] in ('-', '#')] return [line.split(' ')[0] for line in reqs if not line[0] in ('-', '#')]

View File

@@ -10,6 +10,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture @pytest.fixture
def app(): def app():
"""Create the Flask application, with test settings."""
app = create_app(instance_path=os.path.join(HERE, 'instance')) app = create_app(instance_path=os.path.join(HERE, 'instance'))
yield app yield app
@@ -17,4 +18,5 @@ def app():
@pytest.fixture @pytest.fixture
def client(app): def client(app):
"""Create a test client based on the test app."""
return app.test_client() return app.test_client()

84
tests/functional_tests.py Normal file
View File

@@ -0,0 +1,84 @@
"""Test page requests."""
import re
def test_page_that_exists(client):
"""Test that the app can serve a basic file at the index."""
response = client.get('/')
assert response.status_code == 200
assert b'<h1>test index</h1>' in response.data
def test_page_that_doesnt_exist(client):
"""Test that the app returns 404 for nonsense requests."""
response = client.get('/ohuesthaoeusth')
assert response.status_code == 404
def test_page_with_title_metadata(client):
"""Test that a page with title metadata has its title written."""
response = client.get('/')
assert response.status_code == 200
assert b'<title>Index - incorporeal.org</title>' in response.data
def test_page_without_title_metadata(client):
"""Test that a page without title metadata gets the default title."""
response = client.get('/no-title')
assert response.status_code == 200
assert b'<title>incorporeal.org</title>' in response.data
assert b'<h1>this page doesn\'t have a title!</h1>' in response.data
def test_page_with_card_metadata(client):
"""Test that a page with opengraph metadata."""
response = client.get('/more-metadata')
assert response.status_code == 200
assert b'<meta property="og:title" content="title for the page">' in response.data
assert b'<meta property="og:description" content="description of this page made even longer">' in response.data
assert b'<meta property="og:image" content="http://buh.com/test.img">' in response.data
def test_page_has_modified_timestamp(client):
"""Test that pages have modified timestamps in them."""
response = client.get('/')
assert response.status_code == 200
assert re.search(r'Last modified: ....-..-.. ..:..:.. ...', response.data.decode()) is not None
def test_that_page_request_redirects_to_directory(client):
"""Test that a request to /foo reirects to /foo/, if foo is a directory.
This might be useful in cases where a formerly page-only page has been
converted to a directory with subpages.
"""
response = client.get('/subdir')
assert response.status_code == 301
def test_that_dir_request_does_not_redirect(client):
"""Test that a request to /foo/ serves the index page, if foo is a directory."""
response = client.get('/subdir/')
assert response.status_code == 200
assert b'another page' in response.data
def test_setting_selected_style_includes_cookie(client):
"""Test that a request with style=foo sets the cookie and renders appropriately."""
response = client.get('/')
style_cookie = next((cookie for cookie in client.cookie_jar if cookie.name == 'user-style'), None)
assert style_cookie is None
response = client.get('/?style=light')
style_cookie = next((cookie for cookie in client.cookie_jar if cookie.name == 'user-style'), None)
assert response.status_code == 200
assert b'light.css' in response.data
assert b'dark.css' not in response.data
assert style_cookie.value == 'light'
response = client.get('/?style=dark')
style_cookie = next((cookie for cookie in client.cookie_jar if cookie.name == 'user-style'), None)
assert response.status_code == 200
assert b'dark.css' in response.data
assert b'light.css' not in response.data
assert style_cookie.value == 'dark'

View File

@@ -1,3 +1,5 @@
"""Configure the test application."""
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
'formatters': { 'formatters': {

View File

@@ -0,0 +1,3 @@
Test page
word --- word

View File

@@ -0,0 +1,6 @@
Title: title for the page
Description: description of this page
made even longer
Image: http://buh.com/test.img
hello

View File

@@ -13,6 +13,36 @@ def test_config():
assert create_app(instance_path=instance_path, test_config={"TESTING": True}).testing assert create_app(instance_path=instance_path, test_config={"TESTING": True}).testing
def test_markdown_meta_extension_always():
"""Test that the markdown meta extension is always loaded, even if not specified."""
app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'MARKDOWN_EXTENSIONS': []})
client = app.test_client()
response = client.get('/')
assert response.status_code == 200
assert b'<title>Index - incorporeal.org</title>' in response.data
def test_custom_markdown_extensions_work():
"""Test we can change extensions via config, and that they work.
This used to test smarty, but that's added by default now, so we test
that it can be removed by overriding the option.
"""
app = create_app(instance_path=os.path.join(HERE, 'instance'))
client = app.test_client()
response = client.get('/mdash-or-triple-dash')
assert response.status_code == 200
assert b'word &mdash; word' in response.data
app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'MARKDOWN_EXTENSIONS': []})
client = app.test_client()
response = client.get('/mdash-or-triple-dash')
assert response.status_code == 200
assert b'word --- word' in response.data
def test_title_override(): def test_title_override():
"""Test that a configuration with a specific title overrides the default.""" """Test that a configuration with a specific title overrides the default."""
instance_path = os.path.join(HERE, 'instance') instance_path = os.path.join(HERE, 'instance')
@@ -24,6 +54,7 @@ def test_title_override():
def test_media_file_access(client): def test_media_file_access(client):
"""Test that media files are served, and properly."""
response = client.get('/media/favicon.png') response = client.get('/media/favicon.png')
assert response.status_code == 200 assert response.status_code == 200
assert response.headers['content-type'] == 'image/png' assert response.headers['content-type'] == 'image/png'

View File

@@ -1,67 +1,100 @@
"""Test page views and helper methods.""" """Unit test helper methods."""
import re from werkzeug.http import dump_cookie
from incorporealcms.pages import generate_parent_navs, resolve_page_file from incorporealcms.pages import generate_parent_navs, is_file_path_actually_dir_path, render, resolve_page_file
def test_resolve_page_file_dir_to_index(): def test_resolve_page_file_dir_to_index():
"""Test that a request to a directory path results in the dir's index.md."""
assert resolve_page_file('foo/') == 'pages/foo/index.md' assert resolve_page_file('foo/') == 'pages/foo/index.md'
def test_resolve_page_file_subdir_to_index(): def test_resolve_page_file_subdir_to_index():
"""Test that a request to a dir's subdir path results in the subdir's index.md."""
assert resolve_page_file('foo/bar/') == 'pages/foo/bar/index.md' assert resolve_page_file('foo/bar/') == 'pages/foo/bar/index.md'
def test_resolve_page_file_other_requests_fine(): def test_resolve_page_file_other_requests_fine():
"""Test that a request to non-dir path results in a Markdown file."""
assert resolve_page_file('foo/baz') == 'pages/foo/baz.md' assert resolve_page_file('foo/baz') == 'pages/foo/baz.md'
def test_page_that_exists(client):
response = client.get('/')
assert response.status_code == 200
assert b'<h1>test index</h1>' in response.data
def test_page_that_doesnt_exist(client):
response = client.get('/ohuesthaoeusth')
assert response.status_code == 404
def test_page_with_title_metadata(client):
response = client.get('/')
assert response.status_code == 200
assert b'<title>Index - incorporeal.org</title>' in response.data
def test_page_without_title_metadata(client):
response = client.get('/no-title')
assert response.status_code == 200
assert b'<title>incorporeal.org</title>' in response.data
assert b'<h1>this page doesn\'t have a title!</h1>' in response.data
def test_page_has_modified_timestamp(client):
response = client.get('/')
assert response.status_code == 200
assert re.search(r'Last modified: ....-..-.. ..:..:.. ...', response.data.decode()) is not None
def test_generate_page_navs_index(app): def test_generate_page_navs_index(app):
"""Test that the index page has navs to the root (itself)."""
with app.app_context(): with app.app_context():
assert generate_parent_navs('pages/index.md') == [('incorporeal.org', '/')] assert generate_parent_navs('/') == [('incorporeal.org', '/')]
def test_generate_page_navs_alternate_index(app):
"""Test that the index page (as a page, not a path) also has navs only to the root (by path)."""
with app.app_context():
assert generate_parent_navs('index') == [('incorporeal.org', '/')]
def test_generate_page_navs_subdir_index(app): def test_generate_page_navs_subdir_index(app):
"""Test that dir pages have navs to the root and themselves."""
with app.app_context(): with app.app_context():
assert generate_parent_navs('pages/subdir/index.md') == [('incorporeal.org', '/')] assert generate_parent_navs('subdir/') == [('incorporeal.org', '/'), ('/subdir/', '/subdir/')]
def test_generate_page_navs_subdir_real_page(app): def test_generate_page_navs_subdir_real_page(app):
"""Test that real pages have navs to the root, their parent, and themselves."""
with app.app_context(): with app.app_context():
assert generate_parent_navs('pages/subdir/page.md') == [('incorporeal.org', '/'), ('subdir', '/subdir/')] assert generate_parent_navs('subdir/page') == [('incorporeal.org', '/'), ('/subdir/', '/subdir/'),
('Page', '/subdir/page')]
def test_generate_page_navs_subdir_with_title_parsing_real_page(app): def test_generate_page_navs_subdir_with_title_parsing_real_page(app):
"""Test that title metadata is used in the nav text."""
with app.app_context(): with app.app_context():
assert generate_parent_navs('pages/subdir-with-title/page.md') == [('incorporeal.org', '/'), assert generate_parent_navs('subdir-with-title/page') == [
('SUB!', '/subdir-with-title/')] ('incorporeal.org', '/'),
('SUB!', '/subdir-with-title/'),
('/subdir-with-title/page', '/subdir-with-title/page')
]
def test_is_file_path_actually_dir_path_valid_file_is_yes(app):
"""Test that a file request for what's actually a directory is detected as such."""
with app.app_context():
assert is_file_path_actually_dir_path('/subdir')
def test_is_file_path_actually_dir_path_valid_dir_is_no(app):
"""Test that a directory request is still a directory request."""
with app.app_context():
assert not is_file_path_actually_dir_path('/subdir/')
def test_is_file_path_actually_dir_path_nonsense_file_is_no(app):
"""Test that requests to nonsense file-looking paths aren't treated as dirs."""
with app.app_context():
assert not is_file_path_actually_dir_path('/antphnathpnthapnthsnthax')
def test_is_file_path_actually_dir_path_nonsense_dir_is_no(app):
"""Test that a directory request is a directory request even if the dir doesn't exist."""
with app.app_context():
assert not is_file_path_actually_dir_path('/antphnathpnthapnthsnthax/')
def test_render_with_user_dark_theme(app):
"""Test that a request with the dark theme selected renders the dark theme."""
cookie = dump_cookie("user-style", 'dark')
with app.test_request_context(headers={'COOKIE': cookie}):
assert b'dark.css' in render('base.html').data
assert b'light.css' not in render('base.html').data
def test_render_with_user_light_theme(app):
"""Test that a request with the light theme selected renders the light theme."""
with app.test_request_context():
assert b'light.css' in render('base.html').data
assert b'dark.css' not in render('base.html').data
def test_render_with_no_user_theme(app):
"""Test that a request with no theme set renders the light theme."""
with app.test_request_context():
assert b'light.css' in render('base.html').data
assert b'dark.css' not in render('base.html').data

104
tox.ini Normal file
View File

@@ -0,0 +1,104 @@
# tox (https://tox.readthedocs.io/) is a tool for running tests
# in multiple virtualenvs. This configuration file will run the
# test suite on all supported python versions. To use it, "pip install tox"
# and then run "tox" from this directory.
[tox]
envlist = begin,py37,py38,coverage,security,lint,bundle
[testenv]
# build a wheel and test it
wheel = true
wheel_build_env = build
# whitelist commands we need
whitelist_externals = cp
# install everything via requirements-dev.txt, so that developer environment
# is the same as the tox environment (for ease of use/no weird gotchas in
# local dev results vs. tox results) and also to avoid ticky-tacky maintenance
# of "oh this particular env has weird results unless I install foo" --- just
# shotgun blast install everything everywhere
deps =
-rrequirements/requirements-dev.txt
[testenv:build]
# require setuptools when building
deps = setuptools
[testenv:begin]
# clean up potential previous coverage runs
skip_install = true
commands = coverage erase
[testenv:py37]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:py38]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:coverage]
# report on coverage runs from above
skip_install = true
commands =
coverage report --fail-under=95 --show-missing
[testenv:security]
# run security checks
#
# again it seems the most valuable here to run against the packaged code
commands =
bandit {envsitepackagesdir}/incorporealcms/ -r
[testenv:lint]
# run style checks
commands =
flake8
- flake8 --disable-noqa --ignore= --select=E,W,F,C,D,A,G,B,I,T,M,DUO
[testenv:bundle]
# take extra actions (build sdist, sphinx, whatever) to completely package the app
commands =
cp -r {distdir} .
python setup.py sdist
[coverage:paths]
source =
./
.tox/**/site-packages/
[coverage:run]
branch = True
# redundant with pytest --cov above, but this tricks the coverage.xml report into
# using the full path, otherwise files with the same name in different paths
# get clobbered. maybe appends would fix this, IDK
include =
.tox/**/incorporealcms/
omit =
**/_version.py
[flake8]
enable-extensions = G,M
exclude =
.tox/
versioneer.py
_version.py
instance/
extend-ignore = T101
max-complexity = 10
max-line-length = 120
[isort]
line_length = 120
[pytest]
python_files =
*_tests.py
tests.py
test_*.py