From c8c39befb3db0c717868a1dd99ebef26a73d7d82 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 16 Mar 2025 11:46:02 -0500 Subject: [PATCH] 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 --- incorporealcms/config.py | 4 +- incorporealcms/feed.py | 66 ++++++++------ tests/instance/feed/20250316-more-metadata.md | 1 + tests/test_factory.py | 6 ++ tests/test_feed.py | 87 ++++++++++++------- 5 files changed, 103 insertions(+), 61 deletions(-) create mode 120000 tests/instance/feed/20250316-more-metadata.md diff --git a/incorporealcms/config.py b/incorporealcms/config.py index 6bdba05..92c97a7 100644 --- a/incorporealcms/config.py +++ b/incorporealcms/config.py @@ -62,10 +62,10 @@ class Config(object): DOMAIN_NAME = 'example.org' TITLE_SUFFIX = DOMAIN_NAME BASE_HOST = 'http://' + DOMAIN_NAME - CONTACT_EMAIL = 'admin@example.com' + CONTACT_EMAIL = 'admin@example.org' # feed settings - AUTHOR = {'name': 'Test Name', 'email': 'admin@example.com'} + AUTHOR = {'name': 'Test Name', 'email': 'admin@example.org'} FAVICON = '/static/img/favicon.png' diff --git a/incorporealcms/feed.py b/incorporealcms/feed.py index 305434f..899488a 100644 --- a/incorporealcms/feed.py +++ b/incorporealcms/feed.py @@ -14,60 +14,72 @@ import os import re from feedgen.feed import FeedGenerator -from flask import Blueprint, Response, abort -from flask import current_app as app -from incorporealcms.lib import instance_resource_path_to_request_path, parse_md +from incorporealcms.config import Config +from incorporealcms.markdown import instance_resource_path_to_request_path, parse_md logger = logging.getLogger(__name__) -bp = Blueprint('feed', __name__, url_prefix='/feed') +def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None: + """Generate the Atom or RSS feed as requested. -@bp.route('/') -def serve_feed(feed_type): - """Serve the Atom or RSS feed as requested.""" - logger.warning("wat") + Args: + feed_type: 'atom' or 'rss' feed + instance_dir: the directory for the instance, containing both the feed dir and pages + dest_dir: the directory to place the feed subdir and requested feed + """ if feed_type not in ('atom', 'rss'): - abort(404) + raise ValueError(f"unsupported feed type {feed_type}") fg = FeedGenerator() - fg.id(f'https://{app.config["DOMAIN_NAME"]}/') - fg.title(f'{app.config["TITLE_SUFFIX"]}') - fg.author(app.config["AUTHOR"]) - fg.link(href=f'https://{app.config["DOMAIN_NAME"]}/feed/{feed_type}', rel='self') - fg.link(href=f'https://{app.config["DOMAIN_NAME"]}', rel='alternate') - fg.subtitle(f"Blog posts and other dated materials from {app.config['TITLE_SUFFIX']}") + fg.id(f'https://{Config.DOMAIN_NAME}/') + fg.title(f'{Config.TITLE_SUFFIX}') + fg.author(Config.AUTHOR) + fg.link(href=f'https://{Config.DOMAIN_NAME}/feed/{feed_type}', rel='self') + fg.link(href=f'https://{Config.DOMAIN_NAME}', rel='alternate') + fg.subtitle(f"Blog posts and other interesting materials from {Config.TITLE_SUFFIX}") # get recent feeds - feed_path = os.path.join(app.instance_path, 'feed') + feed_path = os.path.join(instance_dir, 'feed') feed_entry_paths = [os.path.join(dirpath, filename) for dirpath, _, filenames in os.walk(feed_path) for filename in filenames if os.path.islink(os.path.join(dirpath, filename))] for feed_entry_path in sorted(feed_entry_paths): # get the actual file to parse it - resolved_path = os.path.realpath(feed_entry_path).replace(f'{app.instance_path}/', '') + os.chdir(os.path.abspath(os.path.join(instance_dir, 'pages'))) + resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), os.path.join(instance_dir, 'pages')) try: content, md, page_name, page_title, mtime = parse_md(resolved_path) - link = f'https://{app.config["DOMAIN_NAME"]}/{instance_resource_path_to_request_path(resolved_path)}' + link = f'https://{Config.DOMAIN_NAME}{instance_resource_path_to_request_path(resolved_path)}' except (OSError, ValueError, TypeError): logger.exception("error loading/rendering markdown!") - abort(500) + raise fe = fg.add_entry() - fe.id(_generate_feed_id(feed_entry_path)) - fe.title(page_name if page_name else page_title) - fe.author(app.config["AUTHOR"]) + fe.id(_generate_feed_id(feed_entry_path, instance_resource_path_to_request_path(resolved_path))) + fe.title(page_title) + fe.author(Config.AUTHOR) fe.link(href=link) fe.content(content, type='html') if feed_type == 'atom': - return Response(fg.atom_str(pretty=True), mimetype='application/atom+xml') + 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: - 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.""" date = re.sub(r'.*(\d{4})(\d{2})(\d{2}).*', r'\1-\2-\3', feed_entry_path) - cleaned = feed_entry_path.replace('#', '/').replace('feed/', '', 1).replace(app.instance_path, '') - return f'tag:{app.config["DOMAIN_NAME"]},{date}:{cleaned}' + cleaned = request_path.replace('#', '/') + return f'tag:{Config.DOMAIN_NAME},{date}:{cleaned}' diff --git a/tests/instance/feed/20250316-more-metadata.md b/tests/instance/feed/20250316-more-metadata.md new file mode 120000 index 0000000..f898f46 --- /dev/null +++ b/tests/instance/feed/20250316-more-metadata.md @@ -0,0 +1 @@ +../pages/more-metadata.md \ No newline at end of file diff --git a/tests/test_factory.py b/tests/test_factory.py index 312fbf3..1d53950 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -13,6 +13,12 @@ HERE = os.path.dirname(os.path.abspath(__file__)) def test_config(): """Test that the app initialization sets values not normally present in the config.""" + # this may have gotten here from other imports in other tests + try: + delattr(Config, 'INSTANCE_VALUE') + except AttributeError: + pass + assert not getattr(Config, 'INSTANCE_VALUE', None) assert not getattr(Config, 'EXTRA_VALUE', None) diff --git a/tests/test_feed.py b/tests/test_feed.py index 16c41f1..454a746 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -3,42 +3,65 @@ SPDX-FileCopyrightText: © 2023 Brian S. Stephan SPDX-License-Identifier: AGPL-3.0-or-later """ -from incorporealcms.feed import serve_feed +import os +import tempfile + +from incorporealcms import init_instance +from incorporealcms.feed import generate_feed + +HERE = os.path.dirname(os.path.abspath(__file__)) + +init_instance(instance_path=os.path.join(HERE, 'instance')) -def test_unknown_type_is_404(client): - """Test that requesting a feed type that doesn't exist is a 404.""" - response = client.get('/feed/wat') - assert response.status_code == 404 +def test_atom_type_generated(): + """Test that an ATOM feed can be generated.""" + with tempfile.TemporaryDirectory() as tmpdir: + src_dir = os.path.join(HERE, 'instance') + generate_feed('atom', src_dir, tmpdir) + + with open(os.path.join(tmpdir, 'feed', 'atom'), 'r') as feed_output: + data = feed_output.read() + assert '\n' in data + assert 'https://example.org/' in data + assert 'admin@example.org' in data + assert 'Test Name' in data + + # forced-no-title.md + assert 'example.org' in data + assert '' in data + assert 'tag:example.org,2023-12-01:/forced-no-title' in data + assert '<p>some words are here</p>' in data + + # more-metadata.md + assert 'title for the page - example.org' in data + assert '' in data + assert 'tag:example.org,2025-03-16:/more-metadata' in data + assert '<p>hello</p>' in data -def test_atom_type_is_200(client): - """Test that requesting an ATOM feed is found.""" - response = client.get('/feed/atom') - assert response.status_code == 200 - assert 'application/atom+xml' in response.content_type - print(response.text) +def test_rss_type_generated(): + """Test that an RSS feed can be generated.""" + with tempfile.TemporaryDirectory() as tmpdir: + src_dir = os.path.join(HERE, 'instance') + generate_feed('rss', src_dir, tmpdir) + with open(os.path.join(tmpdir, 'feed', 'rss'), 'r') as feed_output: + data = feed_output.read() + assert '\n' in data + assert 'https://example.org' in data -def test_rss_type_is_200(client): - """Test that requesting an RSS feed is found.""" - response = client.get('/feed/rss') - assert response.status_code == 200 - assert 'application/rss+xml' in response.content_type - print(response.text) + # forced-no-title.md + assert 'example.org' in data + assert 'https://example.org/forced-no-title' in data + assert 'tag:example.org,2023-12-01:/forced-no-title' in data + assert '<p>some words are here</p>' in data + assert 'admin@example.org (Test Name)' in data - -def test_feed_generator_atom(app): - """Test the root feed generator.""" - with app.test_request_context(): - content = serve_feed('atom') - assert b'https://example.com/' in content.data - assert b'admin@example.com' in content.data - assert b'Test 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'admin@example.com (Test Name)' in content.data + # more-metadata.md + assert 'title for the page - example.org' in data + assert 'https://example.org/more-metadata' in data + assert 'tag:example.org,2025-03-16:/more-metadata' in data + assert '<p>hello</p>' in data + assert 'admin@example.org (Test Name)' in data