Compare commits

...

10 Commits

Author SHA1 Message Date
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
15 changed files with 288 additions and 144 deletions

View File

@ -2,6 +2,35 @@
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.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 ## v2.0.5
### Features ### Features

View File

@ -7,8 +7,8 @@ A lightweight static site generator for Markdown-based sites.
Something like the following should suffice: 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

View File

@ -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

@ -51,7 +51,7 @@ def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None:
# get the actual file to parse it # get the actual file to parse it
resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), pages_dir) resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), pages_dir)
try: try:
content, md, page_name, page_title, mtime = parse_md(os.path.join(pages_dir, resolved_path), pages_dir) 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

@ -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
@ -82,11 +83,42 @@ def parse_md(path: str, pages_root: str):
logger.debug("file metadata: %s", md.Meta) logger.debug("file metadata: %s", md.Meta)
rel_path = os.path.relpath(path, pages_root) rel_path = os.path.relpath(path, pages_root)
page_name = get_meta_str(md, 'title') if md.Meta.get('title') else instance_resource_path_to_request_path(rel_path)
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 _get_metadata_from_parsed_page(md, content, path: str):
"""Get the page name and description from a Markdown object and/or HTML output of a page.
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: def handle_markdown_file_path(path: str, pages_root: str) -> str:
@ -97,7 +129,7 @@ def handle_markdown_file_path(path: str, pages_root: str) -> str:
pages_root: the absolute path to the pages/ dir, which the path should be within. necessary 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) proper resolution of resolving parent pages (which needs to know when to stop)
""" """
content, md, page_name, page_title, mtime = parse_md(path, pages_root) content, md, page_name, page_title, page_description, mtime = parse_md(path, pages_root)
relative_path = os.path.relpath(path, pages_root) relative_path = os.path.relpath(path, pages_root)
parent_navs = generate_parent_navs(relative_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
@ -111,7 +143,7 @@ def handle_markdown_file_path(path: str, pages_root: str) -> str:
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=Config.BASE_HOST + 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(relative_path), base_url=Config.BASE_HOST + instance_resource_path_to_request_path(relative_path),
@ -155,16 +187,8 @@ def generate_parent_navs(path, pages_root: str):
try: try:
with open(os.path.join(pages_root, 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, pages_root) + [(page_name, request_path)]
except FileNotFoundError: except FileNotFoundError:
return generate_parent_navs(parent_resource_path, pages_root) + [(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

@ -10,8 +10,8 @@ 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 = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",

View File

@ -6,35 +6,36 @@
# #
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.8.3 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,63 +172,66 @@ 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==2025.9.1 regex==2026.1.15
# via nltk # via nltk
requests==2.32.5 requests==2.32.5
# via # via
@ -227,62 +241,72 @@ requests==2.32.5
# 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
safety==3.3.1 safety==3.7.0
# 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.5.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

@ -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

@ -10,8 +10,7 @@ import pytest
from incorporealcms import init_instance 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__))
INSTANCE_DIR = os.path.join(HERE, 'instance') INSTANCE_DIR = os.path.join(HERE, 'instance')
@ -26,14 +25,21 @@ def test_generate_page_navs_index():
assert generate_parent_navs('index.md', PAGES_DIR) == [('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', PAGES_DIR) == [('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', PAGES_DIR) == [('example.org', '/'), ('subdir', '/subdir/'), assert generate_parent_navs('subdir/page.md', PAGES_DIR) == [('example.org', '/'),
('another page', '/subdir/'),
('Page', '/subdir/page')] ('Page', '/subdir/page')]
@ -42,7 +48,7 @@ def test_generate_page_navs_subdir_with_title_parsing_real_page():
assert generate_parent_navs('subdir-with-title/page.md', PAGES_DIR) == [ 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')
] ]
@ -51,7 +57,7 @@ def test_generate_page_navs_subdir_with_no_index():
assert generate_parent_navs('no-index-dir/page.md', PAGES_DIR) == [ 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')
] ]
@ -123,51 +129,79 @@ 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(os.path.join(PAGES_DIR, 'more-metadata.md'), PAGES_DIR) 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(os.path.join(PAGES_DIR, 'forced-no-title.md'), PAGES_DIR) 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(os.path.join(PAGES_DIR, 'subdir/index.md'), PAGES_DIR) 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(os.path.join(PAGES_DIR, 'nope.md'), PAGES_DIR) 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(os.path.join(PAGES_DIR, 'actually-a-png.md'), PAGES_DIR) 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(): 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.""" """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) 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">Foo</a>' in content
assert '<a href="foo#anchor">Anchored 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">Sub Foo</a>' in content
@ -176,7 +210,7 @@ def test_md_extension_in_source_link_is_stripped():
def test_index_in_source_link_is_stripped(): 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.""" """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) 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/">Cool</a>' in content
assert '<a href="cool/#anchor">Anchored 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=".">This Index</a>' in content

View File

@ -129,3 +129,26 @@ def test_build_in_destination_ignores_dot_files():
generator.build_in_destination(os.path.join(src_dir, 'pages'), tmpdir) generator.build_in_destination(os.path.join(src_dir, 'pages'), tmpdir)
assert not os.path.exists(os.path.join(tmpdir, '.ignored-file.md')) 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'))

View File

@ -5,7 +5,7 @@
[tox] [tox]
isolated_build = true isolated_build = true
envlist = begin,py39,py310,py311,py312,py313,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 =