static site generator part 8 - port the feed generator

I think this is everything? now just for more functional tests

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
This commit is contained in:
Brian S. Stephan 2025-03-16 11:46:02 -05:00
parent 4e96199920
commit c8c39befb3
Signed by: bss
GPG Key ID: 3DE06D3180895FCB
5 changed files with 103 additions and 61 deletions

View File

@ -62,10 +62,10 @@ class Config(object):
DOMAIN_NAME = 'example.org' DOMAIN_NAME = 'example.org'
TITLE_SUFFIX = DOMAIN_NAME TITLE_SUFFIX = DOMAIN_NAME
BASE_HOST = 'http://' + DOMAIN_NAME BASE_HOST = 'http://' + DOMAIN_NAME
CONTACT_EMAIL = 'admin@example.com' CONTACT_EMAIL = 'admin@example.org'
# feed settings # feed settings
AUTHOR = {'name': 'Test Name', 'email': 'admin@example.com'} AUTHOR = {'name': 'Test Name', 'email': 'admin@example.org'}
FAVICON = '/static/img/favicon.png' FAVICON = '/static/img/favicon.png'

View File

@ -14,60 +14,72 @@ import os
import re import re
from feedgen.feed import FeedGenerator from feedgen.feed import FeedGenerator
from flask import Blueprint, Response, abort
from flask import current_app as app
from incorporealcms.lib import instance_resource_path_to_request_path, parse_md from incorporealcms.config import Config
from incorporealcms.markdown import instance_resource_path_to_request_path, parse_md
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
bp = Blueprint('feed', __name__, url_prefix='/feed')
def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None:
"""Generate the Atom or RSS feed as requested.
@bp.route('/<feed_type>') Args:
def serve_feed(feed_type): feed_type: 'atom' or 'rss' feed
"""Serve the Atom or RSS feed as requested.""" instance_dir: the directory for the instance, containing both the feed dir and pages
logger.warning("wat") dest_dir: the directory to place the feed subdir and requested feed
"""
if feed_type not in ('atom', 'rss'): if feed_type not in ('atom', 'rss'):
abort(404) raise ValueError(f"unsupported feed type {feed_type}")
fg = FeedGenerator() fg = FeedGenerator()
fg.id(f'https://{app.config["DOMAIN_NAME"]}/') fg.id(f'https://{Config.DOMAIN_NAME}/')
fg.title(f'{app.config["TITLE_SUFFIX"]}') fg.title(f'{Config.TITLE_SUFFIX}')
fg.author(app.config["AUTHOR"]) fg.author(Config.AUTHOR)
fg.link(href=f'https://{app.config["DOMAIN_NAME"]}/feed/{feed_type}', rel='self') fg.link(href=f'https://{Config.DOMAIN_NAME}/feed/{feed_type}', rel='self')
fg.link(href=f'https://{app.config["DOMAIN_NAME"]}', rel='alternate') fg.link(href=f'https://{Config.DOMAIN_NAME}', rel='alternate')
fg.subtitle(f"Blog posts and other dated materials from {app.config['TITLE_SUFFIX']}") fg.subtitle(f"Blog posts and other interesting materials from {Config.TITLE_SUFFIX}")
# get recent feeds # get recent feeds
feed_path = os.path.join(app.instance_path, 'feed') feed_path = os.path.join(instance_dir, 'feed')
feed_entry_paths = [os.path.join(dirpath, filename) for dirpath, _, filenames in os.walk(feed_path) 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
resolved_path = os.path.realpath(feed_entry_path).replace(f'{app.instance_path}/', '') os.chdir(os.path.abspath(os.path.join(instance_dir, 'pages')))
resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), os.path.join(instance_dir, 'pages'))
try: try:
content, md, page_name, page_title, mtime = parse_md(resolved_path) content, md, page_name, page_title, mtime = parse_md(resolved_path)
link = f'https://{app.config["DOMAIN_NAME"]}/{instance_resource_path_to_request_path(resolved_path)}' link = f'https://{Config.DOMAIN_NAME}{instance_resource_path_to_request_path(resolved_path)}'
except (OSError, ValueError, TypeError): except (OSError, ValueError, TypeError):
logger.exception("error loading/rendering markdown!") logger.exception("error loading/rendering markdown!")
abort(500) raise
fe = fg.add_entry() fe = fg.add_entry()
fe.id(_generate_feed_id(feed_entry_path)) fe.id(_generate_feed_id(feed_entry_path, instance_resource_path_to_request_path(resolved_path)))
fe.title(page_name if page_name else page_title) fe.title(page_title)
fe.author(app.config["AUTHOR"]) fe.author(Config.AUTHOR)
fe.link(href=link) fe.link(href=link)
fe.content(content, type='html') fe.content(content, type='html')
if feed_type == 'atom': if feed_type == 'atom':
return Response(fg.atom_str(pretty=True), mimetype='application/atom+xml') try:
os.mkdir(os.path.join(dest_dir, 'feed'))
except FileExistsError:
pass
with open(os.path.join(dest_dir, 'feed', 'atom'), 'wb') as feed_file:
feed_file.write(fg.atom_str(pretty=True))
else: else:
return Response(fg.rss_str(pretty=True), mimetype='application/rss+xml') try:
os.mkdir(os.path.join(dest_dir, 'feed'))
except FileExistsError:
pass
with open(os.path.join(dest_dir, 'feed', 'rss'), 'wb') as feed_file:
feed_file.write(fg.rss_str(pretty=True))
def _generate_feed_id(feed_entry_path): def _generate_feed_id(feed_entry_path, request_path):
"""For a relative file path, generate the Atom/RSS feed ID for it.""" """For a relative file path, generate the Atom/RSS feed ID for it."""
date = re.sub(r'.*(\d{4})(\d{2})(\d{2}).*', r'\1-\2-\3', feed_entry_path) date = re.sub(r'.*(\d{4})(\d{2})(\d{2}).*', r'\1-\2-\3', feed_entry_path)
cleaned = feed_entry_path.replace('#', '/').replace('feed/', '', 1).replace(app.instance_path, '') cleaned = request_path.replace('#', '/')
return f'tag:{app.config["DOMAIN_NAME"]},{date}:{cleaned}' return f'tag:{Config.DOMAIN_NAME},{date}:{cleaned}'

