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
46 changes: 45 additions & 1 deletion scripts/build_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, []


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
144 changes: 144 additions & 0 deletions scripts/inject_canonical_link.py
Original file line number Diff line number Diff line change
@@ -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 `<link rel="canonical">` 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 <head>), 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'<link rel="canonical" href="[^"]*">')


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 `<link rel="canonical">` 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'<link rel="canonical" href="{html.escape(canonical_url, quote=True)}">'
)

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 "</head>" in text:
new_text = text.replace("</head>", new_tag + "</head>", 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]} <path-to-doccarchive> <canonical-base-url>\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()
1 change: 1 addition & 0 deletions scripts/sources.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"version": {
"slug": "main"
},
"canonical_base_url": "https://docs.swift.org/latest",
"sources": [
{
"id": "swift-book",
Expand Down
Loading