diff --git a/scripts/build_docs.py b/scripts/build_docs.py index 27776d75..2aa99f4f 100755 --- a/scripts/build_docs.py +++ b/scripts/build_docs.py @@ -29,7 +29,7 @@ from pathlib import Path from strip_availability import strip_archive -from curate_navigator import curate_navigator, validate_navigation, NavigationError +from curate_navigator import curate_navigator, set_version_paragraph, validate_navigation, NavigationError class ArchiveFetchError(Exception): @@ -140,6 +140,16 @@ def validate_sources(config): if "version" not in config: errors.append("Top-level 'version' field is missing") + elif not isinstance(config["version"], dict): + errors.append( + "Top-level 'version' field must be an object with 'slug' and " + "'descriptive-name'" + ) + else: + if not config["version"].get("slug"): + errors.append("Top-level 'version' object is missing 'slug'") + if not config["version"].get("descriptive-name"): + errors.append("Top-level 'version' object is missing 'descriptive-name'") if "sources" not in config: errors.append("Top-level 'sources' field is missing") @@ -758,7 +768,7 @@ def inject_custom_templates_into_stubs(archive_path, common_dir): return patched -def _finalize_combined_archive(all_archives, output_dir, version, 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, version=None): """Merge per-source archives and apply the static-hosting transform. Returns (succeeded_steps, failed_steps): names that should be added to @@ -790,16 +800,20 @@ def _finalize_combined_archive(all_archives, output_dir, version, docc_cmd, prio print(f" - {ma}") return [], ["combined-merge"] - combined_output = output_dir / f"{version}" + combined_output = output_dir / f"{version_slug}" try: merge_archives( all_archives, combined_output, docc_cmd, - landing_page_name=f"Swift - {version}", + landing_page_name="Swift Documentation", ) except subprocess.CalledProcessError: print("Error: docc merge failed") return [], ["combined-merge"] + descriptive_name = (version or {}).get("descriptive-name") + if descriptive_name: + set_version_paragraph(combined_output, f"Swift version: {descriptive_name}") + if navigation is not None: try: curate_navigator(combined_output, navigation) @@ -809,7 +823,7 @@ def _finalize_combined_archive(all_archives, output_dir, version, docc_cmd, prio return ["combined-merge"], ["navigator-curation"] try: - transform_static_hosting(combined_output, hosting_base_path or version, docc_cmd) + transform_static_hosting(combined_output, hosting_base_path or version_slug, docc_cmd) except subprocess.CalledProcessError: print("Error: docc process-archive transform-for-static-hosting failed") curated = ["combined-merge"] @@ -926,7 +940,8 @@ def main(): print("No navigation.json found — combined navigator will not be curated.") version = config["version"] - hosting_base_path = f"{args.extra_hosting_prefix}/{version}" if args.extra_hosting_prefix else version + version_slug = version["slug"] + hosting_base_path = f"{args.extra_hosting_prefix}/{version_slug}" if args.extra_hosting_prefix else version_slug sources = config["sources"] # Ensure consistent, pretty-printed DocC JSON output @@ -1006,9 +1021,9 @@ def attempt_build(index, source, fatal=(), recoverable=()): # Merge all archives — only when building everything (not --only) if all_archives and not args.only: s_steps, f_steps = _finalize_combined_archive( - all_archives, output_dir, version, tools.docc, failed, + all_archives, output_dir, version_slug, tools.docc, failed, common_dir=common_dir, navigation=navigation, - hosting_base_path=hosting_base_path, + hosting_base_path=hosting_base_path, version=version, ) succeeded.extend(s_steps) failed.extend(f_steps) diff --git a/scripts/curate_navigator.py b/scripts/curate_navigator.py index a773075a..071cf8e2 100644 --- a/scripts/curate_navigator.py +++ b/scripts/curate_navigator.py @@ -295,6 +295,31 @@ def _section_anchor(title): return "-".join(title.split()) +def set_version_paragraph(archive_path, text): + """Set the synthesized landing page's body to a single paragraph of text. + + Overwrites any existing `primaryContentSections` in data/documentation.json — the + freshly-merged landing page ships with none (DocC omits empty content sections), so + replacing the array wholesale keeps repeated builds idempotent. No-op when + data/documentation.json is absent. See hacking-synthesized-landing-page.md. + """ + page = Path(archive_path) / "data" / "documentation.json" + if not page.is_file(): + return + doc = json.loads(page.read_text()) + doc["primaryContentSections"] = [{ + "kind": "content", + "content": [{ + "type": "paragraph", + "inlineContent": [{"type": "text", "text": text}], + }], + }] + + tmp = page.with_suffix(".json.tmp") + tmp.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n") + os.replace(tmp, page) + + def dry_run(archive_path, navigation): """Compute the curated navigator WITHOUT modifying the archive. diff --git a/scripts/hacking-index-json.md b/scripts/hacking-index-json.md index 2b10b196..4b2fc186 100644 --- a/scripts/hacking-index-json.md +++ b/scripts/hacking-index-json.md @@ -40,10 +40,11 @@ but be aware they will be stale if something else reads them. **All curation happens inside `.interfaceLanguages.swift[0].children[]`.** `swift[0]` is the synthesized root node — `{ "type": "module", "title": -"Swift - main", "path": "/documentation", "children": [...] }` (the -`--synthesized-landing-page-name`). Its **`children`** array holds **one -`module` node per merged module**, in merge order. That array is the curation -surface. (In the `main` build it has **36 entries** — see below.) +"Swift Documentation", "path": "/documentation", "children": [...] }` (the +`--synthesized-landing-page-name`, a fixed string independent of the build's +`version`). Its **`children`** array holds **one `module` node per merged +module**, in merge order. That array is the curation surface. (In the `main` +build it has **36 entries** — see below.) ## The `Node` object diff --git a/scripts/hacking-synthesized-landing-page.md b/scripts/hacking-synthesized-landing-page.md new file mode 100644 index 00000000..0be73511 --- /dev/null +++ b/scripts/hacking-synthesized-landing-page.md @@ -0,0 +1,176 @@ +# Hacking the Synthesized Landing Page Body — DocC Merge Post-Processing + +Companion to `hacking-index-json.md` (sidebar/navigator). This file covers the +**page body** of the combined archive's synthesized landing page +(`/documentation/`), rendered from `data/documentation.json`, and specifically +how to inject prose (e.g. a "Swift version: X" paragraph) into it. + +Verified against the +[`swiftlang/swift-docc`](https://github.com/swiftlang/swift-docc) source +(specifically +[`MergeAction+SynthesizedLandingPage.swift`](https://github.com/swiftlang/swift-docc/blob/main/Sources/DocCCommandLine/Action/Actions/Merge/MergeAction%2BSynthesizedLandingPage.swift) +and +[`Sources/SwiftDocC/Model/Rendering/`](https://github.com/swiftlang/swift-docc/tree/main/Sources/SwiftDocC/Model/Rendering)), +not just observed output — the mechanics below are read from the code that +produces the page, not guessed from a JSON sample. + +## What `docc merge` builds + +`MergeAction.makeSynthesizedLandingPage(...)` constructs a `RenderNode` with: + +- `identifier`, `kind: .article` +- `metadata.title` = `--synthesized-landing-page-name` +- `metadata.roleHeading` = `--synthesized-landing-page-kind` (e.g. `"Project"`) +- `metadata.role` = `"collection"` +- `topicSectionsStyle` from `--synthesized-landing-page-topics-style` +- `topicSections` = one or two sections (`"Modules"` / `"Tutorials"`, or a + single unnamed section) built from every root-level render reference found + under the merged archives' `data/documentation/` and `data/tutorials/` +- `sections = []` — **the synthesized page starts with zero primary content + sections.** There is no auto-generated abstract or body prose. + +`renderNode.sections` is the in-memory name for what encodes to the JSON key +**`primaryContentSections`** (see +[`RenderNode.swift`](https://github.com/swiftlang/swift-docc/blob/main/Sources/SwiftDocC/Model/Rendering/RenderNode.swift) +/ +[`RenderNode+Codable.swift`](https://github.com/swiftlang/swift-docc/blob/main/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderNode%2BCodable.swift): +`primaryContentSectionsVariants`, encoded via `encodeVariantCollectionArrayIfNotEmpty`). +Because it starts empty, **the key is absent from the emitted JSON entirely** +(empty variant collections are omitted, not written as `[]`). Don't assume the +key exists — check for it before indexing into it. + +## Where prose paragraphs live: `primaryContentSections` + +A content section on any `RenderNode` (landing page included) has exactly two +JSON keys — confirmed from +[`ContentRenderSection.swift`](https://github.com/swiftlang/swift-docc/blob/main/Sources/SwiftDocC/Model/Rendering/Symbol/ContentRenderSection.swift): + +```jsonc +{ + "kind": "content", + "content": [ /* RenderBlockContent[] */ ] +} +``` + +`heading` is a constructor convenience on the Swift side only — passing one +inserts a `heading` block at index 0 of `content`; it is **not** a separate +JSON key. + +A plain paragraph block (confirmed against real fixtures under +[`Tests/SwiftDocCTests/Rendering/Rendering Fixtures/tables.json`]() +and +[`Tests/SwiftDocCTests/Test Resources/link-button-render-node.json`]()): + +```jsonc +{ + "type": "paragraph", + "inlineContent": [ + { "type": "text", "text": "Swift version: 6.3" } + ] +} +``` + +So the minimal addition to make the landing page show one paragraph of body +text is: + +```jsonc +"primaryContentSections": [ + { + "kind": "content", + "content": [ + { + "type": "paragraph", + "inlineContent": [ + { "type": "text", "text": "Swift version: 6.3" } + ] + } + ] + } +] +``` + +This renders as normal running prose **above the Topics section** and below +the title/role-heading — it is body content, not the short one-line +`abstract` teaser (a separate, also-currently-absent top-level key). + +## Where this lives in our pipeline + +`scripts/curate_navigator.py` → `set_version_paragraph(archive_path, text)`: +reads `data/documentation.json`, unconditionally replaces +`doc["primaryContentSections"]` with a single content section containing one +paragraph, writes atomically. No-op if the file is missing. Because it +replaces the array wholesale rather than appending, calling it repeatedly +(e.g. re-running the build) is idempotent — it does not accumulate +paragraphs. + +Called from `scripts/build_docs.py` → `_finalize_combined_archive()`, +**unconditionally** (not gated on `navigation.json` existing, unlike +`curate_navigator()`/topic-section curation), right after `merge_archives()` +succeeds and before the static-hosting transform: + +```python +descriptive_name = (version or {}).get("descriptive-name") +if descriptive_name: + set_version_paragraph(combined_output, f"Swift version: {descriptive_name}") +``` + +`version` is the full `sources.json` `"version"` object +(`{"slug": ..., "descriptive-name": ...}`); the text is built here in +`build_docs.py`, not inside `curate_navigator.py`, so `set_version_paragraph` +stays a generic "set this text as the landing page body" primitive. + +### Why it must run before the static-hosting transform + +`transform_static_hosting()` runs `docc process-archive +transform-for-static-hosting`, which produces a **fresh archive** at a temp +path and then replaces the original wholesale (`shutil.rmtree` + +`shutil.move`). Whatever is in `data/documentation.json` at the time that +command runs is what gets baked into the final per-route HTML; editing the +JSON afterward doesn't help because the static-hosting archive may not carry +the same `data/*.json` structure forward. Same ordering constraint as +`curate_navigator()`'s landing-page section rewriting — see +`hacking-index-json.md`. + +## Extending this further + +To add more structure (multiple paragraphs, a heading, a link, an aside) to +the landing page body, grow the `content` array inside the single content +section using the same `RenderBlockContent` shapes DocC itself emits — +check +[`Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift`](https://github.com/swiftlang/swift-docc/blob/main/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift) +for the `type` enum and each case's JSON shape, and cross-reference a fixture +under +[`Tests/SwiftDocCTests/Rendering/Rendering Fixtures/`]() +before trusting a shape. Useful block `type`s seen in fixtures: `paragraph`, +`heading`, `aside`, `codeListing`, `orderedList`/`unorderedList`, `table`, +`links` (card grid). + +If a change needs a different *inline* content shape (e.g. a link, code +voice, or emphasis inside the paragraph), the encoding for `RenderInlineContent` +follows the same "read the Swift source, confirm against a fixture" approach — +don't guess key names from memory, DocC's render JSON schema evolves. + +For a broader, HTML-rendered walkthrough of the RenderNode/RenderIndex JSON +structures (a faster first stop than grepping Swift source for an unfamiliar +shape), see [`heckj/DocCArchive`](https://github.com/heckj/DocCArchive)'s +[`docs/`](https://github.com/heckj/DocCArchive/tree/main/docs) directory, +published at . + +## Gotchas + +- **Don't assume `primaryContentSections` exists.** It's omitted, not `[]`, + when empty — `doc.get("primaryContentSections")` before indexing. +- **`abstract` is a different, separate key** (the short one-line teaser under + the title) — also omitted when empty on the synthesized landing page. Don't + conflate it with `primaryContentSections`; this doc's approach targets body + prose, not the teaser line. +- **Ordering vs. navigator curation:** `set_version_paragraph()` and + `curate_navigator()` both rewrite `data/documentation.json` but touch + disjoint keys (`primaryContentSections` vs. `topicSections`/`references`), + so call order between them doesn't matter — verified by both being simple + read-modify-write-atomic passes over the same file. +- **Local toolchain mismatches are real.** A quick empirical `docc convert`/ + `docc merge` smoke test against a much newer local Xcode beta toolchain + produced *no* `data/` directory at all (content baked directly into HTML + instead) — don't trust ad hoc local repro against a toolchain newer than the + one this repo pins; prefer reading the pinned `swift-docc` source directly. diff --git a/scripts/sources.json b/scripts/sources.json index 256ad072..1a260a97 100644 --- a/scripts/sources.json +++ b/scripts/sources.json @@ -1,5 +1,8 @@ { - "version": "main", + "version": { + "slug": "main", + "descriptive-name": "prototype" + }, "sources": [ { "id": "swift-book", diff --git a/scripts/test_build_docs.py b/scripts/test_build_docs.py index 88402173..1bb6530a 100644 --- a/scripts/test_build_docs.py +++ b/scripts/test_build_docs.py @@ -46,7 +46,49 @@ def _validate(config): def _wrap(entry): - return {"version": "main", "sources": [entry]} + return { + "version": {"slug": "main", "descriptive-name": "prototype"}, + "sources": [entry], + } + + +class ValidateVersionField(unittest.TestCase): + def _config(self, version): + return { + "version": version, + "sources": [{ + "id": "stdlib", + "type": "archive", + "url": "https://example.com/Swift.doccarchive.tar.gz", + "docc_archive_name": "Swift.doccarchive", + }], + } + + def test_valid_version_object(self): + output = _validate(self._config({"slug": "main", "descriptive-name": "prototype"})) + self.assertIsNone(output) + + def test_version_missing_is_rejected(self): + config = self._config({"slug": "main", "descriptive-name": "prototype"}) + del config["version"] + output = _validate(config) + self.assertIsNotNone(output) + self.assertIn("version", output) + + def test_version_as_plain_string_is_rejected(self): + output = _validate(self._config("main")) + self.assertIsNotNone(output) + self.assertIn("version", output) + + def test_version_missing_slug_is_rejected(self): + output = _validate(self._config({"descriptive-name": "prototype"})) + self.assertIsNotNone(output) + self.assertIn("slug", output) + + def test_version_missing_descriptive_name_is_rejected(self): + output = _validate(self._config({"slug": "main"})) + self.assertIsNotNone(output) + self.assertIn("descriptive-name", output) class ValidateArchiveType(unittest.TestCase): @@ -749,7 +791,7 @@ def fake_run(cmd, **kw): with mock.patch.object(build_docs.subprocess, "run", side_effect=fake_run): build_docs.merge_archives( [archive], output, ["docc"], - landing_page_name=f"Swift - {version}", + landing_page_name="Swift Documentation", ) return captured["cmd"] @@ -758,7 +800,7 @@ def test_passes_landing_page_name_and_list_style(self): self.assertIn("--synthesized-landing-page-name", cmd) name_idx = cmd.index("--synthesized-landing-page-name") + 1 - self.assertEqual(cmd[name_idx], "Swift - 6.2") + self.assertEqual(cmd[name_idx], "Swift Documentation") self.assertIn("--synthesized-landing-page-topics-style", cmd) style_idx = cmd.index("--synthesized-landing-page-topics-style") + 1 @@ -768,10 +810,13 @@ def test_passes_landing_page_name_and_list_style(self): kind_idx = cmd.index("--synthesized-landing-page-kind") + 1 self.assertEqual(cmd[kind_idx], "Project") - def test_landing_page_name_uses_version_verbatim(self): - cmd = self._capture_merge_cmd("main") - name_idx = cmd.index("--synthesized-landing-page-name") + 1 - self.assertEqual(cmd[name_idx], "Swift - main") + 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): @@ -841,6 +886,29 @@ def fake_run(cmd, **kw): self.assertEqual(succeeded, ["combined-merge"]) self.assertEqual(failed, ["static-hosting-transform"]) + def test_merge_uses_fixed_landing_page_name_regardless_of_version(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + archive = tmp_path / "a.doccarchive" + archive.mkdir() + + calls = [] + + def fake_run(cmd, **kw): + calls.append(cmd) + out_idx = cmd.index("--output-path") + 1 + Path(cmd[out_idx]).mkdir(parents=True, exist_ok=True) + raise subprocess.CalledProcessError(1, cmd) + + with mock.patch.object(build_docs.subprocess, "run", side_effect=fake_run): + build_docs._finalize_combined_archive( + [archive], tmp_path, "6.2", ["docc"], prior_failed=[] + ) + + merge_cmd = calls[0] + name_idx = merge_cmd.index("--synthesized-landing-page-name") + 1 + self.assertEqual(merge_cmd[name_idx], "Swift Documentation") + def test_full_success_records_both_steps(self): with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) @@ -870,6 +938,44 @@ def fake_run(cmd, **kw): self.assertEqual(succeeded, ["combined-merge", "static-hosting-transform"]) self.assertEqual(failed, []) + def test_version_paragraph_written_to_landing_page(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + archive = tmp_path / "a.doccarchive" + archive.mkdir() + captured = {} + + def fake_run(cmd, **kw): + if "merge" in cmd: + 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") + _write_landing_page(out, [("Modules", ["doc://Test/documentation/Swift"])]) + return subprocess.CompletedProcess(cmd, 0) + # transform-for-static-hosting: capture the landing page as it + # stood right before docc replaces the archive in place. + archive_arg = Path(cmd[cmd.index("transform-for-static-hosting") + 1]) + captured["doc"] = json.loads( + (archive_arg / "data" / "documentation.json").read_text() + ) + out_idx = cmd.index("--output-path") + 1 + out = Path(cmd[out_idx]) + out.mkdir(parents=True, exist_ok=True) + (out / "index.html").write_text("transformed") + return subprocess.CompletedProcess(cmd, 0) + + with mock.patch.object(build_docs.subprocess, "run", side_effect=fake_run): + build_docs._finalize_combined_archive( + [archive], tmp_path, "main", ["docc"], prior_failed=[], + version={"slug": "main", "descriptive-name": "6.3"}, + ) + + self.assertEqual( + captured["doc"]["primaryContentSections"][0]["content"][0]["inlineContent"][0]["text"], + "Swift version: 6.3", + ) + def _merge_writes_index(self, modules): """Build a fake subprocess.run that writes a merged index.json on merge. @@ -887,7 +993,7 @@ def fake_run(cmd, **kw): "schemaVersion": {"major": 0, "minor": 1, "patch": 2}, "interfaceLanguages": { "swift": [{ - "title": "Swift - main", + "title": "Swift Documentation", "children": [ {"type": "module", "title": t, "path": p} for t, p in modules @@ -1229,7 +1335,7 @@ def _make_index(tmp_path, children, langs=("swift",)): "interfaceLanguages": { lang: [ { - "title": "Swift - main", + "title": "Swift Documentation", "children": [dict(c) for c in children], "path": "/documentation", "type": "module", @@ -1248,7 +1354,10 @@ def _children_of(archive, lang="swift"): class ValidateNavigation(unittest.TestCase): - SOURCES = {"version": "main", "sources": [{"id": "a"}, {"id": "b"}]} + SOURCES = { + "version": {"slug": "main", "descriptive-name": "prototype"}, + "sources": [{"id": "a"}, {"id": "b"}], + } def _nav(self, **over): nav = { @@ -1470,6 +1579,46 @@ def test_unlisted_module_raises(self): curate_navigator.dry_run(archive, self._nav()) +class AutoArchiveVersionSlug(unittest.TestCase): + """_auto_archive must resolve the {slug, descriptive-name} version object.""" + + def _sources_path(self, tmp_path): + scripts_dir = tmp_path / "scripts" + scripts_dir.mkdir() + return scripts_dir / "sources.json" + + def test_uses_slug_from_version_object(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + sources_path = self._sources_path(tmp_path) + sources = { + "version": {"slug": "main", "descriptive-name": "prototype"}, + "sources": [], + } + index_dir = tmp_path / ".build-output" / "main" / "index" + index_dir.mkdir(parents=True) + (index_dir / "index.json").write_text("{}") + + found = validate_navigation_cli._auto_archive(sources, sources_path) + self.assertEqual(found, tmp_path.resolve() / ".build-output" / "main") + + def test_missing_slug_returns_none(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + sources_path = self._sources_path(tmp_path) + sources = { + "version": {"descriptive-name": "prototype"}, + "sources": [], + } + self.assertIsNone(validate_navigation_cli._auto_archive(sources, sources_path)) + + def test_version_missing_entirely_returns_none(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + sources_path = self._sources_path(tmp_path) + self.assertIsNone(validate_navigation_cli._auto_archive({"sources": []}, sources_path)) + + class ValidateNavigationCLI(unittest.TestCase): def _setup(self, tmp, nav, sources, archive_modules=None): """Write nav + sources files (and optionally an archive); return argv.""" @@ -1484,7 +1633,10 @@ def _setup(self, tmp, nav, sources, archive_modules=None): argv += ["--archive", str(archive)] return argv - SOURCES = {"version": "main", "sources": [{"id": "a"}, {"id": "b"}]} + SOURCES = { + "version": {"slug": "main", "descriptive-name": "prototype"}, + "sources": [{"id": "a"}, {"id": "b"}], + } def _nav(self): return { @@ -1721,5 +1873,53 @@ def test_landing_page_idempotent(self): self.assertEqual(once, twice) +class SetVersionParagraph(unittest.TestCase): + def test_adds_paragraph_content_section(self): + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + _write_landing_page(archive, [("Modules", ["doc://Test/documentation/Swift"])]) + curate_navigator.set_version_paragraph(archive, "Swift version: 6.3") + doc = json.loads((archive / "data" / "documentation.json").read_text()) + + self.assertEqual(doc["primaryContentSections"], [{ + "kind": "content", + "content": [{ + "type": "paragraph", + "inlineContent": [{"type": "text", "text": "Swift version: 6.3"}], + }], + }]) + + def test_overwrites_existing_primary_content_sections(self): + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + _write_landing_page(archive, [("Modules", ["doc://Test/documentation/Swift"])]) + curate_navigator.set_version_paragraph(archive, "Swift version: 6.3") + curate_navigator.set_version_paragraph(archive, "Swift version: 6.4") + doc = json.loads((archive / "data" / "documentation.json").read_text()) + + self.assertEqual(len(doc["primaryContentSections"]), 1) + self.assertEqual( + doc["primaryContentSections"][0]["content"][0]["inlineContent"][0]["text"], + "Swift version: 6.4", + ) + + def test_preserves_topic_sections_and_references(self): + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + _write_landing_page(archive, [("Modules", ["doc://Test/documentation/Swift"])]) + before = json.loads((archive / "data" / "documentation.json").read_text()) + curate_navigator.set_version_paragraph(archive, "Swift version: 6.3") + after = json.loads((archive / "data" / "documentation.json").read_text()) + + self.assertEqual(after["topicSections"], before["topicSections"]) + self.assertEqual(after["references"], before["references"]) + + def test_missing_landing_page_is_noop(self): + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + curate_navigator.set_version_paragraph(archive, "Swift version: 6.3") + self.assertFalse((archive / "data" / "documentation.json").exists()) + + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/scripts/validate_navigation.py b/scripts/validate_navigation.py index c6c98cc6..6627221d 100755 --- a/scripts/validate_navigation.py +++ b/scripts/validate_navigation.py @@ -60,14 +60,17 @@ def _load_json(path): def _auto_archive(sources, sources_path): """Return the conventional combined archive path if it exists, else None. - Resolved relative to sources.json (…//../.build-output/), so + Resolved relative to sources.json (…//../.build-output/), so pointing --sources at a throwaway file does not pick up the real build. """ version = sources.get("version") - if not version: + if not isinstance(version, dict): + return None + slug = version.get("slug") + if not slug: return None repo_root = Path(sources_path).resolve().parent.parent - candidate = repo_root / ".build-output" / str(version) + candidate = repo_root / ".build-output" / slug return candidate if (candidate / "index" / "index.json").is_file() else None