52 Commits

Author SHA1 Message Date
30d6f99c9b return the proper atom and rss content types for the feeds
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 15:31:18 -06:00
575e2ad387 provide author information for the feed and entries
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 15:25:46 -06:00
b26975421c make the feed ID be a valid URL for compliance
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 15:19:57 -06:00
6dc443e59f implement a rudimentary Atom/RSS feed module
this provides a somewhat unconfigurable (at the moment) feed module
which provides Atom and RSS feeds. entries are determined by symlinks to
content pages, because my core CMS usage is still more general and not
blog-like. the symlinks allow for arbitrarily adding entries as I see
fit.

this also moves core Markdown parser stuff to the library module, since
that's used by the feed as well as normal pages

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 14:55:02 -06:00
5a9a36f463 deduplicate TITLE_SUFFIX from new DOMAIN_NAME
I will need the domain name for feed stuff, and I'm already crudely
using the title suffix in the nav as if it was a domain name, so let's just be
explicit in the case I ever change my mind on domain-in-title styling

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 11:55:01 -06:00
680a2bc764 add python 3.12 to tox environments
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 11:13:08 -06:00
713632fe7a unpin tox in requirements
for some reason bandit wasn't earlier catching the SubElement usage but
now it is, but it's harmless anyway so we'll just suppress it.

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 11:12:59 -06:00
bf646db1e8 convert tooling to pyproject.toml based
still has dynamic versioning and etc.

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 10:33:28 -06:00
2871e5a000 version bumps
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-28 15:40:56 -06:00
7b225a6de3 adding a compliant copyright line to all code
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-28 15:33:41 -06:00
e1dc2afc7b add SPDX-License-Identifier and DCO information
this includes my personal signoff on the MAINTAINERS.md for DCO purposes

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-18 11:15:56 -06:00
0fef13c71a version bumps, especially flask and werkzeug 3 2023-10-07 14:02:23 -05:00
6b5cdb7f7e add python 3.11 to tox tests 2023-10-07 13:07:05 -05:00
55cfad90a9 use werkzeug safe_join to sanitize the requested path
no tests changed, so my implementation might have been good, but let's
use the provided check
2022-12-31 11:53:14 -06:00
b3dfab2611 simplify and better standardize the link underline 2022-12-31 11:33:36 -06:00
715bc38d78 serve per-instance static files at custom-static/ 2022-12-31 10:51:36 -06:00
e9af2de21e don't assume all styles are in the static directory
this is to make room for a second, instance-configured spot for them
2022-12-31 10:16:35 -06:00
83bc8b2c21 requirements bump, only affected dev tools 2022-12-31 10:13:20 -06:00
4a2f650a33 don't hardcode styles to present, use config
now that we can override the styles in practice, we also need to only
present what is possible in the HTML
2022-12-31 09:53:22 -06:00
fd0fb390ff allow for overriding PAGE_STYLES
moving this allows for per-instance customizations later, but that won't
be practical until serving styles from the instance dir is also allowed.
but, this sets the ground work and does allow for removing some styles
(e.g. if someone wanted to only allow 'plain').

also I still need to add the ability to present the themes list dynamically
2022-12-31 09:40:13 -06:00
be8a8dd35a test full path for stylesheets
I'm going to be screwing around with this code in some future commits so
it's better to be explicit
2022-12-31 09:02:57 -06:00
0f19fcb174 fix bad copy and paste job on link styles 2022-09-16 14:16:13 -05:00
f1684a57a9 requirements recompile 2022-09-16 13:49:57 -05:00
83eb464be9 style the potential for links in the footer 2022-09-16 13:40:23 -05:00
0f03ad6f38 allow pages to supply extra footer text 2022-09-16 13:35:40 -05:00
21ea24ffa1 header style tweaks, deemphasizing it a bit 2022-06-05 21:30:49 -05:00
724a2240b2 requirements bump for latest release 2022-05-25 07:24:03 -05:00
aa6a27dd8b make the header bigger, and align header and content padding better 2022-05-17 07:57:23 -05:00
c80172cffd go back to red headers and links as normal-colored text
the new way to do the links without adding links to images is probably
dumb and/or missing some stuff, but it works and does what I want, and I
think I like the old look of the colors better, so time to try it and
see if it sticks still
2022-05-17 07:57:23 -05:00
89ea2fb87e give the header nav links an underline on hover 2022-05-17 07:57:23 -05:00
8ac5b25208 get rid of the slight recoloring of bold text 2022-05-17 07:57:23 -05:00
54b953f5ed go back to the old, balanced width alignments 2022-05-17 07:57:23 -05:00
de0641b08f tweak the two-tone backgrounds and whitespace up the header 2022-05-17 07:57:23 -05:00
cc3e311738 clarify my DCO-like stance, and provide cloning info 2022-05-17 07:52:58 -05:00
985bb93839 do a requirements bump
notable changes:

* Flask 1.1 treats redirects to itself as relative URLs now, so a spate
  of tests were updated
2022-04-19 08:41:59 -05:00
3454de17fc style links with color, not underlines
this allows me to link to images (e.g. in figures) without having a
separate override class to remove their underline, which is ugly. on
account of making links red, it was looking like too much red
(especially on the index) with headers also being red, so this makes
headers the default text color.

still not 100% committed to this but I'm trying it out
2022-04-19 08:41:59 -05:00
1fe6623587 remove .img-frame class, unnecessary with figures 2022-04-19 08:41:59 -05:00
ec042e466c scoot the footer down a bit, and give it margins like the main content's 2022-04-19 08:41:59 -05:00
6daed848d0 remove duplicate link styles
all these subclasses or whatever are the same
2022-04-19 08:41:59 -05:00
502adac89c make the pydot syntax backwards compatible with code fences
without this, the code fence parser was getting thrown off if you didn't
have the pydot extension loaded, which was thwarting backwards
compatibility. this makes the pydot bits look like an attribute to the
vanilla parser, so at least then the vanilla markdown renders as
intended
2022-04-19 08:41:59 -05:00
08305e26db pydot: handle multiple dot images in one file 2022-04-19 08:41:59 -05:00
9a65dfffaf tweaks to the content responsiveness and header padding
more readability stuff for desktop without (hopefully) affecting the
mobile experience much
2022-04-19 08:41:58 -05:00
a42ce34aad add a bit of whitespace in content text
meant to help readability slightly, in part because of what I'm screwing
around with in changing link and header colors
2022-04-19 08:41:58 -05:00
9ce8e9a1cc restyle figures and captions for more flexibility 2022-04-19 08:41:58 -05:00
d07260331b remove unused figure classes
these are being reworked and I don't think I'm going to do responsive
stuff anymore
2022-04-19 08:41:58 -05:00
2c47501589 remove unused thumbnail class
used to appear on a couple images, the styling was replaced with some
more explicit image tags and soon will be replaced again with figure
stuff
2022-04-19 08:41:58 -05:00
f15d955067 initial crack at a block-level figure parser
I didn't like the other figure + figcaption parsers, they either assumed
a lot about usage (e.g. images only), or they were inline parsers that
either wrapped the figure in a paragraph tag (which is incorrect syntax)
or did span trickery (annoying)

so, this handles images and maybe other things, and does things properly
with figures as their own blocks. incomplete but it works with my
images, and should allow for looping (for multi-line content) in the
future?
2022-04-19 08:41:55 -05:00
b3c3c0de45 clean up the default markdown configuration a bit
this brings the config a bit closer to my default, and updates the
expectations of a test accordingly
2022-04-19 06:45:32 -05:00
853a58b78b make the breadcrumbs and style menu a bit nicer 2022-03-24 22:41:34 -05:00
3cade4fe73 add the image floats to the plain CSS
primarily it's the main page that just looks weird without it
2022-03-24 22:28:09 -05:00
63a764c6a0 don't use system tz, display timestamps as UTC
no particular reason, just one less dependency, and it goes further
towards my idyllic world where everyone is on UTC and there are no
timezones
2022-03-24 22:12:00 -05:00
d129d04e04 remove mdx_linkify and do requirement bumps 2022-03-24 22:00:37 -05:00
44 changed files with 964 additions and 2855 deletions

3
.gitignore vendored
View File

@@ -117,3 +117,6 @@ dmypy.json
# vim stuff # vim stuff
*.swp *.swp
# autogenerated versioning
_version.py

View File

