diff --git a/.gitignore b/.gitignore index ed340052..64546233 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ Package.resolved .build-output/ **/*.docc/header.html **/*.docc/footer.html +**/*.docc/favicon.ico scripts/__pycache__/ scripts/.coverage diff --git a/.licenseignore b/.licenseignore index 1a6520c1..01e5dd75 100644 --- a/.licenseignore +++ b/.licenseignore @@ -1,4 +1,6 @@ **/*.gitkeep **/*.html +**/*.ico +**/*.svg **/Package.swift common/README diff --git a/common/favicon.ico b/common/favicon.ico new file mode 100644 index 00000000..ffb5e7b2 Binary files /dev/null and b/common/favicon.ico differ diff --git a/common/favicon.svg b/common/favicon.svg new file mode 100644 index 00000000..637a1e96 --- /dev/null +++ b/common/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md index a7f6529a..d6c78f1e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,7 +12,7 @@ python3 -m http.server 8123 --directory .build-output Then in another terminal: ```bash -open http://localhost:8123/latest/documentation/ +open http://localhost:8123/main/documentation/ ``` Serve from `.build-output` (the parent), not `.build-output/latest`: the build diff --git a/scripts/build_docs.py b/scripts/build_docs.py index dc3afabb..3fa4976f 100755 --- a/scripts/build_docs.py +++ b/scripts/build_docs.py @@ -66,6 +66,19 @@ class ArchiveFetchError(Exception): # Common template files to copy into each .docc catalog before building TEMPLATE_FILES = ["header.html", "footer.html"] +# Shared favicon copied verbatim (no placeholder substitution) into each .docc +# catalog root before building. DocC recognizes a favicon.ico at the catalog +# root as a custom favicon and copies it into the output, overriding its own +# default icon (see swift-docc's DocumentationBundleFileTypes.isCustomFavicon). +FAVICON_FILE = "favicon.ico" + +# Shared mask-icon SVG (Safari pinned-tab icon). Unlike favicon.ico, DocC has +# no catalog-level override for this file — a loose favicon.svg dropped into +# a .docc catalog is silently discarded by `docc convert`. The only place +# this can be fixed is the post-transform restore in +# _finalize_combined_archive (see restore_custom_favicon). +FAVICON_SVG_FILE = "favicon.svg" + def parse_args(): parser = argparse.ArgumentParser( @@ -477,7 +490,8 @@ def render_common_template(text, year=None): def install_templates(catalog_dir, common_dir, source_id): - """Copy common template files (header.html, footer.html) into a .docc catalog.""" + """Copy common template files (header.html, footer.html) and the shared + favicon.ico into a .docc catalog.""" for tmpl in TEMPLATE_FILES: src = common_dir / tmpl dst = catalog_dir / tmpl @@ -486,6 +500,13 @@ def install_templates(catalog_dir, common_dir, source_id): dst.write_text(render_common_template(src.read_text())) print(f" Installed {tmpl} -> {catalog_dir}/") + favicon_src = common_dir / FAVICON_FILE + favicon_dst = catalog_dir / FAVICON_FILE + if favicon_dst.exists(): + print(f" WARNING: overwriting existing {FAVICON_FILE} in {source_id} catalog") + shutil.copyfile(str(favicon_src), str(favicon_dst)) + print(f" Installed {FAVICON_FILE} -> {catalog_dir}/") + def find_doccarchive(search_dir, target): """Find a .doccarchive directory produced by swift package generate-documentation.""" @@ -892,6 +913,22 @@ def inject_custom_templates_into_stubs(archive_path, common_dir): return patched +def restore_custom_favicon(archive_path, common_dir, filename=FAVICON_FILE): + """Reinstate a shared favicon asset after the static-hosting transform. + + Workaround for `docc process-archive transform-for-static-hosting` + unconditionally overwriting favicon.ico/favicon.svg with its own bundled + defaults (swift-docc's TransformForStaticHostingAction copies every file + from its HTML template directory over the output, with no exclusion for + either file). Since this transform always runs last, this is the only + point where the custom favicon needs to be reapplied — drop this when + swift-docc excludes these files from that copy. + """ + favicon_src = Path(common_dir) / filename + favicon_dst = Path(archive_path) / filename + shutil.copyfile(str(favicon_src), str(favicon_dst)) + + 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. @@ -982,6 +1019,12 @@ def _finalize_combined_archive(all_archives, output_dir, version_slug, docc_cmd, patched = inject_custom_templates_into_stubs(combined_output, common_dir) print(f"Patched custom-header/footer into {patched} per-route stub(s).") + # Workaround: transform-for-static-hosting overwrites the favicons + # with DocC's own defaults (see restore_custom_favicon docstring). + restore_custom_favicon(combined_output, common_dir) + restore_custom_favicon(combined_output, common_dir, FAVICON_SVG_FILE) + print("Restored shared favicon.ico/favicon.svg after static-hosting transform.") + prior_steps.append("static-hosting-transform") if canonical_base_url: @@ -1047,8 +1090,8 @@ def main(): check_prerequisites() tools = discover_tools() - # Validate common template files exist - for tmpl in TEMPLATE_FILES: + # Validate common template files and the shared favicon exist + for tmpl in TEMPLATE_FILES + [FAVICON_FILE, FAVICON_SVG_FILE]: tmpl_path = common_dir / tmpl if not tmpl_path.is_file(): print(f"Error: common template '{tmpl}' not found at {tmpl_path}") diff --git a/scripts/test_build_docs.py b/scripts/test_build_docs.py index ea261434..841d9fba 100644 --- a/scripts/test_build_docs.py +++ b/scripts/test_build_docs.py @@ -2028,6 +2028,52 @@ def test_curation_success_records_navigator_curation(self): ) self.assertEqual(failed, []) + def test_favicon_survives_static_hosting_transform(self): + """Reproduces docc process-archive transform-for-static-hosting + overwriting favicon.ico/favicon.svg with its own bundled defaults, + and verifies build_docs.py restores the shared ones afterward.""" + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + common_dir = tmp_path / "common" + common_dir.mkdir() + (common_dir / "favicon.ico").write_bytes(b"SWIFT-FAVICON") + (common_dir / "favicon.svg").write_bytes(b"SWIFT-FAVICON-SVG") + (common_dir / "header.html").write_text("HDR") + (common_dir / "footer.html").write_text("FTR") + 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) + (out / "favicon.ico").write_bytes(b"SWIFT-FAVICON") + (out / "favicon.svg").write_bytes(b"DOCC-DEFAULT-GLOBE-SVG") + else: + # Simulate transform-for-static-hosting clobbering the + # favicons with DocC's own bundled defaults. + (out / "favicon.ico").write_bytes(b"DOCC-DEFAULT-GLOBE") + (out / "favicon.svg").write_bytes(b"DOCC-DEFAULT-GLOBE-SVG") + 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=[], + common_dir=common_dir, + ) + self.assertEqual(failed, []) + combined_output = tmp_path / "main" + self.assertEqual( + (combined_output / "favicon.ico").read_bytes(), b"SWIFT-FAVICON" + ) + self.assertEqual( + (combined_output / "favicon.svg").read_bytes(), b"SWIFT-FAVICON-SVG" + ) + class RenderCommonTemplate(unittest.TestCase): def test_substitutes_copyright_year_placeholder(self): @@ -2065,6 +2111,7 @@ def test_substitutes_copyright_year_in_installed_footer(self): common.mkdir() (common / "header.html").write_text("HDR") (common / "footer.html").write_text("Copyright {{COPYRIGHT_YEAR}}") + (common / "favicon.ico").write_bytes(b"ICO") catalog = root / "Foo.docc" catalog.mkdir() build_docs.install_templates(catalog, common, "foo") @@ -2080,12 +2127,43 @@ def test_warns_and_overwrites_existing_template(self): common.mkdir() (common / "header.html").write_text("HDR") (common / "footer.html").write_text("FTR") + (common / "favicon.ico").write_bytes(b"ICO") catalog = root / "Foo.docc" catalog.mkdir() (catalog / "footer.html").write_text("stale") build_docs.install_templates(catalog, common, "foo") self.assertEqual((catalog / "footer.html").read_text(), "FTR") + def _write_common_templates(self, common): + (common / "header.html").write_text("HDR") + (common / "footer.html").write_text("FTR") + + def test_copies_favicon_byte_for_byte(self): + favicon_bytes = bytes(range(256)) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + common = root / "common" + common.mkdir() + self._write_common_templates(common) + (common / "favicon.ico").write_bytes(favicon_bytes) + catalog = root / "Foo.docc" + catalog.mkdir() + build_docs.install_templates(catalog, common, "foo") + self.assertEqual((catalog / "favicon.ico").read_bytes(), favicon_bytes) + + def test_warns_and_overwrites_existing_favicon(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + common = root / "common" + common.mkdir() + self._write_common_templates(common) + (common / "favicon.ico").write_bytes(b"NEW") + catalog = root / "Foo.docc" + catalog.mkdir() + (catalog / "favicon.ico").write_bytes(b"stale") + build_docs.install_templates(catalog, common, "foo") + self.assertEqual((catalog / "favicon.ico").read_bytes(), b"NEW") + class InjectCustomTemplatesIntoStubs(unittest.TestCase): """Workaround for swiftlang/swift-docc#1532: see build_docs function docstring.""" @@ -2213,6 +2291,47 @@ def test_template_ordering_matches_docc_convert(self): self.assertLess(footer_pos, header_pos) +class RestoreCustomFavicon(unittest.TestCase): + """Workaround for `docc process-archive transform-for-static-hosting` + unconditionally overwriting favicon.ico with its own bundled default + (swift-docc's TransformForStaticHostingAction copies every file from its + HTML template directory over the output, with no exclusion for + favicon.ico).""" + + def test_overwrites_transformed_favicon_with_shared_one(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + common = root / "common" + common.mkdir() + (common / "favicon.ico").write_bytes(b"SWIFT-FAVICON") + archive = root / "main" + archive.mkdir() + (archive / "favicon.ico").write_bytes(b"DOCC-DEFAULT-GLOBE") + build_docs.restore_custom_favicon(archive, common) + self.assertEqual( + (archive / "favicon.ico").read_bytes(), b"SWIFT-FAVICON" + ) + + def test_restores_favicon_svg_when_filename_given(self): + """favicon.svg has no DocC catalog-level override at all (unlike + favicon.ico), so this post-transform restore is the only place it + can ever be fixed.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + common = root / "common" + common.mkdir() + (common / "favicon.svg").write_bytes(b"SWIFT-FAVICON-SVG") + archive = root / "main" + archive.mkdir() + (archive / "favicon.svg").write_bytes(b"DOCC-DEFAULT-GLOBE-SVG") + build_docs.restore_custom_favicon( + archive, common, filename=build_docs.FAVICON_SVG_FILE + ) + self.assertEqual( + (archive / "favicon.svg").read_bytes(), b"SWIFT-FAVICON-SVG" + ) + + class CleanPackageBuildDirs(unittest.TestCase): def test_removes_build_dir_for_local_source(self): with tempfile.TemporaryDirectory() as tmp: