41 Commits

Author SHA1 Message Date
9c937835a9 document v2.1.2
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-25 14:48:35 -06:00
f72894f437 add a class for forcing something full width
using this for the occasional figure that I want to be full width but
also have the caption stuff, since the default (that I use often) is
figures are 30%

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-25 14:38:20 -06:00
34639edd74 remove the broken/deprecated redirect tag support
when this project *wasn't* a SSG, it could serve up redirects, which is
something that was only rarely needed, and looking now, I don't do it at
all on any of my current sites, so I'm just going to remove the tiny bit
of code for it that remained in the SSG project

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-25 09:54:16 -06:00
bcb2b1be7e add the ability to specify the content license in the footer
e.g. for marking all pages as CC BY-SA 4.0

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-24 10:08:20 -06:00
d22c3f84ac have floating img/figure clear their side, to avoid "waterfalls"
I have many things floating to the right on the ttrpg site, where the
first would be most right, the second would be right but left of the
first thing, and so on. this forces those to clear their respective side
and create a quasi-column of things rather than making a bizarre ratchet
of content.

.......... A
.......... A
.......... B
(good)

vs.

.......... A
........ B A
(bad)

I can't see why I'd want the bad behavior on my other sites, but I'll
just mention that I didn't check to see what they were doing

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-24 10:02:57 -06:00
2d5528fa82 style super links for footnotes
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-24 10:00:31 -06:00
23c4c57f2f give the plain HTML table some borders
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-24 10:00:30 -06:00
6a7d009f35 style hr in the main light/dark styles, used in footnotes
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-24 10:00:28 -06:00
7ec8c05bb4 slightly tweak footnote refs, and actually style footnotes
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-24 10:00:26 -06:00
b10fe555df tweak table header bg color to stand out less
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-24 10:00:24 -06:00
20e8cdbbf1 remove unnecessary type="text/javascript"
CSS validator says: Warning: The type attribute is unnecessary for JavaScript resources

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-02-24 10:00:15 -06:00
e056f57797 Changelog for v2.1.1
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-01-28 16:16:11 -06:00
9b7ab74644 remove unused request_path_to_breadcrumb_display
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-01-28 16:10:53 -06:00
204e7bc416 use h1-as-title logic while generating breadcrumbs
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-01-28 16:08:41 -06:00
ee4215ede2 Changelog for v2.1.0
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-01-28 14:46:54 -06:00
f7e211564e refer to py3.10 in instructions, now that 3.9 is unsupported
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-01-28 14:40:45 -06:00
8238787900 use beautifulsoup to parse the description from the first paragraph
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-01-28 14:40:45 -06:00
20673c178a use beautifulsoup to derive title from HTML h1
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-01-28 14:40:45 -06:00
3ca13cc6f8 requirements bump
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-01-28 14:40:45 -06:00
7b2bf6905a test the high level SSG build command
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-01-28 14:40:45 -06:00
dd2f5eeaea remove python 3.9 from supported versions
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2026-01-28 14:40:45 -06:00
d66a471c76 Changelog for v2.0.5
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-09-18 16:45:36 -05:00
bbab9de1f6 regenerate requirements-dev.txt to get some safety stuff to shut up
weirdly, if I rebuild the whole requirements-dev.txt, test coverage
drops, so I'm sure that's a problem to worry about later

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-09-18 16:45:01 -05:00
d4f27c9ad8 add python3.13 to tox environments
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-09-18 16:36:14 -05:00
424ec3621d replace links that have .md suffixes with clean links
to aid viewing the raw markdown source in e.g. a gitlab source browser,
or to aid navigation in vim with "gf" style commands to jump between
files, allow the markdown source to specify foo.md or whatever/index.md
explicitly, yet generate the clean URLs for linking in the HTML output

this assumes that nginx is serving "foo" with foo.html, and "bar/" with
bar/index.html

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-09-18 14:19:48 -05:00
7205bb2aa5 changelog for v2.0.4
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-21 11:48:20 -05:00
88b678931e properly handle symlinks as relative to the output dir
the code meant to make symlinks relative to the output directory, but
only actually succeeded at it for targets in the root of the output
directory; since we already check that the target is not breaking out of
the instance, we can generate the output symlink relative to itself and
fix the subdirred-symlink behavior

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-21 11:43:18 -05:00
8c75947088 remove os.chdir usage, rely on absolute and relative paths more
os.chdir was getting confusing and hurting the log output, and
potentially the cause of a couple bugs left to fix, so this removes it,
but it means we need to pass around the pages/ absolute path into the
markdown parser, because it relies on knowing both the absolute path
now (to open files), and also the path relative to the pages dir in
order to know where to stop reading parent files/how to generate proper
URL-like references to other files.

probably this should be refactored at some point to inherit the pages/
path from the SSG somehow, rather than passing it through a bunch of
methods, but this seems to work for now

fixes #22

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-21 10:47:42 -05:00
e75d5c48d2 changelog for v2.0.3, though I forgot to put it in the tag
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-21 08:36:29 -05:00
201cd80804 when symlinking foo.md, also symlink foo.html if generating .html
fixes #24

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-21 08:31:31 -05:00
a327c6b89c remove the Flask classifier, since we don't do it anymore
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-21 08:23:31 -05:00
27d4d16572 go back to using the "or any later version" clause of GPLv3
I think arguments that "Such new versions will be similar in spirit to
the present version", in my own reading and readings such as in
https://www.draketo.de/software/gpl-or-later, convince me for now that
it is acceptable to allow the "or later" for compatibility and future
problem's sake

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-21 08:22:49 -05:00
b69bdb424a CHANGELOG for v2.0.2
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-18 21:28:15 -05:00
c46a1c0bae don't handle custom-static dirs specially anymore
they're just more static files among all the static files we serve, they
should go into pages/ like everything else

fixes #20

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-18 21:26:25 -05:00
359916e7d9 do some trivial cleanups in README
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-18 21:20:12 -05:00
d49b9d48a8 don't add an artificial ./ subdir due to how os.path.relpath works
this fixes stuff like og:urls of https://foo/./ or https://foo/./page
and also removes an extra layer of depth in the breadcrumb hierarchy,
just by suppressing the '.' in relpath output at the root of pages/

fixes #21

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-18 21:09:50 -05:00
9caf08a277 changelog for v2.0.1
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-18 08:24:46 -05:00
8aabd93273 don't copy .files into the SSG output dir
they may be vim swap files and that kind of garbage

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-18 08:20:58 -05:00
6d7987cfae don't require the host to be in the Image tag
now that we know our base host via config, we can stop hardcoding it in
each Image tag

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-18 08:13:18 -05:00
abc05ee4e8 fix how my email address displays in gitea
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-17 13:22:30 -05:00
f623ffdd7c don't refer to my manual uploads now that I push to PyPI
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-03-17 13:13:25 -05:00
47 changed files with 704 additions and 330 deletions

View File