@@ -1,7 +1,10 @@
# How to Contribute # How to Contribute
incorporeal-cms is a personal project seeking to implement a simpler, cleaner form of what would incorporeal-cms is a personal project seeking to implement a simpler, cleaner form of what would
commonly be called a "CMS". I appreciate any help in making incorporeal-cms better. commonly be called a "CMS". I appreciate any help in making it better.
incorporeal-cms is made available under the GNU Affero General Public License version 3, or any
later version.
## Opening Issues ## Opening Issues
@@ -10,8 +13,57 @@ Issues should be posted to my Gitea instance at
recommend starting the title with "Improvement:", "Bug:", or similar, so I can do a high level of recommend starting the title with "Improvement:", "Bug:", or similar, so I can do a high level of
prioritization. prioritization.
## Guidelines for Patches, etc. ## Contributions
### Sign Offs/Custody of Contributions
I do not request the copyright of contributions be assigned to me or to the project, and I require no provision that I
be allowed to relicense your contributions. My personal oath is to maintain inbound=outbound in my open source projects,
and the expectation is authors are responsible for their contributions.
I am following the the [Developer Certificate of Origin (DCO)](https://developercertificate.org/), also available at
`DCO.txt`. The DCO is a way for contributors to certify that they wrote or otherwise have the right to license their
code contributions to the project. Contributors must sign-off that they adhere to these requirements by adding a
`Signed-off-by` line to their commit message, and/or, for frequent contributors, by signing off on their entry in
`MAINTAINERS.md`.
This process is followed by a number of open source projects, most notably the Linux kernel. Here's the gist of it:
```
[Your normal Git commit message here.]
Signed-off-by: Random J Developer <random@developer.example.org>
```
`git help commit` has more info on adding this:
```
-s, --signoff
Add Signed-off-by line by the committer at the end of the commit log
message. The meaning of a signoff depends on the project, but it typically
certifies that committer has the rights to submit this work under the same
license and agrees to a Developer Certificate of Origin (see
http://developercertificate.org/ for more information).
```
### Submitting Contributions
I don't expect contributors to sign up for my personal Gitea in order to send contributions, but it
of course makes it easier. If you wish to go this route, please sign up at
<https://git.incorporeal.org/bss/incorporeal-cms> and fork the project. People planning on
contributing often are also welcome to request access to the project directly.
Otherwise, contact me via any means you know to reach me at, or <bss@incorporeal.org>, to discuss
your change and to tell me how to pull your changes.
### Guidelines for Patches, etc.
* Cloning
* Clone the project. I would advise using a pull-based workflow where I have access to the hosted
repository --- using my Gitea, cloning to a public GitHub, etc. --- rather than doing this over
email, but that works too if we must.
* Make your contributions in a new branch, generally off of `master`.
* Send me a pull request when you're ready, and we'll go through a code review.
* Code: * Code:
* Keep in mind that I strive for simplicity in the software. It serves files and renders * Keep in mind that I strive for simplicity in the software. It serves files and renders
Markdown, that's pretty much it. Features around that function are good; otherwise, I need Markdown, that's pretty much it. Features around that function are good; otherwise, I need
@@ -27,22 +79,7 @@ prioritization.
* Squash tiny commits if you'd like. I prefer commits that make one atomic conceptual change * Squash tiny commits if you'd like. I prefer commits that make one atomic conceptual change
that doesn't affect the rest of the code, assembling multiple of those commits into larger that doesn't affect the rest of the code, assembling multiple of those commits into larger
changes. changes.
* Follow something like [Chris Beams'](https://chris.beams.io/posts/git-commit/) post on * Follow something like [Chris Beams's post](https://chris.beams.io/posts/git-commit/) on
formatting a good commit message. formatting a good commit message.
* Please make sure your Author contact information is stable, in case I need to reach you.
## Contributions * Consider cryptographically signing (`git commit -S`) your commits.
I don't expect contributors to sign up for my personal Gitea in order to send contributions, but it
of course makes it easier. If you wish to go this route, please sign up at
<https://git.incorporeal.org/bss/incorporeal-cms> and fork the project. People planning on
contributing often are also welcome to request access to the project directly.
Otherwise, contact me via any means you know to reach me at, or <bss@incorporeal.org>, to discuss
your change and to tell me how to pull your changes.
### Copyright of Contributions
Accepted changes remain the copyright of the original author, but please include appropriate contact
methods in the event I choose to provide the project under a new license and need to contact you
to approve the new license terms. Please note that the software is provided under the GNU AGPLv3 (or
later).

34
DCO.txt Normal file
View File

@@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.

10
MAINTAINERS.md Normal file
View File

@@ -0,0 +1,10 @@
# Maintainers
This file contains information about people permitted to make major decisions and direction on the project.
## Contributing Under the DCO
By adding your name and email address to this section, you certify that all of your subsequent contributions to
incorporeal-cms are made under the terms of the Developer's Certificate of Origin 1.1, available at `DCO.txt`.
* Brian S. Stephan (<bss@incorporeal.org>)

View File

@@ -1,15 +1,14 @@
"""An application for running my Markdown-based sites.""" """An application for running my Markdown-based sites.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import logging import logging
import os import os
from logging.config import dictConfig from logging.config import dictConfig
from flask import Flask, request from flask import Flask, request
from ._version import get_versions
__version__ = get_versions()['version']
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.""" """Create the Flask app, with allowances for customizing path and test settings."""
@@ -40,8 +39,10 @@ def create_app(instance_path=None, test_config=None):
logger.info("RESPONSE: %s %s: %s", request.method, request.path, response.status) logger.info("RESPONSE: %s %s: %s", request.method, request.path, response.status)
return response return response
from . import error_pages, pages from . import error_pages, feed, pages, static
app.register_blueprint(feed.bp)
app.register_blueprint(pages.bp) app.register_blueprint(pages.bp)
app.register_blueprint(static.bp)
app.register_error_handler(400, error_pages.bad_request) app.register_error_handler(400, error_pages.bad_request)
app.register_error_handler(404, error_pages.page_not_found) app.register_error_handler(404, error_pages.page_not_found)
app.register_error_handler(500, error_pages.internal_server_error) app.register_error_handler(500, error_pages.internal_server_error)

View File

@@ -1,520 +0,0 @@
# This file helps to compute a version number in source trees obtained from
# git-archive tarball (such as those provided by githubs download-from-tag
# feature). Distribution tarballs (built by setup.py sdist) and build
# directories (produced by setup.py build) will contain a much shorter file
# that just contains the computed version number.
# This file is released into the public domain. Generated by
# versioneer-0.18 (https://github.com/warner/python-versioneer)
"""Git implementation of _version.py."""
import errno
import os
import re
import subprocess
import sys
def get_keywords():
"""Get the keywords needed to look up the version information."""
# these strings will be replaced by git during git-archive.
# setup.py/versioneer.py will grep for the variable names, so they must
# each be defined on a line of their own. _version.py will just call
# get_keywords().
git_refnames = "$Format:%d$"
git_full = "$Format:%H$"
git_date = "$Format:%ci$"
keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
return keywords
class VersioneerConfig:
"""Container for Versioneer configuration parameters."""
def get_config():
"""Create, populate and return the VersioneerConfig() object."""
# these strings are filled in when 'setup.py versioneer' creates
# _version.py
cfg = VersioneerConfig()
cfg.VCS = "git"
cfg.style = "pep440-post"
cfg.tag_prefix = "v"
cfg.parentdir_prefix = "None"
cfg.versionfile_source = "incorporealcms/_version.py"
cfg.verbose = False
return cfg
class NotThisMethod(Exception):
"""Exception raised if a method is not valid for the current scenario."""
LONG_VERSION_PY = {}
HANDLERS = {}
def register_vcs_handler(vcs, method): # decorator
"""Decorator to mark a method as the handler for a particular VCS."""
def decorate(f):
"""Store f in HANDLERS[vcs][method]."""
if vcs not in HANDLERS:
HANDLERS[vcs] = {}
HANDLERS[vcs][method] = f
return f
return decorate
def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
env=None):
"""Call the given command(s)."""
assert isinstance(commands, list)
p = None
for c in commands:
try:
dispcmd = str([c] + args)
# remember shell=False, so use git.cmd on windows, not just git
p = subprocess.Popen([c] + args, cwd=cwd, env=env,
stdout=subprocess.PIPE,
stderr=(subprocess.PIPE if hide_stderr
else None))
break
except EnvironmentError:
e = sys.exc_info()[1]
if e.errno == errno.ENOENT:
continue
if verbose:
print("unable to run %s" % dispcmd)
print(e)
return None, None
else:
if verbose:
print("unable to find command, tried %s" % (commands,))
return None, None
stdout = p.communicate()[0].strip()
if sys.version_info[0] >= 3:
stdout = stdout.decode()
if p.returncode != 0:
if verbose:
print("unable to run %s (error)" % dispcmd)
print("stdout was %s" % stdout)
return None, p.returncode
return stdout, p.returncode
def versions_from_parentdir(parentdir_prefix, root, verbose):
"""Try to determine the version from the parent directory name.
Source tarballs conventionally unpack into a directory that includes both
the project name and a version string. We will also support searching up
two directory levels for an appropriately named parent directory
"""
rootdirs = []
for i in range(3):
dirname = os.path.basename(root)
if dirname.startswith(parentdir_prefix):
return {"version": dirname[len(parentdir_prefix):],
"full-revisionid": None,
"dirty": False, "error": None, "date": None}
else:
rootdirs.append(root)
root = os.path.dirname(root) # up a level
if verbose:
print("Tried directories %s but none started with prefix %s" %
(str(rootdirs), parentdir_prefix))
raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
@register_vcs_handler("git", "get_keywords")
def git_get_keywords(versionfile_abs):
"""Extract version information from the given file."""
# the code embedded in _version.py can just fetch the value of these
# keywords. When used from setup.py, we don't want to import _version.py,
# so we do it with a regexp instead. This function is not used from
# _version.py.
keywords = {}
try:
f = open(versionfile_abs, "r")
for line in f.readlines():
if line.strip().startswith("git_refnames ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["refnames"] = mo.group(1)
if line.strip().startswith("git_full ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["full"] = mo.group(1)
if line.strip().startswith("git_date ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["date"] = mo.group(1)
f.close()
except EnvironmentError:
pass
return keywords
@register_vcs_handler("git", "keywords")
def git_versions_from_keywords(keywords, tag_prefix, verbose):
"""Get version information from git keywords."""
if not keywords:
raise NotThisMethod("no keywords at all, weird")
date = keywords.get("date")
if date is not None:
# git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
# datestamp. However we prefer "%ci" (which expands to an "ISO-8601
# -like" string, which we must then edit to make compliant), because
# it's been around since git-1.5.3, and it's too difficult to
# discover which version we're using, or to work around using an
# older one.
date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
refnames = keywords["refnames"].strip()
if refnames.startswith("$Format"):
if verbose:
print("keywords are unexpanded, not using")
raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
refs = set([r.strip() for r in refnames.strip("()").split(",")])
# starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
# just "foo-1.0". If we see a "tag: " prefix, prefer those.
TAG = "tag: "
tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
if not tags:
# Either we're using git < 1.8.3, or there really are no tags. We use
# a heuristic: assume all version tags have a digit. The old git %d
# expansion behaves like git log --decorate=short and strips out the
# refs/heads/ and refs/tags/ prefixes that would let us distinguish
# between branches and tags. By ignoring refnames without digits, we
# filter out many common branch names like "release" and
# "stabilization", as well as "HEAD" and "master".
tags = set([r for r in refs if re.search(r'\d', r)])
if verbose:
print("discarding '%s', no digits" % ",".join(refs - tags))
if verbose:
print("likely tags: %s" % ",".join(sorted(tags)))
for ref in sorted(tags):
# sorting will prefer e.g. "2.0" over "2.0rc1"
if ref.startswith(tag_prefix):
r = ref[len(tag_prefix):]
if verbose:
print("picking %s" % r)
return {"version": r,
"full-revisionid": keywords["full"].strip(),
"dirty": False, "error": None,
"date": date}
# no suitable tags, so version is "0+unknown", but full hex is still there
if verbose:
print("no suitable tags, using unknown + full revision id")
return {"version": "0+unknown",
"full-revisionid": keywords["full"].strip(),
"dirty": False, "error": "no suitable tags", "date": None}
@register_vcs_handler("git", "pieces_from_vcs")
def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
"""Get version from 'git describe' in the root of the source tree.
This only gets called if the git-archive 'subst' keywords were *not*
expanded, and _version.py hasn't already been rewritten with a short
version string, meaning we're inside a checked out source tree.
"""
GITS = ["git"]
if sys.platform == "win32":
GITS = ["git.cmd", "git.exe"]
out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
hide_stderr=True)
if rc != 0:
if verbose:
print("Directory %s not under git control" % root)
raise NotThisMethod("'git rev-parse --git-dir' returned error")
# if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
# if there isn't one, this yields HEX[-dirty] (no NUM)
describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
"--always", "--long",
"--match", "%s*" % tag_prefix],
cwd=root)
# --long was added in git-1.5.5
if describe_out is None:
raise NotThisMethod("'git describe' failed")
describe_out = describe_out.strip()
full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
if full_out is None:
raise NotThisMethod("'git rev-parse' failed")
full_out = full_out.strip()
pieces = {}
pieces["long"] = full_out
pieces["short"] = full_out[:7] # maybe improved later
pieces["error"] = None
# parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
# TAG might have hyphens.
git_describe = describe_out
# look for -dirty suffix
dirty = git_describe.endswith("-dirty")
pieces["dirty"] = dirty
if dirty:
git_describe = git_describe[:git_describe.rindex("-dirty")]
# now we have TAG-NUM-gHEX or HEX
if "-" in git_describe:
# TAG-NUM-gHEX
mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
if not mo:
# unparseable. Maybe git-describe is misbehaving?
pieces["error"] = ("unable to parse git-describe output: '%s'"
% describe_out)
return pieces
# tag
full_tag = mo.group(1)
if not full_tag.startswith(tag_prefix):
if verbose:
fmt = "tag '%s' doesn't start with prefix '%s'"
print(fmt % (full_tag, tag_prefix))
pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
% (full_tag, tag_prefix))
return pieces
pieces["closest-tag"] = full_tag[len(tag_prefix):]
# distance: number of commits since tag
pieces["distance"] = int(mo.group(2))
# commit: short hex revision ID
pieces["short"] = mo.group(3)
else:
# HEX: no tags
pieces["closest-tag"] = None
count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
cwd=root)
pieces["distance"] = int(count_out) # total number of commits
# commit date: see ISO-8601 comment in git_versions_from_keywords()
date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"],
cwd=root)[0].strip()
pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
return pieces
def plus_or_dot(pieces):
"""Return a + if we don't already have one, else return a ."""
if "+" in pieces.get("closest-tag", ""):
return "."
return "+"
def render_pep440(pieces):
"""Build up version string, with post-release "local version identifier".
Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
Exceptions:
1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += plus_or_dot(pieces)
rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
else:
# exception #1
rendered = "0+untagged.%d.g%s" % (pieces["distance"],
pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
return rendered
def render_pep440_pre(pieces):
"""TAG[.post.devDISTANCE] -- No -dirty.
Exceptions:
1: no tags. 0.post.devDISTANCE
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"]:
rendered += ".post.dev%d" % pieces["distance"]
else:
# exception #1
rendered = "0.post.dev%d" % pieces["distance"]
return rendered
def render_pep440_post(pieces):
"""TAG[.postDISTANCE[.dev0]+gHEX] .
The ".dev0" means dirty. Note that .dev0 sorts backwards
(a dirty tree will appear "older" than the corresponding clean one),
but you shouldn't be releasing software with -dirty anyways.
Exceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += ".post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
rendered += plus_or_dot(pieces)
rendered += "g%s" % pieces["short"]
else:
# exception #1
rendered = "0.post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
rendered += "+g%s" % pieces["short"]
return rendered
def render_pep440_old(pieces):
"""TAG[.postDISTANCE[.dev0]] .
The ".dev0" means dirty.
Eexceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += ".post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
else:
# exception #1
rendered = "0.post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
return rendered
def render_git_describe(pieces):
"""TAG[-DISTANCE-gHEX][-dirty].
Like 'git describe --tags --dirty --always'.
Exceptions:
1: no tags. HEX[-dirty] (note: no 'g' prefix)
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"]:
rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
else:
# exception #1
rendered = pieces["short"]
if pieces["dirty"]:
rendered += "-dirty"
return rendered
def render_git_describe_long(pieces):
"""TAG-DISTANCE-gHEX[-dirty].
Like 'git describe --tags --dirty --always -long'.
The distance/hash is unconditional.
Exceptions:
1: no tags. HEX[-dirty] (note: no 'g' prefix)
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
else:
# exception #1
rendered = pieces["short"]
if pieces["dirty"]:
rendered += "-dirty"
return rendered
def render(pieces, style):
"""Render the given version pieces into the requested style."""
if pieces["error"]:
return {"version": "unknown",
"full-revisionid": pieces.get("long"),
"dirty": None,
"error": pieces["error"],
"date": None}
if not style or style == "default":
style = "pep440" # the default
if style == "pep440":
rendered = render_pep440(pieces)
elif style == "pep440-pre":
rendered = render_pep440_pre(pieces)
elif style == "pep440-post":
rendered = render_pep440_post(pieces)
elif style == "pep440-old":
rendered = render_pep440_old(pieces)
elif style == "git-describe":
rendered = render_git_describe(pieces)
elif style == "git-describe-long":
rendered = render_git_describe_long(pieces)
else:
raise ValueError("unknown style '%s'" % style)
return {"version": rendered, "full-revisionid": pieces["long"],
"dirty": pieces["dirty"], "error": None,
"date": pieces.get("date")}
def get_versions():
"""Get version information or return default if unable to do so."""
# I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
# __file__, we can work backwards from there to the root. Some
# py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
# case we can only use expanded keywords.
cfg = get_config()
verbose = cfg.verbose
try:
return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
verbose)
except NotThisMethod:
pass
try:
root = os.path.realpath(__file__)
# versionfile_source is the relative path from the top of the source
# tree (where the .git directory might live) to this file. Invert
# this to find the root from __file__.
for i in cfg.versionfile_source.split('/'):
root = os.path.dirname(root)
except NameError:
return {"version": "0+unknown", "full-revisionid": None,
"dirty": None,
"error": "unable to find root of source tree",
"date": None}
try:
pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
return render(pieces, cfg.style)
except NotThisMethod:
pass
try:
if cfg.parentdir_prefix:
return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
except NotThisMethod:
pass
return {"version": "0+unknown", "full-revisionid": None,
"dirty": None,
"error": "unable to compute version", "date": None}

View File

@@ -1,4 +1,8 @@
"""Default configuration.""" """Default configuration.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
class Config(object): class Config(object):
@@ -32,10 +36,9 @@ class Config(object):
}, },
} }
MARKDOWN_EXTENSIONS = ['extra', 'mdx_linkify', 'sane_lists', 'smarty', 'tables'] MARKDOWN_EXTENSIONS = ['extra', 'incorporealcms.mdx.figures', 'sane_lists', 'smarty', 'toc']
MARKDOWN_EXTENSION_CONFIGS = { MARKDOWN_EXTENSION_CONFIGS = {
'extra': { 'extra': {
'attr_list': {},
'footnotes': { 'footnotes': {
'UNIQUE_IDS': True, 'UNIQUE_IDS': True,
}, },
@@ -51,8 +54,18 @@ class Config(object):
MEDIA_DIR = 'media' MEDIA_DIR = 'media'
# customizations # customizations
PAGE_STYLES = {
'dark': '/static/css/dark.css',
'light': '/static/css/light.css',
'plain': '/static/css/plain.css',
}
DEFAULT_PAGE_STYLE = 'light' DEFAULT_PAGE_STYLE = 'light'
TITLE_SUFFIX = 'example.com' DOMAIN_NAME = 'example.com'
TITLE_SUFFIX = DOMAIN_NAME
CONTACT_EMAIL = 'admin@example.com' CONTACT_EMAIL = 'admin@example.com'
# feed settings
AUTHOR = {'name': 'Test Name', 'email': 'admin@example.com'}
# specify FAVICON in your instance config.py to override the provided icon # specify FAVICON in your instance config.py to override the provided icon

View File

@@ -1,4 +1,8 @@
"""Error page views for 400, 404, etc.""" """Error page views for 400, 404, etc.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
from incorporealcms.lib import render from incorporealcms.lib import render

73
incorporealcms/feed.py Normal file
View File

