diff --git a/conf.py b/conf.py
index d6fab26cd14..5bd92a52a86 100644
--- a/conf.py
+++ b/conf.py
@@ -187,7 +187,7 @@
# Output file base name for HTML help builder.
htmlhelp_basename = 'ros2_docsdoc'
-html_baseurl = 'https://docs.ros.org/en'
+html_baseurl = 'https://docs.ros.org/en/rolling'
# The sitemap_url_scheme is used by the sitemap generator to figure out how
# to generate links. Essentially, the sitemap generator uses the following:
@@ -215,10 +215,10 @@ def generate(cls, app):
return
redirect_html_fragment = """
-
+
"""
redirections = {
@@ -265,7 +265,7 @@ def generate(cls, app):
'skip_sitemap': 'redirect',
'title': os.path.basename(redirect_url),
'metatags': redirect_html_fragment.format(
- base_url=app.config.html_baseurl,
+ canonical_abs_url=app.config.html_baseurl.rstrip('/') + '/' + canonical_url + app.builder.out_suffix,
url=app.builder.get_relative_uri(
redirect_url, canonical_url
)
@@ -306,8 +306,9 @@ def smv_rewrite_configs(app, config):
# external defines are setup, and environment variables aren't passed through to
# conf.py). Instead, hook into the 'config-inited' event which is late enough
# to rewrite the various configuration items with the current version.
+
if app.config.smv_current_version != '':
- app.config.html_baseurl = app.config.html_baseurl + '/' + app.config.smv_current_version
+ app.config.html_baseurl = 'https://docs.ros.org/en/' + app.config.smv_current_version
app.config.project = 'ROS 2 Documentation: ' + app.config.smv_current_version.title()
app.config.html_logo = 'source/Releases/' + app.config.smv_current_version + '-small.png'
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 00000000000..9421d69cf23
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+addopts = -p no:launch-testing-ros -p no:ament-lint -p no:ament-copyright -p no:ament-xmllint -p no:ament-pep257 -p no:ament-flake8
+pythonpath = .
\ No newline at end of file
diff --git a/test/test_redirect_canonical.py b/test/test_redirect_canonical.py
new file mode 100644
index 00000000000..befdcb28486
--- /dev/null
+++ b/test/test_redirect_canonical.py
@@ -0,0 +1,162 @@
+"""
+Tests for RedirectFrom canonical URL generation.
+Regression test for: https://github.com/ros2/ros2_documentation/issues/6112
+
+These tests verify the actual conf.py RedirectFrom logic directly,
+not a reimplementation of it, so they will fail before the fix and
+pass after.
+"""
+import os
+import sys
+import re
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+# Add repo root to path so conf.py can be imported
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+
+def make_mock_app(html_baseurl, out_suffix='.html'):
+ app = MagicMock()
+ app.config.html_baseurl = html_baseurl
+ app.builder.out_suffix = out_suffix
+
+ def fake_relative_uri(from_doc, to_doc):
+ """Simulates Sphinx get_relative_uri for test purposes."""
+ from_parts = from_doc.split('/')
+ to_parts = to_doc.split('/')
+ common = 0
+ for a, b in zip(from_parts[:-1], to_parts[:-1]):
+ if a == b:
+ common += 1
+ else:
+ break
+ ups = len(from_parts) - 1 - common
+ rel = '../' * ups + '/'.join(to_parts[common:]) + out_suffix
+ return rel
+
+ app.builder.get_relative_uri.side_effect = fake_relative_uri
+ return app
+
+
+def get_canonical_href_from_conf(html_baseurl, redirect_url, canonical_url):
+ """
+ Run the actual RedirectFrom metatags generation from conf.py
+ and extract the canonical href.
+ """
+ # Import the real redirect_html_fragment and format logic from conf.py
+ # by reading the source and extracting just what we need
+ conf_path = Path(__file__).parent.parent / 'conf.py'
+ source = conf_path.read_text()
+
+ # Extract the redirect_html_fragment template from conf.py
+ match = re.search(
+ r'redirect_html_fragment\s*=\s*"""(.*?)"""',
+ source, re.DOTALL
+ )
+ assert match, "Could not find redirect_html_fragment in conf.py"
+ template = match.group(1)
+
+ # Check which format keys the template uses
+ uses_canonical_abs_url = '{canonical_abs_url}' in template
+
+ app = make_mock_app(html_baseurl)
+
+ if uses_canonical_abs_url:
+ # New fixed format
+ metatags = template.format(
+ canonical_abs_url=app.config.html_baseurl.rstrip('/') + '/' + canonical_url + app.builder.out_suffix,
+ url=app.builder.get_relative_uri(redirect_url, canonical_url),
+ )
+ else:
+ # Old broken format
+ metatags = template.format(
+ base_url=app.config.html_baseurl,
+ url=app.builder.get_relative_uri(redirect_url, canonical_url),
+ )
+
+ href_match = re.search(r' How-To-Guides/) previously
+ produced hrefs like https://docs.ros.org/en/rolling/../How-To-Guides/...
+ """
+ href = get_canonical_href_from_conf(
+ html_baseurl='https://docs.ros.org/en/rolling',
+ redirect_url='Guides/Ament-CMake-Documentation',
+ canonical_url='How-To-Guides/Ament-CMake-Documentation',
+ )
+ assert '..' not in href, \
+ f"Canonical href must not contain '..' segments, got: {href}"
+ assert href.startswith('https://'), \
+ f"Canonical href must be absolute, got: {href}"
+
+ def test_canonical_includes_rolling_in_plain_build(self):
+ """
+ A plain `make html` build (no sphinx-multiversion) must produce a
+ canonical URL that includes the distro version ('rolling').
+ Previously html_baseurl defaulted to 'https://docs.ros.org/en'
+ with no version, producing a URL that 404s.
+ """
+ href = get_canonical_href_from_conf(
+ html_baseurl='https://docs.ros.org/en/rolling', # what conf.py now sets by default
+ redirect_url='Guides/Ament-CMake-Documentation',
+ canonical_url='How-To-Guides/Ament-CMake-Documentation',
+ )
+ assert 'rolling' in href, \
+ f"Expected distro version in canonical href, got: {href}"
+
+ def test_canonical_includes_distro_with_multiversion(self):
+ """
+ When sphinx-multiversion runs for e.g. 'humble', the canonical URL
+ must include 'humble', not 'rolling'.
+ """
+ href = get_canonical_href_from_conf(
+ html_baseurl='https://docs.ros.org/en/humble',
+ redirect_url='How-To-Guides/Old-Page',
+ canonical_url='How-To-Guides/Using-Custom-Rosdistro',
+ )
+ assert 'humble' in href, \
+ f"Expected 'humble' in canonical href, got: {href}"
+
+ def test_meta_refresh_still_uses_relative_url(self):
+ """
+ The should keep using a relative URL so it works
+ within the built site structure — only the canonical link needs
+ to be absolute.
+ """
+ conf_path = Path(__file__).parent.parent / 'conf.py'
+ source = conf_path.read_text()
+ match = re.search(
+ r'redirect_html_fragment\s*=\s*"""(.*?)"""',
+ source, re.DOTALL
+ )
+ template = match.group(1)
+ app = make_mock_app('https://docs.ros.org/en/rolling')
+
+ if '{canonical_abs_url}' in template:
+ metatags = template.format(
+ canonical_abs_url='https://docs.ros.org/en/rolling/How-To-Guides/Page.html',
+ url=app.builder.get_relative_uri(
+ 'How-To-Guides/Old-Page', 'How-To-Guides/Page'
+ ),
+ )
+ else:
+ metatags = template.format(
+ base_url='https://docs.ros.org/en/rolling',
+ url=app.builder.get_relative_uri(
+ 'How-To-Guides/Old-Page', 'How-To-Guides/Page'
+ ),
+ )
+
+ meta_match = re.search(r'content="0; url=([^"]+)"', metatags)
+ assert meta_match, "No meta refresh found"
+ assert not meta_match.group(1).startswith('https://'), \
+ f"Meta refresh should be relative, got: {meta_match.group(1)}"
\ No newline at end of file