1 Commits

Author SHA1 Message Date
d66a471c76 Changelog for v2.0.5
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-09-18 16:45:36 -05:00
13 changed files with 118 additions and 232 deletions

View File

@@ -22,7 +22,7 @@ jinja_env = Environment(
try:
# packaged/pip install -e . value
from ._version import version as __version__
except ImportError: # pragma: no cover
except ImportError:
# local clone value
from setuptools_scm import get_version
__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
resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), pages_dir)
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)}'
except (OSError, ValueError, TypeError):
logger.exception("error loading/rendering markdown!")

View File

@@ -13,7 +13,6 @@ import os
import re
import markdown
from bs4 import BeautifulSoup
from markupsafe import Markup
from incorporealcms import jinja_env
@@ -83,30 +82,11 @@ def parse_md(path: str, pages_root: str):
logger.debug("file metadata: %s", md.Meta)
rel_path = os.path.relpath(path, pages_root)
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(rel_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', ' ')
page_name = get_meta_str(md, 'title') if md.Meta.get('title') else instance_resource_path_to_request_path(rel_path)
page_title = f'{page_name} - {Config.TITLE_SUFFIX}' if page_name else Config.TITLE_SUFFIX
logger.debug("title (potentially derived): %s", page_title)
return content, md, page_name, page_title, page_description, mtime
return content, md, page_name, page_title, mtime
def handle_markdown_file_path(path: str, pages_root: str) -> str:
@@ -117,7 +97,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
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)
content, md, page_name, page_title, 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
@@ -131,7 +111,7 @@ def handle_markdown_file_path(path: str, pages_root: str) -> str:
template = jinja_env.get_template(template_name)
return template.render(title=page_title,
config=Config,
description=page_description,
description=get_meta_str(md, 'description'),
image=Config.BASE_HOST + get_meta_str(md, 'image'),
content=content,
base_url=Config.BASE_HOST + instance_resource_path_to_request_path(relative_path),

View File

@@ -10,8 +10,8 @@ license = {text = "GPL-3.0-or-later"}
authors = [
{name = "Brian S. Stephan", email = "bss@incorporeal.org"},
]
requires-python = ">=3.10"
dependencies = ["beautifulsoup4", "feedgen", "jinja2", "Markdown", "termcolor"]
requires-python = ">=3.9"
dependencies = ["feedgen", "jinja2", "Markdown", "termcolor"]
dynamic = ["version"]
classifiers = [
"Programming Language :: Python :: 3",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
# 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

@@ -134,77 +134,40 @@ def test_request_path_to_breadcrumb_display_patterns():
def test_parse_md_metadata():
"""Test the direct results of parsing a markdown file."""
content, md, page_name, page_title, page_desc, mtime = parse_md(
os.path.join(PAGES_DIR, 'more-metadata.md'),
PAGES_DIR
)
content, md, page_name, page_title, mtime = parse_md(os.path.join(PAGES_DIR, 'more-metadata.md'), PAGES_DIR)
assert page_name == 'title for the page'
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():
"""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_title == 'example.org'
def test_parse_md_metadata_no_title_so_h1():
def test_parse_md_metadata_no_title_so_path():
"""Test the direct results of parsing a markdown file."""
content, md, page_name, page_title, _, mtime = parse_md(os.path.join(PAGES_DIR, 'subdir/index.md'), PAGES_DIR)
assert page_name == 'another page'
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'
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_title == '/subdir/ - example.org'
def test_parse_md_no_file():
"""Test the direct results of parsing a markdown file."""
with pytest.raises(FileNotFoundError):
content, md, page_name, page_title, _, mtime = parse_md(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():
"""Test the direct results of parsing a markdown file."""
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():
"""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#anchor">Anchored Foo</a>' in content
assert '<a href="sub/foo">Sub Foo</a>' in content
@@ -213,7 +176,7 @@ def test_md_extension_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."""
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/#anchor">Anchored Cool</a>' in content
assert '<a href=".">This Index</a>' in content

View File

@@ -129,26 +129,3 @@ def test_build_in_destination_ignores_dot_files():
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'))

View File

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