@@ -0,0 +1,73 @@
"""Generate Atom and RSS feeds based on content in a blog-ish location.
This parses a special root directory, feed/, for feed/YYYY/MM/DD/file files,
and combines them into an Atom or RSS feed. These files *should* be symlinks
to the real pages, which may mirror the same YYYY/MM/DD/file naming scheme
under pages/ (which may make sense for a blog) if they want, but could just
as well be pages/foo content.
SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import logging
import os
import re
from feedgen.feed import FeedGenerator
from flask import Blueprint, Response, abort
from flask import current_app as app
from incorporealcms.lib import instance_resource_path_to_request_path, parse_md
logger = logging.getLogger(__name__)
bp = Blueprint('feed', __name__, url_prefix='/feed')
@bp.route('/<feed_type>')
def serve_feed(feed_type):
"""Serve the Atom or RSS feed as requested."""
logger.warning("wat")
if feed_type not in ('atom', 'rss'):
abort(404)
fg = FeedGenerator()
fg.id(f'https://{app.config["DOMAIN_NAME"]}/')
fg.title(f'{app.config["TITLE_SUFFIX"]}')
fg.author(app.config["AUTHOR"])
fg.link(href=f'https://{app.config["DOMAIN_NAME"]}/feed/{feed_type}', rel='self')
fg.link(href=f'https://{app.config["DOMAIN_NAME"]}', rel='alternate')
fg.subtitle(f"Blog posts and other dated materials from {app.config['TITLE_SUFFIX']}")
# get recent feeds
feed_path = os.path.join(app.instance_path, 'feed')
feed_entry_paths = [os.path.join(dirpath, filename) for dirpath, _, filenames in os.walk(feed_path)
for filename in filenames if os.path.islink(os.path.join(dirpath, filename))]
for feed_entry_path in sorted(feed_entry_paths):
# get the actual file to parse it
resolved_path = os.path.realpath(feed_entry_path).replace(f'{app.instance_path}/', '')
try:
content, md, page_name, page_title, mtime = parse_md(resolved_path)
link = f'https://{app.config["DOMAIN_NAME"]}/{instance_resource_path_to_request_path(resolved_path)}'
except (OSError, ValueError, TypeError):
logger.exception("error loading/rendering markdown!")
abort(500)
fe = fg.add_entry()
fe.id(_generate_feed_id(feed_entry_path))
fe.title(page_name if page_name else page_title)
fe.author(app.config["AUTHOR"])
fe.link(href=link)
fe.content(content, type='html')
if feed_type == 'atom':
return Response(fg.atom_str(pretty=True), mimetype='application/atom+xml')
else:
return Response(fg.rss_str(pretty=True), mimetype='application/rss+xml')
def _generate_feed_id(feed_entry_path):
"""For a relative file path, generate the Atom/RSS feed ID for it."""
date = re.sub(r'.*/(\d+)/(\d+)/(\d+).*', r'\1-\2-\3', feed_entry_path)
cleaned = feed_entry_path.replace('#', '/').replace('feed/', '', 1).replace(app.instance_path, '')
return f'tag:{app.config["DOMAIN_NAME"]},{date}:{cleaned}'

View File

@@ -1,9 +1,17 @@
"""Miscellaneous helper functions and whatnot.""" """Miscellaneous helper functions and whatnot.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import datetime
import logging import logging
import os
import re
import markdown import markdown
from flask import current_app as app from flask import current_app as app
from flask import make_response, render_template, request from flask import make_response, render_template, request
from markupsafe import Markup
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,24 +34,59 @@ def init_md():
extension_configs=app.config['MARKDOWN_EXTENSION_CONFIGS']) extension_configs=app.config['MARKDOWN_EXTENSION_CONFIGS'])
def instance_resource_path_to_request_path(path):
"""Reverse a (presumed to exist) RELATIVE disk path to the canonical path that would show up in a Flask route.
This does not include the leading /, so aside from the root index case, this should be
bidirectional.
"""
return re.sub(r'^pages/', '', re.sub(r'.md$', '', re.sub(r'index.md$', '', path)))
def parse_md(resolved_path):
"""Given a file to parse, return file content and other derived data along with the md object."""
try:
logger.debug("opening resolved path '%s'", resolved_path)
with app.open_instance_resource(resolved_path, 'r') as entry_file:
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(entry_file.name), tz=datetime.timezone.utc)
entry = entry_file.read()
logger.debug("resolved path '%s' read", resolved_path)
md = init_md()
content = Markup(md.convert(entry))
except OSError:
logger.exception("resolved path '%s' could not be opened!", resolved_path)
raise
except ValueError:
logger.exception("error parsing/rendering markdown!")
raise
except TypeError:
logger.exception("error loading/rendering markdown!")
raise
logger.debug("file metadata: %s", md.Meta)
page_name = (get_meta_str(md, 'title') if md.Meta.get('title') else
f'/{instance_resource_path_to_request_path(resolved_path)}')
page_title = f'{page_name} - {app.config["TITLE_SUFFIX"]}' if page_name else app.config['TITLE_SUFFIX']
logger.debug("title (potentially derived): %s", page_title)
return content, md, page_name, page_title, mtime
def render(template_name_or_list, **context): def render(template_name_or_list, **context):
"""Wrap Flask's render_template. """Wrap Flask's render_template.
* Determine the proper site theme to use in the template and provide it. * Determine the proper site theme to use in the template and provide it.
""" """
PAGE_STYLES = { page_styles = app.config['PAGE_STYLES']
'dark': 'css/dark.css',
'light': 'css/light.css',
'plain': 'css/plain.css',
}
selected_style = request.args.get('style', None) selected_style = request.args.get('style', None)
if selected_style: if selected_style:
user_style = selected_style user_style = selected_style
else: else:
user_style = request.cookies.get('user-style') user_style = request.cookies.get('user-style')
logger.debug("user style cookie: %s", 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'])) context['user_style'] = page_styles.get(user_style, page_styles.get(app.config['DEFAULT_PAGE_STYLE']))
context['page_styles'] = page_styles
resp = make_response(render_template(template_name_or_list, **context)) resp = make_response(render_template(template_name_or_list, **context))
if selected_style: if selected_style:

View File

@@ -1 +1,5 @@
"""Markdown extensions.""" """Markdown extensions.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""

View File

@@ -0,0 +1,64 @@
"""Create generic figures with captions.
SPDX-FileCopyrightText: © 2022 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import re
from xml.etree.ElementTree import SubElement # nosec B405 - not parsing untrusted XML here
import markdown
class FigureExtension(markdown.Extension):
"""Wrap the markdown prepcoressor."""
def extendMarkdown(self, md):
"""Add FigureBlockProcessor to the Markdown instance."""
md.parser.blockprocessors.register(FigureBlockProcessor(md.parser), 'figure', 100)
class FigureBlockProcessor(markdown.blockprocessors.BlockProcessor):
"""Process figures."""
# |> thing to put in the figure
# |: optional caption for the figure
# optional whatever else, like maybe an attr_list
figure_regex = re.compile(r'^[ ]{0,3}\|>[ ]{0,3}(?P<content>[^\n]*)')
caption_regex = re.compile(r'^[ ]{0,3}\|:[ ]{0,3}(?P<caption>[^\n]*)')
def test(self, parent, block):
"""Determine if we should process this block."""
lines = block.split('\n')
return bool(self.figure_regex.search(lines[0]))
def run(self, parent, blocks):
"""Replace the top block with HTML."""
block = blocks.pop(0)
lines = block.split('\n')
# consume line and create a figure
figure_match = self.figure_regex.search(lines[0])
lines.pop(0)
content = figure_match.group('content')
figure = SubElement(parent, 'figure')
figure.text = content
if lines:
if caption_match := self.caption_regex.search(lines[0]):
# consume line and add the caption as a child of the figure
lines.pop(0)
caption = caption_match.group('caption')
figcaption = SubElement(figure, 'figcaption')
figcaption.text = caption
if lines:
# other lines are mysteries, might be attr_list, so re-append
# make sure there's a child to hang the rest (which is maybe an attr_list?) off of
# this is probably a bad hack
if not len(list(figure)):
SubElement(figure, 'span')
rest = '\n'.join(lines)
figure[-1].tail = f'\n{rest}'
def makeExtension(*args, **kwargs):
"""Provide the extension to the markdown extension loader."""
return FigureExtension(*args, **kwargs)

View File

@@ -1,4 +1,8 @@
"""Serve dot diagrams inline.""" """Serve dot diagrams inline.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import base64 import base64
import logging import logging
import re import re
@@ -20,14 +24,17 @@ class InlinePydot(markdown.Extension):
class InlinePydotPreprocessor(markdown.preprocessors.Preprocessor): class InlinePydotPreprocessor(markdown.preprocessors.Preprocessor):
"""Identify dot codeblocks and run them through pydot.""" """Identify dot codeblocks and run them through pydot."""
BLOCK_RE = re.compile(r'~~~pydot:(?P<filename>[^\s]+)\n(?P<content>.*?)~~~', re.DOTALL) BLOCK_RE = re.compile(r'~~~{\s+pydot:(?P<filename>[^\s]+)\s+}\n(?P<content>.*?)~~~', re.DOTALL)
def run(self, lines): def run(self, lines):
"""Match and generate diagrams from dot code blocks.""" """Match and generate diagrams from dot code blocks."""
text = '\n'.join(lines) text = '\n'.join(lines)
for match in self.BLOCK_RE.finditer(text): out = text
filename = match.group(1) for block_match in self.BLOCK_RE.finditer(text):
dot_string = match.group(2) filename = block_match.group(1)
dot_string = block_match.group(2)
logger.debug("matched markdown block: %s", dot_string)
logger.debug("match start/end: %s/%s", block_match.start(), block_match.end())
# use pydot to turn the text into pydot # use pydot to turn the text into pydot
graphs = pydot.graph_from_dot_data(dot_string) graphs = pydot.graph_from_dot_data(dot_string)
@@ -41,9 +48,9 @@ class InlinePydotPreprocessor(markdown.preprocessors.Preprocessor):
inline_image = f'![{filename}]({data_path})' inline_image = f'![{filename}]({data_path})'
# replace the image in the output markdown # replace the image in the output markdown
text = f'{text[:match.start()]}\n{inline_image}\n{text[match.end():]}' out = out.replace(block_match.group(0), inline_image)
return text.split('\n') return out.split('\n')
def makeExtension(*args, **kwargs): def makeExtension(*args, **kwargs):

View File

@@ -1,15 +1,18 @@
"""General page functionality.""" """General page functionality.
import datetime
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import logging import logging
import os import os
import re
from flask import Blueprint, Markup, abort from flask import Blueprint, abort
from flask import current_app as app from flask import current_app as app
from flask import redirect, request, send_from_directory from flask import redirect, request, send_from_directory
from tzlocal import get_localzone from markupsafe import Markup
from werkzeug.security import safe_join
from incorporealcms.lib import get_meta_str, init_md, render from incorporealcms.lib import get_meta_str, init_md, instance_resource_path_to_request_path, parse_md, render
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,34 +52,19 @@ def display_page(path):
def handle_markdown_file_path(resolved_path): def handle_markdown_file_path(resolved_path):
"""Given a location on disk, attempt to open it and render the markdown within.""" """Given a location on disk, attempt to open it and render the markdown within."""
try: try:
logger.debug("opening resolved path '%s'", resolved_path) content, md, page_name, page_title, mtime = parse_md(resolved_path)
with app.open_instance_resource(resolved_path, 'r') as entry_file:
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(entry_file.name), get_localzone())
entry = entry_file.read()
logger.debug("resolved path '%s' read", resolved_path)
except OSError: except OSError:
logger.exception("resolved path '%s' could not be opened!", resolved_path) logger.exception("resolved path '%s' could not be opened!", resolved_path)
abort(500) abort(500)
else:
try:
md = init_md()
content = Markup(md.convert(entry))
except ValueError: except ValueError:
logger.exception("error parsing/rendering markdown!") logger.exception("error parsing/rendering markdown!")
abort(500) abort(500)
except TypeError: except TypeError:
logger.exception("error loading/rendering markdown!") logger.exception("error loading/rendering markdown!")
abort(500) abort(500)
else:
logger.debug("file metadata: %s", md.Meta)
parent_navs = generate_parent_navs(resolved_path) parent_navs = generate_parent_navs(resolved_path)
extra_footer = get_meta_str(md, 'footer') if md.Meta.get('footer') else None
page_name = (get_meta_str(md, 'title') if md.Meta.get('title') else
f'/{instance_resource_path_to_request_path(resolved_path)}')
page_title = f'{page_name} - {app.config["TITLE_SUFFIX"]}' if page_name else app.config['TITLE_SUFFIX']
logger.debug("title (potentially derived): %s", page_title)
template = get_meta_str(md, 'template') if md.Meta.get('template') else 'base.html' template = get_meta_str(md, 'template') if md.Meta.get('template') else 'base.html'
# check if this has a HTTP redirect # check if this has a HTTP redirect
@@ -87,7 +75,8 @@ def handle_markdown_file_path(resolved_path):
return render(template, title=page_title, description=get_meta_str(md, 'description'), return render(template, title=page_title, description=get_meta_str(md, 'description'),
image=get_meta_str(md, 'image'), base_url=request.base_url, content=content, 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')) navs=parent_navs, mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'),
extra_footer=extra_footer)
def request_path_to_instance_resource_path(path): def request_path_to_instance_resource_path(path):
@@ -99,15 +88,16 @@ def request_path_to_instance_resource_path(path):
""" """
# check if the path is allowed # check if the path is allowed
base_dir = os.path.realpath(f'{app.instance_path}/pages/') base_dir = os.path.realpath(f'{app.instance_path}/pages/')
verbatim_path = os.path.abspath(os.path.join(base_dir, path)) safe_path = safe_join(base_dir, path)
resolved_path = os.path.realpath(verbatim_path)
logger.debug("base_dir '%s', constructed resolved_path '%s' for path '%s'", base_dir, resolved_path, path)
# bail if the requested real path isn't inside the base directory # bail if the requested real path isn't inside the base directory
if base_dir != os.path.commonpath((base_dir, resolved_path)): if not safe_path:
logger.warning("client tried to request a path '%s' outside of the base_dir!", path) logger.warning("client tried to request a path '%s' outside of the base_dir!", path)
raise PermissionError raise PermissionError
verbatim_path = os.path.abspath(safe_path)
resolved_path = os.path.realpath(verbatim_path)
logger.debug("base_dir '%s', constructed resolved_path '%s' for path '%s'", base_dir, resolved_path, path)
# see if we have a real file or if we should infer markdown rendering # see if we have a real file or if we should infer markdown rendering
if os.path.exists(resolved_path): if os.path.exists(resolved_path):
# if this is a file-like request but actually a directory, redirect the user # if this is a file-like request but actually a directory, redirect the user
@@ -129,7 +119,7 @@ def request_path_to_instance_resource_path(path):
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'file' return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'file'
# if we're here, this isn't direct file access, so try markdown inference # if we're here, this isn't direct file access, so try markdown inference
verbatim_path = os.path.abspath(os.path.join(base_dir, f'{path}.md')) verbatim_path = f'{safe_path}.md'
resolved_path = os.path.realpath(verbatim_path) resolved_path = os.path.realpath(verbatim_path)
# does the final file actually exist? # does the final file actually exist?
@@ -147,20 +137,11 @@ def request_path_to_instance_resource_path(path):
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown' return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown'
def instance_resource_path_to_request_path(path):
"""Reverse a (presumed to exist) disk path to the canonical path that would show up in a Flask route.
This does not include the leading /, so aside from the root index case, this should be
bidirectional.
"""
return re.sub(r'^pages/', '', re.sub(r'.md$', '', re.sub(r'index.md$', '', path)))
def generate_parent_navs(path): def generate_parent_navs(path):
"""Create a series of paths/links to navigate up from the given resource path.""" """Create a series of paths/links to navigate up from the given resource path."""
if path == 'pages/index.md': if path == 'pages/index.md':
# bail and return the title suffix (generally the domain name) as a terminal case # bail and return the domain name as a terminal case
return [(app.config['TITLE_SUFFIX'], '/')] return [(app.config['DOMAIN_NAME'], '/')]
else: else:
if path.endswith('index.md'): if path.endswith('index.md'):
# index case: one dirname for foo/bar/index.md -> foo/bar, one for foo/bar -> foo # index case: one dirname for foo/bar/index.md -> foo/bar, one for foo/bar -> foo

