Skip to content

Commit d20c847

Browse files
authored
feat: declare the RFC Editor info page canonical for RFCs and subseries (#11481)
* feat: declare the RFC Editor info page canonical for RFCs and subseries The authoritative home of an RFC, and of a bcp/std/fyi subseries document, is the RFC Editor's info page, so declare that page canonical on the datatracker pages that render the same document. This deliberately consolidates search results on rfc-editor.org for those documents. Documents of other types keep the self-referential canonical they had. The subseries pages declared no canonical at all before this, as they do not include opengraph.html, so they gain a pagehead block. * fix: redirect /doc/html/{bcp,std,fyi}N to the trailing-slash info URL The RFC Editor serves its info pages with a trailing slash, so the bcp and std redirects were sending visitors to a URL that redirects again. Add the missing fyi equivalent while here.
1 parent ccdb08a commit d20c847

7 files changed

Lines changed: 132 additions & 5 deletions

File tree

ietf/doc/templatetags/ietf_filters.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from ietf.doc.models import ConsensusDocEvent
2828
from ietf.ietfauth.utils import can_request_rfc_publication as utils_can_request_rfc_publication
2929
from ietf.utils import log
30-
from ietf.doc.utils import prettify_std_name
30+
from ietf.doc.utils import external_canonical_url, prettify_std_name
3131
from ietf.utils.html import clean_html
3232
from ietf.utils.text import wordwrap, fill, wrap_text_if_unwrapped, linkify
3333
from ietf.utils.validators import validate_url
@@ -140,6 +140,19 @@ def rfceditor_info_url(rfcnum : str):
140140
"""Link to the RFC editor info page for an RFC"""
141141
return urljoin(settings.RFC_EDITOR_INFO_BASE_URL, f'rfc{rfcnum}/')
142142

143+
@register.simple_tag(takes_context=True)
144+
def canonical_url(context, doc):
145+
"""Absolute URL to declare canonical for doc on the current page
146+
147+
Never returns None - an empty or "None" href would be a canonical pointing at a
148+
URL that does not exist.
149+
"""
150+
if not context.get("snapshot"):
151+
external = external_canonical_url(doc)
152+
if external:
153+
return external
154+
return urljoin(settings.IDTRACKER_BASE_URL, context["request"].path)
155+
143156

144157
def doc_name(name):
145158
"""Check whether a given document exists, and return its canonical name"""

ietf/doc/tests.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,13 @@
4747
BallotDocEventFactory, DocumentAuthorFactory,
4848
NewRevisionDocEventFactory,
4949
StatusChangeFactory, DocExtResourceFactory,
50-
RgDraftFactory, BcpFactory, RfcAuthorFactory)
50+
RgDraftFactory, BcpFactory, StdFactory,
51+
FyiFactory, RfcAuthorFactory)
5152
from ietf.doc.forms import NotifyForm
5253
from ietf.doc.fields import SearchableDocumentsField
5354
from ietf.doc.utils import (
5455
create_ballot_if_not_open,
56+
external_canonical_url,
5557
investigate_fragment,
5658
uppercase_std_abbreviated_name,
5759
DraftAliasGenerator,
@@ -2345,6 +2347,98 @@ def test_template_tags(self):
23452347
failures, tests = doctest.testmod(ietf_filters)
23462348
self.assertEqual(failures, 0)
23472349

2350+
@override_settings(RFC_EDITOR_INFO_BASE_URL="https://www.rfc-editor.example.org/info/")
2351+
class CanonicalUrlTests(TestCase):
2352+
"""Tests of the rel=canonical link declared by document pages"""
2353+
2354+
def canonical_href(self, r):
2355+
"""Extract the canonical href from a response, asserting that it is usable
2356+
2357+
An empty href resolves to the current URL and an href of "None" resolves to a
2358+
URL that does not exist - neither is visible when eyeballing a rendered page.
2359+
"""
2360+
self.assertEqual(r.status_code, 200)
2361+
links = PyQuery(r.content)("link[rel='canonical']")
2362+
self.assertEqual(len(links), 1)
2363+
href = links.attr("href")
2364+
self.assertNotIn(href, ["", "None", None])
2365+
return href
2366+
2367+
def test_external_canonical_url(self):
2368+
for doc in [WgRfcFactory(), BcpFactory(), StdFactory(), FyiFactory()]:
2369+
self.assertEqual(
2370+
external_canonical_url(doc),
2371+
f"https://www.rfc-editor.example.org/info/{doc.name}/",
2372+
f"{doc.type_id} belongs to the RFC Editor",
2373+
)
2374+
for doc in [WgDraftFactory(), CharterFactory(), StatusChangeFactory()]:
2375+
self.assertIsNone(external_canonical_url(doc), f"{doc.type_id} is ours")
2376+
2377+
def test_rfc_pages_canonicalize_to_rfc_editor(self):
2378+
rfc = WgRfcFactory()
2379+
rfc.save_with_history([DocEventFactory(doc=rfc)])
2380+
(Path(settings.RFC_PATH) / rfc.get_base_name()).touch()
2381+
expected = f"https://www.rfc-editor.example.org/info/{rfc.name}/"
2382+
2383+
for viewname in [
2384+
"ietf.doc.views_doc.document_main",
2385+
"ietf.doc.views_doc.document_html",
2386+
]:
2387+
url = urlreverse(viewname, kwargs=dict(name=rfc.name))
2388+
r = self.client.get(url)
2389+
self.assertEqual(self.canonical_href(r), expected, f"{url} canonical")
2390+
2391+
def test_draft_pages_canonicalize_to_datatracker(self):
2392+
draft = WgDraftFactory()
2393+
# an active draft's file is in both of these - see Document.get_file_path()
2394+
for dir in [settings.INTERNET_DRAFT_PATH, settings.INTERNET_ALL_DRAFTS_ARCHIVE_DIR]:
2395+
(Path(dir) / draft.get_base_name()).touch()
2396+
2397+
for viewname in [
2398+
"ietf.doc.views_doc.document_main",
2399+
"ietf.doc.views_doc.document_html",
2400+
]:
2401+
url = urlreverse(viewname, kwargs=dict(name=draft.name))
2402+
r = self.client.get(url)
2403+
self.assertEqual(
2404+
self.canonical_href(r),
2405+
f"{settings.IDTRACKER_BASE_URL}{url}",
2406+
f"{url} canonical",
2407+
)
2408+
2409+
def test_subseries_pages_canonicalize_to_rfc_editor(self):
2410+
for doc in [BcpFactory(), StdFactory(), FyiFactory()]:
2411+
url = urlreverse(
2412+
"ietf.doc.views_doc.document_main", kwargs=dict(name=doc.name)
2413+
)
2414+
r = self.client.get(url)
2415+
self.assertEqual(
2416+
self.canonical_href(r),
2417+
f"https://www.rfc-editor.example.org/info/{doc.name}/",
2418+
f"{url} canonical",
2419+
)
2420+
2421+
2422+
class SubseriesHtmlRedirectTests(TestCase):
2423+
"""Tests of the /doc/html/ redirects for the bcp/std/fyi subseries
2424+
2425+
These patterns interpolate RFC_EDITOR_INFO_BASE_URL when the URLconf is imported,
2426+
so override_settings cannot reach them - build the expectation from the setting.
2427+
"""
2428+
2429+
def test_subseries_html_redirects_to_rfc_editor(self):
2430+
for name in ["bcp1", "std2", "fyi3"]:
2431+
for suffix in ["", "/", ".txt", ".html"]:
2432+
url = f"/doc/html/{name}{suffix}"
2433+
r = self.client.get(url)
2434+
self.assertEqual(r.status_code, 302, url)
2435+
self.assertEqual(
2436+
r["Location"],
2437+
f"{settings.RFC_EDITOR_INFO_BASE_URL}{name}/",
2438+
url,
2439+
)
2440+
2441+
23482442
class ReferencesTest(TestCase):
23492443

23502444
def test_references(self):

ietf/doc/urls.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@
7373
url(r'^stats/person/(?P<id>[0-9]+)/drafts/data/?$', views_stats.chart_data_person_drafts),
7474

7575
# This block should really all be at the idealized docs.ietf.org service
76-
url(r'^html/(?P<name>bcp[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s", permanent=False)),
77-
url(r'^html/(?P<name>std[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s", permanent=False)),
76+
url(r'^html/(?P<name>bcp[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s/", permanent=False)),
77+
url(r'^html/(?P<name>std[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s/", permanent=False)),
78+
url(r'^html/(?P<name>fyi[0-9]+?)(\.txt|\.html)?/?$', RedirectView.as_view(url=settings.RFC_EDITOR_INFO_BASE_URL+"%(name)s/", permanent=False)),
7879
url(r'^html/%(name)s(?:-(?P<rev>[0-9]{2}(-[0-9]{2})?))?(\.txt|\.html)?/?$' % settings.URL_REGEXPS, views_doc.document_html),
7980

8081
url(r'^id/%(name)s(?:-%(rev)s)?(?:\.(?P<ext>(txt|html|xml)))?/?$' % settings.URL_REGEXPS, views_doc.document_raw_id),

ietf/doc/utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from hashlib import sha384
1616
from pathlib import Path
1717
from typing import Iterator, Optional, Union, Iterable
18+
from urllib.parse import urljoin
1819
from zoneinfo import ZoneInfo
1920

2021
from django.conf import settings
@@ -807,6 +808,18 @@ def prettify_std_name(n, spacing=" "):
807808
else:
808809
return n
809810

811+
def external_canonical_url(doc):
812+
"""Authoritative external URL for doc, or None if the datatracker is authoritative
813+
814+
The authoritative home of an RFC, and of a bcp/std/fyi subseries document, is the
815+
RFC Editor's info page, so we point search engines there rather than at our own
816+
rendering of the same thing. Documents of other types are ours.
817+
"""
818+
if doc.type_id in ["rfc", "bcp", "std", "fyi"]:
819+
# trailing slash matches the form the RFC Editor serves
820+
return urljoin(settings.RFC_EDITOR_INFO_BASE_URL, f"{doc.name}/")
821+
return None
822+
810823
def default_consensus(doc):
811824
# if someone edits the consensus return that, otherwise
812825
# ietf stream => true and irtf stream => false
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{# Copyright The IETF Trust 2026, All Rights Reserved #}
2+
{% load ietf_filters %}
3+
<link rel="canonical" href="{% canonical_url doc %}">

ietf/templates/doc/document_subseries.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
{% load static %}
55
{% load ietf_filters %}
66
{% block title %}{{ doc.name|prettystdname }}{% endblock %}
7+
{% block pagehead %}
8+
{% include "doc/canonical_link.html" %}
9+
{% endblock %}
710
{% block content %}
811
{% origin %}
912
{{ top|safe }}

ietf/templates/doc/opengraph.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{% origin %}
66
<meta property="og:title" content="{% if doc.type_id == 'rfc' and not snapshot %}RFC {{ rfc_number }}: {% endif %}{{ doc.title }}">
77
<meta property="og:url" content="{{ settings.IDTRACKER_BASE_URL }}{{ request.path }}">
8-
<link rel="canonical" href="{{ settings.IDTRACKER_BASE_URL }}{{ request.path }}">
8+
{% include "doc/canonical_link.html" %}
99
<meta property="og:site_name" content="IETF Datatracker">
1010
<meta property="og:description" content="{{ doc.abstract|clean_whitespace }}">
1111
<meta property="og:type" content="article">

0 commit comments

Comments
 (0)