From be9bd86e3087fb250f6711f26c4faf97be8b6bf9 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:50:07 +0800 Subject: [PATCH 1/3] fix: preserve conditional and external DocC symbols --- README.md | 5 +++++ Scripts/build-site | 16 +++++++++++++++- Tests/test_pipeline.py | 18 +++++++++++++++++- projects.json | 2 +- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e3fd71d..f7a8783 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ products from each tagged manifest and merges multi-module documentation. Source archives are downloaded at the tag's full commit SHA and removed after conversion. No repository checkout is copied into the build cache. +A DocC catalog entry may select `traits` from its tagged package, including +`default` when it needs the default APIs. Both compilation and symbol extraction +use that selection. Omitted traits keep the package defaults. Symbol extraction +includes extensions to external types so those APIs remain linkable in DocC. + For landings without their own identity, the builder adds the repository's current `Logo.png` and derives the named DocC color from the organization's `DESIGN.md`. Existing package directives remain authoritative. A synthesized diff --git a/Scripts/build-site b/Scripts/build-site index b9c05aa..dd1a349 100755 --- a/Scripts/build-site +++ b/Scripts/build-site @@ -57,6 +57,19 @@ def library_targets(manifest, selection): return names +def documentation_traits(manifest, selection): + if selection is None: + return [] + if (not isinstance(selection, list) or not selection + or any(not isinstance(name, str) for name in selection) + or len(selection) != len(set(selection))): + raise SiteError("Documentation traits must be a non-empty list of distinct names") + available = {trait["name"] for trait in manifest.get("traits", [])} | {"default"} + if not set(selection) <= available: + raise SiteError("Documentation traits must be declared by the tagged package") + return ["--traits", ",".join(selection)] + + def build_documentation(config, project, tag, cache, output, toolchain, jobs, color, icon): repo = project["repository"] fingerprint = hashlib.sha256( @@ -87,12 +100,13 @@ def build_documentation(config, project, tag, cache, output, toolchain, jobs, co print(f"{repo}: extracting {len(modules)} modules at {tag['name']}", flush=True) swift_options = ["--build-system", "native", "--disable-index-store", "-Xswiftc", "-gnone", "--scratch-path", str(scratch)] + swift_options += documentation_traits(manifest, project["documentation"].get("traits")) if jobs is not None: swift_options += ["--jobs", str(jobs)] # SwiftPM's symbol extraction includes generated test-runner modules. command(["swift", "build", *swift_options, "--build-tests"], cwd=source) command(["swift", "package", *swift_options, "dump-symbol-graph", - "--minimum-access-level", "public"], cwd=source) + "--minimum-access-level", "public", "--emit-extension-block-symbols"], cwd=source) graph_directories = [path for path in scratch.glob("*/symbolgraph") if path.is_dir()] if not graph_directories: graph_directories = [path for path in scratch.glob("*/*/symbolgraph") if path.is_dir()] diff --git a/Tests/test_pipeline.py b/Tests/test_pipeline.py index c19a4bf..a388e98 100644 --- a/Tests/test_pipeline.py +++ b/Tests/test_pipeline.py @@ -22,7 +22,9 @@ class LinkTests(unittest.TestCase): def setUp(self): - self.temporary = tempfile.TemporaryDirectory() + fixtures = SCRIPTS.parent / ".build/test-fixtures" + fixtures.mkdir(parents=True, exist_ok=True) + self.temporary = tempfile.TemporaryDirectory(dir=fixtures) self.addCleanup(self.temporary.cleanup) self.root = Path(self.temporary.name) @@ -77,6 +79,20 @@ def test_merged_documentation_landing_and_dotted_symbols(self): class InputTests(unittest.TestCase): + def test_documentation_traits_follow_tagged_manifest_and_keep_explicit_defaults(self): + manifest = {"traits": [{"name": "Experimental"}]} + self.assertEqual(build_site.documentation_traits(manifest, None), []) + self.assertEqual(build_site.documentation_traits(manifest, ["Experimental", "default"]), + ["--traits", "Experimental,default"]) + + def test_invalid_trait_selection_fails_before_compilation(self): + manifest = {"traits": [{"name": "Experimental"}]} + for selection in [[], "Experimental", [True], ["Missing"], + ["Experimental", "Experimental"], ["--disable-sandbox"]]: + with self.subTest(selection=selection): + with self.assertRaises(build_site.SiteError): + build_site.documentation_traits(manifest, selection) + def test_first_published_prerelease_enables_documentation(self): class API: def pages(self, endpoint): diff --git a/projects.json b/projects.json index cc233a5..cd974b5 100644 --- a/projects.json +++ b/projects.json @@ -16,7 +16,7 @@ {"repository": "swift-userdefault", "logo_path": "Documentation/Assets/Logo.svg", "documentation": {"kind": "docc", "targets": "library-products"}, "install": "swift-package"}, {"repository": "swift-redux", "logo_path": "Documentation/Assets/Logo.svg", "documentation": {"kind": "docc", "targets": "library-products"}, "install": "swift-package"}, {"repository": "swift-pdf", "logo_path": "Documentation/Assets/Logo.svg", "documentation": {"kind": "docc", "targets": "library-products"}, "install": "swift-package"}, - {"repository": "swift-appstoreconnect", "logo_path": "Documentation/Assets/Logo.svg", "documentation": {"kind": "docc", "targets": "library-products"}, "install": "swift-package"}, + {"repository": "swift-appstoreconnect", "logo_path": "Documentation/Assets/Logo.svg", "documentation": {"kind": "docc", "targets": "library-products", "traits": ["Experimental", "default"]}, "install": "swift-package"}, {"repository": "swift-json-schema", "logo_path": "Documentation/Assets/Logo.svg", "documentation": {"kind": "docc", "targets": "library-products"}, "install": "swift-package"} ] }, From 7f07b3e05b5b99848ef1bd602fde5c64c6b65ec4 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:28:47 +0800 Subject: [PATCH 2/3] fix: preserve navigable documentation hierarchies --- README.md | 4 +++ Scripts/build-site | 3 ++- Scripts/site_checks.py | 26 ++++++++++++++++++- Scripts/site_docc.py | 46 ++++++++++++++++++++++++++++++++++ Tests/test_pipeline.py | 57 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f7a8783..3161feb 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ A DocC catalog entry may select `traits` from its tagged package, including `default` when it needs the default APIs. Both compilation and symbol extraction use that selection. Omitted traits keep the package defaults. Symbol extraction includes extensions to external types so those APIs remain linkable in DocC. +Generated breadcrumbs omit external-type containers for which DocC emits no +page, while keeping the package's actual extension members. The link checker +distinguishes tutorial chapter labels from navigable tutorial pages; explicit +content links still require a real destination. For landings without their own identity, the builder adds the repository's current `Logo.png` and derives the named DocC color from the organization's diff --git a/Scripts/build-site b/Scripts/build-site index dd1a349..220f3e1 100755 --- a/Scripts/build-site +++ b/Scripts/build-site @@ -21,7 +21,7 @@ from site_support import ( from site_docc import ( PIPELINE_SOURCE as DOCC_PIPELINE_SOURCE, add_identity, archive_source, - design_colors, identity_records, merged_identity, + design_colors, identity_records, merged_identity, normalize_extension_hierarchy, ) SCRIPT_SOURCE = Path(__file__).read_text() @@ -159,6 +159,7 @@ def build_documentation(config, project, tag, cache, output, toolchain, jobs, co "--output-path", str(archive), "--synthesized-landing-page-name", repo]) if len(archives) > 1: merged_identity(archive, repo, color, icon) + normalize_extension_hierarchy(archive) if published.exists(): shutil.rmtree(published) command(["xcrun", "docc", "process-archive", "transform-for-static-hosting", str(archive), diff --git a/Scripts/site_checks.py b/Scripts/site_checks.py index f2f04ae..6ffd7fb 100644 --- a/Scripts/site_checks.py +++ b/Scripts/site_checks.py @@ -45,6 +45,27 @@ def json_anchors(value): return result +def referenced_values(value): + if isinstance(value, str): + return {value} + if isinstance(value, dict): + return set().union(*(referenced_values(child) for child in value.values())) + if isinstance(value, list): + return set().union(*(referenced_values(child) for child in value)) + return set() + + +def chapter_labels(document): + # Tutorial chapters label groups in the navigation; their projects are links. + chapters = {chapter.get("reference") for chapter in document.get("hierarchy", {}).get("modules", [])} + content = {key: value for key, value in document.items() if key not in {"hierarchy", "references"}} + used = referenced_values(content) + for identifier, reference in document.get("references", {}).items(): + if identifier not in chapters: + used.update(referenced_values(reference)) + return chapters - used + + class Links: def __init__(self, root, site_url): self.root = root.resolve() @@ -117,7 +138,10 @@ def scan(self): continue for source in sorted((directory / "data").rglob("*.json")): value = json.loads(source.read_text()) - for reference in value.get("references", {}).values(): + labels = chapter_labels(value) + for identifier, reference in value.get("references", {}).items(): + if identifier in labels: + continue if isinstance(reference.get("url"), str): self.check(reference["url"], source, directory.name) for variant in reference.get("variants", []): diff --git a/Scripts/site_docc.py b/Scripts/site_docc.py index 96328e9..14ffadf 100644 --- a/Scripts/site_docc.py +++ b/Scripts/site_docc.py @@ -176,3 +176,49 @@ def identity_records(archive, modules, merged, repository=None): raise SiteError(f"DocC landing has no icon variants: {landing}") records.append({"landing": landing, "color": color, "icons": assets}) return records + + +def normalize_extension_hierarchy(archive): + """Keep breadcrumbs on rendered pages when DocC omits external-type containers.""" + from urllib.parse import unquote, urlsplit + from site_checks import referenced_values + + data = archive / "data" + changed = 0 + for path in data.rglob("*.json"): + document = json.loads(path.read_text()) + references = document.get("references", {}) + hierarchy = document.get("hierarchy", {}).get("paths", []) + ancestors = set(value for branch in hierarchy for value in branch) + + def has_page(reference): + route = unquote(urlsplit(reference.get("url", "")).path).lstrip("/") + target = (data / (route + ".json")).resolve() + return target.is_relative_to(data.resolve()) and target.is_file() + + omitted = set() + for identifier in ancestors: + reference = references.get(identifier, {}) + is_extension = any(fragment.get("kind") == "keyword" and fragment.get("text") == "extension" + for fragment in reference.get("fragments", [])) + if not is_extension or has_page(reference): + continue + omitted.add(identifier) + module = identifier.rsplit("/", 1)[0] + parent = references.get(module, {}) + if module in ancestors and parent.get("role") == "collection" and not has_page(parent): + omitted.add(module) + if not omitted: + continue + document["hierarchy"]["paths"] = [[value for value in branch if value not in omitted] + for branch in hierarchy] + other_content = {key: value for key, value in document.items() if key != "references"} + uses = referenced_values(other_content) + for identifier, reference in references.items(): + if identifier not in omitted: + uses.update(referenced_values(reference)) + for identifier in omitted - uses: + references.pop(identifier, None) + path.write_text(json.dumps(document, ensure_ascii=False) + "\n") + changed += 1 + return changed diff --git a/Tests/test_pipeline.py b/Tests/test_pipeline.py index a388e98..36f56b9 100644 --- a/Tests/test_pipeline.py +++ b/Tests/test_pipeline.py @@ -12,6 +12,7 @@ SCRIPTS = Path(__file__).resolve().parent.parent / "Scripts" sys.path.insert(0, str(SCRIPTS)) from site_checks import Links +from site_docc import normalize_extension_hierarchy from site_support import contributor_names, latest_release loader = importlib.machinery.SourceFileLoader("build_site", str(SCRIPTS / "build-site")) @@ -77,6 +78,62 @@ def test_merged_documentation_landing_and_dotted_symbols(self): self.write("package/data/documentation/module/member.name.json", json.dumps({"sections": [{"anchor": "usage"}]})) self.assertEqual(Links(self.root, "https://example.org/").scan(), []) + def test_tutorial_chapter_labels_do_not_require_standalone_pages(self): + self.write("package/tutorials/example/step/index.html", "") + document = { + "hierarchy": {"modules": [{"reference": "chapter", "projects": [{"reference": "step"}]}]}, + "references": {"chapter": {"url": "/tutorials/example/chapter"}, + "step": {"url": "/tutorials/example/step"}}, + } + path = self.write("package/data/tutorials/example/step.json", json.dumps(document)) + self.assertEqual(Links(self.root, "https://example.org/").scan(), []) + document["abstract"] = [{"type": "reference", "identifier": "chapter"}] + path.write_text(json.dumps(document)) + self.assertEqual(len(Links(self.root, "https://example.org/").scan()), 1) + + def extension_document(self): + return { + "hierarchy": {"paths": [["module", "module/External", "module/External/Value", "member"]]}, + "references": { + "module": {"url": "/documentation/module", "role": "collection"}, + "module/External": {"url": "/documentation/module/external", "role": "collection"}, + "module/External/Value": {"url": "/documentation/module/external/value", "role": "symbol", + "fragments": [{"kind": "keyword", "text": "extension"}]}, + "member": {"url": "/documentation/module/member", "role": "symbol"}, + }, + } + + def test_missing_external_containers_are_removed_from_breadcrumbs(self): + document = self.extension_document() + path = self.write("package/data/documentation/module/member.json", json.dumps(document)) + self.write("package/documentation/module/index.html", "") + self.write("package/documentation/module/member/index.html", "") + self.assertEqual(normalize_extension_hierarchy(self.root / "package"), 1) + actual = json.loads(path.read_text()) + self.assertEqual(actual["hierarchy"]["paths"], [["module", "member"]]) + self.assertEqual(set(actual["references"]), {"module", "member"}) + self.assertEqual(Links(self.root, "https://example.org/").scan(), []) + + def test_rendered_extension_containers_are_preserved(self): + document = self.extension_document() + path = self.write("package/data/documentation/module/member.json", json.dumps(document)) + self.write("package/data/documentation/module/external/value.json", "{}") + self.assertEqual(normalize_extension_hierarchy(self.root / "package"), 0) + self.assertEqual(json.loads(path.read_text()), document) + + def test_missing_authored_links_and_regular_ancestors_still_fail(self): + document = self.extension_document() + document["abstract"] = [{"type": "reference", "identifier": "module/External/Value"}] + path = self.write("package/data/documentation/module/member.json", json.dumps(document)) + self.write("package/documentation/module/member/index.html", "") + normalize_extension_hierarchy(self.root / "package") + actual = json.loads(path.read_text()) + self.assertIn("module/External/Value", actual["references"]) + errors = Links(self.root, "https://example.org/").scan() + self.assertEqual(len(errors), 2) + self.assertTrue(any("/documentation/module/external/value" in error for error in errors)) + self.assertTrue(any(error.endswith("missing target /documentation/module") for error in errors)) + class InputTests(unittest.TestCase): def test_documentation_traits_follow_tagged_manifest_and_keep_explicit_defaults(self): From 8483d8f19e4d649cbfb7424ad673b53d4b8dc1c5 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:30:50 +0800 Subject: [PATCH 3/3] fix: include hierarchy helpers in documentation cache identity --- Scripts/site_checks.py | 14 +++----------- Scripts/site_docc.py | 13 +++++++++++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Scripts/site_checks.py b/Scripts/site_checks.py index 6ffd7fb..53ba673 100644 --- a/Scripts/site_checks.py +++ b/Scripts/site_checks.py @@ -9,6 +9,8 @@ import re from urllib.parse import unquote, urlsplit +from site_docc import referenced_values + class HTML(HTMLParser): def __init__(self): @@ -45,19 +47,9 @@ def json_anchors(value): return result -def referenced_values(value): - if isinstance(value, str): - return {value} - if isinstance(value, dict): - return set().union(*(referenced_values(child) for child in value.values())) - if isinstance(value, list): - return set().union(*(referenced_values(child) for child in value)) - return set() - - def chapter_labels(document): # Tutorial chapters label groups in the navigation; their projects are links. - chapters = {chapter.get("reference") for chapter in document.get("hierarchy", {}).get("modules", [])} + chapters = {chapter.get("reference") for chapter in (document.get("hierarchy") or {}).get("modules", [])} content = {key: value for key, value in document.items() if key not in {"hierarchy", "references"}} used = referenced_values(content) for identifier, reference in document.get("references", {}).items(): diff --git a/Scripts/site_docc.py b/Scripts/site_docc.py index 14ffadf..0aacef6 100644 --- a/Scripts/site_docc.py +++ b/Scripts/site_docc.py @@ -178,17 +178,26 @@ def identity_records(archive, modules, merged, repository=None): return records +def referenced_values(value): + if isinstance(value, str): + return {value} + if isinstance(value, dict): + return set().union(*(referenced_values(child) for child in value.values())) + if isinstance(value, list): + return set().union(*(referenced_values(child) for child in value)) + return set() + + def normalize_extension_hierarchy(archive): """Keep breadcrumbs on rendered pages when DocC omits external-type containers.""" from urllib.parse import unquote, urlsplit - from site_checks import referenced_values data = archive / "data" changed = 0 for path in data.rglob("*.json"): document = json.loads(path.read_text()) references = document.get("references", {}) - hierarchy = document.get("hierarchy", {}).get("paths", []) + hierarchy = (document.get("hierarchy") or {}).get("paths", []) ancestors = set(value for branch in hierarchy for value in branch) def has_page(reference):