18
incorporealcms/static.py Normal file
View File

@@ -0,0 +1,18 @@
"""Serve static files from the instance directory.
SPDX-FileCopyrightText: © 2022 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import os
from flask import Blueprint
from flask import current_app as app
from flask import send_from_directory
bp = Blueprint('static', __name__, url_prefix='/custom-static')
@bp.route('/<path:name>')
def serve_instance_static_file(name):
"""Serve a static file from the instance directory, used for customization."""
return send_from_directory(os.path.join(app.instance_path, 'custom-static'), name)

View File

@@ -12,11 +12,11 @@ body {
} }
.site-wrap-normal-width { .site-wrap-normal-width {
max-width: 70pc; max-width: 65pc;
} }
.site-wrap-double-width { .site-wrap-double-width {
max-width: 140pc; max-width: 130pc;
} }
.site-wrap { .site-wrap {
@@ -26,41 +26,28 @@ body {
margin-right: auto; margin-right: auto;
} }
a:link { a {
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration-line: underline;
} text-decoration-thickness: 1px;
a:visited {
font-weight: bold;
text-decoration: none;
}
a:hover {
font-weight: bold;
text-decoration: none;
}
a:active {
font-weight: bold;
text-decoration: none;
} }
div.header { div.header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 0.75em; font-size: 0.8em;
padding: 0.25em 0.5em; padding: 1rem 1rem;
} padding-bottom: 0;
div.header a {
border-bottom: none;
} }
div.content { div.content {
font-size: 11pt; font-size: 11pt;
padding: 0 1em; padding: 0 1rem;
line-height: 1.5em; line-height: 1.6em;
}
div.content p {
margin: 1.25em 0;
} }
sup, sub { sup, sub {
@@ -79,7 +66,11 @@ footer {
color: #999; color: #999;
padding: 0 1em; padding: 0 1em;
padding-bottom: 16px; padding-bottom: 16px;
margin-top: 15px; margin-top: 30px;
}
.extra-footer {
margin-bottom: 5px;
} }
table { table {
@@ -135,39 +126,26 @@ img {
margin-left: 1em; 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 { figure {
max-width: 400px;
padding: 5px; padding: 5px;
margin: 10px; margin: 0;
margin-top: 0;
margin-bottom: 5px; margin-bottom: 5px;
text-align: center;
max-width: 30%;
min-width: 10em;
display: inline-block;
}
figure.right {
float: right;
margin-left: 10px;
display: block;
}
figure.left {
float: left;
margin-right: 10px;
display: block;
} }
figure img { figure img {
@@ -176,19 +154,8 @@ figure img {
} }
figcaption { figcaption {
font-family: "Times New Roman", serif;
color: #777777;
text-align: center; text-align: center;
font-style: italic; font-size: 0.9em;
line-height: 1.3em;
margin-top: 5px;
}
.thumbnail-image {
width: 150px;
height: auto;
margin: 5px;
display: inline;
} }
.footnote { .footnote {
@@ -200,6 +167,5 @@ figcaption {
} }
.footnote-ref:link, .footnote-ref:visited, .footnote-ref:hover, .footnote-ref:active { .footnote-ref:link, .footnote-ref:visited, .footnote-ref:hover, .footnote-ref:active {
border-bottom: none;
font-weight: normal; font-weight: normal;
} }

View File

@@ -6,43 +6,32 @@ html {
} }
body { body {
background: #090909; background: #111;
}
strong {
color: #EEE;
}
.site-wrap {
background: black;
border: 1px solid #222;
border-top: none;
border-bottom: none;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
color: #B31D15; color: #B31D15;
} }
a:link, a:visited { p a, ul a, ol a {
color: #EEE; color: #DDD;
border-bottom: 1px dotted #EEE;
} }
a:hover, a:active { footer a {
color: #999;
}
p a:hover, ul a:hover, ol a:hover, footer a:hover {
color: #B31D15; color: #B31D15;
border-bottom: 1px dotted #B31D15;
} }
div.header { div.site-wrap {
background: #222; background: black;
border-bottom: 1px solid #222;
color: #BBB;
} }
div.header a { div.header, div.header a {
color: #BBB; color: #555;
text-decoration: none;
} }
table, th, td { table, th, td {
@@ -58,7 +47,11 @@ blockquote {
border: 1px solid #222; border: 1px solid #222;
} }
.img-frame { figure {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
border: 1px solid #333; border: 1px solid #333;
} }
figcaption {
color: #AAAAAA;
}

View File

@@ -6,43 +6,32 @@ html {
} }
body { body {
background: #F6F6F6; background: #EEE;
}
strong {
color: #111;
}
.site-wrap {
background: white;
border: 1px solid #DDD;
border-top: none;
border-bottom: none;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
color: #811610; color: #811610;
} }
a:link, a:visited { p a, ul a, ol a {
color: #111; color: #222;
border-bottom: 1px dotted #111;
} }
a:hover, a:active { footer a {
color: #999;
}
p a:hover, ul a:hover, ol a:hover, footer a:hover {
color: #811610; color: #811610;
border-bottom: 1px dotted #811610;
} }
div.header { div.site-wrap {
background: #DDD; background: white;
border-bottom: 1px solid #DDD;
color: #444;
} }
div.header a { div.header, div.header a {
color: #444; color: #AAA;
text-decoration: none;
} }
table, th, td { table, th, td {
@@ -58,7 +47,11 @@ blockquote {
border: 1px solid #DDD; border: 1px solid #DDD;
} }
.img-frame { figure {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
border: 1px solid #CCC; border: 1px solid #CCC;
} }
figcaption {
color: #666666;
}

View File

@@ -1,4 +1,9 @@
/* specify almost no styling, just fix some image rendering */ /* specify almost no styling, just fix some image and nav rendering */
div.header {
display: flex;
justify-content: space-between;
}
.img-25 { .img-25 {
max-width: 25% !important; max-width: 25% !important;
} }
@@ -10,3 +15,19 @@
.img-75 { .img-75 {
max-width: 75% !important; max-width: 75% !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;
}

View File

@@ -7,7 +7,7 @@
<meta property="og:url" content="{{ base_url }}"> <meta property="og:url" content="{{ base_url }}">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{ url_for('static', filename=user_style) }}"> <link rel="stylesheet" href="{{ user_style }}">
<link rel="icon" href="{% if config.FAVICON %}{{ config.FAVICON }}{% else %}{{ url_for('static', filename='img/favicon.png') }}{% endif %}"> <link rel="icon" href="{% if config.FAVICON %}{{ config.FAVICON }}{% else %}{{ url_for('static', filename='img/favicon.png') }}{% endif %}">
<div {% block site_class %}class="site-wrap site-wrap-normal-width"{% endblock %}> <div {% block site_class %}class="site-wrap site-wrap-normal-width"{% endblock %}>
@@ -20,9 +20,9 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="styles"> <div class="styles">
<a href="?style=dark">[dark]</a> {% for style in page_styles %}
<a href="?style=light">[light]</a> <a href="?style={{ style }}">[{{ style }}]</a>
<a href="?style=plain">[plain]</a> {% endfor %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -31,7 +31,8 @@
{{ content }} {{ content }}
</div> </div>
<footer> <footer>
<i>Last modified: {{ mtime }}</i> {% if extra_footer %}<div class="extra-footer"><i>{{ extra_footer|safe }}</i></div>{% endif %}
<div class="footer"><i>Last modified: {{ mtime }}</i></div>
</footer> </footer>
{% endblock %} {% endblock %}
</div> </div>

54
pyproject.toml Normal file
View File

@@ -0,0 +1,54 @@
[build-system]
requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"
[project]
name = "incorporeal-cms"
description = "A CMS for serving Markdown files with a bit of dynamicism."
readme = "README.md"
license = {text = "AGPL-3.0-or-later"}
authors = [
{name = "Brian S. Stephan", email = "bss@incorporeal.org"},
]
requires-python = ">=3.8"
dependencies = ["feedgen", "Flask", "Markdown"]
dynamic = ["version"]
classifiers = [
"Framework :: Flask",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Operating System :: OS Independent",
"Topic :: Text Processing :: Markup :: Markdown",
]
[project.urls]
"Homepage" = "https://git.incorporeal.org/bss/incorporeal-cms"
"Changelog" = "https://git.incorporeal.org/bss/incorporeal-cms/releases"
"Bug Tracker" = "https://git.incorporeal.org/bss/incorporeal-cms/issues"
[project.optional-dependencies]
dev = ["bandit", "dlint", "flake8", "flake8-blind-except", "flake8-builtins", "flake8-docstrings",
"flake8-executable", "flake8-fixme", "flake8-isort", "flake8-logging-format", "flake8-mutable",
"flake8-pyproject", "mypy", "pip-tools", "pydot", "pytest", "pytest-cov", "safety",
"setuptools-scm", "tox"]
dot = ["pydot"]
[tool.flake8]
enable-extensions = "G,M"
exclude = [".tox/", "venv/", "_version.py"]
extend-ignore = "T101"
max-complexity = 10
max-line-length = 120
[tool.isort]
line_length = 120
[tool.mypy]
ignore_missing_imports = true
[tool.pytest.ini_options]
python_files = ["*_tests.py", "tests.py", "test_*.py"]
[tool.setuptools_scm]
write_to = "incorporealcms/_version.py"

View File

@@ -1,26 +0,0 @@
-r requirements.in
# testing runner, test reporting, packages used during testing (e.g. requests-mock), etc.
pydot
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
safety # check requirements file for issues
# maintenance utilities and tox
pip-tools # pip-compile
tox # CI stuff
tox-wheel # build wheels in tox
versioneer # automatic version numbering

View File

@@ -1,185 +1,191 @@
# #
# This file is autogenerated by pip-compile with python 3.10 # This file is autogenerated by pip-compile with Python 3.12
# To update, run: # by the following command:
# #
# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in # pip-compile --extra=dev --output-file=requirements/requirements-dev.txt
# #
attrs==21.4.0 bandit==1.7.6
# via pytest # via incorporeal-cms (pyproject.toml)
bandit==1.6.2 blinker==1.7.0
# via -r requirements/requirements-dev.in # via flask
bleach==4.1.0 build==1.0.3
# via mdx-linkify # via pip-tools
certifi==2021.10.8 cachetools==5.3.2
# via tox
certifi==2023.11.17
# via requests # via requests
charset-normalizer==2.0.10 chardet==5.2.0
# via tox
charset-normalizer==3.3.2
# via requests # via requests
click==8.0.3 click==8.1.7
# via # via
# flask # flask
# pip-tools # pip-tools
# safety # safety
coverage[toml]==6.2 colorama==0.4.6
# via pytest-cov # via tox
distlib==0.3.4 coverage[toml]==7.4.0
# via
# coverage
# pytest-cov
distlib==0.3.8
# via virtualenv # via virtualenv
dlint==0.12.0 dlint==0.14.1
# via -r requirements/requirements-dev.in # via incorporeal-cms (pyproject.toml)
dparse==0.5.1 dparse==0.6.3
# via safety # via safety
filelock==3.4.2 feedgen==1.0.0
# via incorporeal-cms (pyproject.toml)
filelock==3.13.1
# via # via
# tox # tox
# virtualenv # virtualenv
flake8==4.0.1 flake8==6.1.0
# via # via
# -r requirements/requirements-dev.in
# dlint # dlint
# flake8-builtins # flake8-builtins
# flake8-docstrings # flake8-docstrings
# flake8-executable # flake8-executable
# flake8-isort # flake8-isort
# flake8-mutable # flake8-mutable
flake8-blind-except==0.2.0 # flake8-pyproject
# via -r requirements/requirements-dev.in # incorporeal-cms (pyproject.toml)
flake8-builtins==1.5.3 flake8-blind-except==0.2.1
# via -r requirements/requirements-dev.in # via incorporeal-cms (pyproject.toml)
flake8-docstrings==1.6.0 flake8-builtins==2.2.0
# via -r requirements/requirements-dev.in # via incorporeal-cms (pyproject.toml)
flake8-executable==2.1.1 flake8-docstrings==1.7.0
# via -r requirements/requirements-dev.in # via incorporeal-cms (pyproject.toml)
flake8-executable==2.1.3
# via incorporeal-cms (pyproject.toml)
flake8-fixme==1.1.1 flake8-fixme==1.1.1
# via -r requirements/requirements-dev.in # via incorporeal-cms (pyproject.toml)
flake8-isort==4.1.1 flake8-isort==6.1.1
# via -r requirements/requirements-dev.in # via incorporeal-cms (pyproject.toml)
flake8-logging-format==0.6.0 flake8-logging-format==0.9.0
# via -r requirements/requirements-dev.in # via incorporeal-cms (pyproject.toml)
flake8-mutable==1.2.0 flake8-mutable==1.2.0
# via -r requirements/requirements-dev.in # via incorporeal-cms (pyproject.toml)
flask==2.0.2 flake8-pyproject==1.2.3
# via -r requirements/requirements.in # via incorporeal-cms (pyproject.toml)
gitdb==4.0.9 flask==3.0.0
# via incorporeal-cms (pyproject.toml)
gitdb==4.0.11
# via gitpython # via gitpython
gitpython==3.1.26 gitpython==3.1.40
# via bandit # via bandit
idna==3.3 idna==3.6
# via requests # via requests
iniconfig==1.1.1 iniconfig==2.0.0
# via pytest # via pytest
isort==5.10.1 isort==5.13.2
# via flake8-isort # via flake8-isort
itsdangerous==2.0.1 itsdangerous==2.1.2
# via flask # via flask
jinja2==3.0.3 jinja2==3.1.2
# via flask # via flask
markdown==3.3.6 lxml==5.0.0
# via feedgen
markdown==3.5.1
# via incorporeal-cms (pyproject.toml)
markdown-it-py==3.0.0
# via rich
markupsafe==2.1.3
# via # via
# -r requirements/requirements.in # jinja2
# mdx-linkify # werkzeug
markupsafe==2.0.1 mccabe==0.7.0
# via jinja2
mccabe==0.6.1
# via flake8 # via flake8
mdx-linkify==2.1 mdurl==0.1.2
# via -r requirements/requirements.in # via markdown-it-py
mypy==1.8.0
# via incorporeal-cms (pyproject.toml)
mypy-extensions==1.0.0
# via mypy
packaging==21.3 packaging==21.3
# via # via
# bleach # build
# dparse # dparse
# pyproject-api
# pytest # pytest
# safety # safety
# setuptools-scm
# tox # tox
pbr==5.8.0 pbr==6.0.0
# via stevedore # via stevedore
pep517==0.12.0 pip-tools==7.3.0
# via pip-tools # via incorporeal-cms (pyproject.toml)
pip-tools==6.4.0 platformdirs==4.1.0
# via -r requirements/requirements-dev.in # via
platformdirs==2.4.1 # tox
# via virtualenv # virtualenv
pluggy==1.0.0 pluggy==1.3.0
# via # via
# pytest # pytest
# tox # tox
py==1.11.0 pycodestyle==2.11.1
# via
# pytest
# tox
pycodestyle==2.8.0
# via flake8 # via flake8
pydocstyle==6.1.1 pydocstyle==6.3.0
# via flake8-docstrings # via flake8-docstrings
pydot==1.4.2 pydot==1.4.2
# via -r requirements/requirements-dev.in # via incorporeal-cms (pyproject.toml)
pyflakes==2.4.0 pyflakes==3.1.0
# via flake8 # via flake8
pyparsing==3.0.6 pygments==2.17.2
# via rich
pyparsing==3.1.1
# via # via
# packaging # packaging
# pydot # pydot
pytest==6.2.5 pyproject-api==1.5.0
# via tox
pyproject-hooks==1.0.0
# via build
pytest==7.4.3
# via # via
# -r requirements/requirements-dev.in # incorporeal-cms (pyproject.toml)
# pytest-cov # pytest-cov
pytest-cov==3.0.0 pytest-cov==4.1.0
# via -r requirements/requirements-dev.in # via incorporeal-cms (pyproject.toml)
pytz-deprecation-shim==0.1.0.post0 python-dateutil==2.8.2
# via tzlocal # via feedgen
pyyaml==6.0 pyyaml==6.0.1
# via # via bandit
# bandit requests==2.31.0
# dparse
requests==2.27.1
# via safety # via safety
safety==1.10.3 rich==13.7.0
# via -r requirements/requirements-dev.in # via bandit
ruamel-yaml==0.18.5
# via safety
ruamel-yaml-clib==0.2.8
# via ruamel-yaml
safety==2.3.5
# via incorporeal-cms (pyproject.toml)
setuptools-scm==8.0.4
# via incorporeal-cms (pyproject.toml)
six==1.16.0 six==1.16.0
# via # via python-dateutil
# bandit smmap==5.0.1
# bleach
# tox
# virtualenv
smmap==5.0.0
# via gitdb # via gitdb
snowballstemmer==2.2.0 snowballstemmer==2.2.0
# via pydocstyle # via pydocstyle
stevedore==3.5.0 stevedore==5.1.0
# via bandit # via bandit
testfixtures==6.18.3 tox==4.0.0
# via flake8-isort # via incorporeal-cms (pyproject.toml)
toml==0.10.2 typing-extensions==4.9.0
# via # via
# dparse # mypy
# pytest # setuptools-scm
# tox urllib3==2.1.0
tomli==2.0.0
# via
# coverage
# pep517
tox==3.24.5
# via
# -r requirements/requirements-dev.in
# tox-wheel
tox-wheel==0.7.0
# via -r requirements/requirements-dev.in
tzdata==2021.5
# via pytz-deprecation-shim
tzlocal==4.1
# via -r requirements/requirements.in
urllib3==1.26.8
# via requests # via requests
versioneer==0.21 virtualenv==20.25.0
# via -r requirements/requirements-dev.in
virtualenv==20.13.0
# via tox # via tox
webencodings==0.5.1 werkzeug==3.0.1
# via bleach
werkzeug==2.0.2
# via flask # via flask
wheel==0.37.1 wheel==0.42.0
# via # via pip-tools
# pip-tools
# 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 # pip

View File

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

View File

@@ -1,40 +1,32 @@
# #
# This file is autogenerated by pip-compile with python 3.10 # This file is autogenerated by pip-compile with Python 3.12
# To update, run: # by the following command:
# #
# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in # pip-compile --output-file=requirements/requirements.txt
# #
bleach==4.1.0 blinker==1.7.0
# via mdx-linkify
click==8.0.3
# via flask # via flask
flask==2.0.2 click==8.1.7
# via -r requirements/requirements.in
itsdangerous==2.0.1
# via flask # via flask
jinja2==3.0.3 feedgen==1.0.0
# via incorporeal-cms (pyproject.toml)
flask==3.0.0
# via incorporeal-cms (pyproject.toml)
itsdangerous==2.1.2
# via flask # via flask
markdown==3.3.6 jinja2==3.1.2
# via flask
lxml==5.0.0
# via feedgen
markdown==3.5.1
# via incorporeal-cms (pyproject.toml)
markupsafe==2.1.3
# via # via
# -r requirements/requirements.in # jinja2
# mdx-linkify # werkzeug
markupsafe==2.0.1 python-dateutil==2.8.2
# via jinja2 # via feedgen
mdx-linkify==2.1
# via -r requirements/requirements.in
packaging==21.3
# via bleach
pyparsing==3.0.6
# via packaging
pytz-deprecation-shim==0.1.0.post0
# via tzlocal
six==1.16.0 six==1.16.0
# via bleach # via python-dateutil
tzdata==2021.5 werkzeug==3.0.1
# via pytz-deprecation-shim
tzlocal==4.1
# via -r requirements/requirements.in
webencodings==0.5.1
# via bleach
werkzeug==2.0.2
# via flask # via flask

View File

@@ -1,6 +0,0 @@
[versioneer]
VCS = git
style = pep440-post
versionfile_source = incorporealcms/_version.py
versionfile_build = incorporealcms/_version.py
tag_prefix = v

View File

@@ -1,36 +0,0 @@
"""Setuptools configuration."""
import os
from setuptools import find_packages, setup
import versioneer
HERE = os.path.dirname(os.path.abspath(__file__))
def extract_requires():
"""Get pinned requirements from requirements.txt."""
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 ('-', '#')]
setup(
name='incorporeal-cms',
description='Flask project for running https://suou.net (and eventually others).',
url='https://git.incorporeal.org/bss/incorporeal-cms',
author='Brian S. Stephan',
author_email='bss@incorporeal.org',
license='AGPLv3+',
classifiers=[
'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
],
version=versioneer.get_version(),
cmdclass=versioneer.get_cmdclass(),
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=extract_requires(),
extras_require={
'dot': ['pydot'],
},
)

View File

@@ -1,4 +1,8 @@
"""Create the test app and other fixtures.""" """Create the test app and other fixtures.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import os import os
import pytest import pytest

