Compare commits
10 Commits
0d59e64323
...
aa4d5a3585
Author | SHA1 | Date | |
---|---|---|---|
aa4d5a3585 | |||
f23154ba95 | |||
c8c39befb3 | |||
4e96199920 | |||
ca9e6623ff | |||
76b1800155 | |||
746314f4ed | |||
c9d17523ce | |||
02c548880e | |||
1ace0e1427 |
@ -8,26 +8,26 @@ import logging
|
||||
import os
|
||||
from logging.config import dictConfig
|
||||
|
||||
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||
from termcolor import cprint
|
||||
|
||||
from incorporealcms.config import Config
|
||||
|
||||
env = Environment(
|
||||
loader=PackageLoader('incorporealcms'),
|
||||
autoescape=select_autoescape(),
|
||||
)
|
||||
|
||||
|
||||
def init_instance(instance_path: str, test_config: dict = None):
|
||||
def init_instance(instance_path: str, extra_config: dict = None):
|
||||
"""Create the instance context, with allowances for customizing path and test settings."""
|
||||
# load the instance config.json, if there is one
|
||||
instance_config = os.path.join(instance_path, 'config.json')
|
||||
if os.path.isfile(instance_config):
|
||||
try:
|
||||
with open(instance_config, 'r') as config:
|
||||
Config.update(json.load(config))
|
||||
config_dict = json.load(config)
|
||||
cprint(f"splicing {config_dict} into the config", 'yellow')
|
||||
Config.update(config_dict)
|
||||
except OSError:
|
||||
raise ValueError("instance path does not seem to be a site instance!")
|
||||
|
||||
if test_config:
|
||||
Config.update(test_config)
|
||||
if extra_config:
|
||||
cprint(f"splicing {extra_config} into the config", 'yellow')
|
||||
Config.update(extra_config)
|
||||
|
||||
# stash some stuff
|
||||
Config.INSTANCE_DIR = os.path.abspath(instance_path)
|
||||
|
@ -51,8 +51,6 @@ class Config(object):
|
||||
},
|
||||
}
|
||||
|
||||
MEDIA_DIR = 'media'
|
||||
|
||||
# customizations
|
||||
PAGE_STYLES = {
|
||||
'dark': '/static/css/dark.css',
|
||||
@ -64,12 +62,12 @@ class Config(object):
|
||||
DOMAIN_NAME = 'example.org'
|
||||
TITLE_SUFFIX = DOMAIN_NAME
|
||||
BASE_HOST = 'http://' + DOMAIN_NAME
|
||||
CONTACT_EMAIL = 'admin@example.com'
|
||||
CONTACT_EMAIL = 'admin@example.org'
|
||||
|
||||
# feed settings
|
||||
AUTHOR = {'name': 'Test Name', 'email': 'admin@example.com'}
|
||||
AUTHOR = {'name': 'Test Name', 'email': 'admin@example.org'}
|
||||
|
||||
# specify FAVICON in your instance config.py to override the provided icon
|
||||
FAVICON = '/static/img/favicon.png'
|
||||
|
||||
@classmethod
|
||||
def update(cls, config: dict):
|
||||
|
@ -1,21 +0,0 @@
|
||||
"""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
|
||||
|
||||
|
||||
def bad_request(error):
|
||||
"""Display 400 error messaging."""
|
||||
return render('400.html'), 400
|
||||
|
||||
|
||||
def internal_server_error(error):
|
||||
"""Display 500 error messaging."""
|
||||
return render('500.html'), 500
|
||||
|
||||
|
||||
def page_not_found(error):
|
||||
"""Display 404 error messaging."""
|
||||
return render('404.html'), 404
|
@ -14,60 +14,69 @@ 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
|
||||
from incorporealcms.config import Config
|
||||
from incorporealcms.markdown import instance_resource_path_to_request_path, parse_md
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('feed', __name__, url_prefix='/feed')
|
||||
|
||||
def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None:
|
||||
"""Generate the Atom or RSS feed as requested.
|
||||
|
||||
@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)
|
||||
|
||||
Args:
|
||||
feed_type: 'atom' or 'rss' feed
|
||||
instance_dir: the directory for the instance, containing both the feed dir and pages
|
||||
dest_dir: the directory to place the feed subdir and requested feed
|
||||
"""
|
||||
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']}")
|
||||
fg.id(f'https://{Config.DOMAIN_NAME}/')
|
||||
fg.title(f'{Config.TITLE_SUFFIX}')
|
||||
fg.author(Config.AUTHOR)
|
||||
fg.link(href=f'https://{Config.DOMAIN_NAME}/feed/{feed_type}', rel='self')
|
||||
fg.link(href=f'https://{Config.DOMAIN_NAME}', rel='alternate')
|
||||
fg.subtitle(f"Blog posts and other interesting materials from {Config.TITLE_SUFFIX}")
|
||||
|
||||
# get recent feeds
|
||||
feed_path = os.path.join(app.instance_path, 'feed')
|
||||
feed_path = os.path.join(instance_dir, '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}/', '')
|
||||
os.chdir(os.path.abspath(os.path.join(instance_dir, 'pages')))
|
||||
resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), os.path.join(instance_dir, 'pages'))
|
||||
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)}'
|
||||
link = f'https://{Config.DOMAIN_NAME}{instance_resource_path_to_request_path(resolved_path)}'
|
||||
except (OSError, ValueError, TypeError):
|
||||
logger.exception("error loading/rendering markdown!")
|
||||
abort(500)
|
||||
raise
|
||||
|
||||
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.id(_generate_feed_id(feed_entry_path, instance_resource_path_to_request_path(resolved_path)))
|
||||
fe.title(page_title)
|
||||
fe.author(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')
|
||||
if feed_type == 'rss':
|
||||
try:
|
||||
os.mkdir(os.path.join(dest_dir, 'feed'))
|
||||
except FileExistsError:
|
||||
pass
|
||||
with open(os.path.join(dest_dir, 'feed', 'rss'), 'wb') as feed_file:
|
||||
feed_file.write(fg.rss_str(pretty=True))
|
||||
else:
|
||||
return Response(fg.rss_str(pretty=True), mimetype='application/rss+xml')
|
||||
try:
|
||||
os.mkdir(os.path.join(dest_dir, 'feed'))
|
||||
except FileExistsError:
|
||||
pass
|
||||
with open(os.path.join(dest_dir, 'feed', 'atom'), 'wb') as feed_file:
|
||||
feed_file.write(fg.atom_str(pretty=True))
|
||||
|
||||
|
||||
def _generate_feed_id(feed_entry_path):
|
||||
def _generate_feed_id(feed_entry_path, request_path):
|
||||
"""For a relative file path, generate the Atom/RSS feed ID for it."""
|
||||
date = re.sub(r'.*(\d{4})(\d{2})(\d{2}).*', 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}'
|
||||
cleaned = request_path.replace('#', '/')
|
||||
return f'tag:{Config.DOMAIN_NAME},{date}:{cleaned}'
|
||||
|
@ -1,72 +0,0 @@
|
||||
"""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 os
|
||||
import re
|
||||
|
||||
import markdown
|
||||
from markupsafe import Markup
|
||||
|
||||
from incorporealcms.config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_meta_str(md, key):
|
||||
"""Provide the page's (parsed in Markup obj md) metadata for the specified key, or '' if unset."""
|
||||
return " ".join(md.Meta.get(key)) if md.Meta.get(key) else ""
|
||||
|
||||
|
||||
def init_md():
|
||||
"""Initialize the Markdown parser.
|
||||
|
||||
This used to done at the app level in __init__, but extensions like footnotes apparently
|
||||
assume the parser to only live for the length of parsing one document, and create double
|
||||
footnote ref links if the one parser sees the same document multiple times.
|
||||
"""
|
||||
# initialize markdown parser from config, but include
|
||||
# extensions our app depends on, like the meta extension
|
||||
return markdown.Markdown(extensions=Config.MARKDOWN_EXTENSIONS + ['meta'],
|
||||
extension_configs=Config.MARKDOWN_EXTENSION_CONFIGS)
|
||||
|
||||
|
||||
def instance_resource_path_to_request_path(path):
|
||||
"""Reverse a relative disk path to the path that would show up in a URL request."""
|
||||
return '/' + re.sub(r'.md$', '', re.sub(r'index.md$', '', path))
|
||||
|
||||
|
||||
def parse_md(path: str):
|
||||
"""Given a file to parse, return file content and other derived data along with the md object.
|
||||
|
||||
Args:
|
||||
path: the path to the file to render
|
||||
"""
|
||||
try:
|
||||
logger.debug("opening path '%s'", path)
|
||||
with open(path, 'r') as input_file:
|
||||
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(input_file.name), tz=datetime.timezone.utc)
|
||||
entry = input_file.read()
|
||||
logger.debug("path '%s' read", path)
|
||||
md = init_md()
|
||||
content = Markup(md.convert(entry))
|
||||
except OSError:
|
||||
logger.exception("path '%s' could not be opened!", 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 path
|
||||
page_title = f'{page_name} - {Config.TITLE_SUFFIX}' if page_name else Config.TITLE_SUFFIX
|
||||
logger.debug("title (potentially derived): %s", page_title)
|
||||
|
||||
return content, md, page_name, page_title, mtime
|
146
incorporealcms/markdown.py
Normal file
146
incorporealcms/markdown.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""Process Markdown pages.
|
||||
|
||||
With the project now being a SSG, most files we just let the web server serve
|
||||
as is, but .md files need to be processed with a Markdown parser, so a lot of this
|
||||
is our tweaks and customizations for pages my way.
|
||||
|
||||
SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import markdown
|
||||
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||
from markupsafe import Markup
|
||||
|
||||
from incorporealcms.config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
jinja_env = Environment(
|
||||
loader=PackageLoader('incorporealcms'),
|
||||
autoescape=select_autoescape(),
|
||||
)
|
||||
|
||||
|
||||
def get_meta_str(md, key):
|
||||
"""Provide the page's (parsed in Markup obj md) metadata for the specified key, or '' if unset."""
|
||||
return " ".join(md.Meta.get(key)) if md.Meta.get(key) else ""
|
||||
|
||||
|
||||
def init_md():
|
||||
"""Initialize the Markdown parser.
|
||||
|
||||
This used to done at the app level in __init__, but extensions like footnotes apparently
|
||||
assume the parser to only live for the length of parsing one document, and create double
|
||||
footnote ref links if the one parser sees the same document multiple times.
|
||||
"""
|
||||
# initialize markdown parser from config, but include
|
||||
# extensions our app depends on, like the meta extension
|
||||
return markdown.Markdown(extensions=Config.MARKDOWN_EXTENSIONS + ['meta'],
|
||||
extension_configs=Config.MARKDOWN_EXTENSION_CONFIGS)
|
||||
|
||||
|
||||
def instance_resource_path_to_request_path(path):
|
||||
"""Reverse a relative disk path to the path that would show up in a URL request."""
|
||||
return '/' + re.sub(r'.md$', '', re.sub(r'index.md$', '', path))
|
||||
|
||||
|
||||
def parse_md(path: str):
|
||||
"""Given a file to parse, return file content and other derived data along with the md object.
|
||||
|
||||
Args:
|
||||
path: the path to the file to render
|
||||
"""
|
||||
try:
|
||||
logger.debug("opening path '%s'", path)
|
||||
with open(path, 'r') as input_file:
|
||||
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(input_file.name), tz=datetime.timezone.utc)
|
||||
entry = input_file.read()
|
||||
logger.debug("path '%s' read", path)
|
||||
md = init_md()
|
||||
content = Markup(md.convert(entry))
|
||||
except (OSError, FileNotFoundError):
|
||||
logger.exception("path '%s' could not be opened!", path)
|
||||
raise
|
||||
except ValueError:
|
||||
logger.exception("error parsing/rendering markdown!")
|
||||
raise
|
||||
|
||||
logger.debug("file metadata: %s", md.Meta)
|
||||
|
||||
page_name = get_meta_str(md, 'title') if md.Meta.get('title') else instance_resource_path_to_request_path(path)
|
||||
page_title = f'{page_name} - {Config.TITLE_SUFFIX}' if page_name else Config.TITLE_SUFFIX
|
||||
logger.debug("title (potentially derived): %s", page_title)
|
||||
|
||||
return content, md, page_name, page_title, mtime
|
||||
|
||||
|
||||
def handle_markdown_file_path(path: str) -> str:
|
||||
"""Given a location on disk, attempt to open it and render the markdown within."""
|
||||
content, md, page_name, page_title, mtime = parse_md(path)
|
||||
parent_navs = generate_parent_navs(path)
|
||||
extra_footer = get_meta_str(md, 'footer') if md.Meta.get('footer') else None
|
||||
template_name = get_meta_str(md, 'template') if md.Meta.get('template') else 'base.html'
|
||||
|
||||
# check if this has a HTTP redirect
|
||||
redirect_url = get_meta_str(md, 'redirect') if md.Meta.get('redirect') else None
|
||||
if redirect_url:
|
||||
raise NotImplementedError("redirects in markdown are unsupported!")
|
||||
|
||||
template = jinja_env.get_template(template_name)
|
||||
return template.render(title=page_title,
|
||||
config=Config,
|
||||
description=get_meta_str(md, 'description'),
|
||||
image=get_meta_str(md, 'image'),
|
||||
content=content,
|
||||
base_url=Config.BASE_HOST + instance_resource_path_to_request_path(path),
|
||||
navs=parent_navs,
|
||||
mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'),
|
||||
extra_footer=extra_footer)
|
||||
|
||||
|
||||
def generate_parent_navs(path):
|
||||
"""Create a series of paths/links to navigate up from the given resource path."""
|
||||
if path == 'index.md':
|
||||
# bail and return the domain name as a terminal case
|
||||
return [(Config.DOMAIN_NAME, '/')]
|
||||
else:
|
||||
if path.endswith('index.md'):
|
||||
# index case: one dirname for foo/bar/index.md -> foo/bar, one for foo/bar -> foo
|
||||
parent_resource_dir = os.path.dirname(os.path.dirname(path))
|
||||
else:
|
||||
# usual case: foo/buh.md -> foo
|
||||
parent_resource_dir = os.path.dirname(path)
|
||||
|
||||
# generate the request path (i.e. what the link will be) for this path, and
|
||||
# also the resource path of this parent (which is always a dir, so always index.md)
|
||||
request_path = instance_resource_path_to_request_path(path)
|
||||
parent_resource_path = os.path.join(parent_resource_dir, 'index.md')
|
||||
|
||||
logger.debug("resource path: '%s'; request path: '%s'; parent resource path: '%s'", path,
|
||||
request_path, parent_resource_path)
|
||||
|
||||
# for issues regarding parser reuse (see lib.init_md) we reinitialize the parser here
|
||||
md = init_md()
|
||||
|
||||
# read the resource
|
||||
try:
|
||||
with open(path, 'r') as entry_file:
|
||||
entry = entry_file.read()
|
||||
_ = Markup(md.convert(entry))
|
||||
page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title')
|
||||
else request_path_to_breadcrumb_display(request_path))
|
||||
return generate_parent_navs(parent_resource_path) + [(page_name, request_path)]
|
||||
except FileNotFoundError:
|
||||
return generate_parent_navs(parent_resource_path) + [(request_path, request_path)]
|
||||
|
||||
|
||||
def request_path_to_breadcrumb_display(path):
|
||||
"""Given a request path, e.g. "/foo/bar/baz/", turn it into breadcrumby text "baz"."""
|
||||
undired = path.rstrip('/')
|
||||
leaf = undired[undired.rfind('/'):]
|
||||
return leaf.strip('/')
|
@ -1,152 +0,0 @@
|
||||
"""General page functionality.
|
||||
|
||||
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
from markupsafe import Markup
|
||||
from werkzeug.security import safe_join
|
||||
|
||||
from incorporealcms import env
|
||||
from incorporealcms.config import Config
|
||||
from incorporealcms.lib import get_meta_str, init_md, instance_resource_path_to_request_path, parse_md
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_markdown_file_path(path: str) -> str:
|
||||
"""Given a location on disk, attempt to open it and render the markdown within."""
|
||||
try:
|
||||
content, md, page_name, page_title, mtime = parse_md(path)
|
||||
except OSError:
|
||||
logger.exception("path '%s' could not be opened!", path)
|
||||
raise
|
||||
except ValueError:
|
||||
logger.exception("error parsing/rendering markdown!")
|
||||
raise
|
||||
except TypeError:
|
||||
logger.exception("error loading/rendering markdown!")
|
||||
raise
|
||||
else:
|
||||
parent_navs = generate_parent_navs(path)
|
||||
extra_footer = get_meta_str(md, 'footer') if md.Meta.get('footer') else None
|
||||
template_name = get_meta_str(md, 'template') if md.Meta.get('template') else 'base.html'
|
||||
|
||||
# check if this has a HTTP redirect
|
||||
redirect_url = get_meta_str(md, 'redirect') if md.Meta.get('redirect') else None
|
||||
if redirect_url:
|
||||
raise ValueError("redirects in markdown are unsupported!")
|
||||
|
||||
template = env.get_template(template_name)
|
||||
return template.render(title=page_title,
|
||||
config=Config,
|
||||
description=get_meta_str(md, 'description'),
|
||||
image=get_meta_str(md, 'image'),
|
||||
content=content,
|
||||
user_style=Config.PAGE_STYLES.get(Config.DEFAULT_PAGE_STYLE),
|
||||
base_url=Config.BASE_HOST, 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):
|
||||
"""Turn a request URL path to the full page path.
|
||||
|
||||
flask.Flask.open_instance_resource will open a file like /etc/hosts if you tell it to,
|
||||
which sucks, so we do a lot of work here to make sure we have a valid request to
|
||||
something inside the pages dir.
|
||||
"""
|
||||
# check if the path is allowed
|
||||
base_dir = os.path.realpath(f'{app.instance_path}/pages/')
|
||||
safe_path = safe_join(base_dir, path)
|
||||
# bail if the requested real path isn't inside the base directory
|
||||
if not safe_path:
|
||||
logger.warning("client tried to request a path '%s' outside of the base_dir!", path)
|
||||
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
|
||||
if os.path.exists(resolved_path):
|
||||
# if this is a file-like request but actually a directory, redirect the user
|
||||
if os.path.isdir(resolved_path) and not path.endswith('/'):
|
||||
logger.info("client requested a path '%s' that is actually a directory", path)
|
||||
raise IsADirectoryError
|
||||
|
||||
# if the requested path contains a symlink, redirect the user
|
||||
if verbatim_path != resolved_path:
|
||||
logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path)
|
||||
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink'
|
||||
|
||||
# derive the proper markdown or actual file depending on if this is a dir or file
|
||||
if os.path.isdir(resolved_path):
|
||||
resolved_path = os.path.join(resolved_path, 'index.md')
|
||||
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown'
|
||||
|
||||
logger.info("final DIRECT path = '%s' for request '%s'", resolved_path, path)
|
||||
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
|
||||
verbatim_path = f'{safe_path}.md'
|
||||
resolved_path = os.path.realpath(verbatim_path)
|
||||
|
||||
# does the final file actually exist?
|
||||
if not os.path.exists(resolved_path):
|
||||
logger.warning("requested final path '%s' does not exist!", resolved_path)
|
||||
raise FileNotFoundError
|
||||
|
||||
# check for symlinks
|
||||
if verbatim_path != resolved_path:
|
||||
logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path)
|
||||
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink'
|
||||
|
||||
logger.info("final path = '%s' for request '%s'", resolved_path, path)
|
||||
# we checked that the file exists via absolute path, but now we need to give the path relative to instance dir
|
||||
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown'
|
||||
|
||||
|
||||
def generate_parent_navs(path):
|
||||
"""Create a series of paths/links to navigate up from the given resource path."""
|
||||
if path == 'index.md':
|
||||
# bail and return the domain name as a terminal case
|
||||
return [(Config.DOMAIN_NAME, '/')]
|
||||
else:
|
||||
if path.endswith('index.md'):
|
||||
# index case: one dirname for foo/bar/index.md -> foo/bar, one for foo/bar -> foo
|
||||
parent_resource_dir = os.path.dirname(os.path.dirname(path))
|
||||
else:
|
||||
# usual case: foo/buh.md -> foo
|
||||
parent_resource_dir = os.path.dirname(path)
|
||||
|
||||
# generate the request path (i.e. what the link will be) for this path, and
|
||||
# also the resource path of this parent (which is always a dir, so always index.md)
|
||||
request_path = instance_resource_path_to_request_path(path)
|
||||
parent_resource_path = os.path.join(parent_resource_dir, 'index.md')
|
||||
|
||||
logger.debug("resource path: '%s'; request path: '%s'; parent resource path: '%s'", path,
|
||||
request_path, parent_resource_path)
|
||||
|
||||
# for issues regarding parser reuse (see lib.init_md) we reinitialize the parser here
|
||||
md = init_md()
|
||||
|
||||
# read the resource
|
||||
try:
|
||||
with open(path, 'r') as entry_file:
|
||||
entry = entry_file.read()
|
||||
_ = Markup(md.convert(entry))
|
||||
page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title')
|
||||
else request_path_to_breadcrumb_display(request_path))
|
||||
return generate_parent_navs(parent_resource_path) + [(page_name, request_path)]
|
||||
except FileNotFoundError:
|
||||
return generate_parent_navs(parent_resource_path) + [(request_path, request_path)]
|
||||
|
||||
|
||||
def request_path_to_breadcrumb_display(path):
|
||||
"""Given a request path, e.g. "/foo/bar/baz/", turn it into breadcrumby text "baz"."""
|
||||
undired = path.rstrip('/')
|
||||
leaf = undired[undired.rfind('/'):]
|
||||
return leaf.strip('/')
|
@ -12,7 +12,155 @@ import tempfile
|
||||
from termcolor import cprint
|
||||
|
||||
from incorporealcms import init_instance
|
||||
from incorporealcms.pages import handle_markdown_file_path
|
||||
from incorporealcms.markdown import handle_markdown_file_path
|
||||
|
||||
|
||||
class StaticSiteGenerator(object):
|
||||
"""Generate static site output based on the instance's content."""
|
||||
|
||||
def __init__(self, instance_dir: str, output_dir: str):
|
||||
"""Create the object to run various operations to generate the static site.
|
||||
|
||||
Args:
|
||||
instance_dir: the directory from which to read an instance format set of content
|
||||
output_dir: the directory to write the generated static site to
|
||||
"""
|
||||
self.instance_dir = instance_dir
|
||||
self.output_dir = output_dir
|
||||
|
||||
instance_dir = os.path.abspath(instance_dir)
|
||||
output_dir = os.path.abspath(output_dir)
|
||||
|
||||
# initialize configuration with the path to the instance
|
||||
init_instance(instance_dir)
|
||||
|
||||
def build(self):
|
||||
"""Build the whole static site."""
|
||||
# putting the temporary directory next to the desired output so we can safely rename it later
|
||||
tmp_output_dir = tempfile.mkdtemp(dir=os.path.dirname(self.output_dir))
|
||||
cprint(f"creating temporary directory '{tmp_output_dir}' for writing", 'green')
|
||||
|
||||
# copy core content
|
||||
pages_dir = os.path.join(self.instance_dir, 'pages')
|
||||
self.build_in_destination(pages_dir, tmp_output_dir)
|
||||
|
||||
# copy the program's static dir
|
||||
program_static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
||||
static_output_dir = os.path.join(tmp_output_dir, 'static')
|
||||
try:
|
||||
os.mkdir(static_output_dir)
|
||||
except FileExistsError:
|
||||
# already exists
|
||||
pass
|
||||
self.build_in_destination(program_static_dir, static_output_dir, convert_markdown=False)
|
||||
|
||||
# copy the instance's static dir --- should I deprecate this since it could just be stuff in pages/static/?
|
||||
custom_static_dir = os.path.join(self.instance_dir, 'custom-static')
|
||||
self.build_in_destination(custom_static_dir, static_output_dir, convert_markdown=False)
|
||||
|
||||
# move temporary dir to the destination
|
||||
old_output_dir = f'{self.output_dir}-old-{os.path.basename(tmp_output_dir)}'
|
||||
if os.path.exists(self.output_dir):
|
||||
cprint(f"renaming '{self.output_dir}' to '{old_output_dir}'", 'green')
|
||||
os.rename(self.output_dir, old_output_dir)
|
||||
cprint(f"renaming '{tmp_output_dir}' to '{self.output_dir}'", 'green')
|
||||
os.rename(tmp_output_dir, self.output_dir)
|
||||
os.chmod(self.output_dir,
|
||||
stat.S_IWUSR | stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
# TODO: unlink old dir above? arg flag?
|
||||
|
||||
def build_in_destination(self, source_dir: str, dest_dir: str, convert_markdown: bool = True) -> None:
|
||||
"""Walk the source directory and copy and/or convert its contents into the destination.
|
||||
|
||||
Args:
|
||||
source_dir: the directory to copy into the destination
|
||||
dest_dir: the directory to place copied/converted files into
|
||||
convert_markdown: whether or not to convert Markdown files (or simply copy them)
|
||||
"""
|
||||
cprint(f"copying files from '{source_dir}' to '{dest_dir}'", 'green')
|
||||
os.chdir(source_dir)
|
||||
for base_dir, subdirs, files in os.walk(source_dir):
|
||||
# remove the absolute path of the directory from the base_dir
|
||||
base_dir = os.path.relpath(base_dir, source_dir)
|
||||
# create subdirs seen here for subsequent depth
|
||||
for subdir in subdirs:
|
||||
self.build_subdir_in_destination(source_dir, base_dir, subdir, dest_dir)
|
||||
|
||||
# process and copy files
|
||||
for file_ in files:
|
||||
self.build_file_in_destination(source_dir, base_dir, file_, dest_dir, convert_markdown)
|
||||
|
||||
def build_subdir_in_destination(self, source_dir: str, base_dir: str, subdir: str, dest_dir: str) -> None:
|
||||
"""Create a subdir (which might actually be a symlink) in the output dir.
|
||||
|
||||
Args:
|
||||
source_dir: the absolute path of the location in the instance, contains subdir
|
||||
base_dir: the relative path of the location in the instance, contains subdir
|
||||
subdir: the subdir in the instance to replicate in the output
|
||||
dest_dir: the output directory to place the subdir in
|
||||
"""
|
||||
dst = os.path.join(dest_dir, base_dir, subdir)
|
||||
if os.path.islink(os.path.join(base_dir, subdir)):
|
||||
# keep the link relative to the output directory
|
||||
src = self.symlink_to_relative_dest(source_dir, os.path.join(base_dir, subdir))
|
||||
print(f"creating directory symlink '{dst}' -> '{src}'")
|
||||
os.symlink(src, dst, target_is_directory=True)
|
||||
else:
|
||||
print(f"creating directory '{dst}'")
|
||||
try:
|
||||
os.mkdir(dst)
|
||||
except FileExistsError:
|
||||
# already exists
|
||||
pass
|
||||
|
||||
def build_file_in_destination(self, source_dir: str, base_dir: str, file_: str, dest_dir: str,
|
||||
convert_markdown=False) -> None:
|
||||
"""Create a file (which might actually be a symlink) in the output dir.
|
||||
|
||||
Args:
|
||||
source_dir: the absolute path of the location in the instance, contains subdir
|
||||
base_dir: the relative path of the location in the instance, contains subdir
|
||||
file_: the file in the instance to replicate in the output
|
||||
dest_dir: the output directory to place the subdir in
|
||||
"""
|
||||
dst = os.path.join(dest_dir, base_dir, file_)
|
||||
if os.path.islink(os.path.join(base_dir, file_)):
|
||||
# keep the link relative to the output directory
|
||||
src = self.symlink_to_relative_dest(source_dir, os.path.join(base_dir, file_))
|
||||
print(f"creating symlink '{dst}' -> '{src}'")
|
||||
os.symlink(src, dst, target_is_directory=False)
|
||||
else:
|
||||
src = os.path.join(base_dir, file_)
|
||||
print(f"copying file '{src}' -> '{dst}'")
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
# render markdown as HTML
|
||||
if src.endswith('.md') and convert_markdown:
|
||||
rendered_file = dst.removesuffix('.md') + '.html'
|
||||
try:
|
||||
content = handle_markdown_file_path(src)
|
||||
except UnicodeDecodeError:
|
||||
# perhaps this isn't a markdown file at all for some reason; we
|
||||
# copied it above so stick with tha
|
||||
cprint(f"{src} has invalid bytes! skipping", 'yellow')
|
||||
else:
|
||||
with open(rendered_file, 'w') as dst_file:
|
||||
dst_file.write(content)
|
||||
|
||||
def symlink_to_relative_dest(self, base_dir: str, source: str) -> str:
|
||||
"""Given a symlink, make sure it points to something inside the instance and provide its real destination.
|
||||
|
||||
Args:
|
||||
base_dir: the full absolute path of the instance's pages dir, which the symlink destination must be in.
|
||||
source: the symlink to check
|
||||
Returns:
|
||||
what the symlink points at
|
||||
"""
|
||||
if not os.path.realpath(source).startswith(base_dir):
|
||||
raise ValueError(f"symlink destination {os.path.realpath(source)} is outside the instance!")
|
||||
# this symlink points to realpath inside base_dir, so relative to base_dir, the symlink dest is...
|
||||
return os.path.relpath(os.path.realpath(source), base_dir)
|
||||
|
||||
|
||||
def build():
|
||||
@ -35,86 +183,5 @@ def build():
|
||||
if os.path.exists(args.output_dir):
|
||||
raise ValueError(f"specified output path '{args.output_dir}' exists as a file!")
|
||||
|
||||
output_dir = os.path.abspath(args.output_dir)
|
||||
instance_dir = os.path.abspath(args.instance_dir)
|
||||
pages_dir = os.path.join(instance_dir, 'pages')
|
||||
|
||||
# initialize configuration with the path to the instance
|
||||
init_instance(instance_dir)
|
||||
|
||||
# putting the temporary directory next to the desired output so we can safely rename it later
|
||||
tmp_output_dir = tempfile.mkdtemp(dir=os.path.dirname(output_dir))
|
||||
cprint(f"creating temporary directory '{tmp_output_dir}' for writing", 'green')
|
||||
|
||||
# CORE CONTENT
|
||||
# render and/or copy into the output dir after changing into the instance dir (to simplify paths)
|
||||
os.chdir(pages_dir)
|
||||
for base_dir, subdirs, files in os.walk(pages_dir):
|
||||
# remove the absolute path of the pages directory from the base_dir
|
||||
base_dir = os.path.relpath(base_dir, pages_dir)
|
||||
# create subdirs seen here for subsequent depth
|
||||
for subdir in subdirs:
|
||||
dst = os.path.join(tmp_output_dir, base_dir, subdir)
|
||||
if os.path.islink(os.path.join(base_dir, subdir)):
|
||||
# keep the link relative to the output directory
|
||||
src = symlink_to_relative_dest(pages_dir, os.path.join(base_dir, subdir))
|
||||
print(f"creating directory symlink '{dst}' -> '{src}'")
|
||||
os.symlink(src, dst, target_is_directory=True)
|
||||
else:
|
||||
print(f"creating directory '{dst}'")
|
||||
os.mkdir(dst)
|
||||
|
||||
# process and copy files
|
||||
for file_ in files:
|
||||
dst = os.path.join(tmp_output_dir, base_dir, file_)
|
||||
if os.path.islink(os.path.join(base_dir, file_)):
|
||||
# keep the link relative to the output directory
|
||||
src = symlink_to_relative_dest(pages_dir, os.path.join(base_dir, file_))
|
||||
print(f"creating symlink '{dst}' -> '{src}'")
|
||||
os.symlink(src, dst, target_is_directory=False)
|
||||
else:
|
||||
src = os.path.join(base_dir, file_)
|
||||
print(f"copying file '{src}' -> '{dst}'")
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
# render markdown as HTML
|
||||
if src.endswith('.md'):
|
||||
rendered_file = dst.removesuffix('.md') + '.html'
|
||||
try:
|
||||
content = handle_markdown_file_path(src)
|
||||
except UnicodeDecodeError:
|
||||
# perhaps this isn't a markdown file at all for some reason; we
|
||||
# copied it above so stick with tha
|
||||
cprint(f"{src} has invalid bytes! skipping", 'yellow')
|
||||
continue
|
||||
with open(rendered_file, 'w') as dst_file:
|
||||
dst_file.write(content)
|
||||
|
||||
# TODO: STATIC DIR
|
||||
|
||||
# move temporary dir to the destination
|
||||
old_output_dir = f'{output_dir}-old-{os.path.basename(tmp_output_dir)}'
|
||||
if os.path.exists(output_dir):
|
||||
cprint(f"renaming '{output_dir}' to '{old_output_dir}'", 'green')
|
||||
os.rename(output_dir, old_output_dir)
|
||||
cprint(f"renaming '{tmp_output_dir}' to '{output_dir}'", 'green')
|
||||
os.rename(tmp_output_dir, output_dir)
|
||||
os.chmod(output_dir,
|
||||
stat.S_IWUSR | stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
# TODO: unlink old dir above? arg flag?
|
||||
|
||||
|
||||
def symlink_to_relative_dest(base_dir: str, source: str) -> str:
|
||||
"""Given a symlink, make sure it points to something inside the instance and provide its real destination.
|
||||
|
||||
Args:
|
||||
base_dir: the full absolute path of the instance's pages dir, which the symlink destination must be in.
|
||||
source: the symlink to check
|
||||
Returns:
|
||||
what the symlink points at
|
||||
"""
|
||||
if not os.path.realpath(source).startswith(base_dir):
|
||||
raise ValueError(f"symlink destination {os.path.realpath(source)} is outside the instance!")
|
||||
# this symlink points to realpath inside base_dir, so relative to base_dir, the symlink dest is...
|
||||
return os.path.relpath(os.path.realpath(source), base_dir)
|
||||
site_gen = StaticSiteGenerator(args.instance_dir, args.output_dir)
|
||||
site_gen.build()
|
||||
|
@ -1,18 +0,0 @@
|
||||
"""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)
|
@ -6,16 +6,64 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<title>{{ title }}</title>
|
||||
<meta charset="utf-8">
|
||||
{% if title %}<meta property="og:title" content="{{ title }}">{% endif %}
|
||||
{% if description %}<meta property="og:description" content="{{ description }}">{% endif %}
|
||||
<meta property="og:url" content="{{ base_url }}">
|
||||
{% if image %}<meta property="og:image" content="{{ image }}">{% endif %}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="{{ user_style }}">
|
||||
<link rel="icon" href="{% if config.FAVICON %}{{ config.FAVICON }}{% else %}/static/img/favicon.png{% endif %}">
|
||||
<link rel="stylesheet" type="text/css" title="{{ config.DEFAULT_PAGE_STYLE }}" href="{{ config.PAGE_STYLES[config.DEFAULT_PAGE_STYLE] }}">
|
||||
{% for style, stylesheet in config.PAGE_STYLES.items() %}
|
||||
<link rel="alternate stylesheet" type="text/css" title="{{ style }}" href="{{ stylesheet }}">
|
||||
{% endfor %}
|
||||
<link rel="icon" href="{{ config.FAVICON }}">
|
||||
<link rel="alternate" type="application/atom+xml" href="/feed/atom">
|
||||
<link rel="alternate" type="application/rss+xml" href="/feed/rss">
|
||||
<script type="text/javascript">
|
||||
// loathe as I am to use JavaScript, this style selection is one of my favorite parts
|
||||
// of my CMS, so I want to keep it around even in the static site
|
||||
function applyStyle(styleName) {
|
||||
// disable all stylesheets except the one to apply, the user style
|
||||
var i, link_tag;
|
||||
for (i = 0, link_tag = document.getElementsByTagName("link"); i < link_tag.length; i++ ) {
|
||||
// find the stylesheets with titles, meaning they can be disabled/enabled
|
||||
if ((link_tag[i].rel.indexOf("stylesheet") != -1) && link_tag[i].title) {
|
||||
alert(link_tag[i].title);
|
||||
link_tag[i].disabled = true;
|
||||
if (link_tag[i].title == styleName) {
|
||||
link_tag[i].disabled = false ;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setStyle(styleName) {
|
||||
document.cookie = "user-style=" + encodeURIComponent(styleName) + "; max-age=31536000";
|
||||
applyStyle(styleName);
|
||||
}
|
||||
|
||||
|
||||
function applyStyleFromCookie() {
|
||||
// get the user style cookie and set that specified style as the active one
|
||||
var styleName = getCookie("user-style");
|
||||
alert(styleName);
|
||||
if (styleName) {
|
||||
applyStyle(styleName);
|
||||
}
|
||||
}
|
||||
|
||||
function getCookie(cookieName) {
|
||||
// find the desired cookie from the document's cookie(s) string
|
||||
let matches = document.cookie.match(new RegExp(
|
||||
"(?:^|; )" + cookieName.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + "=([^;]*)"
|
||||
));
|
||||
alert(matches);
|
||||
return matches ? decodeURIComponent(matches[1]) : undefined;
|
||||
}
|
||||
|
||||
applyStyleFromCookie();
|
||||
</script>
|
||||
|
||||
<div {% block site_class %}class="site-wrap site-wrap-normal-width"{% endblock %}>
|
||||
{% block header %}
|
||||
@ -26,11 +74,13 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
{% if not loop.last %} » {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if page_styles %}
|
||||
<div class="styles">
|
||||
{% for style in page_styles %}
|
||||
<a href="?style={{ style }}">[{{ style }}]</a>
|
||||
<a href="#" onclick="setStyle('{{ style }}'); return false;">[{{ style }}]</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
|
@ -10,8 +10,8 @@ license = {text = "AGPL-3.0-or-later"}
|
||||
authors = [
|
||||
{name = "Brian S. Stephan", email = "bss@incorporeal.org"},
|
||||
]
|
||||
requires-python = ">=3.8"
|
||||
dependencies = ["feedgen", "Flask", "Markdown", "termcolor"]
|
||||
requires-python = ">=3.9"
|
||||
dependencies = ["feedgen", "jinja2", "Markdown", "termcolor"]
|
||||
dynamic = ["version"]
|
||||
classifiers = [
|
||||
"Framework :: Flask",
|
||||
@ -34,6 +34,9 @@ dev = ["bandit", "dlint", "flake8", "flake8-blind-except", "flake8-builtins", "f
|
||||
"setuptools-scm", "tox", "twine"]
|
||||
dot = ["pydot"]
|
||||
|
||||
[project.scripts]
|
||||
incorporealcms-build = "incorporealcms.ssg:build"
|
||||
|
||||
[tool.flake8]
|
||||
enable-extensions = "G,M"
|
||||
exclude = [".tox/", "venv/", "_version.py"]
|
||||
|
@ -6,7 +6,7 @@
|
||||
#
|
||||
annotated-types==0.7.0
|
||||
# via pydantic
|
||||
attrs==25.2.0
|
||||
attrs==25.3.0
|
||||
# via reuse
|
||||
authlib==1.5.1
|
||||
# via safety
|
||||
@ -14,8 +14,6 @@ bandit==1.8.3
|
||||
# via incorporeal-cms (pyproject.toml)
|
||||
binaryornot==0.4.4
|
||||
# via reuse
|
||||
blinker==1.7.0
|
||||
# via flask
|
||||
boolean-py==4.0
|
||||
# via
|
||||
# license-expression
|
||||
@ -36,9 +34,8 @@ charset-normalizer==3.4.1
|
||||
# via
|
||||
# python-debian
|
||||
# requests
|
||||
click==8.1.7
|
||||
click==8.1.8
|
||||
# via
|
||||
# flask
|
||||
# nltk
|
||||
# pip-tools
|
||||
# reuse
|
||||
@ -97,8 +94,6 @@ flake8-mutable==1.2.0
|
||||
# via incorporeal-cms (pyproject.toml)
|
||||
flake8-pyproject==1.2.3
|
||||
# via incorporeal-cms (pyproject.toml)
|
||||
flask==3.0.3
|
||||
# via incorporeal-cms (pyproject.toml)
|
||||
id==1.5.0
|
||||
# via twine
|
||||
idna==3.10
|
||||
@ -107,8 +102,6 @@ iniconfig==2.0.0
|
||||
# via pytest
|
||||
isort==6.0.1
|
||||
# via flake8-isort
|
||||
itsdangerous==2.1.2
|
||||
# via flask
|
||||
jaraco-classes==3.4.0
|
||||
# via keyring
|
||||
jaraco-context==6.0.1
|
||||
@ -121,7 +114,7 @@ jeepney==0.9.0
|
||||
# secretstorage
|
||||
jinja2==3.1.3
|
||||
# via
|
||||
# flask
|
||||
# incorporeal-cms (pyproject.toml)
|
||||
# reuse
|
||||
# safety
|
||||
joblib==1.4.2
|
||||
@ -137,9 +130,7 @@ markdown==3.6
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
markupsafe==2.1.5
|
||||
# via
|
||||
# jinja2
|
||||
# werkzeug
|
||||
# via jinja2
|
||||
marshmallow==3.26.1
|
||||
# via safety
|
||||
mccabe==0.7.0
|
||||
@ -293,8 +284,6 @@ urllib3==2.3.0
|
||||
# twine
|
||||
virtualenv==20.29.3
|
||||
# via tox
|
||||
werkzeug==3.0.2
|
||||
# via flask
|
||||
wheel==0.45.1
|
||||
# via pip-tools
|
||||
|
||||
|
@ -4,31 +4,19 @@
|
||||
#
|
||||
# pip-compile --output-file=requirements/requirements.txt
|
||||
#
|
||||
blinker==1.7.0
|
||||
# via flask
|
||||
click==8.1.7
|
||||
# via flask
|
||||
feedgen==1.0.0
|
||||
# via incorporeal-cms (pyproject.toml)
|
||||
flask==3.0.3
|
||||
# via incorporeal-cms (pyproject.toml)
|
||||
itsdangerous==2.1.2
|
||||
# via flask
|
||||
jinja2==3.1.3
|
||||
# via flask
|
||||
# via incorporeal-cms (pyproject.toml)
|
||||
lxml==5.2.1
|
||||
# via feedgen
|
||||
markdown==3.6
|
||||
# via incorporeal-cms (pyproject.toml)
|
||||
markupsafe==2.1.5
|
||||
# via
|
||||
# jinja2
|
||||
# werkzeug
|
||||
# via jinja2
|
||||
python-dateutil==2.9.0.post0
|
||||
# via feedgen
|
||||
six==1.16.0
|
||||
# via python-dateutil
|
||||
termcolor==2.5.0
|
||||
# via incorporeal-cms (pyproject.toml)
|
||||
werkzeug==3.0.2
|
||||
# via flask
|
||||
|
@ -1,26 +0,0 @@
|
||||
"""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 pytest
|
||||
|
||||
from incorporealcms import create_app
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create the Flask application, with test settings."""
|
||||
app = create_app(instance_path=os.path.join(HERE, 'instance'))
|
||||
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create a test client based on the test app."""
|
||||
return app.test_client()
|
@ -4,64 +4,62 @@ SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from incorporealcms import create_app
|
||||
import pytest
|
||||
|
||||
from incorporealcms import init_instance
|
||||
from incorporealcms.ssg import StaticSiteGenerator
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def app_with_pydot():
|
||||
"""Create the test app, including the pydot extension."""
|
||||
return create_app(instance_path=os.path.join(HERE, 'instance'),
|
||||
test_config={'MARKDOWN_EXTENSIONS': ['incorporealcms.mdx.pydot']})
|
||||
|
||||
|
||||
def test_functional_initialization():
|
||||
"""Test initialization with the graphviz config."""
|
||||
app = app_with_pydot()
|
||||
assert app is not None
|
||||
init_instance(instance_path=os.path.join(HERE, 'instance'),
|
||||
extra_config={'MARKDOWN_EXTENSIONS': ['incorporealcms.mdx.pydot', 'incorporealcms.mdx.figures',
|
||||
'attr_list']})
|
||||
|
||||
|
||||
def test_graphviz_is_rendered():
|
||||
"""Initialize the app with the graphviz extension and ensure it does something."""
|
||||
app = app_with_pydot()
|
||||
client = app.test_client()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
ssg = StaticSiteGenerator(src_dir, tmpdir)
|
||||
os.chdir(os.path.join(src_dir, 'pages'))
|
||||
|
||||
response = client.get('/test-graphviz')
|
||||
assert response.status_code == 200
|
||||
assert b'~~~pydot' not 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
|
||||
ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'pages'), '', 'test-graphviz.md', tmpdir, True)
|
||||
with open(os.path.join(tmpdir, 'test-graphviz.html'), 'r') as graphviz_output:
|
||||
data = graphviz_output.read()
|
||||
assert 'data:image/png;base64' in data
|
||||
os.chdir(HERE)
|
||||
|
||||
|
||||
def test_invalid_graphviz_is_not_rendered():
|
||||
"""Check that invalid graphviz doesn't blow things up."""
|
||||
app = app_with_pydot()
|
||||
client = app.test_client()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
ssg = StaticSiteGenerator(src_dir, tmpdir)
|
||||
os.chdir(os.path.join(src_dir, 'pages'))
|
||||
|
||||
response = client.get('/test-invalid-graphviz')
|
||||
assert response.status_code == 500
|
||||
assert b'INTERNAL SERVER ERROR' in response.data
|
||||
with pytest.raises(ValueError):
|
||||
ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'pages'), '', 'test-invalid-graphviz.md',
|
||||
tmpdir, True)
|
||||
os.chdir(HERE)
|
||||
|
||||
|
||||
def test_figures_are_rendered(client):
|
||||
def test_figures_are_rendered():
|
||||
"""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
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
ssg = StaticSiteGenerator(src_dir, tmpdir)
|
||||
os.chdir(os.path.join(src_dir, 'pages'))
|
||||
|
||||
ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'pages'), '', 'figures.md', tmpdir, True)
|
||||
with open(os.path.join(tmpdir, 'figures.html'), 'r') as graphviz_output:
|
||||
data = graphviz_output.read()
|
||||
assert ('<figure class="right"><img alt="fancy captioned logo" src="bss-square-no-bg.png" />'
|
||||
'<figcaption>this is my cool logo!</figcaption></figure>') in data
|
||||
assert ('<figure><img alt="vanilla captioned logo" src="bss-square-no-bg.png" />'
|
||||
'<figcaption>this is my cool logo without an attr!</figcaption>\n</figure>') in data
|
||||
assert ('<figure class="left"><img alt="fancy logo" src="bss-square-no-bg.png" />'
|
||||
'<span></span></figure>') in data
|
||||
assert '<figure><img alt="just a logo" src="bss-square-no-bg.png" /></figure>' in data
|
||||
os.chdir(HERE)
|
||||
|
@ -1,244 +0,0 @@
|
||||
"""Test page requests.
|
||||
|
||||
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_page_that_exists(client):
|
||||
"""Test that the app can serve a basic file at the index."""
|
||||
response = client.get('/')
|
||||
assert response.status_code == 200
|
||||
assert b'<h1 id="test-index">test index</h1>' in response.data
|
||||
|
||||
|
||||
def test_direct_file_that_exists(client):
|
||||
"""Test that the app can serve a basic file at the index."""
|
||||
response = client.get('/foo.txt')
|
||||
assert response.status_code == 200
|
||||
assert b'test file' in response.data
|
||||
|
||||
|
||||
def test_page_that_doesnt_exist(client):
|
||||
"""Test that the app returns 404 for nonsense requests and they use my error page."""
|
||||
response = client.get('/ohuesthaoeusth')
|
||||
assert response.status_code == 404
|
||||
assert b'<b><tt>/ohuesthaoeusth</tt></b> does not seem to exist' in response.data
|
||||
# test the contact email config
|
||||
assert b'admin@example.com' in response.data
|
||||
|
||||
|
||||
def test_files_outside_pages_do_not_get_served(client):
|
||||
"""Test that page pathing doesn't break out of the instance/pages/ dir, and the error uses my error page."""
|
||||
response = client.get('/../unreachable')
|
||||
assert response.status_code == 400
|
||||
assert b'You\'re doing something you\'re not supposed to. Stop it?' in response.data
|
||||
|
||||
|
||||
def test_internal_server_error_serves_error_page(client):
|
||||
"""Test that various exceptions serve up the 500 page."""
|
||||
response = client.get('/actually-a-png')
|
||||
assert response.status_code == 500
|
||||
assert b'INTERNAL SERVER ERROR' in response.data
|
||||
# test the contact email config
|
||||
assert b'admin@example.com' in response.data
|
||||
|
||||
|
||||
def test_oserror_is_500(client, app):
|
||||
"""Test that an OSError raises as a 500."""
|
||||
with app.test_request_context():
|
||||
with patch('flask.current_app.open_instance_resource', side_effect=OSError):
|
||||
response = client.get('/')
|
||||
assert response.status_code == 500
|
||||
assert b'INTERNAL SERVER ERROR' in response.data
|
||||
|
||||
|
||||
def test_unsupported_file_type_is_500(client, app):
|
||||
"""Test a coding condition mishap raises as a 500."""
|
||||
with app.test_request_context():
|
||||
with patch('incorporealcms.pages.request_path_to_instance_resource_path', return_value=('foo', 'bar')):
|
||||
response = client.get('/')
|
||||
assert response.status_code == 500
|
||||
assert b'INTERNAL SERVER ERROR' in response.data
|
||||
|
||||
|
||||
def test_weird_paths_do_not_get_served(client):
|
||||
"""Test that we clean up requests as desired."""
|
||||
response = client.get('/../../')
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_page_with_title_metadata(client):
|
||||
"""Test that a page with title metadata has its title written."""
|
||||
response = client.get('/')
|
||||
assert response.status_code == 200
|
||||
assert b'<title>Index - example.com</title>' in response.data
|
||||
|
||||
|
||||
def test_page_without_title_metadata(client):
|
||||
"""Test that a page without title metadata gets the default title."""
|
||||
response = client.get('/no-title')
|
||||
assert response.status_code == 200
|
||||
assert b'<title>/no-title - example.com</title>' in response.data
|
||||
|
||||
|
||||
def test_page_in_subdir_without_title_metadata(client):
|
||||
"""Test that the title-less page display is as expected."""
|
||||
response = client.get('/subdir//page-no-title')
|
||||
assert response.status_code == 200
|
||||
assert b'<title>/subdir/page-no-title - example.com</title>' in response.data
|
||||
|
||||
|
||||
def test_page_with_card_metadata(client):
|
||||
"""Test that a page with opengraph metadata."""
|
||||
response = client.get('/more-metadata')
|
||||
assert response.status_code == 200
|
||||
assert b'<meta property="og:title" content="title for the page - example.com">' in response.data
|
||||
assert b'<meta property="og:description" content="description of this page made even longer">' in response.data
|
||||
assert b'<meta property="og:image" content="http://buh.com/test.img">' in response.data
|
||||
|
||||
|
||||
def test_page_with_card_title_even_when_no_metadata(client):
|
||||
"""Test that a page without metadata still has a card with the derived title."""
|
||||
response = client.get('/no-title')
|
||||
assert response.status_code == 200
|
||||
assert b'<meta property="og:title" content="/no-title - example.com">' in response.data
|
||||
assert b'<meta property="og:description"' not in response.data
|
||||
assert b'<meta property="og:image"' not in response.data
|
||||
|
||||
|
||||
def test_page_with_forced_empty_title_just_shows_suffix(client):
|
||||
"""Test that if a page specifies a blank Title meta tag explicitly, only the suffix is used in the title."""
|
||||
response = client.get('/forced-no-title')
|
||||
assert response.status_code == 200
|
||||
assert b'<title>example.com</title>' in response.data
|
||||
|
||||
|
||||
def test_page_with_redirect_meta_url_redirects(client):
|
||||
"""Test that if a page specifies a URL to redirect to, that the site serves up a 301."""
|
||||
response = client.get('/redirect')
|
||||
assert response.status_code == 301
|
||||
assert response.location == 'http://www.google.com/'
|
||||
|
||||
|
||||
def test_page_has_modified_timestamp(client):
|
||||
"""Test that pages have modified timestamps in them."""
|
||||
response = client.get('/')
|
||||
assert response.status_code == 200
|
||||
assert re.search(r'Last modified: ....-..-.. ..:..:.. ...', response.data.decode()) is not None
|
||||
|
||||
|
||||
def test_that_page_request_redirects_to_directory(client):
|
||||
"""Test that a request to /foo reirects to /foo/, if foo is a directory.
|
||||
|
||||
This might be useful in cases where a formerly page-only page has been
|
||||
converted to a directory with subpages.
|
||||
"""
|
||||
response = client.get('/subdir')
|
||||
assert response.status_code == 301
|
||||
assert response.location == '/subdir/'
|
||||
|
||||
|
||||
def test_that_request_to_symlink_redirects_markdown(client):
|
||||
"""Test that a request to /foo redirects to /what-foo-points-at."""
|
||||
response = client.get('/symlink-to-no-title')
|
||||
assert response.status_code == 301
|
||||
assert response.location == '/no-title'
|
||||
|
||||
|
||||
def test_that_request_to_symlink_redirects_file(client):
|
||||
"""Test that a request to /foo.txt redirects to /what-foo-points-at.txt."""
|
||||
response = client.get('/symlink-to-foo.txt')
|
||||
assert response.status_code == 301
|
||||
assert response.location == '/foo.txt'
|
||||
|
||||
|
||||
def test_that_request_to_symlink_redirects_directory(client):
|
||||
"""Test that a request to /foo/ redirects to /what-foo-points-at/."""
|
||||
response = client.get('/symlink-to-subdir/')
|
||||
assert response.status_code == 301
|
||||
assert response.location == '/subdir'
|
||||
# sadly, this location also redirects
|
||||
response = client.get('/subdir')
|
||||
assert response.status_code == 301
|
||||
assert response.location == '/subdir/'
|
||||
# but we do get there
|
||||
response = client.get('/subdir/')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_that_request_to_symlink_redirects_subdirectory(client):
|
||||
"""Test that a request to /foo/bar redirects to /what-foo-points-at/bar."""
|
||||
response = client.get('/symlink-to-subdir/page-no-title')
|
||||
assert response.status_code == 301
|
||||
assert response.location == '/subdir/page-no-title'
|
||||
response = client.get('/subdir/page-no-title')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_that_dir_request_does_not_redirect(client):
|
||||
"""Test that a request to /foo/ serves the index page, if foo is a directory."""
|
||||
response = client.get('/subdir/')
|
||||
assert response.status_code == 200
|
||||
assert b'another page' in response.data
|
||||
|
||||
|
||||
def test_setting_selected_style_includes_cookie(client):
|
||||
"""Test that a request with style=foo sets the cookie and renders appropriately."""
|
||||
response = client.get('/')
|
||||
style_cookie = client.get_cookie('user-style')
|
||||
assert style_cookie is None
|
||||
|
||||
response = client.get('/?style=light')
|
||||
style_cookie = client.get_cookie('user-style')
|
||||
assert response.status_code == 200
|
||||
assert b'/static/css/light.css' in response.data
|
||||
assert b'/static/css/dark.css' not in response.data
|
||||
assert style_cookie.value == 'light'
|
||||
|
||||
response = client.get('/?style=dark')
|
||||
style_cookie = client.get_cookie('user-style')
|
||||
assert response.status_code == 200
|
||||
assert b'/static/css/dark.css' in response.data
|
||||
assert b'/static/css/light.css' not in response.data
|
||||
assert style_cookie.value == 'dark'
|
||||
|
||||
|
||||
def test_pages_can_supply_alternate_templates(client):
|
||||
"""Test that pages can supply templates other than the default."""
|
||||
response = client.get('/')
|
||||
assert b'class="site-wrap site-wrap-normal-width"' in response.data
|
||||
assert b'class="site-wrap site-wrap-double-width"' not in response.data
|
||||
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-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
|
1
tests/instance/broken/redirect.md
Normal file
1
tests/instance/broken/redirect.md
Normal file
@ -0,0 +1 @@
|
||||
Redirect: http://www.google.com/
|
@ -23,5 +23,6 @@
|
||||
"handlers": ["console"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"INSTANCE_VALUE": "hi"
|
||||
}
|
||||
|
1
tests/instance/feed/20250316-more-metadata.md
Symbolic link
1
tests/instance/feed/20250316-more-metadata.md
Symbolic link
@ -0,0 +1 @@
|
||||
../pages/more-metadata.md
|
@ -5,79 +5,34 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
import os
|
||||
|
||||
from incorporealcms import create_app
|
||||
import pytest
|
||||
|
||||
from incorporealcms import init_instance
|
||||
from incorporealcms.config import Config
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def test_config():
|
||||
"""Test create_app without passing test config."""
|
||||
"""Test that the app initialization sets values not normally present in the config."""
|
||||
# this may have gotten here from other imports in other tests
|
||||
try:
|
||||
delattr(Config, 'INSTANCE_VALUE')
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
assert not getattr(Config, 'INSTANCE_VALUE', None)
|
||||
assert not getattr(Config, 'EXTRA_VALUE', None)
|
||||
|
||||
instance_path = os.path.join(HERE, 'instance')
|
||||
assert not create_app(instance_path=instance_path).testing
|
||||
assert create_app(instance_path=instance_path, test_config={"TESTING": True}).testing
|
||||
init_instance(instance_path=instance_path, extra_config={"EXTRA_VALUE": "hello"})
|
||||
|
||||
assert getattr(Config, 'INSTANCE_VALUE', None) == "hi"
|
||||
assert getattr(Config, 'EXTRA_VALUE', None) == "hello"
|
||||
|
||||
|
||||
def test_markdown_meta_extension_always():
|
||||
"""Test that the markdown meta extension is always loaded, even if not specified."""
|
||||
app = create_app(instance_path=os.path.join(HERE, 'instance'),
|
||||
test_config={'MARKDOWN_EXTENSIONS': []})
|
||||
client = app.test_client()
|
||||
response = client.get('/')
|
||||
assert response.status_code == 200
|
||||
assert b'<title>Index - example.com</title>' in response.data
|
||||
|
||||
|
||||
def test_custom_markdown_extensions_work():
|
||||
"""Test we can change extensions via config, and that they work.
|
||||
|
||||
This used to test smarty, but that's added by default now, so we test
|
||||
that it can be removed by overriding the option.
|
||||
"""
|
||||
app = create_app(instance_path=os.path.join(HERE, 'instance'))
|
||||
client = app.test_client()
|
||||
response = client.get('/mdash-or-triple-dash')
|
||||
assert response.status_code == 200
|
||||
assert b'word — word' in response.data
|
||||
|
||||
app = create_app(instance_path=os.path.join(HERE, 'instance'),
|
||||
test_config={'MARKDOWN_EXTENSIONS': []})
|
||||
client = app.test_client()
|
||||
response = client.get('/mdash-or-triple-dash')
|
||||
assert response.status_code == 200
|
||||
assert b'word --- word' in response.data
|
||||
|
||||
|
||||
def test_title_override():
|
||||
"""Test that a configuration with a specific title overrides the default."""
|
||||
instance_path = os.path.join(HERE, 'instance')
|
||||
app = create_app(instance_path=instance_path, test_config={'TITLE_SUFFIX': 'suou.net'})
|
||||
client = app.test_client()
|
||||
response = client.get('/no-title')
|
||||
assert response.status_code == 200
|
||||
assert b'<title>/no-title - suou.net</title>' in response.data
|
||||
|
||||
|
||||
def test_media_file_access(client):
|
||||
"""Test that media files are served, and properly."""
|
||||
response = client.get('/media/favicon.png')
|
||||
assert response.status_code == 200
|
||||
assert response.headers['content-type'] == 'image/png'
|
||||
|
||||
|
||||
def test_favicon_override():
|
||||
"""Test that a configuration with a specific favicon overrides the default."""
|
||||
instance_path = os.path.join(HERE, 'instance')
|
||||
app = create_app(instance_path=instance_path, test_config={'FAVICON': '/media/foo.png'})
|
||||
client = app.test_client()
|
||||
response = client.get('/no-title')
|
||||
assert response.status_code == 200
|
||||
assert b'<link rel="icon" href="/media/foo.png">' in response.data
|
||||
|
||||
|
||||
def test_misconfigured_markdown_extensions():
|
||||
"""Test that a misconfigured markdown extensions leads to a 500 at render time."""
|
||||
instance_path = os.path.join(HERE, 'instance')
|
||||
app = create_app(instance_path=instance_path, test_config={'MARKDOWN_EXTENSIONS': 'WRONG'})
|
||||
client = app.test_client()
|
||||
response = client.get('/no-title')
|
||||
assert response.status_code == 500
|
||||
def test_broken_config():
|
||||
"""Test that the app initialization errors when not given an instance-looking thing."""
|
||||
with pytest.raises(ValueError):
|
||||
instance_path = os.path.join(HERE, 'blah')
|
||||
init_instance(instance_path=instance_path)
|
||||
|
@ -3,42 +3,75 @@
|
||||
SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
from incorporealcms.feed import serve_feed
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from incorporealcms import init_instance
|
||||
from incorporealcms.feed import generate_feed
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
init_instance(instance_path=os.path.join(HERE, 'instance'))
|
||||
|
||||
|
||||
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_generated():
|
||||
"""Test that an ATOM feed can be generated."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
generate_feed('atom', src_dir, tmpdir)
|
||||
|
||||
with open(os.path.join(tmpdir, 'feed', 'atom'), 'r') as feed_output:
|
||||
data = feed_output.read()
|
||||
assert '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n<feed xmlns="http://www.w3.org/2005/Atom">' in data
|
||||
assert '<id>https://example.org/</id>' in data
|
||||
assert '<email>admin@example.org</email>' in data
|
||||
assert '<name>Test Name</name>' in data
|
||||
|
||||
# forced-no-title.md
|
||||
assert '<title>example.org</title>' in data
|
||||
assert '<link href="https://example.org/forced-no-title"/>' in data
|
||||
assert '<id>tag:example.org,2023-12-01:/forced-no-title</id>' in data
|
||||
assert '<content type="html"><p>some words are here</p></content>' in data
|
||||
|
||||
# more-metadata.md
|
||||
assert '<title>title for the page - example.org</title>' in data
|
||||
assert '<link href="https://example.org/more-metadata"/>' in data
|
||||
assert '<id>tag:example.org,2025-03-16:/more-metadata</id>' in data
|
||||
assert '<content type="html"><p>hello</p></content>' in data
|
||||
|
||||
|
||||
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_generated():
|
||||
"""Test that an RSS feed can be generated."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
generate_feed('rss', src_dir, tmpdir)
|
||||
|
||||
with open(os.path.join(tmpdir, 'feed', 'rss'), 'r') as feed_output:
|
||||
data = feed_output.read()
|
||||
assert '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n<rss xmlns:atom="http://www.w3.org/2005/Atom"' in data
|
||||
assert 'xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">' in data
|
||||
assert '<link>https://example.org</link>' in data
|
||||
|
||||
# forced-no-title.md
|
||||
assert '<title>example.org</title>' in data
|
||||
assert '<link>https://example.org/forced-no-title</link>' in data
|
||||
assert '<guid isPermaLink="false">tag:example.org,2023-12-01:/forced-no-title</guid>' in data
|
||||
assert '<description><p>some words are here</p></description>' in data
|
||||
assert '<author>admin@example.org (Test Name)</author>' in data
|
||||
|
||||
# more-metadata.md
|
||||
assert '<title>title for the page - example.org</title>' in data
|
||||
assert '<link>https://example.org/more-metadata</link>' in data
|
||||
assert '<guid isPermaLink="false">tag:example.org,2025-03-16:/more-metadata</guid>' in data
|
||||
assert '<description><p>hello</p></description>' in data
|
||||
assert '<author>admin@example.org (Test Name)</author>' in data
|
||||
|
||||
|
||||
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
|
||||
def test_multiple_runs_without_error():
|
||||
"""Test that we can run the RSS and Atom feed generators in any order."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
generate_feed('atom', src_dir, tmpdir)
|
||||
generate_feed('rss', src_dir, tmpdir)
|
||||
generate_feed('atom', src_dir, tmpdir)
|
||||
generate_feed('rss', src_dir, tmpdir)
|
||||
|
150
tests/test_markdown.py
Normal file
150
tests/test_markdown.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""Test the conversion of Markdown pages.
|
||||
|
||||
SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from incorporealcms.markdown import (generate_parent_navs, handle_markdown_file_path,
|
||||
instance_resource_path_to_request_path, parse_md,
|
||||
request_path_to_breadcrumb_display)
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
os.chdir(os.path.join(HERE, 'instance/', 'pages/'))
|
||||
|
||||
|
||||
def test_generate_page_navs_index():
|
||||
"""Test that the index page has navs to the root (itself)."""
|
||||
assert generate_parent_navs('index.md') == [('example.org', '/')]
|
||||
|
||||
|
||||
def test_generate_page_navs_subdir_index():
|
||||
"""Test that dir pages have navs to the root and themselves."""
|
||||
assert generate_parent_navs('subdir/index.md') == [('example.org', '/'), ('subdir', '/subdir/')]
|
||||
|
||||
|
||||
def test_generate_page_navs_subdir_real_page():
|
||||
"""Test that real pages have navs to the root, their parent, and themselves."""
|
||||
assert generate_parent_navs('subdir/page.md') == [('example.org', '/'), ('subdir', '/subdir/'),
|
||||
('Page', '/subdir/page')]
|
||||
|
||||
|
||||
def test_generate_page_navs_subdir_with_title_parsing_real_page():
|
||||
"""Test that title metadata is used in the nav text."""
|
||||
assert generate_parent_navs('subdir-with-title/page.md') == [
|
||||
('example.org', '/'),
|
||||
('SUB!', '/subdir-with-title/'),
|
||||
('page', '/subdir-with-title/page')
|
||||
]
|
||||
|
||||
|
||||
def test_generate_page_navs_subdir_with_no_index():
|
||||
"""Test that breadcrumbs still generate even if a subdir doesn't have an index.md."""
|
||||
assert generate_parent_navs('no-index-dir/page.md') == [
|
||||
('example.org', '/'),
|
||||
('/no-index-dir/', '/no-index-dir/'),
|
||||
('page', '/no-index-dir/page')
|
||||
]
|
||||
|
||||
|
||||
def test_page_includes_themes_with_default():
|
||||
"""Test that a request contains the configured themes and sets the default as appropriate."""
|
||||
assert '<link rel="stylesheet" type="text/css" title="light" href="/static/css/light.css">'\
|
||||
in handle_markdown_file_path('index.md')
|
||||
assert '<link rel="alternate stylesheet" type="text/css" title="dark" href="/static/css/dark.css">'\
|
||||
in handle_markdown_file_path('index.md')
|
||||
|
||||
|
||||
def test_render_with_style_overrides():
|
||||
"""Test that the default can be changed."""
|
||||
with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'dark'):
|
||||
assert '<link rel="stylesheet" type="text/css" title="dark" href="/static/css/dark.css">'\
|
||||
in handle_markdown_file_path('index.md')
|
||||
assert '<link rel="alternate stylesheet" type="text/css" title="light" href="/static/css/light.css">'\
|
||||
in handle_markdown_file_path('index.md')
|
||||
|
||||
|
||||
def test_render_with_default_style_override():
|
||||
"""Test that theme overrides work, and if a requested theme doesn't exist, the default is loaded."""
|
||||
with patch('incorporealcms.Config.PAGE_STYLES', {'cool': '/static/css/cool.css',
|
||||
'warm': '/static/css/warm.css'}):
|
||||
with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'warm'):
|
||||
assert '<link rel="stylesheet" type="text/css" title="warm" href="/static/css/warm.css">'\
|
||||
in handle_markdown_file_path('index.md')
|
||||
assert '<link rel="alternate stylesheet" type="text/css" title="cool" href="/static/css/cool.css">'\
|
||||
in handle_markdown_file_path('index.md')
|
||||
assert '<link rel="alternate stylesheet" type="text/css" title="light" href="/static/css/light.css">'\
|
||||
not in handle_markdown_file_path('index.md')
|
||||
|
||||
|
||||
def test_redirects_error_unsupported():
|
||||
"""Test that we throw a warning about the barely-used Markdown redirect tag, which we can't support via SSG."""
|
||||
os.chdir(os.path.join(HERE, 'instance/', 'broken/'))
|
||||
with pytest.raises(NotImplementedError):
|
||||
handle_markdown_file_path('redirect.md')
|
||||
os.chdir(os.path.join(HERE, 'instance/', 'pages/'))
|
||||
|
||||
|
||||
def test_instance_resource_path_to_request_path_on_index():
|
||||
"""Test index.md -> /."""
|
||||
assert instance_resource_path_to_request_path('index.md') == '/'
|
||||
|
||||
|
||||
def test_instance_resource_path_to_request_path_on_page():
|
||||
"""Test no-title.md -> no-title."""
|
||||
assert instance_resource_path_to_request_path('no-title.md') == '/no-title'
|
||||
|
||||
|
||||
def test_instance_resource_path_to_request_path_on_subdir():
|
||||
"""Test subdir/index.md -> subdir/."""
|
||||
assert instance_resource_path_to_request_path('subdir/index.md') == '/subdir/'
|
||||
|
||||
|
||||
def test_instance_resource_path_to_request_path_on_subdir_and_page():
|
||||
"""Test subdir/page.md -> subdir/page."""
|
||||
assert instance_resource_path_to_request_path('subdir/page.md') == '/subdir/page'
|
||||
|
||||
|
||||
def test_request_path_to_breadcrumb_display_patterns():
|
||||
"""Test various conversions from request path to leaf nodes for display in the breadcrumbs."""
|
||||
assert request_path_to_breadcrumb_display('/foo') == 'foo'
|
||||
assert request_path_to_breadcrumb_display('/foo/') == 'foo'
|
||||
assert request_path_to_breadcrumb_display('/foo/bar') == 'bar'
|
||||
assert request_path_to_breadcrumb_display('/foo/bar/') == 'bar'
|
||||
assert request_path_to_breadcrumb_display('/') == ''
|
||||
|
||||
|
||||
def test_parse_md_metadata():
|
||||
"""Test the direct results of parsing a markdown file."""
|
||||
content, md, page_name, page_title, mtime = parse_md('more-metadata.md')
|
||||
assert page_name == 'title for the page'
|
||||
assert page_title == 'title for the page - example.org'
|
||||
|
||||
|
||||
def test_parse_md_metadata_forced_no_title():
|
||||
"""Test the direct results of parsing a markdown file."""
|
||||
content, md, page_name, page_title, mtime = parse_md('forced-no-title.md')
|
||||
assert page_name == ''
|
||||
assert page_title == 'example.org'
|
||||
|
||||
|
||||
def test_parse_md_metadata_no_title_so_path():
|
||||
"""Test the direct results of parsing a markdown file."""
|
||||
content, md, page_name, page_title, mtime = parse_md('subdir/index.md')
|
||||
assert page_name == '/subdir/'
|
||||
assert page_title == '/subdir/ - example.org'
|
||||
|
||||
|
||||
def test_parse_md_no_file():
|
||||
"""Test the direct results of parsing a markdown file."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
content, md, page_name, page_title, mtime = parse_md('nope.md')
|
||||
|
||||
|
||||
def test_parse_md_bad_file():
|
||||
"""Test the direct results of parsing a markdown file."""
|
||||
with pytest.raises(ValueError):
|
||||
content, md, page_name, page_title, mtime = parse_md('actually-a-png.md')
|
@ -1,282 +0,0 @@
|
||||
"""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
|
||||
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,
|
||||
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):
|
||||
"""Test that the index page has navs to the root (itself)."""
|
||||
with app.app_context():
|
||||
assert generate_parent_navs('pages/index.md') == [('example.com', '/')]
|
||||
|
||||
|
||||
def test_generate_page_navs_subdir_index(app):
|
||||
"""Test that dir pages have navs to the root and themselves."""
|
||||
with app.app_context():
|
||||
assert generate_parent_navs('pages/subdir/index.md') == [('example.com', '/'), ('subdir', '/subdir/')]
|
||||
|
||||
|
||||
def test_generate_page_navs_subdir_real_page(app):
|
||||
"""Test that real pages have navs to the root, their parent, and themselves."""
|
||||
with app.app_context():
|
||||
assert generate_parent_navs('pages/subdir/page.md') == [('example.com', '/'), ('subdir', '/subdir/'),
|
||||
('Page', '/subdir/page')]
|
||||
|
||||
|
||||
def test_generate_page_navs_subdir_with_title_parsing_real_page(app):
|
||||
"""Test that title metadata is used in the nav text."""
|
||||
with app.app_context():
|
||||
assert generate_parent_navs('pages/subdir-with-title/page.md') == [
|
||||
('example.com', '/'),
|
||||
('SUB!', '/subdir-with-title/'),
|
||||
('page', '/subdir-with-title/page')
|
||||
]
|
||||
|
||||
|
||||
def test_generate_page_navs_subdir_with_no_index(app):
|
||||
"""Test that breadcrumbs still generate even if a subdir doesn't have an index.md."""
|
||||
with app.app_context():
|
||||
assert generate_parent_navs('pages/no-index-dir/page.md') == [
|
||||
('example.com', '/'),
|
||||
('/no-index-dir/', '/no-index-dir/'),
|
||||
('page', '/no-index-dir/page')
|
||||
]
|
||||
|
||||
|
||||
def test_render_with_user_dark_theme(app):
|
||||
"""Test that a request with the dark theme selected renders the dark theme."""
|
||||
cookie = dump_cookie("user-style", 'dark')
|
||||
with app.test_request_context(headers={'COOKIE': cookie}):
|
||||
assert b'/static/css/dark.css' 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):
|
||||
"""Test that a request with the light theme selected renders the light theme."""
|
||||
with app.test_request_context():
|
||||
assert b'/static/css/light.css' 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):
|
||||
"""Test that a request with no theme set renders the light theme."""
|
||||
with app.test_request_context():
|
||||
assert b'/static/css/light.css' 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):
|
||||
"""Test a normal URL request is transformed into the file path."""
|
||||
with app.test_request_context():
|
||||
assert request_path_to_instance_resource_path('index') == ('pages/index.md', 'markdown')
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_direct_file(app):
|
||||
"""Test a normal URL request is transformed into the file path."""
|
||||
with app.test_request_context():
|
||||
assert request_path_to_instance_resource_path('no-title') == ('pages/no-title.md', 'markdown')
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_in_subdir(app):
|
||||
"""Test a normal URL request is transformed into the file path."""
|
||||
with app.test_request_context():
|
||||
assert request_path_to_instance_resource_path('subdir/page') == ('pages/subdir/page.md', 'markdown')
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_subdir_index(app):
|
||||
"""Test a normal URL request is transformed into the file path."""
|
||||
with app.test_request_context():
|
||||
assert request_path_to_instance_resource_path('subdir/') == ('pages/subdir/index.md', 'markdown')
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_relatives_walked(app):
|
||||
"""Test a normal URL request is transformed into the file path."""
|
||||
with app.test_request_context():
|
||||
assert (request_path_to_instance_resource_path('subdir/more-subdir/../../more-metadata') ==
|
||||
('pages/more-metadata.md', 'markdown'))
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_relatives_walked_indexes_work_too(app):
|
||||
"""Test a normal URL request is transformed into the file path."""
|
||||
with app.test_request_context():
|
||||
assert request_path_to_instance_resource_path('subdir/more-subdir/../../') == ('pages/index.md', 'markdown')
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_relatives_walked_into_subdirs_also_fine(app):
|
||||
"""Test a normal URL request is transformed into the file path."""
|
||||
with app.test_request_context():
|
||||
assert (request_path_to_instance_resource_path('subdir/more-subdir/../../subdir/page') ==
|
||||
('pages/subdir/page.md', 'markdown'))
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_permission_error_on_ref_above_pages(app):
|
||||
"""Test that attempts to get above the base dir ("/../../foo") fail."""
|
||||
with app.test_request_context():
|
||||
with pytest.raises(PermissionError):
|
||||
assert request_path_to_instance_resource_path('../unreachable')
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_isadirectory_on_file_like_req_for_dir(app):
|
||||
"""Test that a request for e.g. '/foo' when foo is a dir indicate to redirect."""
|
||||
with app.test_request_context():
|
||||
with pytest.raises(IsADirectoryError):
|
||||
assert request_path_to_instance_resource_path('subdir')
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_actual_file(app):
|
||||
"""Test that a request for e.g. '/foo.png' when foo.png is a real file works."""
|
||||
with app.test_request_context():
|
||||
assert (request_path_to_instance_resource_path('bss-square-no-bg.png') ==
|
||||
('pages/bss-square-no-bg.png', 'file'))
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_markdown_symlink(app):
|
||||
"""Test that a request for e.g. '/foo' when foo.md is a symlink to another .md file redirects."""
|
||||
with app.test_request_context():
|
||||
assert (request_path_to_instance_resource_path('symlink-to-no-title') ==
|
||||
('pages/no-title.md', 'symlink'))
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_file_symlink(app):
|
||||
"""Test that a request for e.g. '/foo' when foo.txt is a symlink to another .txt file redirects."""
|
||||
with app.test_request_context():
|
||||
assert (request_path_to_instance_resource_path('symlink-to-foo.txt') ==
|
||||
('pages/foo.txt', 'symlink'))
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_dir_symlink(app):
|
||||
"""Test that a request for e.g. '/foo' when /foo is a symlink to /bar redirects."""
|
||||
with app.test_request_context():
|
||||
assert (request_path_to_instance_resource_path('symlink-to-subdir/') ==
|
||||
('pages/subdir', 'symlink'))
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_subdir_symlink(app):
|
||||
"""Test that a request for e.g. '/foo/baz' when /foo is a symlink to /bar redirects."""
|
||||
with app.test_request_context():
|
||||
assert (request_path_to_instance_resource_path('symlink-to-subdir/page-no-title') ==
|
||||
('pages/subdir/page-no-title.md', 'symlink'))
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_nonexistant_file_errors(app):
|
||||
"""Test that a request for something not on disk errors."""
|
||||
with app.test_request_context():
|
||||
with pytest.raises(FileNotFoundError):
|
||||
assert request_path_to_instance_resource_path('nthanpthpnh')
|
||||
|
||||
|
||||
def test_request_path_to_instance_resource_path_absolute_file_errors(app):
|
||||
"""Test that a request for something not on disk errors."""
|
||||
with app.test_request_context():
|
||||
with pytest.raises(PermissionError):
|
||||
assert request_path_to_instance_resource_path('/etc/hosts')
|
||||
|
||||
|
||||
def test_instance_resource_path_to_request_path_on_index(app):
|
||||
"""Test index.md -> /."""
|
||||
with app.test_request_context():
|
||||
assert instance_resource_path_to_request_path('index.md') == ''
|
||||
|
||||
|
||||
def test_instance_resource_path_to_request_path_on_page(app):
|
||||
"""Test no-title.md -> no-title."""
|
||||
with app.test_request_context():
|
||||
assert instance_resource_path_to_request_path('no-title.md') == 'no-title'
|
||||
|
||||
|
||||
def test_instance_resource_path_to_request_path_on_subdir(app):
|
||||
"""Test subdir/index.md -> subdir/."""
|
||||
with app.test_request_context():
|
||||
assert instance_resource_path_to_request_path('subdir/index.md') == 'subdir/'
|
||||
|
||||
|
||||
def test_instance_resource_path_to_request_path_on_subdir_and_page(app):
|
||||
"""Test subdir/page.md -> subdir/page."""
|
||||
with app.test_request_context():
|
||||
assert instance_resource_path_to_request_path('subdir/page.md') == 'subdir/page'
|
||||
|
||||
|
||||
def test_request_resource_request_root(app):
|
||||
"""Test that a request can resolve to a resource and back to a request."""
|
||||
with app.test_request_context():
|
||||
instance_path, _ = request_path_to_instance_resource_path('index')
|
||||
instance_resource_path_to_request_path(instance_path) == ''
|
||||
|
||||
|
||||
def test_request_resource_request_page(app):
|
||||
"""Test that a request can resolve to a resource and back to a request."""
|
||||
with app.test_request_context():
|
||||
instance_path, _ = request_path_to_instance_resource_path('no-title')
|
||||
instance_resource_path_to_request_path(instance_path) == 'no-title'
|
||||
|
||||
|
||||
def test_request_path_to_breadcrumb_display_patterns():
|
||||
"""Test various conversions from request path to leaf nodes for display in the breadcrumbs."""
|
||||
assert request_path_to_breadcrumb_display('/foo') == 'foo'
|
||||
assert request_path_to_breadcrumb_display('/foo/') == 'foo'
|
||||
assert request_path_to_breadcrumb_display('/foo/bar') == 'bar'
|
||||
assert request_path_to_breadcrumb_display('/foo/bar/') == 'bar'
|
||||
assert request_path_to_breadcrumb_display('/') == ''
|
90
tests/test_ssg.py
Normal file
90
tests/test_ssg.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Test the high level SSG operations.
|
||||
|
||||
SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org>
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import incorporealcms.ssg as ssg
|
||||
from incorporealcms import init_instance
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
instance_dir = os.path.join(HERE, 'instance')
|
||||
init_instance(instance_dir)
|
||||
|
||||
|
||||
def test_file_copy():
|
||||
"""Test the ability to sync a file to the output dir."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
generator = ssg.StaticSiteGenerator(src_dir, tmpdir)
|
||||
os.chdir(os.path.join(instance_dir, 'pages'))
|
||||
generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'no-title.md', tmpdir,
|
||||
True)
|
||||
assert os.path.exists(os.path.join(tmpdir, 'no-title.md'))
|
||||
assert os.path.exists(os.path.join(tmpdir, 'no-title.html'))
|
||||
|
||||
|
||||
def test_file_copy_no_markdown():
|
||||
"""Test the ability to sync a file to the output dir."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
generator = ssg.StaticSiteGenerator(src_dir, tmpdir)
|
||||
os.chdir(os.path.join(instance_dir, 'pages'))
|
||||
generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'no-title.md', tmpdir,
|
||||
False)
|
||||
assert os.path.exists(os.path.join(tmpdir, 'no-title.md'))
|
||||
assert not os.path.exists(os.path.join(tmpdir, 'no-title.html'))
|
||||
|
||||
|
||||
def test_file_copy_symlink():
|
||||
"""Test the ability to sync a file to the output dir."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
generator = ssg.StaticSiteGenerator(src_dir, tmpdir)
|
||||
os.chdir(os.path.join(instance_dir, 'pages'))
|
||||
generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'symlink-to-foo.txt', tmpdir)
|
||||
# need to copy the destination for os.path.exists to be happy with this
|
||||
generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'foo.txt', tmpdir)
|
||||
assert os.path.exists(os.path.join(tmpdir, 'symlink-to-foo.txt'))
|
||||
assert os.path.islink(os.path.join(tmpdir, 'symlink-to-foo.txt'))
|
||||
|
||||
|
||||
def test_dir_copy():
|
||||
"""Test the ability to sync a directory to the output dir."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
generator = ssg.StaticSiteGenerator(src_dir, tmpdir)
|
||||
os.chdir(os.path.join(instance_dir, 'pages'))
|
||||
generator.build_subdir_in_destination(os.path.join(instance_dir, 'pages'), '', 'media', tmpdir)
|
||||
assert os.path.exists(os.path.join(tmpdir, 'media'))
|
||||
assert os.path.isdir(os.path.join(tmpdir, 'media'))
|
||||
|
||||
|
||||
def test_dir_copy_symlink():
|
||||
"""Test the ability to sync a directory to the output dir."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
generator = ssg.StaticSiteGenerator(src_dir, tmpdir)
|
||||
os.chdir(os.path.join(instance_dir, 'pages'))
|
||||
generator.build_subdir_in_destination(os.path.join(instance_dir, 'pages'), '', 'symlink-to-subdir', tmpdir)
|
||||
# need to copy the destination for os.path.exists to be happy with this
|
||||
generator.build_subdir_in_destination(os.path.join(instance_dir, 'pages'), '', 'subdir', tmpdir)
|
||||
assert os.path.exists(os.path.join(tmpdir, 'symlink-to-subdir'))
|
||||
assert os.path.isdir(os.path.join(tmpdir, 'symlink-to-subdir'))
|
||||
assert os.path.islink(os.path.join(tmpdir, 'symlink-to-subdir'))
|
||||
|
||||
|
||||
def test_build_in_destination():
|
||||
"""Test the ability to walk a source and populate the destination."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(HERE, 'instance')
|
||||
generator = ssg.StaticSiteGenerator(src_dir, tmpdir)
|
||||
generator.build_in_destination(os.path.join(src_dir, 'pages'), tmpdir)
|
||||
|
||||
assert os.path.exists(os.path.join(tmpdir, 'index.md'))
|
||||
assert os.path.exists(os.path.join(tmpdir, 'index.html'))
|
||||
assert os.path.exists(os.path.join(tmpdir, 'subdir', 'index.md'))
|
||||
assert os.path.exists(os.path.join(tmpdir, 'subdir', 'index.html'))
|
7
tox.ini
7
tox.ini
@ -5,7 +5,7 @@
|
||||
|
||||
[tox]
|
||||
isolated_build = true
|
||||
envlist = begin,py38,py39,py310,py311,py312,coverage,security,lint,reuse
|
||||
envlist = begin,py39,py310,py311,py312,coverage,security,lint,reuse
|
||||
|
||||
[testenv]
|
||||
allow_externals = pytest, coverage
|
||||
@ -21,11 +21,6 @@ deps = setuptools
|
||||
skip_install = true
|
||||
commands = coverage erase
|
||||
|
||||
[testenv:py38]
|
||||
# run pytest with coverage
|
||||
commands =
|
||||
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
|
||||
|
||||
[testenv:py39]
|
||||
# run pytest with coverage
|
||||
commands =
|
||||
|
Loading…
x
Reference in New Issue
Block a user