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
31 changes: 23 additions & 8 deletions scripts/build_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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"]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions scripts/curate_navigator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 5 additions & 4 deletions scripts/hacking-index-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
176 changes: 176 additions & 0 deletions scripts/hacking-synthesized-landing-page.md
Original file line number Diff line number Diff line change
@@ -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`](<https://github.com/swiftlang/swift-docc/blob/main/Tests/SwiftDocCTests/Rendering/Rendering%20Fixtures/tables.json>)
and
[`Tests/SwiftDocCTests/Test Resources/link-button-render-node.json`](<https://github.com/swiftlang/swift-docc/blob/main/Tests/SwiftDocCTests/Test%20Resources/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/`](<https://github.com/swiftlang/swift-docc/tree/main/Tests/SwiftDocCTests/Rendering/Rendering%20Fixtures>)
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 <https://heckj.github.io/DocCArchive/>.

## 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.
5 changes: 4 additions & 1 deletion scripts/sources.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"version": "main",
"version": {
"slug": "main",
"descriptive-name": "prototype"
},
"sources": [
{
"id": "swift-book",
Expand Down
Loading