View File

@@ -1,4 +1,8 @@
"""Test graphviz functionality.""" """Test graphviz functionality.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import os import os
from incorporealcms import create_app from incorporealcms import create_app
@@ -29,6 +33,17 @@ def test_graphviz_is_rendered():
assert b'data:image/png;base64' in response.data assert b'data:image/png;base64' in response.data
def test_two_graphviz_are_rendered():
"""Test two images are rendered."""
app = app_with_pydot()
client = app.test_client()
response = client.get('/test-two-graphviz')
assert response.status_code == 200
assert b'~~~pydot' not in response.data
assert b'data:image/png;base64' in response.data
def test_invalid_graphviz_is_not_rendered(): def test_invalid_graphviz_is_not_rendered():
"""Check that invalid graphviz doesn't blow things up.""" """Check that invalid graphviz doesn't blow things up."""
app = app_with_pydot() app = app_with_pydot()
@@ -37,3 +52,16 @@ def test_invalid_graphviz_is_not_rendered():
response = client.get('/test-invalid-graphviz') response = client.get('/test-invalid-graphviz')
assert response.status_code == 500 assert response.status_code == 500
assert b'INTERNAL SERVER ERROR' in response.data assert b'INTERNAL SERVER ERROR' in response.data
def test_figures_are_rendered(client):
"""Test that a page with my figure syntax renders as expected."""
response = client.get('/figures')
assert response.status_code == 200
assert (b'<figure class="right"><img alt="fancy captioned logo" src="bss-square-no-bg.png" />'
b'<figcaption>this is my cool logo!</figcaption></figure>') in response.data
assert (b'<figure><img alt="vanilla captioned logo" src="bss-square-no-bg.png" />'
b'<figcaption>this is my cool logo without an attr!</figcaption>\n</figure>') in response.data
assert (b'<figure class="left"><img alt="fancy logo" src="bss-square-no-bg.png" />'
b'<span></span></figure>') in response.data
assert b'<figure><img alt="just a logo" src="bss-square-no-bg.png" /></figure>' in response.data

View File

@@ -1,4 +1,8 @@
"""Test page requests.""" """Test page requests.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import re import re
from unittest.mock import patch from unittest.mock import patch
@@ -7,7 +11,7 @@ def test_page_that_exists(client):
"""Test that the app can serve a basic file at the index.""" """Test that the app can serve a basic file at the index."""
response = client.get('/') response = client.get('/')
assert response.status_code == 200 assert response.status_code == 200
assert b'<h1>test index</h1>' in response.data assert b'<h1 id="test-index">test index</h1>' in response.data
def test_direct_file_that_exists(client): def test_direct_file_that_exists(client):
@@ -134,32 +138,32 @@ def test_that_page_request_redirects_to_directory(client):
""" """
response = client.get('/subdir') response = client.get('/subdir')
assert response.status_code == 301 assert response.status_code == 301
assert response.location == 'http://localhost/subdir/' assert response.location == '/subdir/'
def test_that_request_to_symlink_redirects_markdown(client): def test_that_request_to_symlink_redirects_markdown(client):
"""Test that a request to /foo redirects to /what-foo-points-at.""" """Test that a request to /foo redirects to /what-foo-points-at."""
response = client.get('/symlink-to-no-title') response = client.get('/symlink-to-no-title')
assert response.status_code == 301 assert response.status_code == 301
assert response.location == 'http://localhost/no-title' assert response.location == '/no-title'
def test_that_request_to_symlink_redirects_file(client): def test_that_request_to_symlink_redirects_file(client):
"""Test that a request to /foo.txt redirects to /what-foo-points-at.txt.""" """Test that a request to /foo.txt redirects to /what-foo-points-at.txt."""
response = client.get('/symlink-to-foo.txt') response = client.get('/symlink-to-foo.txt')
assert response.status_code == 301 assert response.status_code == 301
assert response.location == 'http://localhost/foo.txt' assert response.location == '/foo.txt'
def test_that_request_to_symlink_redirects_directory(client): def test_that_request_to_symlink_redirects_directory(client):
"""Test that a request to /foo/ redirects to /what-foo-points-at/.""" """Test that a request to /foo/ redirects to /what-foo-points-at/."""
response = client.get('/symlink-to-subdir/') response = client.get('/symlink-to-subdir/')
assert response.status_code == 301 assert response.status_code == 301
assert response.location == 'http://localhost/subdir' assert response.location == '/subdir'
# sadly, this location also redirects # sadly, this location also redirects
response = client.get('/subdir') response = client.get('/subdir')
assert response.status_code == 301 assert response.status_code == 301
assert response.location == 'http://localhost/subdir/' assert response.location == '/subdir/'
# but we do get there # but we do get there
response = client.get('/subdir/') response = client.get('/subdir/')
assert response.status_code == 200 assert response.status_code == 200
@@ -169,7 +173,7 @@ def test_that_request_to_symlink_redirects_subdirectory(client):
"""Test that a request to /foo/bar redirects to /what-foo-points-at/bar.""" """Test that a request to /foo/bar redirects to /what-foo-points-at/bar."""
response = client.get('/symlink-to-subdir/page-no-title') response = client.get('/symlink-to-subdir/page-no-title')
assert response.status_code == 301 assert response.status_code == 301
assert response.location == 'http://localhost/subdir/page-no-title' assert response.location == '/subdir/page-no-title'
response = client.get('/subdir/page-no-title') response = client.get('/subdir/page-no-title')
assert response.status_code == 200 assert response.status_code == 200
@@ -184,21 +188,21 @@ def test_that_dir_request_does_not_redirect(client):
def test_setting_selected_style_includes_cookie(client): def test_setting_selected_style_includes_cookie(client):
"""Test that a request with style=foo sets the cookie and renders appropriately.""" """Test that a request with style=foo sets the cookie and renders appropriately."""
response = client.get('/') response = client.get('/')
style_cookie = next((cookie for cookie in client.cookie_jar if cookie.name == 'user-style'), None) style_cookie = client.get_cookie('user-style')
assert style_cookie is None assert style_cookie is None
response = client.get('/?style=light') response = client.get('/?style=light')
style_cookie = next((cookie for cookie in client.cookie_jar if cookie.name == 'user-style'), None) style_cookie = client.get_cookie('user-style')
assert response.status_code == 200 assert response.status_code == 200
assert b'light.css' in response.data assert b'/static/css/light.css' in response.data
assert b'dark.css' not in response.data assert b'/static/css/dark.css' not in response.data
assert style_cookie.value == 'light' assert style_cookie.value == 'light'
response = client.get('/?style=dark') response = client.get('/?style=dark')
style_cookie = next((cookie for cookie in client.cookie_jar if cookie.name == 'user-style'), None) style_cookie = client.get_cookie('user-style')
assert response.status_code == 200 assert response.status_code == 200
assert b'dark.css' in response.data assert b'/static/css/dark.css' in response.data
assert b'light.css' not in response.data assert b'/static/css/light.css' not in response.data
assert style_cookie.value == 'dark' assert style_cookie.value == 'dark'
@@ -210,3 +214,31 @@ def test_pages_can_supply_alternate_templates(client):
response = client.get('/custom-template') response = client.get('/custom-template')
assert b'class="site-wrap site-wrap-normal-width"' not in response.data assert b'class="site-wrap site-wrap-normal-width"' not in response.data
assert b'class="site-wrap site-wrap-double-width"' in response.data assert b'class="site-wrap site-wrap-double-width"' in response.data
def test_extra_footer_per_page(client):
"""Test that we don't include the extra-footer if there isn't one (or do if there is)."""
response = client.get('/')
assert b'<div class="extra-footer">' not in response.data
response = client.get('/index-but-with-footer')
assert b'<div class="extra-footer"><i>ooo <a href="a">a</a></i>' in response.data
def test_serving_static_files(client):
"""Test the usage of send_from_directory to serve extra static files."""
response = client.get('/custom-static/css/warm.css')
assert response.status_code == 200
# can't serve directories, just files
response = client.get('/custom-static/')
assert response.status_code == 404
response = client.get('/custom-static/css/')
assert response.status_code == 404
response = client.get('/custom-static/css')
assert response.status_code == 404
# can't serve files that don't exist or bad paths
response = client.get('/custom-static/css/cold.css')
assert response.status_code == 404
response = client.get('/custom-static/css/../../unreachable.md')
assert response.status_code == 404