View File

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

View File

@ -13,6 +13,12 @@ HERE = os.path.dirname(os.path.abspath(__file__))
def test_config(): def test_config():
"""Test that the app initialization sets values not normally present in the config.""" """Test that the app initialization sets values not normally present in the config."""
# this may have gotten here from other imports in other tests
try:
delattr(Config, 'INSTANCE_VALUE')
except AttributeError:
pass
assert not getattr(Config, 'INSTANCE_VALUE', None) assert not getattr(Config, 'INSTANCE_VALUE', None)
assert not getattr(Config, 'EXTRA_VALUE', None) assert not getattr(Config, 'EXTRA_VALUE', None)

View File

@ -3,42 +3,65 @@
SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org> SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later SPDX-License-Identifier: AGPL-3.0-or-later
""" """
from incorporealcms.feed import serve_feed import os
import tempfile
from incorporealcms import init_instance
from incorporealcms.feed import generate_feed
HERE = os.path.dirname(os.path.abspath(__file__))
init_instance(instance_path=os.path.join(HERE, 'instance'))
def test_unknown_type_is_404(client): def test_atom_type_generated():
"""Test that requesting a feed type that doesn't exist is a 404.""" """Test that an ATOM feed can be generated."""
response = client.get('/feed/wat') with tempfile.TemporaryDirectory() as tmpdir:
assert response.status_code == 404 src_dir = os.path.join(HERE, 'instance')
generate_feed('atom', src_dir, tmpdir)
with open(os.path.join(tmpdir, 'feed', 'atom'), 'r') as feed_output:
data = feed_output.read()
assert '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n<feed xmlns="http://www.w3.org/2005/Atom">' in data
assert '<id>https://example.org/</id>' in data
assert '<email>admin@example.org</email>' in data
assert '<name>Test Name</name>' in data
# forced-no-title.md
assert '<title>example.org</title>' in data
assert '<link href="https://example.org/forced-no-title"/>' in data
assert '<id>tag:example.org,2023-12-01:/forced-no-title</id>' in data
assert '<content type="html">&lt;p&gt;some words are here&lt;/p&gt;</content>' in data
# more-metadata.md
assert '<title>title for the page - example.org</title>' in data
assert '<link href="https://example.org/more-metadata"/>' in data
assert '<id>tag:example.org,2025-03-16:/more-metadata</id>' in data
assert '<content type="html">&lt;p&gt;hello&lt;/p&gt;</content>' in data
def test_atom_type_is_200(client): def test_rss_type_generated():
"""Test that requesting an ATOM feed is found.""" """Test that an RSS feed can be generated."""
response = client.get('/feed/atom') with tempfile.TemporaryDirectory() as tmpdir:
assert response.status_code == 200 src_dir = os.path.join(HERE, 'instance')
assert 'application/atom+xml' in response.content_type generate_feed('rss', src_dir, tmpdir)
print(response.text)
with open(os.path.join(tmpdir, 'feed', 'rss'), 'r') as feed_output:
data = feed_output.read()
assert '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n<rss xmlns:atom="http://www.w3.org/2005/Atom"' in data
assert 'xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">' in data
assert '<link>https://example.org</link>' in data
def test_rss_type_is_200(client): # forced-no-title.md
"""Test that requesting an RSS feed is found.""" assert '<title>example.org</title>' in data
response = client.get('/feed/rss') assert '<link>https://example.org/forced-no-title</link>' in data
assert response.status_code == 200 assert '<guid isPermaLink="false">tag:example.org,2023-12-01:/forced-no-title</guid>' in data
assert 'application/rss+xml' in response.content_type assert '<description>&lt;p&gt;some words are here&lt;/p&gt;</description>' in data
print(response.text) assert '<author>admin@example.org (Test Name)</author>' in data
# more-metadata.md
def test_feed_generator_atom(app): assert '<title>title for the page - example.org</title>' in data
"""Test the root feed generator.""" assert '<link>https://example.org/more-metadata</link>' in data
with app.test_request_context(): assert '<guid isPermaLink="false">tag:example.org,2025-03-16:/more-metadata</guid>' in data
content = serve_feed('atom') assert '<description>&lt;p&gt;hello&lt;/p&gt;</description>' in data
assert b'<id>https://example.com/</id>' in content.data assert '<author>admin@example.org (Test Name)</author>' in data
assert b'<email>admin@example.com</email>' in content.data
assert b'<name>Test Name</name>' in content.data
def test_feed_generator_rss(app):
"""Test the root feed generator."""
with app.test_request_context():
content = serve_feed('rss')
assert b'<author>admin@example.com (Test Name)</author>' in content.data