From 3250f01b283d022ecb9879989443e5101589a69c Mon Sep 17 00:00:00 2001 From: Simon Carstensen Date: Fri, 15 May 2026 17:25:33 +0200 Subject: [PATCH] Add source:markdown to RSS feeds Implements the source:markdown RSS extension Manton documented at https://www.manton.org/2025/11/11/markdown-in-rss.html (namespace https://source.scripting.com/). Each now carries the page's raw markdown source alongside the rendered HTML in , so a reader that knows how can prefer the markdown. This also reshapes the feeds: previously held the diff-summary string (e.g. "Added X to Y"); it now holds the rendered HTML of the revision's content, which is what feed readers actually want to render. JSON Feed gains the matching `content_text` field populated from the same raw markdown source. Wikilinks inside the rendered HTML are resolved against the absolute site root so they survive in external readers. Co-Authored-By: Claude Opus 4.7 (1M context) --- jottit/templates/feeds/changes.rss.xml | 17 +++++---- jottit/templates/feeds/history.rss.xml | 17 +++++---- jottit/views/page.py | 39 ++++++++++++++++----- jottit/views/site.py | 48 +++++++++++++++++++------- tests/test_feeds.py | 43 +++++++++++++++++++++-- 5 files changed, 128 insertions(+), 36 deletions(-) diff --git a/jottit/templates/feeds/changes.rss.xml b/jottit/templates/feeds/changes.rss.xml index 25568a7..3e32366 100644 --- a/jottit/templates/feeds/changes.rss.xml +++ b/jottit/templates/feeds/changes.rss.xml @@ -1,17 +1,20 @@ - + {{ site_title|e }} — recent changes {{ site_url|e }} Recent changes across all pages. - {% for c in changes %} + {% for item in items %} - {{ (c.page_name or "Home")|e }} — revision {{ c.revision }} - {{ absolute_url(page_slug(c.page_name) + "?r=" + c.revision|string)|e }} - {{ absolute_url(page_slug(c.page_name) + "?r=" + c.revision|string)|e }} - {{ c.created.strftime("%a, %d %b %Y %H:%M:%S GMT") }} - {% if c.changes %}{% endif %} + {{ item.title|e }} + {{ item.url|e }} + {{ item.url|e }} + {{ item.created_rfc822 }} + + {{ item.content_markdown|e }} {% endfor %} diff --git a/jottit/templates/feeds/history.rss.xml b/jottit/templates/feeds/history.rss.xml index 622f525..c45ca44 100644 --- a/jottit/templates/feeds/history.rss.xml +++ b/jottit/templates/feeds/history.rss.xml @@ -1,17 +1,20 @@ - + {{ site_title|e }} — {{ page_label|e }} history {{ page_url|e }} Revision history for {{ page_label|e }}. - {% for r in revisions %} + {% for item in items %} - Revision {{ r.revision }} - {{ page_url|e }}?r={{ r.revision }} - {{ page_url|e }}?r={{ r.revision }} - {{ r.created.strftime("%a, %d %b %Y %H:%M:%S GMT") }} - {% if r.changes %}{% endif %} + Revision {{ item.revision }} + {{ item.url|e }} + {{ item.url|e }} + {{ item.created_rfc822 }} + + {{ item.content_markdown|e }} {% endfor %} diff --git a/jottit/views/page.py b/jottit/views/page.py index 9f10703..dfe64ee 100644 --- a/jottit/views/page.py +++ b/jottit/views/page.py @@ -113,10 +113,10 @@ def _render_history_rss(conn: Connection, page_name: str) -> ResponseReturnValue if page is None: abort(404) revisions_page = get_revisions(conn, page_id=page.id, limit=20) + items = [_feed_item(r, page_name=page_name) for r in revisions_page] body = render_template( "feeds/history.rss.xml", - page_name=page_name, - revisions=revisions_page, + items=items, page_url=_page_absolute_url(page_name), feed_url=_page_absolute_url(page_name, "m=history_rss"), site_title=(g.site.title or g.site.public_url or g.site.secret_url), @@ -132,6 +132,7 @@ def _render_history_json(conn: Connection, page_name: str) -> ResponseReturnValu if page is None: abort(404) revisions_page = get_revisions(conn, page_id=page.id, limit=20) + items = [_feed_item(r, page_name=page_name) for r in revisions_page] payload = { "version": "https://jsonfeed.org/version/1.1", "title": (g.site.title or g.site.public_url or g.site.secret_url) @@ -140,13 +141,14 @@ def _render_history_json(conn: Connection, page_name: str) -> ResponseReturnValu "feed_url": _page_absolute_url(page_name, "m=history_json"), "items": [ { - "id": _page_absolute_url(page_name, f"r={r.revision}"), - "url": _page_absolute_url(page_name, f"r={r.revision}"), - "title": f"Revision {r.revision}", - "content_html": r.changes or "", - "date_published": r.created.isoformat() + "Z", + "id": item["url"], + "url": item["url"], + "title": f"Revision {item['revision']}", + "content_html": item["content_html"], + "content_text": item["content_markdown"], + "date_published": item["created_iso"], } - for r in revisions_page + for item in items ], } response = jsonify(payload) @@ -154,6 +156,27 @@ def _render_history_json(conn: Connection, page_name: str) -> ResponseReturnValu return response +def _feed_item(revision_row: Row, *, page_name: str) -> dict[str, object]: + """Render a revision into the shape both the RSS template and JSON Feed want. + + `content_html` is the page content rendered to HTML (what feed readers + display); `content_markdown` is the raw markdown source carried in + source:markdown for the RSS extension. Wikilinks are resolved against + an absolute site root so links survive being read outside the browser + context. + """ + absolute_root = f"{request.scheme}://{request.host}{site_root()}" + return { + "revision": revision_row.revision, + "created": revision_row.created, + "created_iso": revision_row.created.isoformat() + "Z", + "created_rfc822": revision_row.created.strftime("%a, %d %b %Y %H:%M:%S GMT"), + "url": _page_absolute_url(page_name, f"r={revision_row.revision}"), + "content_markdown": revision_row.content, + "content_html": format_content(revision_row.content, site_root=absolute_root), + } + + def _page_absolute_url(page_name: str, query: str = "") -> str: base = f"{request.scheme}://{request.host}{site_root()}{page_slug(page_name)}" return f"{base}?{query}" if query else base diff --git a/jottit/views/site.py b/jottit/views/site.py index dd4b67a..0cdedfc 100644 --- a/jottit/views/site.py +++ b/jottit/views/site.py @@ -13,6 +13,7 @@ recover_password, set_change_pwd_token, ) +from jottit.render import format_content from jottit.urls import page_slug, site_root _ALLOWED_SECURITY_LEVELS = {"private", "public", "open"} @@ -240,16 +241,13 @@ def changes_rss(site_slug: str) -> ResponseReturnValue: abort(404) if (response := auth.gate("view")) is not None: return response - rows = _recent_changes() + items = _changes_feed_items() body = render_template( "feeds/changes.rss.xml", - changes=rows, + items=items, site_title=g.site.title or g.site.public_url or g.site.secret_url, feed_url=_absolute_url("site/changes.rss"), site_url=_absolute_url(""), - site_root_path=site_root(), - page_slug=page_slug, - absolute_url=_absolute_url, ) return body, 200, {"Content-Type": "application/rss+xml; charset=utf-8"} @@ -260,7 +258,7 @@ def changes_json(site_slug: str) -> ResponseReturnValue: abort(404) if (response := auth.gate("view")) is not None: return response - rows = _recent_changes() + items = _changes_feed_items() payload = { "version": "https://jsonfeed.org/version/1.1", "title": (g.site.title or g.site.public_url or g.site.secret_url) + " — changes", @@ -268,13 +266,14 @@ def changes_json(site_slug: str) -> ResponseReturnValue: "feed_url": _absolute_url("site/changes.json"), "items": [ { - "id": _absolute_url(f"{page_slug(c.page_name)}?r={c.revision}"), - "url": _absolute_url(f"{page_slug(c.page_name)}?r={c.revision}"), - "title": (c.page_name or "Home") + f" — revision {c.revision}", - "content_html": c.changes or "", - "date_published": c.created.isoformat() + "Z", + "id": item["url"], + "url": item["url"], + "title": item["title"], + "content_html": item["content_html"], + "content_text": item["content_markdown"], + "date_published": item["created_iso"], } - for c in rows + for item in items ], } from flask import jsonify @@ -284,6 +283,31 @@ def changes_json(site_slug: str) -> ResponseReturnValue: return response +def _changes_feed_items() -> list[dict[str, object]]: + """Render every recent revision into the shape both feed formats want. + + Each entry carries the page content as both rendered HTML (the + description / content_html) and raw markdown (source:markdown / + content_text). Wikilinks are resolved against the absolute site root + so they survive being read in an external reader. + """ + rows = _recent_changes() + absolute_root = _absolute_url("") + items: list[dict[str, object]] = [] + for c in rows: + items.append( + { + "url": _absolute_url(f"{page_slug(c.page_name)}?r={c.revision}"), + "title": (c.page_name or "Home") + f" — revision {c.revision}", + "created_rfc822": c.created.strftime("%a, %d %b %Y %H:%M:%S GMT"), + "created_iso": c.created.isoformat() + "Z", + "content_markdown": c.content, + "content_html": format_content(c.content, site_root=absolute_root), + } + ) + return items + + def _recent_changes(): conn = get_request_conn() if conn is None: diff --git a/tests/test_feeds.py b/tests/test_feeds.py index 55804d1..6a59c6d 100644 --- a/tests/test_feeds.py +++ b/tests/test_feeds.py @@ -64,6 +64,25 @@ def test_history_rss_returns_rss(client: FlaskClient, db_engine: Engine) -> None assert "?m=history_rss" in body +def test_history_rss_includes_source_markdown(client: FlaskClient, db_engine: Engine) -> None: + """source:markdown carries the raw markdown alongside the rendered description.""" + site_id = _seed_site(db_engine, secret_url="hf1a", public_url="alpha2") + with db_engine.begin() as conn: + page = get_page(conn, site_id=site_id, page_name="") + assert page is not None + update_page(conn, page_id=page.id, content="**bold body**") + + response = client.get("/?m=history_rss", base_url="http://alpha2.jottit.test/") + + body = response.data.decode() + # Namespace declared on the rss element. + assert 'xmlns:source="https://source.scripting.com/"' in body + # Raw markdown verbatim inside source:markdown (XML-escaped). + assert "**bold body**" in body + # And the description carries the rendered HTML. + assert "bold body" in body + + # ---- /?m=history_json ---- @@ -72,7 +91,7 @@ def test_history_json_returns_jsonfeed(client: FlaskClient, db_engine: Engine) - with db_engine.begin() as conn: page = get_page(conn, site_id=site_id, page_name="") assert page is not None - update_page(conn, page_id=page.id, content="v2") + update_page(conn, page_id=page.id, content="**v2 body**") response = client.get("/?m=history_json", base_url="http://beta.jottit.test/") @@ -84,6 +103,9 @@ def test_history_json_returns_jsonfeed(client: FlaskClient, db_engine: Engine) - assert payload["items"][0]["title"] == "Revision 2" assert payload["items"][1]["title"] == "Revision 1" assert payload["items"][0]["url"].endswith("?r=2") + # Latest entry: content_html is rendered, content_text is the raw markdown. + assert "v2 body" in payload["items"][0]["content_html"] + assert payload["items"][0]["content_text"] == "**v2 body**" # ---- /site/changes.rss ---- @@ -103,13 +125,27 @@ def test_site_changes_rss(client: FlaskClient, db_engine: Engine) -> None: assert "notes" in body +def test_site_changes_rss_includes_source_markdown(client: FlaskClient, db_engine: Engine) -> None: + site_id = _seed_site(db_engine, secret_url="cf1a", public_url="gamma2") + with db_engine.begin() as conn: + new_page(conn, site_id=site_id, name="post", content="raw *italic* source") + + response = client.get("/site/changes.rss", base_url="http://gamma2.jottit.test/") + + body = response.data.decode() + assert 'xmlns:source="https://source.scripting.com/"' in body + assert "raw *italic* source" in body + # Description carries the rendered HTML. + assert "italic" in body + + # ---- /site/changes.json ---- def test_site_changes_json(client: FlaskClient, db_engine: Engine) -> None: site_id = _seed_site(db_engine, secret_url="cf2", public_url="delta") with db_engine.begin() as conn: - new_page(conn, site_id=site_id, name="notes", content="notes body") + new_page(conn, site_id=site_id, name="notes", content="# heading\n\nbody") response = client.get("/site/changes.json", base_url="http://delta.jottit.test/") @@ -119,6 +155,9 @@ def test_site_changes_json(client: FlaskClient, db_engine: Engine) -> None: assert payload["version"].startswith("https://jsonfeed.org/version/") item_urls = [item["url"] for item in payload["items"]] assert any("notes" in url for url in item_urls) + notes_item = next(item for item in payload["items"] if "notes" in item["url"]) + assert "

heading

" in notes_item["content_html"] + assert notes_item["content_text"] == "# heading\n\nbody" # ---- Auth: private gates feeds; public/open allow them ----