View File

@@ -1,4 +1,8 @@
"""Configure the test application.""" """Configure the test application.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
@@ -15,6 +19,10 @@ LOGGING = {
}, },
}, },
'loggers': { 'loggers': {
'incorporealcms.mdx': {
'level': 'DEBUG',
'handlers': ['console'],
},
'incorporealcms.pages': { 'incorporealcms.pages': {
'level': 'DEBUG', 'level': 'DEBUG',
'handlers': ['console'], 'handlers': ['console'],

View File

@@ -0,0 +1,3 @@
* {
color: red;
}

View File

@@ -0,0 +1 @@
../../../../pages/forced-no-title.md

View File

@@ -0,0 +1 @@
../../../../pages/subdir-with-title/page.md

View File

@@ -0,0 +1,13 @@
# test of figures
|> ![fancy captioned logo](bss-square-no-bg.png)
|: this is my cool logo!
{: .right }
|> ![vanilla captioned logo](bss-square-no-bg.png)
|: this is my cool logo without an attr!
|> ![fancy logo](bss-square-no-bg.png)
{: .left }
|> ![just a logo](bss-square-no-bg.png)

View File

@@ -0,0 +1,6 @@
Title: Index
Footer: ooo <a href="a">a</a>
# test index
this is some test content

View File

@@ -1,7 +1,7 @@
# test # test
test test
~~~pydot:attack-plan ~~~{ pydot:attack-plan }
digraph G { digraph G {
rankdir=LR rankdir=LR
Earth Earth

View File

@@ -1,7 +1,7 @@
# test # test
test test
~~~pydot:attack-plan ~~~{ pydot:attack-plan }
rankdir=LR rankdir=LR
Earth Earth
Mars Mars

View File

@@ -0,0 +1,25 @@
# test
test
~~~{ pydot:attack-plan }
digraph G {
rankdir=LR
Earth
Mars
Earth -> Mars
}
~~~
more test
~~~{ pydot:new-attack-plan }
digraph H {
rankdir=LR
Venus
Mars
Venus -> Mars
}
~~~
done

View File

@@ -1,4 +1,8 @@
"""Test basic configuration stuff.""" """Test basic configuration stuff.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import os import os
from incorporealcms import create_app from incorporealcms import create_app

