diff --git a/scripts/build_docs.py b/scripts/build_docs.py
index dc6a94b4..b5f9672b 100755
--- a/scripts/build_docs.py
+++ b/scripts/build_docs.py
@@ -22,12 +22,14 @@
import sys
import tarfile
import urllib.error
+import urllib.parse
import urllib.request
import zipfile
from collections import namedtuple
from datetime import datetime, timezone
from pathlib import Path
+from inject_canonical_link import canonicalize_archive as canonicalize_link_archive
from strip_availability import strip_archive
from strip_language_toggle import strip_archive as strip_language_toggle_archive
from suppress_eyebrows import suppress_archive as suppress_eyebrow_archive
@@ -271,6 +273,36 @@ def validate_sources(config):
return errors
+def _resolve_canonical_base_url(config):
+ """Return config's 'canonical_base_url' if present and well-formed, else None.
+
+ A missing field is silent — canonical-link injection just doesn't run.
+ A malformed field (wrong type, empty, no scheme, no host) prints an
+ explicit warning to stdout — so it shows up in the build script's own
+ output and therefore in CI logs — and disables injection for this build
+ rather than failing the whole documentation build over what's purely an
+ SEO metadata concern.
+ """
+ canonical_base_url = config.get("canonical_base_url")
+ if canonical_base_url is None:
+ return None
+
+ if isinstance(canonical_base_url, str):
+ parsed = urllib.parse.urlparse(canonical_base_url)
+ else:
+ parsed = None
+
+ if parsed and parsed.scheme in ("http", "https") and parsed.netloc:
+ return canonical_base_url
+
+ print(
+ f"Warning: 'canonical_base_url' ({canonical_base_url!r}) is not a valid "
+ "absolute http(s) URL (expected e.g. 'https://docs.swift.org/latest'); "
+ "skipping canonical link injection."
+ )
+ return None
+
+
def clean_package_build_dirs(root_dir, sources):
"""Remove `.build/` dirs for every local Swift package this script touches.
@@ -842,7 +874,7 @@ def inject_custom_templates_into_stubs(archive_path, common_dir):
return patched
-def _finalize_combined_archive(all_archives, output_dir, version_slug, docc_cmd, prior_failed, common_dir=None, navigation=None, hosting_base_path=None):
+def _finalize_combined_archive(all_archives, output_dir, version_slug, docc_cmd, prior_failed, common_dir=None, navigation=None, hosting_base_path=None, canonical_base_url=None):
"""Merge per-source archives and apply the static-hosting transform.
Returns (succeeded_steps, failed_steps): names that should be added to
@@ -933,6 +965,16 @@ def _finalize_combined_archive(all_archives, output_dir, version_slug, docc_cmd,
print(f"Patched custom-header/footer into {patched} per-route stub(s).")
prior_steps.append("static-hosting-transform")
+
+ if canonical_base_url:
+ try:
+ scanned, modified = canonicalize_link_archive(combined_output, canonical_base_url)
+ print(f"Canonical link injection: scanned {scanned} file(s), modified {modified}.")
+ except (OSError, ValueError) as e:
+ print(f"Error: canonical link injection failed: {e}")
+ return prior_steps, ["canonical-link-injection"]
+ prior_steps.append("canonical-link-injection")
+
return prior_steps, []
@@ -1036,6 +1078,7 @@ def main():
version = config["version"]
version_slug = version["slug"]
hosting_base_path = f"{args.extra_hosting_prefix}/{version_slug}" if args.extra_hosting_prefix else version_slug
+ canonical_base_url = _resolve_canonical_base_url(config)
sources = config["sources"]
# Ensure consistent, pretty-printed DocC JSON output
@@ -1118,6 +1161,7 @@ def attempt_build(index, source, fatal=(), recoverable=()):
all_archives, output_dir, version_slug, tools.docc, failed,
common_dir=common_dir, navigation=navigation,
hosting_base_path=hosting_base_path,
+ canonical_base_url=canonical_base_url,
)
succeeded.extend(s_steps)
failed.extend(f_steps)
diff --git a/scripts/inject_canonical_link.py b/scripts/inject_canonical_link.py
new file mode 100755
index 00000000..5efe65d1
--- /dev/null
+++ b/scripts/inject_canonical_link.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+##===----------------------------------------------------------------------===##
+##
+## This source file is part of the Swift.org open source project
+##
+## Copyright (c) 2026 Apple Inc. and the Swift.org project authors
+## Licensed under Apache License v2.0
+##
+## See LICENSE.txt for license information
+## See CONTRIBUTORS.txt for the list of Swift.org project authors
+##
+## SPDX-License-Identifier: Apache-2.0
+##
+##===----------------------------------------------------------------------===##
+"""
+inject_canonical_link.py — Stamp a `` tag into every
+per-route index.html stub of a `docc process-archive
+transform-for-static-hosting`-transformed archive.
+
+DocC's static-hosting output is a single-page app: every route gets its own
+index.html stub (regenerated by the transform-for-static-hosting step, one
+per route, sharing an otherwise-identical
), and the real per-page
+content is client-rendered from JSON under data/. None of those stubs carry
+any page-identifying metadata today, so replicated builds of the same
+content (versioned copies, mirrors) have nothing telling search engines
+which copy is authoritative — they risk being treated as duplicate/spam
+content and de-indexed.
+
+This walks every index.html under an archive root and inserts a
+self-describing canonical link whose target is always rooted at a single
+fixed `canonical_base_url` (e.g. "https://docs.swift.org/latest"),
+regardless of which version_slug actually produced this archive — every
+build is expected to declare the same "latest" copy as canonical.
+
+Idempotent: re-running with the same base URL is a no-op, and re-running
+with a different one replaces the existing tag in place rather than adding
+a second one — so this can be re-run safely if the canonical target ever
+changes.
+
+Usage:
+ ./inject_canonical_link.py path/to/archive.doccarchive https://docs.swift.org/latest
+"""
+
+import html
+import os
+import re
+import sys
+import tempfile
+from pathlib import Path
+
+CANONICAL_LINK_RE = re.compile(r'')
+
+
+def _route_for(index_html_path, archive_root):
+ """Return the route (POSIX, no leading/trailing slash) for a stub's
+ directory, relative to the archive root. The root stub's own route is "".
+ """
+ route_dir = index_html_path.parent.relative_to(archive_root)
+ return route_dir.as_posix() if route_dir != Path(".") else ""
+
+
+def _atomic_write(path, text):
+ """Write `text` to `path` via a same-directory tempfile + os.replace, so a
+ failure partway through never leaves `path` truncated or corrupted.
+ """
+ dir_ = os.path.dirname(path)
+ fd, tmp = tempfile.mkstemp(prefix=".inject-canonical-link-", dir=dir_)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ f.write(text)
+ os.replace(tmp, path)
+ except BaseException:
+ try:
+ os.unlink(tmp)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+def canonicalize_archive(archive_path, canonical_base_url):
+ """Insert or replace a self-describing `` tag in
+ every index.html stub under `archive_path`, pointed at the equivalent
+ route under `canonical_base_url`.
+
+ Returns (files_scanned, files_modified). Raises ValueError if
+ archive_path doesn't look like a transformed .doccarchive (missing a
+ root index.html).
+ """
+ archive = Path(archive_path)
+ root_index = archive / "index.html"
+ if not root_index.is_file():
+ raise ValueError(
+ f"{os.fspath(archive)!r} does not look like a transformed "
+ f".doccarchive (missing root index.html)"
+ )
+
+ base = canonical_base_url.rstrip("/")
+
+ files_scanned = 0
+ files_modified = 0
+
+ for index_path in archive.rglob("index.html"):
+ files_scanned += 1
+ route = _route_for(index_path, archive)
+ canonical_url = f"{base}/{route}" if route else base
+ new_tag = (
+ f''
+ )
+
+ text = index_path.read_text(encoding="utf-8")
+ if CANONICAL_LINK_RE.search(text):
+ new_text = CANONICAL_LINK_RE.sub(new_tag, text, count=1)
+ elif "" in text:
+ new_text = text.replace("", new_tag + "", 1)
+ else:
+ continue
+
+ if new_text != text:
+ _atomic_write(index_path, new_text)
+ files_modified += 1
+
+ return files_scanned, files_modified
+
+
+def main():
+ if len(sys.argv) != 3:
+ sys.stderr.write(
+ f"usage: {sys.argv[0]} \n"
+ )
+ sys.exit(2)
+
+ archive = os.path.abspath(sys.argv[1])
+ canonical_base_url = sys.argv[2]
+ try:
+ files_scanned, files_modified = canonicalize_archive(archive, canonical_base_url)
+ except (OSError, ValueError) as e:
+ sys.stderr.write(f"error: {e}\n")
+ sys.exit(1)
+
+ print(f"scanned {files_scanned} files; modified {files_modified}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/sources.json b/scripts/sources.json
index 61632b82..7a6ef2fa 100644
--- a/scripts/sources.json
+++ b/scripts/sources.json
@@ -2,6 +2,7 @@
"version": {
"slug": "main"
},
+ "canonical_base_url": "https://docs.swift.org/latest",
"sources": [
{
"id": "swift-book",
diff --git a/scripts/test_build_docs.py b/scripts/test_build_docs.py
index 99d40df2..348133ce 100644
--- a/scripts/test_build_docs.py
+++ b/scripts/test_build_docs.py
@@ -31,6 +31,7 @@
import build_docs # noqa: E402
import curate_navigator # noqa: E402
+import inject_canonical_link # noqa: E402
import strip_availability # noqa: E402
import strip_language_toggle # noqa: E402
import suppress_eyebrows # noqa: E402
@@ -88,6 +89,87 @@ def test_version_missing_slug_is_rejected(self):
self.assertIn("slug", output)
+class ValidateCanonicalBaseUrlField(unittest.TestCase):
+ """canonical_base_url is deliberately NOT validated by validate_sources():
+ a malformed value should warn and disable canonical-link injection (see
+ ResolveCanonicalBaseUrl below), not fail the whole documentation build.
+ """
+
+ def _config(self, canonical_base_url=None):
+ config = {
+ "version": {"slug": "main"},
+ "sources": [{
+ "id": "stdlib",
+ "type": "archive",
+ "url": "https://example.com/Swift.doccarchive.tar.gz",
+ "docc_archive_name": "Swift.doccarchive",
+ }],
+ }
+ if canonical_base_url is not None:
+ config["canonical_base_url"] = canonical_base_url
+ return config
+
+ def test_omitted_is_valid(self):
+ output = _validate(self._config())
+ self.assertIsNone(output)
+
+ def test_valid_https_url_is_accepted(self):
+ output = _validate(self._config("https://docs.swift.org/latest"))
+ self.assertIsNone(output)
+
+ def test_malformed_value_does_not_fail_validation(self):
+ for bad_value in ({"not": "a string"}, "", "docs.swift.org/latest"):
+ with self.subTest(bad_value=bad_value):
+ output = _validate(self._config(bad_value))
+ self.assertIsNone(output)
+
+
+class ResolveCanonicalBaseUrl(unittest.TestCase):
+ """build_docs._resolve_canonical_base_url() is the single point that
+ decides whether canonical-link injection runs. A missing field is silent;
+ a malformed field prints an explicit warning (so it shows up in the build
+ script's own output, and therefore in CI logs) and disables injection for
+ this build rather than failing it outright.
+ """
+
+ def test_absent_returns_none_silently(self):
+ with mock.patch("builtins.print") as mock_print:
+ result = build_docs._resolve_canonical_base_url({})
+ self.assertIsNone(result)
+ mock_print.assert_not_called()
+
+ def test_valid_https_url_is_returned(self):
+ config = {"canonical_base_url": "https://docs.swift.org/latest"}
+ with mock.patch("builtins.print") as mock_print:
+ result = build_docs._resolve_canonical_base_url(config)
+ self.assertEqual(result, "https://docs.swift.org/latest")
+ mock_print.assert_not_called()
+
+ def test_valid_http_url_is_returned(self):
+ config = {"canonical_base_url": "http://example.com/latest"}
+ result = build_docs._resolve_canonical_base_url(config)
+ self.assertEqual(result, "http://example.com/latest")
+
+ def test_malformed_value_warns_and_disables_injection(self):
+ bad_values = [
+ {"not": "a string"},
+ "",
+ "docs.swift.org/latest",
+ "https://",
+ "ftp://docs.swift.org/latest",
+ ]
+ for bad_value in bad_values:
+ with self.subTest(canonical_base_url=bad_value):
+ config = {"canonical_base_url": bad_value}
+ with mock.patch("builtins.print") as mock_print:
+ result = build_docs._resolve_canonical_base_url(config)
+ self.assertIsNone(result)
+ mock_print.assert_called_once()
+ warning = mock_print.call_args[0][0]
+ self.assertIn("Warning", warning)
+ self.assertIn("canonical_base_url", warning)
+
+
class ValidateArchiveType(unittest.TestCase):
def test_minimal_valid(self):
entry = {
@@ -189,26 +271,18 @@ def test_unknown_type_still_rejected(self):
self.assertIsNotNone(output)
self.assertIn("unknown type", output)
- def test_strip_availability_true_is_valid(self):
- entry = {
- "id": "stdlib",
- "type": "archive",
- "url": "https://example.com/Swift.doccarchive.zip",
- "docc_archive_name": "Swift.doccarchive",
- "format": "zip",
- "strip_availability": True,
- }
- self.assertIsNone(_validate(_wrap(entry)))
-
- def test_strip_availability_false_is_valid(self):
- entry = {
- "id": "stdlib",
- "type": "archive",
- "url": "https://example.com/Swift.doccarchive.tar.gz",
- "docc_archive_name": "Swift.doccarchive",
- "strip_availability": False,
- }
- self.assertIsNone(_validate(_wrap(entry)))
+ def test_strip_availability_bool_is_valid(self):
+ for value in (True, False):
+ with self.subTest(strip_availability=value):
+ entry = {
+ "id": "stdlib",
+ "type": "archive",
+ "url": "https://example.com/Swift.doccarchive.zip",
+ "docc_archive_name": "Swift.doccarchive",
+ "format": "zip",
+ "strip_availability": value,
+ }
+ self.assertIsNone(_validate(_wrap(entry)))
def test_strip_availability_non_bool_rejected(self):
entry = {
@@ -708,6 +782,232 @@ def test_missing_data_dir_raises(self):
strip_language_toggle.strip_archive(not_an_archive)
+def _make_transformed_archive(root, archive_name="Combined.doccarchive"):
+ """Build a minimal post-transform-for-static-hosting archive tree: a root
+ index.html stub and two nested per-route stubs, mirroring the shape
+ `docc process-archive transform-for-static-hosting` produces (one
+ index.html per route, sharing the same generic ).
+
+ Returns the archive path.
+ """
+ archive = root / archive_name
+ stub = "Documentation"
+
+ (archive).mkdir(parents=True)
+ (archive / "index.html").write_text(stub)
+
+ doc_dir = archive / "documentation"
+ doc_dir.mkdir()
+ (doc_dir / "index.html").write_text(stub)
+
+ nested_dir = doc_dir / "wasmguide"
+ nested_dir.mkdir()
+ (nested_dir / "index.html").write_text(stub)
+
+ return archive
+
+
+class CanonicalizeArchive(unittest.TestCase):
+ def test_injects_canonical_link_for_root_and_nested_routes(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ archive = _make_transformed_archive(Path(tmp))
+
+ scanned, modified = inject_canonical_link.canonicalize_archive(
+ archive, "https://docs.swift.org/latest"
+ )
+
+ root_html = (archive / "index.html").read_text()
+ doc_html = (archive / "documentation" / "index.html").read_text()
+ nested_html = (
+ archive / "documentation" / "wasmguide" / "index.html"
+ ).read_text()
+
+ self.assertIn(
+ '',
+ root_html,
+ )
+ self.assertIn(
+ '',
+ doc_html,
+ )
+ self.assertIn(
+ '',
+ nested_html,
+ )
+ self.assertEqual(scanned, 3)
+ self.assertEqual(modified, 3)
+
+ def test_trailing_slash_on_base_url_is_normalized(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ archive = _make_transformed_archive(Path(tmp))
+
+ inject_canonical_link.canonicalize_archive(
+ archive, "https://docs.swift.org/latest/"
+ )
+
+ root_html = (archive / "index.html").read_text()
+ self.assertIn(
+ '',
+ root_html,
+ )
+
+ def test_replaces_existing_canonical_link_idempotently(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ archive = _make_transformed_archive(Path(tmp))
+ stale = (
+ "Documentation"
+ ''
+ ""
+ )
+ (archive / "index.html").write_text(stale)
+
+ scanned, modified = inject_canonical_link.canonicalize_archive(
+ archive, "https://docs.swift.org/latest"
+ )
+
+ root_html = (archive / "index.html").read_text()
+ self.assertEqual(
+ root_html.count('rel="canonical"'), 1,
+ "must replace the stale tag, not add a second one",
+ )
+ self.assertIn(
+ '',
+ root_html,
+ )
+ self.assertNotIn("docs.swift.org/main", root_html)
+ self.assertEqual(scanned, 3)
+ self.assertEqual(modified, 3)
+
+ def test_rerun_with_unchanged_url_reports_no_further_modification(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ archive = _make_transformed_archive(Path(tmp))
+ inject_canonical_link.canonicalize_archive(
+ archive, "https://docs.swift.org/latest"
+ )
+
+ scanned, modified = inject_canonical_link.canonicalize_archive(
+ archive, "https://docs.swift.org/latest"
+ )
+
+ self.assertEqual(scanned, 3)
+ self.assertEqual(modified, 0)
+
+ def test_route_segment_is_html_escaped_in_href(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ archive = root / "Combined.doccarchive"
+ archive.mkdir()
+ stub = (
+ "Documentation"
+ ""
+ )
+ (archive / "index.html").write_text(stub)
+ odd_dir = archive / 'a&b'
+ odd_dir.mkdir()
+ (odd_dir / "index.html").write_text(stub)
+
+ inject_canonical_link.canonicalize_archive(
+ archive, "https://docs.swift.org/latest"
+ )
+
+ odd_html = (odd_dir / "index.html").read_text()
+ self.assertIn(
+ '',
+ odd_html,
+ )
+
+ def test_file_without_head_close_tag_is_scanned_but_not_modified(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ archive = _make_transformed_archive(Path(tmp))
+ (archive / "index.html").write_text("no head here")
+
+ scanned, modified = inject_canonical_link.canonicalize_archive(
+ archive, "https://docs.swift.org/latest"
+ )
+
+ self.assertEqual(scanned, 3)
+ self.assertEqual(modified, 2)
+ self.assertEqual(
+ (archive / "index.html").read_text(),
+ "no head here",
+ )
+
+ def test_missing_root_index_html_raises(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ not_an_archive = Path(tmp) / "NotAnArchive"
+ not_an_archive.mkdir()
+ with self.assertRaises(ValueError):
+ inject_canonical_link.canonicalize_archive(
+ not_an_archive, "https://docs.swift.org/latest"
+ )
+
+ def test_non_ascii_content_round_trips(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ archive = Path(tmp) / "Combined.doccarchive"
+ archive.mkdir()
+ stub = (
+ ""
+ "Café Documentation — wasm→native"
+ ""
+ )
+ (archive / "index.html").write_text(stub, encoding="utf-8")
+
+ scanned, modified = inject_canonical_link.canonicalize_archive(
+ archive, "https://docs.swift.org/latest"
+ )
+
+ root_html = (archive / "index.html").read_text(encoding="utf-8")
+ self.assertEqual(scanned, 1)
+ self.assertEqual(modified, 1)
+ self.assertIn("Café Documentation — wasm→native", root_html)
+ self.assertIn(
+ '',
+ root_html,
+ )
+
+ def test_write_failure_leaves_original_file_untouched(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ archive = _make_transformed_archive(Path(tmp))
+ original = (archive / "index.html").read_text()
+
+ with mock.patch.object(
+ inject_canonical_link.os, "replace", side_effect=OSError("boom")
+ ):
+ with self.assertRaises(OSError):
+ inject_canonical_link.canonicalize_archive(
+ archive, "https://docs.swift.org/latest"
+ )
+
+ self.assertEqual((archive / "index.html").read_text(), original)
+ leftover_temp_files = [
+ p for p in archive.iterdir() if p.name.startswith(".")
+ ]
+ self.assertEqual(
+ leftover_temp_files, [],
+ "a failed write must not leave a stray temp file behind",
+ )
+
+
+class InjectCanonicalLinkCLI(unittest.TestCase):
+ def test_os_error_from_canonicalize_archive_exits_cleanly(self):
+ argv = ["inject_canonical_link.py", "/some/archive", "https://docs.swift.org/latest"]
+ with mock.patch.object(sys, "argv", argv):
+ with mock.patch.object(
+ inject_canonical_link, "canonicalize_archive",
+ side_effect=OSError("boom"),
+ ):
+ with mock.patch.object(sys, "stderr") as mock_stderr:
+ with self.assertRaises(SystemExit) as cm:
+ inject_canonical_link.main()
+ self.assertEqual(cm.exception.code, 1)
+ written = "".join(call.args[0] for call in mock_stderr.write.call_args_list)
+ self.assertIn("error:", written)
+ self.assertIn("boom", written)
+
+
def _make_archive_with_collections(root, archive_name="Combined.doccarchive"):
"""Build a minimal merged .doccarchive tree with two module landing pages,
the synthesized combined landing page, and one non-collection page.
@@ -1193,11 +1493,8 @@ def test_passes_landing_page_name_and_list_style(self):
def test_landing_page_name_is_version_independent(self):
cmd_main = self._capture_merge_cmd("main")
- cmd_6_2 = self._capture_merge_cmd("6.2")
name_idx_main = cmd_main.index("--synthesized-landing-page-name") + 1
- name_idx_6_2 = cmd_6_2.index("--synthesized-landing-page-name") + 1
self.assertEqual(cmd_main[name_idx_main], "Swift Documentation")
- self.assertEqual(cmd_6_2[name_idx_6_2], "Swift Documentation")
class FinalizeCombinedArchive(unittest.TestCase):
@@ -1328,7 +1625,45 @@ def fake_run(cmd, **kw):
)
self.assertEqual(failed, [])
- def test_language_toggle_suppression_failure_records_only_that_step(self):
+ def test_step_failure_records_only_that_step(self):
+ cases = [
+ ("strip_language_toggle_archive",
+ ["combined-merge", "eyebrow-suppression"],
+ ["language-toggle-suppression"]),
+ ("canonicalize_link_archive",
+ ["combined-merge", "eyebrow-suppression", "language-toggle-suppression",
+ "static-hosting-transform"],
+ ["canonical-link-injection"]),
+ ]
+ for mock_target, expected_succeeded, expected_failed in cases:
+ with self.subTest(mock_target=mock_target):
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_path = Path(tmp)
+ archive = tmp_path / "a.doccarchive"
+ archive.mkdir()
+ (archive / "index.html").write_text("ok")
+
+ def fake_run(cmd, **kw):
+ out_idx = cmd.index("--output-path") + 1
+ out = Path(cmd[out_idx])
+ out.mkdir(parents=True, exist_ok=True)
+ (out / "index.html").write_text("stub")
+ if "merge" in cmd:
+ (out / "data").mkdir(parents=True, exist_ok=True)
+ return subprocess.CompletedProcess(cmd, 0)
+
+ with mock.patch.object(build_docs.subprocess, "run", side_effect=fake_run):
+ with mock.patch.object(
+ build_docs, mock_target, side_effect=OSError("boom"),
+ ):
+ succeeded, failed = build_docs._finalize_combined_archive(
+ [archive], tmp_path, "main", ["docc"], prior_failed=[],
+ canonical_base_url="https://docs.swift.org/latest",
+ )
+ self.assertEqual(succeeded, expected_succeeded)
+ self.assertEqual(failed, expected_failed)
+
+ def test_canonical_base_url_omitted_skips_canonical_link_injection(self):
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
archive = tmp_path / "a.doccarchive"
@@ -1339,19 +1674,58 @@ def fake_run(cmd, **kw):
out_idx = cmd.index("--output-path") + 1
out = Path(cmd[out_idx])
out.mkdir(parents=True, exist_ok=True)
- (out / "index.html").write_text("merged")
+ (out / "index.html").write_text("stub")
+ if "merge" in cmd:
+ (out / "data").mkdir(parents=True, exist_ok=True)
return subprocess.CompletedProcess(cmd, 0)
with mock.patch.object(build_docs.subprocess, "run", side_effect=fake_run):
with mock.patch.object(
- build_docs, "strip_language_toggle_archive",
- side_effect=OSError("boom"),
- ):
+ build_docs, "canonicalize_link_archive"
+ ) as mock_canonicalize:
succeeded, failed = build_docs._finalize_combined_archive(
[archive], tmp_path, "main", ["docc"], prior_failed=[]
)
- self.assertEqual(succeeded, ["combined-merge", "eyebrow-suppression"])
- self.assertEqual(failed, ["language-toggle-suppression"])
+ mock_canonicalize.assert_not_called()
+ self.assertNotIn("canonical-link-injection", succeeded)
+ self.assertEqual(failed, [])
+
+ def test_canonical_base_url_given_runs_and_records_injection_step(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_path = Path(tmp)
+ archive = tmp_path / "a.doccarchive"
+ archive.mkdir()
+ (archive / "index.html").write_text("ok")
+
+ def fake_run(cmd, **kw):
+ out_idx = cmd.index("--output-path") + 1
+ out = Path(cmd[out_idx])
+ out.mkdir(parents=True, exist_ok=True)
+ (out / "index.html").write_text(
+ "Documentation"
+ )
+ if "merge" in cmd:
+ (out / "data").mkdir(parents=True, exist_ok=True)
+ return subprocess.CompletedProcess(cmd, 0)
+
+ with mock.patch.object(build_docs.subprocess, "run", side_effect=fake_run):
+ succeeded, failed = build_docs._finalize_combined_archive(
+ [archive], tmp_path, "main", ["docc"], prior_failed=[],
+ canonical_base_url="https://docs.swift.org/latest",
+ )
+ self.assertEqual(
+ succeeded,
+ ["combined-merge", "eyebrow-suppression", "language-toggle-suppression",
+ "static-hosting-transform", "canonical-link-injection"],
+ )
+ self.assertEqual(failed, [])
+ combined_output = tmp_path / "main"
+ self.assertNotEqual(
+ (combined_output / "index.html").read_text(),
+ "Documentation",
+ "canonical-link-injection step must actually modify the stub "
+ "(exact injected HTML shape is CanonicalizeArchive's concern)",
+ )
def _merge_writes_index(self, modules):
"""Build a fake subprocess.run that writes a merged index.json on merge.