Skip to content

Commit 754bc24

Browse files
Merge issue-163-docs-faq-roots: Canonicalize docs and FAQ root URLs (#163)
2 parents c28d353 + 533b5a8 commit 754bc24

11 files changed

Lines changed: 153 additions & 22 deletions

content/docs_projection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ def docs_children(parent_path: str | None) -> tuple[dict[str, Any], ...]:
337337

338338

339339
def docs_breadcrumbs(page: Mapping[str, Any]) -> tuple[dict[str, str], ...]:
340-
result: list[dict[str, str]] = [{"title": "Documentation", "public_path": "/docs"}]
340+
result: list[dict[str, str]] = [{"title": "Documentation", "public_path": DOCS_ROOT_PATH}]
341341
chain: list[dict[str, Any]] = []
342342
current = page
343343
by_path = {item["public_path"]: item for item in docs_projection()["pages"]}

content/public_data.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -739,9 +739,9 @@ def public_paths() -> tuple[str, ...]:
739739
"/events",
740740
"/courses",
741741
"/wiki",
742-
"/docs",
742+
"/docs/",
743743
"/docs/courses/ai-dev-tools-zoomcamp/getting-started/",
744-
"/faq",
744+
"/faq/",
745745
"/faq/ai-dev-tools-zoomcamp.html",
746746
"/slack",
747747
"/courses/ai-dev-tools-zoomcamp/cohorts/ai-dev-tools-2026",

content/public_views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -879,14 +879,14 @@ def _section_records(section: str) -> tuple[tuple[str, str], ...]:
879879
("/slack", ""),
880880
),
881881
"docs": (
882-
("/docs", ""),
882+
("/docs/", ""),
883883
("/docs/courses/ai-dev-tools-zoomcamp/getting-started/", ""),
884884
),
885885
}
886886
if section in static_sections:
887887
return static_sections[section]
888888
if section == "faq":
889-
return (("/faq", ""),) + tuple((course["public_path"], "") for course in faq_courses())
889+
return (("/faq/", ""),) + tuple((course["public_path"], "") for course in faq_courses())
890890
if section == "blog":
891891
return (("/blog", ""),) + tuple(
892892
(record["public_path"], record["published"][:10]) for record in projection["articles"]

content/review_views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def docs_home(request: HttpRequest) -> HttpResponse:
192192
return _render(
193193
request,
194194
"review/docs_home.html",
195-
path="/docs",
195+
path="/docs/",
196196
title="Documentation — DataTalks.Club",
197197
description=document.get("description")
198198
or "Guides for DataTalks.Club courses and community learning.",
@@ -275,7 +275,7 @@ def faq_home(request: HttpRequest) -> HttpResponse:
275275
return _render(
276276
request,
277277
"review/faq_home.html",
278-
path="/faq",
278+
path="/faq/",
279279
title="Frequently Asked Questions — DataTalks.Club",
280280
description="Answers to common questions about DataTalks.Club courses.",
281281
context={
@@ -339,7 +339,7 @@ def _faq_structured_data(course: dict[str, Any]) -> str:
339339
"@type": "ListItem",
340340
"position": 2,
341341
"name": "FAQ",
342-
"item": _canonical("/faq"),
342+
"item": _canonical("/faq/"),
343343
},
344344
{
345345
"@type": "ListItem",

content/tests/test_docs_projection.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ def test_projection_is_complete_and_pinned(self) -> None:
3232
self.assertEqual(page["parent_path"], "/docs/courses/ai-dev-tools-zoomcamp/")
3333

3434
def test_docs_home_and_detail_emit_source_content_and_canonicals(self) -> None:
35-
home = self.client.get("/docs")
35+
home = self.client.get("/docs/")
3636
self.assertEqual(home.status_code, 200)
37-
self.assertContains(home, '<link rel="canonical" href="https://datatalks.club/docs">')
37+
self.assertNotIn("Location", home.headers)
38+
self.assertContains(home, '<link rel="canonical" href="https://datatalks.club/docs/">')
39+
self.assertContains(home, '<meta property="og:url" content="https://datatalks.club/docs/">')
3840
self.assertContains(home, "DataTalks.Club Zoomcamps Notes and Resources")
3941
self.assertContains(home, "/docs/courses/")
4042

@@ -55,8 +57,6 @@ def test_docs_home_and_detail_emit_source_content_and_canonicals(self) -> None:
5557
def test_every_projected_page_is_a_trailing_slash_public_page(self) -> None:
5658
for page in docs_projection()["pages"]:
5759
public_path = page["public_path"]
58-
if public_path == DOCS_ROOT_PATH:
59-
public_path = "/docs"
6060
with self.subTest(public_path=public_path):
6161
response = self.client.get(public_path)
6262
self.assertEqual(response.status_code, 200)
@@ -72,7 +72,7 @@ def test_navigation_breadcrumbs_follow_source_hierarchy(self) -> None:
7272
self.assertEqual(
7373
[(item["title"], item["public_path"]) for item in breadcrumbs],
7474
[
75-
("Documentation", "/docs"),
75+
("Documentation", "/docs/"),
7676
("General", "/docs/general/"),
7777
("Community Guidelines", "/docs/general/guidelines/"),
7878
],

content/tests/test_faq.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@ def test_projection_has_the_pinned_inventory_and_stable_question_ids(self) -> No
2121
)
2222

2323
def test_faq_hub_lists_every_course(self) -> None:
24-
response = self.client.get("/faq")
24+
response = self.client.get("/faq/")
2525
self.assertEqual(response.status_code, 200)
26+
self.assertNotIn("Location", response.headers)
27+
self.assertContains(response, '<link rel="canonical" href="https://datatalks.club/faq/">')
28+
self.assertContains(
29+
response, '<meta property="og:url" content="https://datatalks.club/faq/">'
30+
)
2631
for course in faq_courses():
2732
self.assertContains(response, course["name"])
2833
self.assertContains(response, f'href="{course["public_path"]}"')

content/tests/test_public_routes_and_seo.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,69 @@
1717
class PublicRouteAndSeoTests(TestCase):
1818
maxDiff = None
1919

20+
def test_docs_and_faq_roots_preserve_trailing_slash_contract(self) -> None:
21+
roots = (("/docs/", "/docs"), ("/faq/", "/faq"))
22+
query = "utm_source=oncall%2Btest&x=a%2Fb&blank="
23+
canonical_inventory = set(public_paths())
24+
25+
for final_path, alias_path in roots:
26+
with self.subTest(final_path=final_path):
27+
self.assertIn(final_path, canonical_inventory)
28+
self.assertNotIn(alias_path, canonical_inventory)
29+
30+
final = self.client.get(f"{final_path}?{query}", follow=False)
31+
self.assertEqual(final.status_code, 200)
32+
self.assertNotIn("Location", final.headers)
33+
self.assertEqual(final.headers["X-Robots-Tag"], "noindex, nofollow")
34+
self.assertContains(
35+
final,
36+
f'<link rel="canonical" href="https://datatalks.club{final_path}">',
37+
count=1,
38+
)
39+
self.assertContains(
40+
final,
41+
f'<meta property="og:url" content="https://datatalks.club{final_path}">',
42+
count=1,
43+
)
44+
self.assertNotRegex(
45+
final.content.decode(), rf'href="https://datatalks.club{alias_path}(?:"|[?#])'
46+
)
47+
self.assertNotRegex(final.content.decode(), rf'href="{alias_path}(?:"|[?#])')
48+
self.assertEqual(self.client.head(final_path).status_code, 200)
49+
self.assertEqual(self.client.post(final_path).status_code, 405)
50+
51+
alias = self.client.get(f"{alias_path}?{query}", follow=False)
52+
self.assertEqual(alias.status_code, 301)
53+
self.assertEqual(alias.headers["Location"], f"{final_path}?{query}")
54+
self.assertEqual(alias.headers["X-Robots-Tag"], "noindex, nofollow")
55+
head = self.client.head(f"{alias_path}?{query}", follow=False)
56+
self.assertEqual(head.status_code, 301)
57+
self.assertEqual(head.headers["Location"], f"{final_path}?{query}")
58+
self.assertEqual(self.client.post(alias_path).status_code, 405)
59+
60+
faq_detail = self.client.get("/faq/ai-dev-tools-zoomcamp.html")
61+
match = re.search(
62+
r'<script type="application/ld\+json">\s*(.*?)\s*</script>',
63+
faq_detail.content.decode(),
64+
re.DOTALL,
65+
)
66+
if match is None:
67+
self.fail("FAQ detail does not emit JSON-LD")
68+
graph = json.loads(match.group(1))["@graph"]
69+
breadcrumb = next(item for item in graph if item["@type"] == "BreadcrumbList")
70+
self.assertEqual(breadcrumb["itemListElement"][1]["item"], "https://datatalks.club/faq/")
71+
72+
for section, root in (("docs", "/docs/"), ("faq", "/faq/")):
73+
with self.subTest(section=section):
74+
response = self.client.get(f"/sitemaps/{section}.xml")
75+
self.assertEqual(response.status_code, 200)
76+
document = ElementTree.fromstring(response.content)
77+
locations = [
78+
node.text or "" for node in document.findall("s:url/s:loc", SITEMAP_NAMESPACE)
79+
]
80+
self.assertEqual(locations.count(f"https://datatalks.club{root}"), 1)
81+
self.assertNotIn(f"https://datatalks.club{root.rstrip('/')}", locations)
82+
2083
def test_explicit_hub_redirects_are_permanent_one_hop_and_query_preserving(self) -> None:
2184
redirects = {
2285
"/articles.html": "/blog",

playwright_tests/test_accessibility.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,8 @@ def accessibility_environment() -> AccessibilityEnvironment:
234234
"events": Surface("/events"),
235235
"courses": Surface("/courses"),
236236
"wiki": Surface("/wiki"),
237-
"docs": Surface("/docs"),
238-
"faq": Surface("/faq"),
237+
"docs": Surface("/docs/"),
238+
"faq": Surface("/faq/"),
239239
"slack": Surface("/slack"),
240240
"article": Surface(article["public_path"]),
241241
"podcast-detail": Surface(podcast["public_path"]),

playwright_tests/test_foundation_smoke.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44
from playwright.sync_api import Browser, Page, ViewportSize, expect
55

6+
from content.docs_projection import docs_projection
67
from content.public_data import public_projection
78

89
pytestmark = [pytest.mark.core, pytest.mark.django_db(transaction=True)]
@@ -112,6 +113,68 @@ def test_public_home_and_hubs(
112113
)
113114

114115

116+
@pytest.mark.core
117+
@pytest.mark.parametrize(
118+
"viewport",
119+
[{"width": 1280, "height": 800}, {"width": 390, "height": 844}],
120+
)
121+
def test_docs_and_faq_root_trailing_slash_browser_contract(
122+
page: Page,
123+
live_server,
124+
viewport: ViewportSize,
125+
) -> None:
126+
page.set_viewport_size(viewport)
127+
origin = live_server.url
128+
query = "utm_source=oncall%2Btest&x=a%2Fb&blank="
129+
130+
for final_path, alias_path, heading in (
131+
("/docs/", "/docs", "DataTalks.Club Zoomcamps Notes and Resources"),
132+
("/faq/", "/faq", "Frequently Asked Questions"),
133+
):
134+
alias = page.request.get(f"{origin}{alias_path}?{query}", max_redirects=0)
135+
assert alias.status == 301
136+
assert alias.headers["location"] == f"{final_path}?{query}"
137+
head = page.request.head(f"{origin}{alias_path}?{query}", max_redirects=0)
138+
assert head.status == 301
139+
assert head.headers["location"] == f"{final_path}?{query}"
140+
141+
response = page.goto(f"{origin}{final_path}?{query}", wait_until="networkidle")
142+
assert response is not None and response.status == 200
143+
assert "location" not in response.headers
144+
expect(page).to_have_url(f"{origin}{final_path}?{query}")
145+
expect(page.get_by_role("heading", name=heading, exact=True)).to_be_visible()
146+
expect(page.locator('link[rel="canonical"]')).to_have_attribute(
147+
"href", f"https://datatalks.club{final_path}"
148+
)
149+
expect(page.locator('meta[property="og:url"]')).to_have_attribute(
150+
"content", f"https://datatalks.club{final_path}"
151+
)
152+
expect(page.locator(f'a[href="{final_path}"]')).to_have_count(1)
153+
expect(page.locator(f'a[href="{alias_path}"]')).to_have_count(0)
154+
155+
redirected = page.goto(f"{origin}{alias_path}?{query}", wait_until="networkidle")
156+
assert redirected is not None and redirected.status == 200
157+
expect(page).to_have_url(f"{origin}{final_path}?{query}")
158+
159+
docs_detail = page.goto(
160+
f"{origin}/docs/courses/ai-dev-tools-zoomcamp/getting-started/",
161+
wait_until="networkidle",
162+
)
163+
assert docs_detail is not None and docs_detail.status == 200
164+
assert page.locator('a[href="/docs/"]').count() >= 1
165+
docs_asset_path = docs_projection()["assets"][0]["public_path"]
166+
docs_asset = page.request.get(f"{origin}{docs_asset_path}")
167+
assert docs_asset.status == 200
168+
169+
faq_detail = page.goto(f"{origin}/faq/ai-dev-tools-zoomcamp.html", wait_until="networkidle")
170+
assert faq_detail is not None and faq_detail.status == 200
171+
assert page.locator('a[href="/faq/"]').count() >= 1
172+
faq_courses = page.request.get(f"{origin}/faq/json/courses.json")
173+
assert faq_courses.status == 200
174+
faq_course = page.request.get(f"{origin}/faq/json/ai-dev-tools-zoomcamp.json")
175+
assert faq_course.status == 200
176+
177+
115178
@pytest.mark.core
116179
@pytest.mark.parametrize(
117180
("viewport", "suffix"),

templates/site_base.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,14 @@
178178
{% endif %}>
179179
Books
180180
</a>
181-
<a href="{{ docs_url|default:'/docs' }}"
181+
<a href="{{ docs_url|default:'/docs/' }}"
182182
class="app-heading app-link"
183183
{% if primary_navigation_current == "docs" %}
184184
aria-current="page"
185185
{% endif %}>
186186
Docs
187187
</a>
188-
<a href="{{ faq_url|default:'/faq' }}"
188+
<a href="{{ faq_url|default:'/faq/' }}"
189189
class="app-heading app-link"
190190
{% if primary_navigation_current == "faq" %}
191191
aria-current="page"

0 commit comments

Comments
 (0)