Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions jottit/templates/feeds/changes.rss.xml
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<rss version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:source="https://source.scripting.com/">
<channel>
<title>{{ site_title|e }} — recent changes</title>
<link>{{ site_url|e }}</link>
<atom:link href="{{ feed_url|e }}" rel="self" type="application/rss+xml" />
<description>Recent changes across all pages.</description>
{% for c in changes %}
{% for item in items %}
<item>
<title>{{ (c.page_name or "Home")|e }} — revision {{ c.revision }}</title>
<link>{{ absolute_url(page_slug(c.page_name) + "?r=" + c.revision|string)|e }}</link>
<guid isPermaLink="true">{{ absolute_url(page_slug(c.page_name) + "?r=" + c.revision|string)|e }}</guid>
<pubDate>{{ c.created.strftime("%a, %d %b %Y %H:%M:%S GMT") }}</pubDate>
{% if c.changes %}<description><![CDATA[{{ c.changes }}]]></description>{% endif %}
<title>{{ item.title|e }}</title>
<link>{{ item.url|e }}</link>
<guid isPermaLink="true">{{ item.url|e }}</guid>
<pubDate>{{ item.created_rfc822 }}</pubDate>
<description><![CDATA[{{ item.content_html|safe }}]]></description>
<source:markdown>{{ item.content_markdown|e }}</source:markdown>
</item>
{% endfor %}
</channel>
Expand Down
17 changes: 10 additions & 7 deletions jottit/templates/feeds/history.rss.xml
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<rss version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:source="https://source.scripting.com/">
<channel>
<title>{{ site_title|e }} — {{ page_label|e }} history</title>
<link>{{ page_url|e }}</link>
<atom:link href="{{ feed_url|e }}" rel="self" type="application/rss+xml" />
<description>Revision history for {{ page_label|e }}.</description>
{% for r in revisions %}
{% for item in items %}
<item>
<title>Revision {{ r.revision }}</title>
<link>{{ page_url|e }}?r={{ r.revision }}</link>
<guid isPermaLink="true">{{ page_url|e }}?r={{ r.revision }}</guid>
<pubDate>{{ r.created.strftime("%a, %d %b %Y %H:%M:%S GMT") }}</pubDate>
{% if r.changes %}<description><![CDATA[{{ r.changes }}]]></description>{% endif %}
<title>Revision {{ item.revision }}</title>
<link>{{ item.url|e }}</link>
<guid isPermaLink="true">{{ item.url|e }}</guid>
<pubDate>{{ item.created_rfc822 }}</pubDate>
<description><![CDATA[{{ item.content_html|safe }}]]></description>
<source:markdown>{{ item.content_markdown|e }}</source:markdown>
</item>
{% endfor %}
</channel>
Expand Down
39 changes: 31 additions & 8 deletions jottit/views/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
Expand All @@ -140,20 +141,42 @@ 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)
response.headers["Content-Type"] = "application/feed+json"
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
Expand Down
48 changes: 36 additions & 12 deletions jottit/views/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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"}

Expand All @@ -260,21 +258,22 @@ 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",
"home_page_url": _absolute_url(""),
"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
Expand All @@ -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:
Expand Down
43 changes: 41 additions & 2 deletions tests/test_feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<source:markdown>**bold body**</source:markdown>" in body
# And the description carries the rendered HTML.
assert "<strong>bold body</strong>" in body


# ---- /<page>?m=history_json ----


Expand All @@ -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/")

Expand All @@ -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 "<strong>v2 body</strong>" in payload["items"][0]["content_html"]
assert payload["items"][0]["content_text"] == "**v2 body**"


# ---- /site/changes.rss ----
Expand All @@ -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 "<source:markdown>raw *italic* source</source:markdown>" in body
# Description carries the rendered HTML.
assert "<em>italic</em>" 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/")

Expand All @@ -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 "<h1>heading</h1>" in notes_item["content_html"]
assert notes_item["content_text"] == "# heading\n\nbody"


# ---- Auth: private gates feeds; public/open allow them ----
Expand Down
Loading