44
tests/test_feed.py Normal file
View File

@@ -0,0 +1,44 @@
"""Test the feed methods.
SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
from incorporealcms.feed import serve_feed
def test_unknown_type_is_404(client):
"""Test that requesting a feed type that doesn't exist is a 404."""
response = client.get('/feed/wat')
assert response.status_code == 404
def test_atom_type_is_200(client):
"""Test that requesting an ATOM feed is found."""
response = client.get('/feed/atom')
assert response.status_code == 200
assert 'application/atom+xml' in response.content_type
print(response.text)
def test_rss_type_is_200(client):
"""Test that requesting an RSS feed is found."""
response = client.get('/feed/rss')
assert response.status_code == 200
assert 'application/rss+xml' in response.content_type
print(response.text)
def test_feed_generator_atom(app):
"""Test the root feed generator."""
with app.test_request_context():
content = serve_feed('atom')
assert b'<id>https://example.com/</id>' in content.data
assert b'<email>admin@example.com</email>' in content.data
assert b'<name>Test Name</name>' in content.data
def test_feed_generator_rss(app):
"""Test the root feed generator."""
with app.test_request_context():
content = serve_feed('rss')
assert b'<author>admin@example.com (Test Name)</author>' in content.data

View File

@@ -1,10 +1,19 @@
"""Unit test helper methods.""" """Unit test helper methods.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import os
import pytest import pytest
from werkzeug.http import dump_cookie from werkzeug.http import dump_cookie
from incorporealcms import create_app
from incorporealcms.pages import (generate_parent_navs, instance_resource_path_to_request_path, render, from incorporealcms.pages import (generate_parent_navs, instance_resource_path_to_request_path, render,
request_path_to_breadcrumb_display, request_path_to_instance_resource_path) request_path_to_breadcrumb_display, request_path_to_instance_resource_path)
HERE = os.path.dirname(os.path.abspath(__file__))
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).""" """Test that the index page has navs to the root (itself)."""
@@ -49,22 +58,74 @@ def test_render_with_user_dark_theme(app):
"""Test that a request with the dark theme selected renders the dark theme.""" """Test that a request with the dark theme selected renders the dark theme."""
cookie = dump_cookie("user-style", 'dark') cookie = dump_cookie("user-style", 'dark')
with app.test_request_context(headers={'COOKIE': cookie}): with app.test_request_context(headers={'COOKIE': cookie}):
assert b'dark.css' in render('base.html').data assert b'/static/css/dark.css' in render('base.html').data
assert b'light.css' not in render('base.html').data assert b'/static/css/light.css' not in render('base.html').data
def test_render_with_user_light_theme(app): def test_render_with_user_light_theme(app):
"""Test that a request with the light theme selected renders the light theme.""" """Test that a request with the light theme selected renders the light theme."""
with app.test_request_context(): with app.test_request_context():
assert b'light.css' in render('base.html').data assert b'/static/css/light.css' in render('base.html').data
assert b'dark.css' not in render('base.html').data assert b'/static/css/dark.css' not in render('base.html').data
def test_render_with_no_user_theme(app): def test_render_with_no_user_theme(app):
"""Test that a request with no theme set renders the light theme.""" """Test that a request with no theme set renders the light theme."""
with app.test_request_context(): with app.test_request_context():
assert b'light.css' in render('base.html').data assert b'/static/css/light.css' in render('base.html').data
assert b'dark.css' not in render('base.html').data assert b'/static/css/dark.css' not in render('base.html').data
def test_render_with_theme_defaults_affects_html(app):
"""Test that the base themes are all that's presented in the HTML."""
# test we can remove stuff from the default
with app.test_request_context():
assert b'?style=light' in render('base.html').data
assert b'?style=dark' in render('base.html').data
assert b'?style=plain' in render('base.html').data
def test_render_with_theme_overrides_affects_html(app):
"""Test that the overridden themes are presented in the HTML."""
# test we can remove stuff from the default
restyled_app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'PAGE_STYLES': {'light': '/static/css/light.css'}})
with restyled_app.test_request_context():
assert b'?style=light' in render('base.html').data
assert b'?style=dark' not in render('base.html').data
assert b'?style=plain' not in render('base.html').data
# test that we can add new stuff too/instead
restyled_app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'PAGE_STYLES': {'cool': '/static/css/cool.css',
'warm': '/static/css/warm.css'},
'DEFAULT_PAGE_STYLE': 'warm'})
with restyled_app.test_request_context():
assert b'?style=cool' in render('base.html').data
assert b'?style=warm' in render('base.html').data
def test_render_with_theme_overrides(app):
"""Test that the loaded themes can be overridden from the default."""
cookie = dump_cookie("user-style", 'cool')
restyled_app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'PAGE_STYLES': {'cool': '/static/css/cool.css',
'warm': '/static/css/warm.css'}})
with restyled_app.test_request_context(headers={'COOKIE': cookie}):
assert b'/static/css/cool.css' in render('base.html').data
assert b'/static/css/warm.css' not in render('base.html').data
def test_render_with_theme_overrides_not_found_is_default(app):
"""Test that theme overrides work, and if a requested theme doesn't exist, the default is loaded."""
cookie = dump_cookie("user-style", 'nonexistent')
restyled_app = create_app(instance_path=os.path.join(HERE, 'instance'),
test_config={'PAGE_STYLES': {'cool': '/static/css/cool.css',
'warm': '/static/css/warm.css'},
'DEFAULT_PAGE_STYLE': 'warm'})
with restyled_app.test_request_context(headers={'COOKIE': cookie}):
assert b'/static/css/warm.css' in render('base.html').data
assert b'/static/css/nonexistent.css' not in render('base.html').data
def test_request_path_to_instance_resource_path(app): def test_request_path_to_instance_resource_path(app):

53
tox.ini
View File

@@ -4,21 +4,11 @@
# and then run "tox" from this directory. # and then run "tox" from this directory.
[tox] [tox]
envlist = begin,py38,py39,py310,coverage,security,lint,bundle isolated_build = true
envlist = begin,py38,py39,py310,py311,py312,coverage,security,lint
[testenv] [testenv]
# build a wheel and test it allow_externals = pytest, coverage
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 = deps =
-rrequirements/requirements-dev.txt -rrequirements/requirements-dev.txt
@@ -46,6 +36,16 @@ commands =
commands = commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:py311]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:py312]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:coverage] [testenv:coverage]
# report on coverage runs from above # report on coverage runs from above
skip_install = true skip_install = true
@@ -62,16 +62,11 @@ commands =
[testenv:lint] [testenv:lint]
# run style checks # run style checks
# TODO: mypy incorporealcms
commands = commands =
flake8 flake8
- flake8 --disable-noqa --ignore= --select=E,W,F,C,D,A,G,B,I,T,M,DUO - 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] [coverage:paths]
source = source =
./ ./
@@ -82,23 +77,3 @@ branch = True
omit = omit =
**/_version.py **/_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

File diff suppressed because it is too large Load Diff