@@ -1,24 +0,0 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: incorporeal-cms
Upstream-Contact: Brian S. Stephan <bss@incorporeal.org>
Source: https://git.incorporeal.org/bss/incorporeal-cms
# Trivial files
Files: .gitignore .gitattributes
Copyright: © 2020 Brian S. Stephan <bss@incorporeal.org>
License: GPL-3.0-only
# High level repo docs
Files: *.md
Copyright: © 2020 Brian S. Stephan <bss@incorporeal.org>
License: GPL-3.0-only
# Test data
Files: tests/instance/*
Copyright: © 2020 Brian S. Stephan <bss@incorporeal.org>
License: GPL-3.0-only
# Python packaging, scaffolding, and errata
Files: MANIFEST.in pyproject.toml tox.ini requirements/*
Copyright: © 2020 Brian S. Stephan <bss@incorporeal.org>
License: GPL-3.0-only

View File

@@ -2,6 +2,106 @@
Included is a summary of changes to the project, by version. Details can be found in the commit history. Included is a summary of changes to the project, by version. Details can be found in the commit history.
## v2.1.2
### Features
* An optional license declaration can be added to the footer, with a config "LICENSE" directive.
### Improvements
* Style changes in footnotes, hrs, table colors, footnote links, full width figures.
* Have floats clear their side, to not have a waterfall/ratchet effect when too many floating things are next to each
other.
* Add borders to the plain style tables.
### Miscellaneous
* One HTML tweak to make the W3C CSS validator happy.
* Some old code from the pre-SSG days has been removed.
## v2.1.1
### Improvements
* Use the h1-as-name feature from v2.1.0 also to generate the page name in breadcrumbs. This changes the behavior on
pages with an h1 but no Title: meta tag to have a better name, of course, but also changes the behavior on pages with
neither a h1 nor a Title: meta tag to have a leading slash (e.g. /page-filename) where there previously was not one
(e.g. just page-filename). This seems like an acceptable trade-off.
### Miscellaneous
* With the minor breadcrumb change, a method used to finagle the breadcrumb no-name name is no longer necessary.
## v2.1.0
### Features
* The page title (also used in the `og:title` header) and the optional description used in the `og:description` header
can be derived from the contents of the page content, if the markdown meta tags are not supplied. The first `h1` is
used for the title, and the first `p` is used for the description. This is largely to save some time writing pages
that one wants to look nice, especially in a social media card, and removes some repetition.
### Miscellaneous
* Requirements bumped, which led to...
* Python 3.9 has been removed from the supported versions.
* Added some miscellaneous unit tests and coverage changes to keep us at 95% (which only dropped for a library reason I
don't understand).
## v2.0.5
### Features
* The Markdown parser replaces links to e.g. `[Page](page.md)` with a href of `page`, rather than the Markdown source
specifying a link of `page` explicitly. This allows for some improved site navigation when browsing the Markdown
files, e.g. when going to files in Vim, or browsing a site in a Git web UI.
### Miscellaneous
* `tox.ini` also runs tests in a Python 3.13 environment now.
* Some trivial bumps to CI requirements.
## v2.0.4
### Bugfixes
* With some significant refactoring, files are now handled better with respect to relative paths, which fixes an issue
with symlink pages only properly getting resolved to their target if the symlink was in the `pages/` root rather than
a subdir.
## v2.0.3
### Bugfixes
* Symlinks for a `.md` file that are to be served by the web server also need a `.html` symlink pointed to the generated
file, since the web server is looking for HTML files when serving paths.
### Miscellaneous
* The project now comes with the GPLv3 "or any later version" clause.
## v2.0.2
### Bugfixes
* Paths for files in the `pages/` root no longer have an extra `./` in them, which made URLs look ugly and also added an
extra blank breadcrumb in the breadcrumbs.
### Improvements
* `custom-static` in the instance dir is now ignored and has no special handling --- put static files in `pages/static/`
like all the other files that get copied. This also fixes a bug where the build errored if the directory didn't exist.
* Some README typos fixed.
## v2.0.1
### Improvements
* The `Image` tag in Markdown files no longer requires the full URL to be specified. Now `Config.BASE_HOST` is
prepended to the tag value, which should be the full path to the image.
* `.files` are skipped when copying files to the SSG output directory.
## v2.0.0 ## v2.0.0
### Features ### Features

View File

@@ -4,30 +4,29 @@ A lightweight static site generator for Markdown-based sites.
## Installation and Usage ## Installation and Usage
I recommend getting a release from <https://git.incorporeal.org/bss/incorporeal-cms/releases> and Something like the following should suffice:
installing the Python package in a virtualenv. Something like the following should suffice:
``` ```
% virtualenv --python=python3.9 env-py3.9 % virtualenv --python=python3.10 env-py3.10
% source env-py3.9/bin/activate % source env-py3.10/bin/activate
% pip install -U pip % pip install -U pip
% pip install incorporeal-cms % pip install incorporeal-cms
% incorporealcms-build ./path/to/instance ./path/to/output/www/root % incorporealcms-build ./path/to/instance ./path/to/output/www/root
``` ```
This will generate the directory suitable for serving by e.g. Nginx. This will generate the directory suitable for serving by e.g. nginx.
## Creating a Site ## Creating a Site
Put content, notably Markdown content, inside `./your-instance/pages/` and when you are ready, run the build command Put content, notably Markdown content, inside `./your-instance/pages/` and when you are ready, run the build command
above. When you run `incorporealcms-build`, the following happens: above. When you run `incorporealcms-build`, the following happens:
* Markdown files (ending in `.md`) are rendered via Python-Markdown as .html files and output to the static site * Markdown files (ending in `.md`) are rendered via Python-Markdown as `.html` files and output to the static site
directory. The `.md` files are also copied there, though this behavior may be toggleable in the future. directory. The `.md` files are also copied there, though this behavior may be toggleable in the future.
* Directory paths (e.g. a request to `/dir/`) can be served via a `/dir/index.md` file, which will generate * Directory paths (e.g. a request to `/dir/`) can be served via a `/dir/index.md` file, which will generate
`/dir/index.html`, with the appropriate web server configuration to use `index.html` for directory listings. `/dir/index.html`, with the appropriate web server configuration to use `index.html` for directory listings.
* Symlinks to files are retained and mirrored into the output directory, and handled per the web server's configuration, * Symlinks to files are retained and mirrored into the output directory, and handled per the web server's configuration,
whanever it is. whatever it is.
* All other files are copied directly, so images, text files, etc., can be referenced naturally as URLs. * All other files are copied directly, so images, text files, etc., can be referenced naturally as URLs.
## Configuration ## Configuration
@@ -53,19 +52,18 @@ out and discuss issues and features and whatnot.
## Author and Licensing ## Author and Licensing
Written by and copyright (C) 2025 Brian S. Stephan <bss@incorporeal.org>. Written by and copyright (C) 2025 Brian S. Stephan (bss@incorporeal.org).
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify it under the terms of the GNU General
it under the terms of the GNU General Public License as published by Public License as published by the Free Software Foundation, either version 3 of the License, or (at your
the Free Software Foundation, version 3 of the License. option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the
but WITHOUT ANY WARRANTY; without even the implied warranty of implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the more details.
GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License along with this program. If not, see
along with this program. If not, see <https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
### Content Output ### Content Output

28
REUSE.toml Normal file
View File

@@ -0,0 +1,28 @@
version = 1
SPDX-PackageName = "incorporeal-cms"
SPDX-PackageSupplier = "Brian S. Stephan <bss@incorporeal.org>"
SPDX-PackageDownloadLocation = "https://git.incorporeal.org/bss/incorporeal-cms"
[[annotations]]
path = [".gitignore", ".gitattributes"]
precedence = "aggregate"
SPDX-FileCopyrightText = "© 2020 Brian S. Stephan <bss@incorporeal.org>"
SPDX-License-Identifier = "GPL-3.0-or-later"
[[annotations]]
path = "**.md"
precedence = "aggregate"
SPDX-FileCopyrightText = "© 2020 Brian S. Stephan <bss@incorporeal.org>"
SPDX-License-Identifier = "GPL-3.0-or-later"
[[annotations]]
path = "tests/instance/**"
precedence = "aggregate"
SPDX-FileCopyrightText = "© 2020 Brian S. Stephan <bss@incorporeal.org>"
SPDX-License-Identifier = "GPL-3.0-or-later"
[[annotations]]
path = ["MANIFEST.in", "pyproject.toml", "tox.ini", "requirements/**"]
precedence = "aggregate"
SPDX-FileCopyrightText = "© 2020 Brian S. Stephan <bss@incorporeal.org>"
SPDX-License-Identifier = "GPL-3.0-or-later"

View File

@@ -1,7 +1,7 @@
"""An application for running my Markdown-based sites. """An application for running my Markdown-based sites.
SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import json import json
import logging import logging
@@ -22,7 +22,7 @@ jinja_env = Environment(
try: try:
# packaged/pip install -e . value # packaged/pip install -e . value
from ._version import version as __version__ from ._version import version as __version__
except ImportError: except ImportError: # pragma: no cover
# local clone value # local clone value
from setuptools_scm import get_version from setuptools_scm import get_version
__version__ = get_version(root='..', relative_to=__file__) __version__ = get_version(root='..', relative_to=__file__)

View File

@@ -1,7 +1,7 @@
"""Default configuration. """Default configuration.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """

View File

@@ -1,7 +1,7 @@
"""Process the error page templates. """Process the error page templates.
SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import os import os

View File

@@ -7,7 +7,7 @@ under pages/ (which may make sense for a blog) if they want, but could just
as well be pages/foo content. as well be pages/foo content.
SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import logging import logging
import os import os
@@ -24,6 +24,9 @@ logger = logging.getLogger(__name__)
def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None: def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None:
"""Generate the Atom or RSS feed as requested. """Generate the Atom or RSS feed as requested.
Feed entries should be symlinks to .md files in the pages/ directory, so that they
are also linkable and can be browsed outside of the feed.
Args: Args:
feed_type: 'atom' or 'rss' feed feed_type: 'atom' or 'rss' feed
instance_dir: the directory for the instance, containing both the feed dir and pages instance_dir: the directory for the instance, containing both the feed dir and pages
@@ -37,16 +40,18 @@ def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None:
fg.link(href=f'https://{Config.DOMAIN_NAME}', rel='alternate') fg.link(href=f'https://{Config.DOMAIN_NAME}', rel='alternate')
fg.subtitle(f"Blog posts and other interesting materials from {Config.TITLE_SUFFIX}") fg.subtitle(f"Blog posts and other interesting materials from {Config.TITLE_SUFFIX}")
# feed symlinks should all be within the core content subdirectory
pages_dir = os.path.join(instance_dir, 'pages')
# get recent feeds # get recent feeds
feed_path = os.path.join(instance_dir, '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) 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 filename in filenames if os.path.islink(os.path.join(dirpath, filename))]
for feed_entry_path in sorted(feed_entry_paths): for feed_entry_path in sorted(feed_entry_paths):
# get the actual file to parse it # get the actual file to parse it
os.chdir(os.path.abspath(os.path.join(instance_dir, 'pages'))) resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), pages_dir)
resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), os.path.join(instance_dir, 'pages'))
try: try:
content, md, page_name, page_title, mtime = parse_md(resolved_path) content, md, page_name, page_title, _, mtime = parse_md(os.path.join(pages_dir, resolved_path), pages_dir)
link = f'https://{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): except (OSError, ValueError, TypeError):
logger.exception("error loading/rendering markdown!") logger.exception("error loading/rendering markdown!")

View File

@@ -5,7 +5,7 @@ as is, but .md files need to be processed with a Markdown parser, so a lot of th
is our tweaks and customizations for pages my way. is our tweaks and customizations for pages my way.
SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import datetime import datetime
import logging import logging
@@ -13,6 +13,7 @@ import os
import re import re
import markdown import markdown
from bs4 import BeautifulSoup
from markupsafe import Markup from markupsafe import Markup
from incorporealcms import jinja_env from incorporealcms import jinja_env
@@ -44,18 +45,32 @@ def instance_resource_path_to_request_path(path):
return '/' + re.sub(r'.md$', '', re.sub(r'index.md$', '', path)) return '/' + re.sub(r'.md$', '', re.sub(r'index.md$', '', path))
def parse_md(path: str): def parse_md(path: str, pages_root: str):
"""Given a file to parse, return file content and other derived data along with the md object. """Given a file to parse, return file content and other derived data along with the md object.
Args: Args:
path: the path to the file to render path: the path to the file to render
pages_root: the absolute path to the pages/ dir, which the path should be within. necessary for
proper resolution of resolving parent pages (which needs to know when to stop)
""" """
try: try:
logger.debug("opening path '%s'", path) absolute_path = os.path.join(pages_root, path)
with open(path, 'r') as input_file: logger.debug("opening path '%s'", absolute_path)
with open(absolute_path, 'r') as input_file:
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(input_file.name), tz=datetime.timezone.utc) mtime = datetime.datetime.fromtimestamp(os.path.getmtime(input_file.name), tz=datetime.timezone.utc)
entry = input_file.read() entry = input_file.read()
logger.debug("path '%s' read", path)
logger.debug("path '%s' read", absolute_path)
# remove .md extensions used for navigating in vim and replace them with
# the pattern we use for HTML output here
# foo/index.md -> foo/, foo/index.md#anchor -> foo/#anchor
# ../index.md -> ../, ../index.md#anchor -> ../#anchor
entry = re.sub(r'\[([^]]+)\]\(([^)]+)index.md(#[^)]*)?\)', r'[\1](\2\3)', entry)
# index.md -> ., index.md#anchor -> .#anchor
entry = re.sub(r'\[([^]]+)\]\(index.md(#[^)]*)?\)', r'[\1](.\2)', entry)
# bar.md -> bar, foo/bar.md -> foo/bar, bar.md#anchor -> bar#anchor
entry = re.sub(r'\[([^]]+)\]\(([^)]+).md(#[^)]*)?\)', r'[\1](\2\3)', entry)
md = init_md() md = init_md()
content = Markup(md.convert(entry)) # nosec B704 content = Markup(md.convert(entry)) # nosec B704
except (OSError, FileNotFoundError): except (OSError, FileNotFoundError):
@@ -67,39 +82,80 @@ def parse_md(path: str):
logger.debug("file metadata: %s", md.Meta) 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) rel_path = os.path.relpath(path, pages_root)
page_name, page_description = _get_metadata_from_parsed_page(md, content, rel_path)
page_title = f'{page_name} - {Config.TITLE_SUFFIX}' if page_name else Config.TITLE_SUFFIX page_title = f'{page_name} - {Config.TITLE_SUFFIX}' if page_name else Config.TITLE_SUFFIX
logger.debug("title (potentially derived): %s", page_title) logger.debug("title (potentially derived): %s", page_title)
return content, md, page_name, page_title, mtime return content, md, page_name, page_title, page_description, mtime
def handle_markdown_file_path(path: str) -> str: def _get_metadata_from_parsed_page(md, content, path: str):
"""Given a location on disk, attempt to open it and render the markdown within.""" """Get the page name and description from a Markdown object and/or HTML output of a page.
content, md, page_name, page_title, mtime = parse_md(path)
parent_navs = generate_parent_navs(path) Args:
md: the parsed Markdown object, potentially including Meta tags
content: the Markdown page content converted to HTML, to run through BeautifulSoup
path: path of the page, to derive the name from as a fallback
"""
soup = BeautifulSoup(content, features='lxml')
# get the page title first from the markdown tags, second from the first h1, last from the path
page_name = None
if md.Meta.get('title'):
page_name = get_meta_str(md, 'title')
elif h1_tag := soup.find('h1'):
page_name = h1_tag.string
elif not page_name:
page_name = instance_resource_path_to_request_path(path)
# get the page description from the markdown tags or first paragraph
page_description = None
if md.Meta.get('description'):
page_description = get_meta_str(md, 'description')
elif p_tag := soup.find('p'):
if page_description := p_tag.string:
page_description = page_description.replace('\n', ' ')
return page_name, page_description
def handle_markdown_file_path(path: str, pages_root: str) -> str:
"""Given a location on disk, attempt to open it and render the markdown within.
Args:
path: the path to the file to parse and produce metadata for
pages_root: the absolute path to the pages/ dir, which the path should be within. necessary for
proper resolution of resolving parent pages (which needs to know when to stop)
"""
content, md, page_name, page_title, page_description, mtime = parse_md(path, pages_root)
relative_path = os.path.relpath(path, pages_root)
parent_navs = generate_parent_navs(relative_path, pages_root)
extra_footer = get_meta_str(md, 'footer') if md.Meta.get('footer') else None 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' 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) template = jinja_env.get_template(template_name)
return template.render(title=page_title, return template.render(title=page_title,
config=Config, config=Config,
description=get_meta_str(md, 'description'), description=page_description,
image=get_meta_str(md, 'image'), image=Config.BASE_HOST + get_meta_str(md, 'image'),
content=content, content=content,
base_url=Config.BASE_HOST + instance_resource_path_to_request_path(path), base_url=Config.BASE_HOST + instance_resource_path_to_request_path(relative_path),
navs=parent_navs, navs=parent_navs,
mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'), mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'),
extra_footer=extra_footer) extra_footer=extra_footer)
def generate_parent_navs(path): def generate_parent_navs(path, pages_root: str):
"""Create a series of paths/links to navigate up from the given resource path.""" """Create a series of paths/links to navigate up from the given resource path.
Args:
path: the path to parse and generate parent metadata nav links for
pages_root: the absolute path to the pages/ dir, which the path should be within. path is relative,
but opening parents requires the full path
"""
logger.debug("path to generate navs for: %s", path)
if path == 'index.md': if path == 'index.md':
# bail and return the domain name as a terminal case # bail and return the domain name as a terminal case
return [(Config.DOMAIN_NAME, '/')] return [(Config.DOMAIN_NAME, '/')]
@@ -124,18 +180,10 @@ def generate_parent_navs(path):
# read the resource # read the resource
try: try:
with open(path, 'r') as entry_file: with open(os.path.join(pages_root, path), 'r') as entry_file:
entry = entry_file.read() entry = entry_file.read()
_ = Markup(md.convert(entry)) # nosec B704 content = Markup(md.convert(entry)) # nosec B704
page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title') page_name, _ = _get_metadata_from_parsed_page(md, content, os.path.relpath(path, parent_resource_dir))
else request_path_to_breadcrumb_display(request_path)) return generate_parent_navs(parent_resource_path, pages_root) + [(page_name, request_path)]
return generate_parent_navs(parent_resource_path) + [(page_name, request_path)]
except FileNotFoundError: except FileNotFoundError:
return generate_parent_navs(parent_resource_path) + [(request_path, request_path)] return generate_parent_navs(parent_resource_path, pages_root) + [(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('/')

View File

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

View File

@@ -1,7 +1,7 @@
"""Create generic figures with captions. """Create generic figures with captions.
SPDX-FileCopyrightText: © 2022 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2022 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import re import re
from xml.etree.ElementTree import SubElement # nosec B405 from xml.etree.ElementTree import SubElement # nosec B405

View File

@@ -1,7 +1,7 @@
"""Serve dot diagrams inline. """Serve dot diagrams inline.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import base64 import base64
import logging import logging

View File

@@ -1,9 +1,10 @@
"""Build an instance as a static site suitable for serving via e.g. Nginx. """Build an instance as a static site suitable for serving via e.g. Nginx.
SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import argparse import argparse
import logging
import os import os
import shutil import shutil
import stat import stat
@@ -16,6 +17,8 @@ from incorporealcms.error_pages import generate_error_pages
from incorporealcms.feed import generate_feed from incorporealcms.feed import generate_feed
from incorporealcms.markdown import handle_markdown_file_path from incorporealcms.markdown import handle_markdown_file_path
logger = logging.getLogger(__name__)
class StaticSiteGenerator(object): class StaticSiteGenerator(object):
"""Generate static site output based on the instance's content.""" """Generate static site output based on the instance's content."""
@@ -53,10 +56,6 @@ class StaticSiteGenerator(object):
pass pass
self.build_in_destination(program_static_dir, static_output_dir, convert_markdown=False) 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)
# generate the feeds # generate the feeds
cprint("generating feeds", 'green') cprint("generating feeds", 'green')
generate_feed('atom', self.instance_dir, tmp_output_dir) generate_feed('atom', self.instance_dir, tmp_output_dir)
@@ -87,16 +86,20 @@ class StaticSiteGenerator(object):
convert_markdown: whether or not to convert Markdown files (or simply copy them) convert_markdown: whether or not to convert Markdown files (or simply copy them)
""" """
cprint(f"copying files from '{source_dir}' to '{dest_dir}'", 'green') 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): for base_dir, subdirs, files in os.walk(source_dir):
logger.debug("starting to build against %s || %s || %s", base_dir, subdirs, files)
# remove the absolute path of the directory from the base_dir # remove the absolute path of the directory from the base_dir
base_dir = os.path.relpath(base_dir, source_dir) relpath = os.path.relpath(base_dir, source_dir)
base_dir = relpath if relpath != '.' else ''
# create subdirs seen here for subsequent depth # create subdirs seen here for subsequent depth
for subdir in subdirs: for subdir in subdirs:
self.build_subdir_in_destination(source_dir, base_dir, subdir, dest_dir) self.build_subdir_in_destination(source_dir, base_dir, subdir, dest_dir)
# process and copy files # process and copy files
for file_ in files: for file_ in files:
if file_[0] == '.':
cprint(f"skipping {file_}", 'yellow')
continue
self.build_file_in_destination(source_dir, base_dir, file_, dest_dir, convert_markdown) 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: def build_subdir_in_destination(self, source_dir: str, base_dir: str, subdir: str, dest_dir: str) -> None:
@@ -109,9 +112,12 @@ class StaticSiteGenerator(object):
dest_dir: the output directory to place the subdir in dest_dir: the output directory to place the subdir in
""" """
dst = os.path.join(dest_dir, base_dir, subdir) dst = os.path.join(dest_dir, base_dir, subdir)
if os.path.islink(os.path.join(base_dir, subdir)): absolute_dir = os.path.join(source_dir, base_dir, subdir)
logger.debug("checking if %s is a symlink or not", absolute_dir)
if os.path.islink(absolute_dir):
logger.debug("symlink; raw destination is %s", os.path.realpath(absolute_dir))
# keep the link relative to the output directory # keep the link relative to the output directory
src = self.symlink_to_relative_dest(source_dir, os.path.join(base_dir, subdir)) src = self.symlink_to_relative_dest(source_dir, absolute_dir)
print(f"creating directory symlink '{dst}' -> '{src}'") print(f"creating directory symlink '{dst}' -> '{src}'")
os.symlink(src, dst, target_is_directory=True) os.symlink(src, dst, target_is_directory=True)
else: else:
@@ -133,21 +139,33 @@ class StaticSiteGenerator(object):
dest_dir: the output directory to place the subdir in dest_dir: the output directory to place the subdir in
""" """
dst = os.path.join(dest_dir, base_dir, file_) dst = os.path.join(dest_dir, base_dir, file_)
if os.path.islink(os.path.join(base_dir, file_)): absolute_file = os.path.join(source_dir, base_dir, file_)
logger.debug("checking if %s is a symlink or not", absolute_file)
if os.path.islink(absolute_file):
logger.debug("symlink; raw destination is %s", os.path.realpath(absolute_file))
# keep the link relative to the output directory # keep the link relative to the output directory
src = self.symlink_to_relative_dest(source_dir, os.path.join(base_dir, file_)) src = self.symlink_to_relative_dest(source_dir, absolute_file)
print(f"creating symlink '{dst}' -> '{src}'") print(f"creating symlink '{dst}' -> '{src}'")
os.symlink(src, dst, target_is_directory=False) os.symlink(src, dst, target_is_directory=False)
if src.endswith('.md') and convert_markdown:
# we also need to make a .html symlink so that web server configs
# pick up the "redirect"
second_src = src.removesuffix('.md') + '.html'
second_dst = dst.removesuffix('.md') + '.html'
print(f"creating symlink '{second_dst}' -> '{second_src}'")
os.symlink(second_src, second_dst, target_is_directory=False)
else: else:
src = os.path.join(base_dir, file_) src = os.path.join(source_dir, base_dir, file_)
print(f"copying file '{src}' -> '{dst}'") print(f"copying file '{src}' -> '{dst}'")
shutil.copy2(src, dst) shutil.copy2(src, dst)
# render markdown as HTML # render markdown as HTML
if src.endswith('.md') and convert_markdown: if src.endswith('.md') and convert_markdown:
rendered_file = dst.removesuffix('.md') + '.html' rendered_file = dst.removesuffix('.md') + '.html'
print(f"rendering file '{src}' -> '{rendered_file}'")
try: try:
content = handle_markdown_file_path(src) content = handle_markdown_file_path(src, source_dir)
except UnicodeDecodeError: except UnicodeDecodeError:
# perhaps this isn't a markdown file at all for some reason; we # perhaps this isn't a markdown file at all for some reason; we
# copied it above so stick with tha # copied it above so stick with tha
@@ -159,6 +177,10 @@ class StaticSiteGenerator(object):
def symlink_to_relative_dest(self, base_dir: str, source: str) -> str: 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. """Given a symlink, make sure it points to something inside the instance and provide its real destination.
This is made to be relative to the location of the symlink in all
circumstances, in order to avoid breaking out of the instance or output
dirs.
Args: Args:
base_dir: the full absolute path of the instance's pages dir, which the symlink destination must be in. base_dir: the full absolute path of the instance's pages dir, which the symlink destination must be in.
source: the symlink to check source: the symlink to check
@@ -167,8 +189,8 @@ class StaticSiteGenerator(object):
""" """
if not os.path.realpath(source).startswith(base_dir): if not os.path.realpath(source).startswith(base_dir):
raise ValueError(f"symlink destination {os.path.realpath(source)} is outside the instance!") 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... # this symlink points to realpath inside base_dir, so relative to the source, the symlink dest is...
return os.path.relpath(os.path.realpath(source), base_dir) return os.path.relpath(os.path.realpath(source), os.path.dirname(source))
def build(): def build():

View File

@@ -1,6 +1,6 @@
/* /*
* SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
html { html {
@@ -116,19 +116,26 @@ img {
max-width: 75% !important; max-width: 75% !important;
} }
.full-width {
max-width: 100%;
}
.img-center { .img-center {
display: block; display: block;
clear: both;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.img-left { .img-left {
float: left; float: left;
clear: left;
margin-right: 1em; margin-right: 1em;
} }
.img-right { .img-right {
float: right; float: right;
clear: right;
margin-left: 1em; margin-left: 1em;
} }
@@ -144,12 +151,14 @@ figure {
figure.right { figure.right {
float: right; float: right;
clear: right;
margin-left: 10px; margin-left: 10px;
display: block; display: block;
} }
figure.left { figure.left {
float: left; float: left;
clear: left;
margin-right: 10px; margin-right: 10px;
display: block; display: block;
} }
@@ -164,14 +173,19 @@ figcaption {
font-size: 0.9em; font-size: 0.9em;
} }
.footnote { div.content .footnote {
font-size: 0.8em; font-size: 0.8em;
} }
.footnote p { div.content .footnote p {
margin: 0; margin: 0;
} }
.footnote-ref:link, .footnote-ref:visited, .footnote-ref:hover, .footnote-ref:active { .footnote-ref:link, .footnote-ref:visited, .footnote-ref:hover, .footnote-ref:active {
font-weight: normal; font-weight: normal;
} }
.footnote-ref {
font-size: 0.75em;
margin-left: 1px;
}

View File

@@ -1,6 +1,6 @@
/* /*
* SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
/* common styling via the base.css, used in light and dark */ /* common styling via the base.css, used in light and dark */
@@ -14,11 +14,15 @@ body {
background: #111; background: #111;
} }
hr {
color: #333;
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
color: #B31D15; color: #B31D15;
} }
p a, ul a, ol a { p a, ul a, ol a, sup a {
color: #DDD; color: #DDD;
} }
@@ -26,7 +30,7 @@ footer a {
color: #999; color: #999;
} }
p a:hover, ul a:hover, ol a:hover, footer a:hover { p a:hover, ul a:hover, ol a:hover, footer a:hover, sup a:hover {
color: #B31D15; color: #B31D15;
} }
@@ -44,7 +48,7 @@ table, th, td {
} }
th { th {
background: #333; background: #111;
} }
blockquote { blockquote {

View File

@@ -1,6 +1,6 @@
/* /*
* SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
/* common styling via the base.css, used in light and dark */ /* common styling via the base.css, used in light and dark */
@@ -14,11 +14,15 @@ body {
background: #EEE; background: #EEE;
} }
hr {
color: #CCC;
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
color: #811610; color: #811610;
} }
p a, ul a, ol a { p a, ul a, ol a, sup a {
color: #222; color: #222;
} }
@@ -26,7 +30,7 @@ footer a {
color: #999; color: #999;
} }
p a:hover, ul a:hover, ol a:hover, footer a:hover { p a:hover, ul a:hover, ol a:hover, footer a:hover, sup a:hover {
color: #811610; color: #811610;
} }
@@ -44,7 +48,7 @@ table, th, td {
} }
th { th {
background: #CCC; background: #EEE;
} }
blockquote { blockquote {

View File

@@ -1,6 +1,6 @@
/* /*
* SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
/* specify almost no styling, just fix some image and nav rendering */ /* specify almost no styling, just fix some image and nav rendering */
@@ -9,6 +9,10 @@ div.header {
justify-content: space-between; justify-content: space-between;
} }
table, th, td {
border: 1px solid;
}
.img-25 { .img-25 {
max-width: 25% !important; max-width: 25% !important;
} }

View File

@@ -5,7 +5,7 @@
* of my CMS, so I want to keep it around even in the static site. * of my CMS, so I want to keep it around even in the static site.
* *
* SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> * SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org>
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
/** /**

View File

@@ -1,6 +1,6 @@
{# {#
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
#} #}
{% extends "base.html" %} {% extends "base.html" %}

View File

@@ -1,6 +1,6 @@
{# {#
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
#} #}
{% extends "base.html" %} {% extends "base.html" %}

View File

@@ -1,6 +1,6 @@
{# {#
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
#} #}
{% extends "base.html" %} {% extends "base.html" %}

View File

@@ -1,6 +1,6 @@
{# {#
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
#} #}
{% extends "base.html" %} {% extends "base.html" %}
{% block site_class %}class="site-wrap site-wrap-double-width"{% endblock %} {% block site_class %}class="site-wrap site-wrap-double-width"{% endblock %}

View File

@@ -1,13 +1,13 @@
<!doctype html>{# <!doctype html>{#
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
#} #}
<html lang="en"> <html lang="en">
<title>{{ title }}</title> <title>{{ title }}</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta property="og:url" content="{{ base_url }}">
{% if title %}<meta property="og:title" content="{{ title }}">{% endif %} {% if title %}<meta property="og:title" content="{{ title }}">{% endif %}
{% if description %}<meta property="og:description" content="{{ description }}">{% 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 %} {% if image %}<meta property="og:image" content="{{ image }}">{% endif %}
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -18,7 +18,7 @@ SPDX-License-Identifier: GPL-3.0-only
<link rel="icon" href="{{ config.FAVICON }}"> <link rel="icon" href="{{ config.FAVICON }}">
<link rel="alternate" type="application/atom+xml" href="/feed/atom"> <link rel="alternate" type="application/atom+xml" href="/feed/atom">
<link rel="alternate" type="application/rss+xml" href="/feed/rss"> <link rel="alternate" type="application/rss+xml" href="/feed/rss">
<script type="text/javascript" src="/static/js/style_switcher.js"></script> <script src="/static/js/style_switcher.js"></script>
<div {% block site_class %}class="site-wrap site-wrap-normal-width"{% endblock %}> <div {% block site_class %}class="site-wrap site-wrap-normal-width"{% endblock %}>
{% block header %} {% block header %}
@@ -44,7 +44,11 @@ SPDX-License-Identifier: GPL-3.0-only
</div> </div>
<footer> <footer>
{% if extra_footer %}<div class="extra-footer"><i>{{ extra_footer|safe }}</i></div>{% endif %} {% if extra_footer %}<div class="extra-footer"><i>{{ extra_footer|safe }}</i></div>{% endif %}
<div class="footer"><i>Last modified: {{ mtime }}</i></div> <div class="footer">
<i>Last modified: {{ mtime }}.<br />
{% if config.LICENSE %} Available via {{ config.LICENSE|safe }}{% endif %}.
</i>
</div>
</footer> </footer>
{% endblock %} {% endblock %}
</div> </div>

View File

@@ -6,17 +6,16 @@ build-backend = "setuptools.build_meta"
name = "incorporeal-cms" name = "incorporeal-cms"
description = "A lightweight static site generator for Markdown-based sites." description = "A lightweight static site generator for Markdown-based sites."
readme = "README.md" readme = "README.md"
license = {text = "GPL-3.0-only"} license = {text = "GPL-3.0-or-later"}
authors = [ authors = [
{name = "Brian S. Stephan", email = "bss@incorporeal.org"}, {name = "Brian S. Stephan", email = "bss@incorporeal.org"},
] ]
requires-python = ">=3.9" requires-python = ">=3.10"
dependencies = ["feedgen", "jinja2", "Markdown", "termcolor"] dependencies = ["beautifulsoup4", "feedgen", "jinja2", "Markdown", "termcolor"]
dynamic = ["version"] dynamic = ["version"]
classifiers = [ classifiers = [
"Framework :: Flask",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Topic :: Text Processing :: Markup :: Markdown", "Topic :: Text Processing :: Markup :: Markdown",
] ]

View File

@@ -1,40 +1,41 @@
# #
# This file is autogenerated by pip-compile with Python 3.12 # This file is autogenerated by pip-compile with Python 3.13
# by the following command: # by the following command:
# #
# pip-compile --extra=dev --output-file=requirements/requirements-dev.txt # pip-compile --extra=dev --output-file=requirements/requirements-dev.txt
# #
annotated-types==0.7.0 annotated-types==0.7.0
# via pydantic # via pydantic
attrs==25.3.0 anyio==4.12.1
# via httpx
attrs==25.4.0
# via reuse # via reuse
authlib==1.5.1 authlib==1.6.6
# via safety # via safety
bandit==1.8.3 bandit==1.9.3
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
binaryornot==0.4.4 beautifulsoup4==4.14.3
# via reuse # via incorporeal-cms (pyproject.toml)
boolean-py==4.0 boolean-py==5.0
# via # via license-expression
# license-expression build==1.4.0
# reuse
build==1.2.2.post1
# via pip-tools # via pip-tools
cachetools==5.5.2 cachetools==6.2.6
# via tox # via tox
certifi==2025.1.31 certifi==2026.1.4
# via requests # via
cffi==1.17.1 # httpcore
# httpx
# requests
cffi==2.0.0
# via cryptography # via cryptography
chardet==5.2.0 chardet==5.2.0
# via # via tox
# binaryornot charset-normalizer==3.4.4
# tox
charset-normalizer==3.4.1
# via # via
# python-debian # python-debian
# requests # requests
click==8.1.8 click==8.3.1
# via # via
# nltk # nltk
# pip-tools # pip-tools
@@ -43,17 +44,17 @@ click==8.1.8
# typer # typer
colorama==0.4.6 colorama==0.4.6
# via tox # via tox
coverage[toml]==7.7.0 coverage[toml]==7.13.2
# via pytest-cov # via pytest-cov
cryptography==44.0.2 cryptography==46.0.4
# via # via
# authlib # authlib
# secretstorage # secretstorage
distlib==0.3.9 distlib==0.4.0
# via virtualenv # via virtualenv
dlint==0.16.0 dlint==0.16.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
docutils==0.21.2 docutils==0.22.4
# via readme-renderer # via readme-renderer
dparse==0.6.4 dparse==0.6.4
# via # via
@@ -61,12 +62,12 @@ dparse==0.6.4
# safety-schemas # safety-schemas
feedgen==1.0.0 feedgen==1.0.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
filelock==3.16.1 filelock==3.20.3
# via # via
# safety # safety
# tox # tox
# virtualenv # virtualenv
flake8==7.1.2 flake8==7.3.0
# via # via
# dlint # dlint
# flake8-builtins # flake8-builtins
@@ -78,7 +79,7 @@ flake8==7.1.2
# incorporeal-cms (pyproject.toml) # incorporeal-cms (pyproject.toml)
flake8-blind-except==0.2.1 flake8-blind-except==0.2.1
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
flake8-builtins==2.5.0 flake8-builtins==3.1.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
flake8-docstrings==1.7.0 flake8-docstrings==1.7.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
@@ -86,27 +87,36 @@ flake8-executable==2.1.3
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
flake8-fixme==1.1.1 flake8-fixme==1.1.1
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
flake8-isort==6.1.2 flake8-isort==7.0.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
flake8-logging-format==2024.24.12 flake8-logging-format==2024.24.12
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
flake8-mutable==1.2.0 flake8-mutable==1.2.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
flake8-pyproject==1.2.3 flake8-pyproject==1.2.4
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
h11==0.16.0
# via httpcore
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via safety
id==1.5.0 id==1.5.0
# via twine # via twine
idna==3.10 idna==3.11
# via requests # via
iniconfig==2.0.0 # anyio
# httpx
# requests
iniconfig==2.3.0
# via pytest # via pytest
isort==6.0.1 isort==7.0.0
# via flake8-isort # via flake8-isort
jaraco-classes==3.4.0 jaraco-classes==3.4.0
# via keyring # via keyring
jaraco-context==6.0.1 jaraco-context==6.1.0
# via keyring # via keyring
jaraco-functools==4.1.0 jaraco-functools==4.4.0
# via keyring # via keyring
jeepney==0.9.0 jeepney==0.9.0
# via # via
@@ -117,43 +127,44 @@ jinja2==3.1.6
# incorporeal-cms (pyproject.toml) # incorporeal-cms (pyproject.toml)
# reuse # reuse
# safety # safety
joblib==1.4.2 joblib==1.5.3
# via nltk # via nltk
keyring==25.6.0 keyring==25.7.0
# via twine # via twine
license-expression==30.4.1 librt==0.7.8
# via mypy
license-expression==30.4.4
# via reuse # via reuse
lxml==5.3.1 lxml==6.0.2
# via feedgen # via feedgen
markdown==3.7 markdown==3.10.1
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
markdown-it-py==3.0.0 markdown-it-py==4.0.0
# via rich # via rich
markupsafe==3.0.2 markupsafe==3.0.3
# via jinja2 # via jinja2
marshmallow==3.26.1 marshmallow==4.2.1
# via safety # via safety
mccabe==0.7.0 mccabe==0.7.0
# via flake8 # via flake8
mdurl==0.1.2 mdurl==0.1.2
# via markdown-it-py # via markdown-it-py
more-itertools==10.6.0 more-itertools==10.8.0
# via # via
# jaraco-classes # jaraco-classes
# jaraco-functools # jaraco-functools
mypy==1.15.0 mypy==1.19.1
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
mypy-extensions==1.0.0 mypy-extensions==1.1.0
# via mypy # via mypy
nh3==0.2.21 nh3==0.3.2
# via readme-renderer # via readme-renderer
nltk==3.9.1 nltk==3.9.2
# via safety # via safety
packaging==24.2 packaging==26.0
# via # via
# build # build
# dparse # dparse
# marshmallow
# pyproject-api # pyproject-api
# pytest # pytest
# safety # safety
@@ -161,65 +172,68 @@ packaging==24.2
# setuptools-scm # setuptools-scm
# tox # tox
# twine # twine
pbr==6.1.1 # wheel
# via stevedore pathspec==1.0.4
pip-tools==7.4.1 # via mypy
pip-tools==7.5.2
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
platformdirs==4.3.6 platformdirs==4.5.1
# via # via
# tox # tox
# virtualenv # virtualenv
pluggy==1.5.0 pluggy==1.6.0
# via # via
# pytest # pytest
# pytest-cov
# tox # tox
psutil==6.1.1 pycodestyle==2.14.0
# via safety
pycodestyle==2.12.1
# via flake8 # via flake8
pycparser==2.22 pycparser==3.0
# via cffi # via cffi
pydantic==2.9.2 pydantic==2.12.5
# via # via
# safety # safety
# safety-schemas # safety-schemas
pydantic-core==2.23.4 pydantic-core==2.41.5
# via pydantic # via pydantic
pydocstyle==6.3.0 pydocstyle==6.3.0
# via flake8-docstrings # via flake8-docstrings
pydot==3.0.4 pydot==4.0.1
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
pyflakes==3.2.0 pyflakes==3.4.0
# via flake8 # via flake8
pygments==2.19.1 pygments==2.19.2
# via # via
# pytest
# readme-renderer # readme-renderer
# rich # rich
pyparsing==3.2.1 pyparsing==3.3.2
# via pydot # via pydot
pyproject-api==1.9.0 pyproject-api==1.10.0
# via tox # via tox
pyproject-hooks==1.2.0 pyproject-hooks==1.2.0
# via # via
# build # build
# pip-tools # pip-tools
pytest==8.3.5 pytest==9.0.2
# via # via
# incorporeal-cms (pyproject.toml) # incorporeal-cms (pyproject.toml)
# pytest-cov # pytest-cov
pytest-cov==6.0.0 pytest-cov==7.0.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
# via feedgen # via feedgen
python-debian==1.0.1 python-debian==1.0.1
# via reuse # via reuse
pyyaml==6.0.2 python-magic==0.4.27
# via reuse
pyyaml==6.0.3
# via bandit # via bandit
readme-renderer==44.0 readme-renderer==44.0
# via twine # via twine
regex==2024.11.6 regex==2026.1.15
# via nltk # via nltk
requests==2.32.3 requests==2.32.5
# via # via
# id # id
# requests-toolbelt # requests-toolbelt
@@ -227,64 +241,72 @@ requests==2.32.3
# twine # twine
requests-toolbelt==1.0.0 requests-toolbelt==1.0.0
# via twine # via twine
reuse==5.0.2 reuse==6.2.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
rfc3986==2.0.0 rfc3986==2.0.0
# via twine # via twine
rich==13.9.4 rich==14.3.1
# via # via
# bandit # bandit
# twine # twine
# typer # typer
ruamel-yaml==0.18.10 ruamel-yaml==0.19.1
# via # via
# safety # safety
# safety-schemas # safety-schemas
ruamel-yaml-clib==0.2.12 safety==3.7.0
# via ruamel-yaml
safety==3.3.1
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
safety-schemas==0.0.11 safety-schemas==0.0.16
# via safety # via safety
secretstorage==3.3.3 secretstorage==3.5.0
# via keyring # via keyring
setuptools-scm==8.2.0 setuptools-scm==9.2.2
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
shellingham==1.5.4 shellingham==1.5.4
# via typer # via typer
six==1.17.0 six==1.17.0
# via python-dateutil # via python-dateutil
snowballstemmer==2.2.0 snowballstemmer==3.0.1
# via pydocstyle # via pydocstyle
stevedore==5.4.1 soupsieve==2.8.3
# via beautifulsoup4
stevedore==5.6.0
# via bandit # via bandit
termcolor==2.5.0 tenacity==9.1.2
# via safety
termcolor==3.3.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
tomlkit==0.13.2 tomlkit==0.14.0
# via reuse # via
tox==4.24.2 # reuse
# safety
tox==4.34.1
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
tqdm==4.67.1 tqdm==4.67.1
# via nltk # via nltk
twine==6.1.0 twine==6.2.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
typer==0.15.2 typer==0.21.1
# via safety # via safety
typing-extensions==4.12.2 typing-extensions==4.15.0
# via # via
# beautifulsoup4
# mypy # mypy
# pydantic # pydantic
# pydantic-core # pydantic-core
# safety # safety
# safety-schemas # safety-schemas
# typer # typer
urllib3==2.3.0 # typing-inspection
typing-inspection==0.4.2
# via pydantic
urllib3==2.6.3
# via # via
# requests # requests
# twine # twine
virtualenv==20.29.3 virtualenv==20.36.1
# via tox # via tox
wheel==0.45.1 wheel==0.46.3
# via pip-tools # via pip-tools
# The following packages are considered to be unsafe in a requirements file: # The following packages are considered to be unsafe in a requirements file:

View File

@@ -1,22 +1,28 @@
# #
# This file is autogenerated by pip-compile with Python 3.12 # This file is autogenerated by pip-compile with Python 3.13
# by the following command: # by the following command:
# #
# pip-compile --output-file=requirements/requirements.txt # pip-compile --output-file=requirements/requirements.txt
# #
beautifulsoup4==4.14.3
# via incorporeal-cms (pyproject.toml)
feedgen==1.0.0 feedgen==1.0.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
jinja2==3.1.6 jinja2==3.1.6
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
lxml==5.3.1 lxml==6.0.2
# via feedgen # via feedgen
markdown==3.7 markdown==3.10.1
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
markupsafe==3.0.2 markupsafe==3.0.3
# via jinja2 # via jinja2
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
# via feedgen # via feedgen
six==1.17.0 six==1.17.0
# via python-dateutil # via python-dateutil
termcolor==2.5.0 soupsieve==2.8.3
# via beautifulsoup4
termcolor==3.3.0
# via incorporeal-cms (pyproject.toml) # via incorporeal-cms (pyproject.toml)
typing-extensions==4.15.0
# via beautifulsoup4

View File

@@ -1,7 +1,7 @@
"""Test graphviz functionality. """Test graphviz functionality.
SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import os import os
import tempfile import tempfile
@@ -23,13 +23,11 @@ def test_graphviz_is_rendered():
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance') src_dir = os.path.join(HERE, 'instance')
ssg = StaticSiteGenerator(src_dir, tmpdir) ssg = StaticSiteGenerator(src_dir, tmpdir)
os.chdir(os.path.join(src_dir, 'pages'))
ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'pages'), '', 'test-graphviz.md', tmpdir, True) 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: with open(os.path.join(tmpdir, 'test-graphviz.html'), 'r') as graphviz_output:
data = graphviz_output.read() data = graphviz_output.read()
assert 'data:image/png;base64' in data assert 'data:image/png;base64' in data
os.chdir(HERE)
def test_invalid_graphviz_is_not_rendered(): def test_invalid_graphviz_is_not_rendered():
@@ -37,12 +35,10 @@ def test_invalid_graphviz_is_not_rendered():
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance') src_dir = os.path.join(HERE, 'instance')
ssg = StaticSiteGenerator(src_dir, tmpdir) ssg = StaticSiteGenerator(src_dir, tmpdir)
os.chdir(os.path.join(src_dir, 'broken'))
with pytest.raises(ValueError): with pytest.raises(ValueError):
ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'broken'), '', 'test-invalid-graphviz.md', ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'broken'), '', 'test-invalid-graphviz.md',
tmpdir, True) tmpdir, True)
os.chdir(HERE)
def test_figures_are_rendered(): def test_figures_are_rendered():
@@ -50,7 +46,6 @@ def test_figures_are_rendered():
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance') src_dir = os.path.join(HERE, 'instance')
ssg = StaticSiteGenerator(src_dir, tmpdir) 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) 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: with open(os.path.join(tmpdir, 'figures.html'), 'r') as graphviz_output:
@@ -62,4 +57,29 @@ def test_figures_are_rendered():
assert ('<figure class="left"><img alt="fancy logo" src="bss-square-no-bg.png" />' assert ('<figure class="left"><img alt="fancy logo" src="bss-square-no-bg.png" />'
'<span></span></figure>') in data '<span></span></figure>') in data
assert '<figure><img alt="just a logo" src="bss-square-no-bg.png" /></figure>' in data assert '<figure><img alt="just a logo" src="bss-square-no-bg.png" /></figure>' in data
os.chdir(HERE)
def test_og_image():
"""Test that the og:image meta tag is present as expected."""
with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance')
ssg = StaticSiteGenerator(src_dir, tmpdir)
ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'pages'), '', 'more-metadata.md', tmpdir, True)
with open(os.path.join(tmpdir, 'more-metadata.html'), 'r') as markdown_output:
data = markdown_output.read()
assert ('<meta property="og:image" content="http://example.org/test.img">') in data
def test_og_url():
"""Test that the og:url meta tag is present as expected."""
with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance')
ssg = StaticSiteGenerator(src_dir, tmpdir)
# testing a whole build run because of bugs in how I handle pathing adding a "./" in
# the generated URLs for content in the pages/ root
ssg.build_in_destination(os.path.join(HERE, 'instance', 'pages'), tmpdir, True)
with open(os.path.join(tmpdir, 'index.html'), 'r') as markdown_output:
data = markdown_output.read()
assert ('<meta property="og:url" content="http://example.org/">') in data

View File

@@ -1 +0,0 @@
Redirect: http://www.google.com/

View File

@@ -18,7 +18,11 @@
"level": "DEBUG", "level": "DEBUG",
"handlers": ["console"] "handlers": ["console"]
}, },
"incorporealcms.pages": { "incorporealcms.markdown": {
"level": "DEBUG",
"handlers": ["console"]
},
"incorporealcms.ssg": {
"level": "DEBUG", "level": "DEBUG",
"handlers": ["console"] "handlers": ["console"]
} }

View File

@@ -1,31 +0,0 @@
"""Configure the test application.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only
"""
LOGGING = {
'version': 1,
'formatters': {
'default': {
'format': '[%(asctime)s %(levelname)-7s %(name)s] %(message)s',
},
},
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'default',
},
},
'loggers': {
'incorporealcms.mdx': {
'level': 'DEBUG',
'handlers': ['console'],
},
'incorporealcms.pages': {
'level': 'DEBUG',
'handlers': ['console'],
},
},
}

View File

@@ -0,0 +1 @@
this is ignored

View File

@@ -0,0 +1,6 @@
[Cool](cool/index.md)
[Anchored Cool](cool/index.md#anchor)
[This Index](index.md)
[Anchored This Index](index.md#anchor)
[Parent](../index.md)
[Anchored Parent](../index.md#anchor)

View File

@@ -0,0 +1,4 @@
[Foo](foo.md)
[Anchored Foo](foo.md#anchor)
[Sub Foo](sub/foo.md)
[Anchored Sub Foo](sub/foo.md#anchor)

View File

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

View File

@@ -0,0 +1 @@
there's just some words here but no title tag or h1

View File

@@ -0,0 +1 @@
there's just some words here but no title tag or h1

View File

@@ -0,0 +1 @@
there's just some words here but no title tag or h1

View File

@@ -0,0 +1,6 @@
# rambling test for inferred description
this is a long string of text where
I am typing a lot over multiple lines
this second paragraph shouldn't be in the metadata

View File

@@ -0,0 +1 @@
../more-metadata.md

View File

@@ -1,7 +1,7 @@
"""Test command line invocations. """Test command line invocations.
SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import os import os
import tempfile import tempfile

View File

@@ -1,7 +1,7 @@
"""Test basic configuration stuff. """Test basic configuration stuff.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import os import os

View File

@@ -1,7 +1,7 @@
"""Test the feed methods. """Test the feed methods.
SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import os import os
import tempfile import tempfile

View File

@@ -1,78 +1,89 @@
"""Test the conversion of Markdown pages. """Test the conversion of Markdown pages.
SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import os import os
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from incorporealcms import init_instance
from incorporealcms.markdown import (generate_parent_navs, handle_markdown_file_path, from incorporealcms.markdown import (generate_parent_navs, handle_markdown_file_path,
instance_resource_path_to_request_path, parse_md, instance_resource_path_to_request_path, parse_md)
request_path_to_breadcrumb_display)
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
os.chdir(os.path.join(HERE, 'instance/', 'pages/')) INSTANCE_DIR = os.path.join(HERE, 'instance')
PAGES_DIR = os.path.join(INSTANCE_DIR, 'pages/')
# initialize in order to configure debug logging
init_instance(INSTANCE_DIR)
def test_generate_page_navs_index(): def test_generate_page_navs_index():
"""Test that the index page has navs to the root (itself).""" """Test that the index page has navs to the root (itself)."""
assert generate_parent_navs('index.md') == [('example.org', '/')] assert generate_parent_navs('index.md', PAGES_DIR) == [('example.org', '/')]
def test_generate_page_navs_title_from_h1():
"""Test that the index page has navs to the root (itself)."""
assert generate_parent_navs('no-title.md', PAGES_DIR) == [('example.org', '/'),
('this page doesn\'t have a title!', '/no-title')]
def test_generate_page_navs_subdir_index(): def test_generate_page_navs_subdir_index():
"""Test that dir pages have navs to the root and themselves.""" """Test that dir pages have navs to the root and themselves."""
assert generate_parent_navs('subdir/index.md') == [('example.org', '/'), ('subdir', '/subdir/')] assert generate_parent_navs('subdir/index.md', PAGES_DIR) == [('example.org', '/'), ('another page', '/subdir/')]
def test_generate_page_navs_subdir_real_page(): def test_generate_page_navs_subdir_real_page():
"""Test that real pages have navs to the root, their parent, and themselves.""" """Test that real pages have navs to the root, their parent, and themselves."""
assert generate_parent_navs('subdir/page.md') == [('example.org', '/'), ('subdir', '/subdir/'), assert generate_parent_navs('subdir/page.md', PAGES_DIR) == [('example.org', '/'),
('Page', '/subdir/page')] ('another page', '/subdir/'),
('Page', '/subdir/page')]
def test_generate_page_navs_subdir_with_title_parsing_real_page(): def test_generate_page_navs_subdir_with_title_parsing_real_page():
"""Test that title metadata is used in the nav text.""" """Test that title metadata is used in the nav text."""
assert generate_parent_navs('subdir-with-title/page.md') == [ assert generate_parent_navs('subdir-with-title/page.md', PAGES_DIR) == [
('example.org', '/'), ('example.org', '/'),
('SUB!', '/subdir-with-title/'), ('SUB!', '/subdir-with-title/'),
('page', '/subdir-with-title/page') ('/page', '/subdir-with-title/page')
] ]
def test_generate_page_navs_subdir_with_no_index(): def test_generate_page_navs_subdir_with_no_index():
"""Test that breadcrumbs still generate even if a subdir doesn't have an index.md.""" """Test that breadcrumbs still generate even if a subdir doesn't have an index.md."""
assert generate_parent_navs('no-index-dir/page.md') == [ assert generate_parent_navs('no-index-dir/page.md', PAGES_DIR) == [
('example.org', '/'), ('example.org', '/'),
('/no-index-dir/', '/no-index-dir/'), ('/no-index-dir/', '/no-index-dir/'),
('page', '/no-index-dir/page') ('/page', '/no-index-dir/page')
] ]
def test_page_includes_themes_with_default(): def test_page_includes_themes_with_default():
"""Test that a request contains the configured themes and sets the default as appropriate.""" """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">'\ assert '<link rel="stylesheet" type="text/css" title="light" href="/static/css/light.css">'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
assert '<link rel="alternate stylesheet" type="text/css" title="dark" href="/static/css/dark.css">'\ assert '<link rel="alternate stylesheet" type="text/css" title="dark" href="/static/css/dark.css">'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
assert '<a href="" onclick="setStyle(\'light\'); return false;">[light]</a>'\ assert '<a href="" onclick="setStyle(\'light\'); return false;">[light]</a>'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
assert '<a href="" onclick="setStyle(\'dark\'); return false;">[dark]</a>'\ assert '<a href="" onclick="setStyle(\'dark\'); return false;">[dark]</a>'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
def test_render_with_style_overrides(): def test_render_with_style_overrides():
"""Test that the default can be changed.""" """Test that the default can be changed."""
with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'dark'): with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'dark'):
assert '<link rel="stylesheet" type="text/css" title="dark" href="/static/css/dark.css">'\ assert '<link rel="stylesheet" type="text/css" title="dark" href="/static/css/dark.css">'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
assert '<link rel="alternate stylesheet" type="text/css" title="light" href="/static/css/light.css">'\ assert '<link rel="alternate stylesheet" type="text/css" title="light" href="/static/css/light.css">'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
assert '<a href="" onclick="setStyle(\'light\'); return false;">[light]</a>'\ assert '<a href="" onclick="setStyle(\'light\'); return false;">[light]</a>'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
assert '<a href="" onclick="setStyle(\'dark\'); return false;">[dark]</a>'\ assert '<a href="" onclick="setStyle(\'dark\'); return false;">[dark]</a>'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
def test_render_with_default_style_override(): def test_render_with_default_style_override():
@@ -81,23 +92,15 @@ def test_render_with_default_style_override():
'warm': '/static/css/warm.css'}): 'warm': '/static/css/warm.css'}):
with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'warm'): with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'warm'):
assert '<link rel="stylesheet" type="text/css" title="warm" href="/static/css/warm.css">'\ assert '<link rel="stylesheet" type="text/css" title="warm" href="/static/css/warm.css">'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
assert '<link rel="alternate stylesheet" type="text/css" title="cool" href="/static/css/cool.css">'\ assert '<link rel="alternate stylesheet" type="text/css" title="cool" href="/static/css/cool.css">'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
assert '<link rel="alternate stylesheet" type="text/css" title="light" href="/static/css/light.css">'\ assert '<link rel="alternate stylesheet" type="text/css" title="light" href="/static/css/light.css">'\
not in handle_markdown_file_path('index.md') not in handle_markdown_file_path('index.md', PAGES_DIR)
assert '<a href="" onclick="setStyle(\'warm\'); return false;">[warm]</a>'\ assert '<a href="" onclick="setStyle(\'warm\'); return false;">[warm]</a>'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
assert '<a href="" onclick="setStyle(\'cool\'); return false;">[cool]</a>'\ assert '<a href="" onclick="setStyle(\'cool\'); return false;">[cool]</a>'\
in handle_markdown_file_path('index.md') in handle_markdown_file_path('index.md', PAGES_DIR)
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(): def test_instance_resource_path_to_request_path_on_index():
@@ -120,43 +123,103 @@ def test_instance_resource_path_to_request_path_on_subdir_and_page():
assert instance_resource_path_to_request_path('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(): def test_parse_md_metadata():
"""Test the direct results of parsing a markdown file.""" """Test the direct results of parsing a markdown file."""
content, md, page_name, page_title, mtime = parse_md('more-metadata.md') content, md, page_name, page_title, page_desc, mtime = parse_md(
os.path.join(PAGES_DIR, 'more-metadata.md'),
PAGES_DIR
)
assert page_name == 'title for the page' assert page_name == 'title for the page'
assert page_title == 'title for the page - example.org' assert page_title == 'title for the page - example.org'
assert page_desc == 'description of this page made even longer'
def test_parse_md_metadata_forced_no_title(): def test_parse_md_metadata_forced_no_title():
"""Test the direct results of parsing a markdown file.""" """Test the direct results of parsing a markdown file."""
content, md, page_name, page_title, mtime = parse_md('forced-no-title.md') content, md, page_name, page_title, _, mtime = parse_md(os.path.join(PAGES_DIR, 'forced-no-title.md'), PAGES_DIR)
assert page_name == '' assert page_name == ''
assert page_title == 'example.org' assert page_title == 'example.org'
def test_parse_md_metadata_no_title_so_path(): def test_parse_md_metadata_no_title_so_h1():
"""Test the direct results of parsing a markdown file.""" """Test the direct results of parsing a markdown file."""
content, md, page_name, page_title, mtime = parse_md('subdir/index.md') content, md, page_name, page_title, _, mtime = parse_md(os.path.join(PAGES_DIR, 'subdir/index.md'), PAGES_DIR)
assert page_name == '/subdir/' assert page_name == 'another page'
assert page_title == '/subdir/ - example.org' assert page_title == 'another page - example.org'
def test_parse_md_metadata_no_title_or_h1_so_path():
"""Test the direct results of parsing a markdown file."""
content, md, page_name, page_title, _, mtime = parse_md(os.path.join(PAGES_DIR, 'no-title-or-h1.md'), PAGES_DIR)
assert page_name == '/no-title-or-h1'
assert page_title == '/no-title-or-h1 - example.org'
def test_parse_md_metadata_no_title_or_h1_so_path_dir():
"""Test the direct results of parsing a markdown file."""
content, md, page_name, page_title, _, mtime = parse_md(os.path.join(PAGES_DIR, 'no-title-subdir/index.md'),
PAGES_DIR)
assert page_name == '/no-title-subdir/'
assert page_title == '/no-title-subdir/ - example.org'
def test_parse_md_metadata_no_title_or_h1_so_path_dir_file():
"""Test the direct results of parsing a markdown file."""
content, md, page_name, page_title, _, mtime = parse_md(os.path.join(PAGES_DIR,
'no-title-subdir/no-title-or-h1.md'),
PAGES_DIR)
assert page_name == '/no-title-subdir/no-title-or-h1'
assert page_title == '/no-title-subdir/no-title-or-h1 - example.org'
def test_parse_md_derive_description_from_p():
"""Test that we can get a description from the first paragraph in the file."""
content, md, page_name, page_title, page_desc, mtime = parse_md(
os.path.join(PAGES_DIR, 'rambling.md'),
PAGES_DIR
)
assert page_desc == 'this is a long string of text where I am typing a lot over multiple lines'
def test_parse_md_no_file(): def test_parse_md_no_file():
"""Test the direct results of parsing a markdown file.""" """Test the direct results of parsing a markdown file."""
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
content, md, page_name, page_title, mtime = parse_md('nope.md') content, md, page_name, page_title, _, mtime = parse_md(os.path.join(PAGES_DIR, 'nope.md'), PAGES_DIR)
def test_parse_md_bad_file(): def test_parse_md_bad_file():
"""Test the direct results of parsing a markdown file.""" """Test the direct results of parsing a markdown file."""
with pytest.raises(ValueError): with pytest.raises(ValueError):
content, md, page_name, page_title, mtime = parse_md('actually-a-png.md') content, md, page_name, page_title, _, mtime = parse_md(os.path.join(PAGES_DIR, 'actually-a-png.md'), PAGES_DIR)
def test_md_extension_in_source_link_is_stripped():
"""Test that if a foo.md file link is specified in the Markdown, it is foo in the HTML."""
content, _, _, _, _, _ = parse_md(os.path.join(PAGES_DIR, 'file-with-md-link.md'), PAGES_DIR)
assert '<a href="foo">Foo</a>' in content
assert '<a href="foo#anchor">Anchored Foo</a>' in content
assert '<a href="sub/foo">Sub Foo</a>' in content
assert '<a href="sub/foo#anchor">Anchored Sub Foo</a>' in content
def test_index_in_source_link_is_stripped():
"""Test that if a index.md file link is specified in the Markdown, it is just the dir in the HTML."""
content, _, _, _, _, _ = parse_md(os.path.join(PAGES_DIR, 'file-with-index.md-link.md'), PAGES_DIR)
assert '<a href="cool/">Cool</a>' in content
assert '<a href="cool/#anchor">Anchored Cool</a>' in content
assert '<a href=".">This Index</a>' in content
assert '<a href=".#anchor">Anchored This Index</a>' in content
assert '<a href="../">Parent</a>' in content
assert '<a href="../#anchor">Anchored Parent</a>' in content
def test_license_link():
"""Test that the config's license HTML is displayed in the footer."""
with patch('incorporealcms.Config.LICENSE',
'<a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a>', create=True):
assert 'Available via <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a>.'\
in handle_markdown_file_path('index.md', PAGES_DIR)
# default, no config
assert '<a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a>'\
not in handle_markdown_file_path('index.md', PAGES_DIR)

View File

@@ -1,14 +1,17 @@
"""Test the high level SSG operations. """Test the high level SSG operations.
SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-or-later
""" """
import logging
import os import os
import tempfile import tempfile
import incorporealcms.ssg as ssg import incorporealcms.ssg as ssg
from incorporealcms import init_instance from incorporealcms import init_instance
logger = logging.getLogger(__file__)
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
instance_dir = os.path.join(HERE, 'instance') instance_dir = os.path.join(HERE, 'instance')
@@ -20,7 +23,6 @@ def test_file_copy():
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance') src_dir = os.path.join(HERE, 'instance')
generator = ssg.StaticSiteGenerator(src_dir, tmpdir) 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, generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'no-title.md', tmpdir,
True) True)
assert os.path.exists(os.path.join(tmpdir, 'no-title.md')) assert os.path.exists(os.path.join(tmpdir, 'no-title.md'))
@@ -32,7 +34,6 @@ def test_file_copy_no_markdown():
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance') src_dir = os.path.join(HERE, 'instance')
generator = ssg.StaticSiteGenerator(src_dir, tmpdir) 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, generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'no-title.md', tmpdir,
False) False)
assert os.path.exists(os.path.join(tmpdir, 'no-title.md')) assert os.path.exists(os.path.join(tmpdir, 'no-title.md'))
@@ -44,7 +45,6 @@ def test_file_copy_symlink():
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance') src_dir = os.path.join(HERE, 'instance')
generator = ssg.StaticSiteGenerator(src_dir, tmpdir) 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) 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 # 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) generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'foo.txt', tmpdir)
@@ -52,12 +52,44 @@ def test_file_copy_symlink():
assert os.path.islink(os.path.join(tmpdir, 'symlink-to-foo.txt')) assert os.path.islink(os.path.join(tmpdir, 'symlink-to-foo.txt'))
def test_file_copy_subdir_symlink():
"""Test the ability to sync a symlink in a subdirectory to the output dir."""
with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance')
generator = ssg.StaticSiteGenerator(src_dir, tmpdir)
# need to make the subdirectory as if the generator already did
os.mkdir(os.path.join(tmpdir, 'subdir'))
generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), 'subdir',
'relative-symlink-to-parent.md', 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'), '', 'more-metadata.md', tmpdir)
logger.warning("created symlink %s",
os.readlink(os.path.join(tmpdir, 'subdir', 'relative-symlink-to-parent.md')))
assert os.path.islink(os.path.join(tmpdir, 'subdir', 'relative-symlink-to-parent.md'))
assert os.path.exists(os.path.join(tmpdir, 'subdir', 'relative-symlink-to-parent.md'))
def test_file_copy_symlink_of_markdown_also_has_html_symlink():
"""Test the ability to sync source and generated symlinks to the output dir."""
with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance')
generator = ssg.StaticSiteGenerator(src_dir, tmpdir)
generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'symlink-to-no-title.md', tmpdir,
True)
# 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'), '', 'no-title.md', tmpdir, True)
assert os.path.exists(os.path.join(tmpdir, 'symlink-to-no-title.md'))
assert os.path.islink(os.path.join(tmpdir, 'symlink-to-no-title.md'))
# a .md symlink should also create the .html symlink (see issue #24)
assert os.path.exists(os.path.join(tmpdir, 'symlink-to-no-title.html'))
assert os.path.islink(os.path.join(tmpdir, 'symlink-to-no-title.html'))
def test_dir_copy(): def test_dir_copy():
"""Test the ability to sync a directory to the output dir.""" """Test the ability to sync a directory to the output dir."""
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance') src_dir = os.path.join(HERE, 'instance')
generator = ssg.StaticSiteGenerator(src_dir, tmpdir) 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) 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.exists(os.path.join(tmpdir, 'media'))
assert os.path.isdir(os.path.join(tmpdir, 'media')) assert os.path.isdir(os.path.join(tmpdir, 'media'))
@@ -68,7 +100,6 @@ def test_dir_copy_symlink():
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance') src_dir = os.path.join(HERE, 'instance')
generator = ssg.StaticSiteGenerator(src_dir, tmpdir) 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) 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 # 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) generator.build_subdir_in_destination(os.path.join(instance_dir, 'pages'), '', 'subdir', tmpdir)
@@ -88,3 +119,36 @@ def test_build_in_destination():
assert os.path.exists(os.path.join(tmpdir, 'index.html')) 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.md'))
assert os.path.exists(os.path.join(tmpdir, 'subdir', 'index.html')) assert os.path.exists(os.path.join(tmpdir, 'subdir', 'index.html'))
def test_build_in_destination_ignores_dot_files():
"""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 not os.path.exists(os.path.join(tmpdir, '.ignored-file.md'))
def test_build():
"""Test that the high level build can work against two directories."""
with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(HERE, 'instance')
generator = ssg.StaticSiteGenerator(src_dir, tmpdir)
generator.build()
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'))
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'))
assert os.path.exists(os.path.join(tmpdir, 'media'))
assert os.path.isdir(os.path.join(tmpdir, 'media'))
assert not os.path.exists(os.path.join(tmpdir, '.ignored-file.md'))
assert os.path.exists(os.path.join(tmpdir, 'feed'))
assert os.path.isdir(os.path.join(tmpdir, 'feed'))
assert os.path.exists(os.path.join(tmpdir, 'feed/atom'))
assert os.path.exists(os.path.join(tmpdir, 'feed/rss'))

12
tox.ini
View File

@@ -5,7 +5,7 @@
[tox] [tox]
isolated_build = true isolated_build = true
envlist = begin,py39,py310,py311,py312,coverage,security,lint,reuse envlist = begin,py310,py311,py312,py313,coverage,security,lint,reuse
[testenv] [testenv]
allow_externals = pytest, coverage allow_externals = pytest, coverage
@@ -21,11 +21,6 @@ deps = setuptools
skip_install = true skip_install = true
commands = coverage erase commands = coverage erase
[testenv:py39]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:py310] [testenv:py310]
# run pytest with coverage # run pytest with coverage
commands = commands =
@@ -41,6 +36,11 @@ commands =
commands = commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:py313]
# run pytest with coverage
commands =
pytest --cov-append --cov={envsitepackagesdir}/incorporealcms/ --cov-branch
[testenv:coverage] [testenv:coverage]
# report on coverage runs from above # report on coverage runs from above
skip_install = true skip_install = true