@fern-api/fdr-sdk 1.2.165-67370398 → 1.2.165-757da63274
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/docs-ledger/buildLedgerManifest.d.ts +80 -0
- package/dist/docs-ledger/buildLedgerManifest.d.ts.map +1 -0
- package/dist/docs-ledger/buildLedgerManifest.js +885 -0
- package/dist/docs-ledger/buildLedgerManifest.js.map +1 -0
- package/dist/docs-ledger/docsLedgerHash.d.ts +80 -0
- package/dist/docs-ledger/docsLedgerHash.d.ts.map +1 -0
- package/dist/docs-ledger/docsLedgerHash.js +110 -0
- package/dist/docs-ledger/docsLedgerHash.js.map +1 -0
- package/dist/docs-ledger/docsPublishTransform.d.ts +178 -0
- package/dist/docs-ledger/docsPublishTransform.d.ts.map +1 -0
- package/dist/docs-ledger/docsPublishTransform.js +2136 -0
- package/dist/docs-ledger/docsPublishTransform.js.map +1 -0
- package/dist/docs-ledger/index.d.ts +15 -0
- package/dist/docs-ledger/index.d.ts.map +1 -0
- package/dist/docs-ledger/index.js +15 -0
- package/dist/docs-ledger/index.js.map +1 -0
- package/dist/docs-ledger/inputTypes.d.ts +236 -0
- package/dist/docs-ledger/inputTypes.d.ts.map +1 -0
- package/dist/docs-ledger/inputTypes.js +2 -0
- package/dist/docs-ledger/inputTypes.js.map +1 -0
- package/dist/docs-ledger/stableHash.d.ts +7 -0
- package/dist/docs-ledger/stableHash.d.ts.map +1 -0
- package/dist/docs-ledger/stableHash.js +24 -0
- package/dist/docs-ledger/stableHash.js.map +1 -0
- package/dist/docs-ledger/toBasepathRelative.d.ts +25 -0
- package/dist/docs-ledger/toBasepathRelative.d.ts.map +1 -0
- package/dist/docs-ledger/toBasepathRelative.js +34 -0
- package/dist/docs-ledger/toBasepathRelative.js.map +1 -0
- package/dist/js/docs-ledger/index.js +20157 -0
- package/dist/js/docs-ledger/index.js.map +1 -0
- package/dist/js/docs-ledger/index.mjs +20113 -0
- package/dist/js/docs-ledger/index.mjs.map +1 -0
- package/dist/types/docs-ledger/buildLedgerManifest.d.ts +80 -0
- package/dist/types/docs-ledger/buildLedgerManifest.d.ts.map +1 -0
- package/dist/types/docs-ledger/docsLedgerHash.d.ts +80 -0
- package/dist/types/docs-ledger/docsLedgerHash.d.ts.map +1 -0
- package/dist/types/docs-ledger/docsPublishTransform.d.ts +178 -0
- package/dist/types/docs-ledger/docsPublishTransform.d.ts.map +1 -0
- package/dist/types/docs-ledger/index.d.ts +15 -0
- package/dist/types/docs-ledger/index.d.ts.map +1 -0
- package/dist/types/docs-ledger/inputTypes.d.ts +236 -0
- package/dist/types/docs-ledger/inputTypes.d.ts.map +1 -0
- package/dist/types/docs-ledger/stableHash.d.ts +7 -0
- package/dist/types/docs-ledger/stableHash.d.ts.map +1 -0
- package/dist/types/docs-ledger/toBasepathRelative.d.ts +25 -0
- package/dist/types/docs-ledger/toBasepathRelative.d.ts.map +1 -0
- package/package.json +8 -1
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
import assertNever from "@fern-api/ui-core-utils/assertNever";
|
|
2
|
+
import { collectConfigFilePaths, LEDGER_MANIFEST_SCHEMA_VERSION, LEDGER_SHARD_SCHEMA_VERSION, LedgerLocaleConfigOverrideSchema, LedgerSegmentDetailMetadataSchema, LedgerSegmentMetadataSchema, ledgerSegmentIdentity, selectLedgerSegmentsForLocale } from "../orpc-client/index.js";
|
|
3
|
+
import { stableHash, stableStringify } from "./stableHash.js";
|
|
4
|
+
import { toBasepathRelative } from "./toBasepathRelative.js";
|
|
5
|
+
/**
|
|
6
|
+
* Soft byte target for a single route shard's serialized JSON (ADR 0016). Scopes
|
|
7
|
+
* are bin-packed (first-fit-decreasing) so each shard stays at or under this
|
|
8
|
+
* size; a single scope larger than the target becomes its own oversized shard,
|
|
9
|
+
* still well under the 1 MB per-object budget in practice.
|
|
10
|
+
*/
|
|
11
|
+
const LEDGER_SHARD_TARGET_BYTES = 700 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Maps a pruned-API artifact's metadata id field to the `nodeType` discriminator
|
|
14
|
+
* used by the read path. The manifest keys pruned blobs by `${nodeType}:${nodeId}`.
|
|
15
|
+
*/
|
|
16
|
+
const PRUNED_NODE_ID_FIELDS = [
|
|
17
|
+
["endpointId", "endpoint"],
|
|
18
|
+
["webSocketId", "webSocket"],
|
|
19
|
+
["webhookId", "webhook"],
|
|
20
|
+
["grpcId", "grpc"],
|
|
21
|
+
["graphqlOperationId", "graphql"],
|
|
22
|
+
["typeId", "graphqlType"]
|
|
23
|
+
];
|
|
24
|
+
function defaultContentTypeForArtifactType(type) {
|
|
25
|
+
switch (type) {
|
|
26
|
+
case "markdown":
|
|
27
|
+
case "changelog":
|
|
28
|
+
return "text/markdown";
|
|
29
|
+
case "rest":
|
|
30
|
+
case "asyncapi":
|
|
31
|
+
case "webhook":
|
|
32
|
+
case "grpc":
|
|
33
|
+
case "graphql":
|
|
34
|
+
case "graphqlType":
|
|
35
|
+
return "application/json";
|
|
36
|
+
default:
|
|
37
|
+
return "application/octet-stream";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function detailToManifest(detail) {
|
|
41
|
+
if (detail == null) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
id: detail.hash,
|
|
46
|
+
displayName: detail.displayName,
|
|
47
|
+
icon: detail.icon ?? null,
|
|
48
|
+
sortOrder: detail.sortOrder,
|
|
49
|
+
hidden: detail.hidden,
|
|
50
|
+
viewers: detail.viewers,
|
|
51
|
+
orphaned: detail.orphaned,
|
|
52
|
+
featureFlags: detail.featureFlags,
|
|
53
|
+
metadata: LedgerSegmentDetailMetadataSchema.parse(detail.metadata)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function metadataField(metadata, key) {
|
|
57
|
+
return metadata != null && typeof metadata === "object" && key in metadata
|
|
58
|
+
? metadata[key]
|
|
59
|
+
: undefined;
|
|
60
|
+
}
|
|
61
|
+
function addFileIconPath(paths, icon) {
|
|
62
|
+
if (typeof icon === "string" && icon.startsWith("file:")) {
|
|
63
|
+
const fullPath = icon.slice("file:".length);
|
|
64
|
+
if (fullPath !== "") {
|
|
65
|
+
paths.add(fullPath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function addFilePath(paths, filePath) {
|
|
70
|
+
if (typeof filePath !== "string" || filePath === "") {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const fullPath = filePath.startsWith("file:") ? filePath.slice("file:".length) : filePath;
|
|
74
|
+
if (fullPath !== "") {
|
|
75
|
+
paths.add(fullPath);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* File paths referenced by navigation chrome must remain in the pointer-level
|
|
80
|
+
* `files` map because ProductSelectChrome/SidebarChrome resolve them via
|
|
81
|
+
* `loader.resolveFiles()` (`fileMetadata`/`fileArtifact`), not via page route
|
|
82
|
+
* shards. If the same asset is also embedded in page MDX, the page-file sharder
|
|
83
|
+
* would otherwise move it into a shard and prune it from the pointer, making the
|
|
84
|
+
* navigation icon/image disappear on the ledger path.
|
|
85
|
+
*/
|
|
86
|
+
function collectNavigationChromeFilePaths({ segmentDetails, segments, artifacts }) {
|
|
87
|
+
const paths = new Set();
|
|
88
|
+
for (const detail of segmentDetails) {
|
|
89
|
+
addFileIconPath(paths, detail.icon);
|
|
90
|
+
addFilePath(paths, metadataField(detail.metadata, "image"));
|
|
91
|
+
}
|
|
92
|
+
for (const segment of segments) {
|
|
93
|
+
addFileIconPath(paths, metadataField(segment.metadata, "icon"));
|
|
94
|
+
}
|
|
95
|
+
for (const artifact of artifacts) {
|
|
96
|
+
addFileIconPath(paths, metadataField(artifact.metadata, "icon"));
|
|
97
|
+
}
|
|
98
|
+
return [...paths];
|
|
99
|
+
}
|
|
100
|
+
function manifestRouteArtifactFields(artifact) {
|
|
101
|
+
switch (artifact.type) {
|
|
102
|
+
case "markdown":
|
|
103
|
+
return { artifactType: "markdown", artifactMetadata: artifact.metadata };
|
|
104
|
+
case "page":
|
|
105
|
+
return { artifactType: "page", artifactMetadata: artifact.metadata };
|
|
106
|
+
case "rest":
|
|
107
|
+
return { artifactType: "rest", artifactMetadata: artifact.metadata };
|
|
108
|
+
case "asyncapi":
|
|
109
|
+
return { artifactType: "asyncapi", artifactMetadata: artifact.metadata };
|
|
110
|
+
case "webhook":
|
|
111
|
+
return { artifactType: "webhook", artifactMetadata: artifact.metadata };
|
|
112
|
+
case "grpc":
|
|
113
|
+
return { artifactType: "grpc", artifactMetadata: artifact.metadata };
|
|
114
|
+
case "graphql":
|
|
115
|
+
return { artifactType: "graphql", artifactMetadata: artifact.metadata };
|
|
116
|
+
case "graphqlType":
|
|
117
|
+
return { artifactType: "graphqlType", artifactMetadata: artifact.metadata };
|
|
118
|
+
case "changelog":
|
|
119
|
+
return { artifactType: "changelog", artifactMetadata: artifact.metadata };
|
|
120
|
+
case "link":
|
|
121
|
+
return { artifactType: "link", artifactMetadata: artifact.metadata };
|
|
122
|
+
case "file":
|
|
123
|
+
return { artifactType: "file", artifactMetadata: artifact.metadata };
|
|
124
|
+
case "image":
|
|
125
|
+
return { artifactType: "image", artifactMetadata: artifact.metadata };
|
|
126
|
+
case "redirect":
|
|
127
|
+
return { artifactType: "redirect", artifactMetadata: artifact.metadata };
|
|
128
|
+
default:
|
|
129
|
+
return assertNever(artifact);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Overlay CAS content `created_at` onto the artifact-keyed `createdAt` map
|
|
134
|
+
* used for manifest route entries (sitemap `<lastmod>`). Artifact hashes are
|
|
135
|
+
* salted with `FDR_VERSION`, so their rows are re-minted on the first publish
|
|
136
|
+
* after every FDR deploy; the unsalted content row survives deploys and
|
|
137
|
+
* reflects when the page bytes first appeared. Content-less artifacts (links,
|
|
138
|
+
* files) keep the artifact row's timestamp.
|
|
139
|
+
*/
|
|
140
|
+
export function overlayContentCreatedAt({ artifacts, artifactHashToCreatedAt, contentHashToCreatedAt }) {
|
|
141
|
+
const merged = new Map(artifactHashToCreatedAt);
|
|
142
|
+
for (const artifact of artifacts) {
|
|
143
|
+
if (artifact.contentHash == null) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const contentCreatedAt = contentHashToCreatedAt.get(artifact.contentHash);
|
|
147
|
+
if (contentCreatedAt != null) {
|
|
148
|
+
merged.set(artifact.hash, contentCreatedAt);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return merged;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Assemble the pointer + immutable CAS read model (ADR 0016) from the
|
|
155
|
+
* in-memory transform result the DAO just persisted. No DB read-back: every
|
|
156
|
+
* field comes from `params` / `baseResult` / `inlineTranslations`, and segment /
|
|
157
|
+
* artifact ids are content hashes (the S3-only reader never correlates against
|
|
158
|
+
* postgres).
|
|
159
|
+
*
|
|
160
|
+
* Leaf content (page markdown, API definitions) is referenced by CAS hash only
|
|
161
|
+
* — the bundle fetches those blobs directly. Hot-path chrome text
|
|
162
|
+
* (header/footer/css.inline/js.inline) is re-inlined into the pointer so page
|
|
163
|
+
* renders do not pay extra CAS reads for site chrome.
|
|
164
|
+
*/
|
|
165
|
+
export function buildLedgerReadModel(args) {
|
|
166
|
+
const { params, baseResult, inlineTranslations } = args;
|
|
167
|
+
// ── Merge base + translation locales ──────────────────────────────
|
|
168
|
+
const segmentDetails = [
|
|
169
|
+
...params.segmentDetails,
|
|
170
|
+
...inlineTranslations.flatMap((t) => t.segmentDetails)
|
|
171
|
+
];
|
|
172
|
+
const artifacts = [...params.artifacts, ...inlineTranslations.flatMap((t) => t.artifacts)];
|
|
173
|
+
const segments = [...params.segments, ...inlineTranslations.flatMap((t) => t.segments)];
|
|
174
|
+
const nav = [...params.nav, ...inlineTranslations.flatMap((t) => t.nav)];
|
|
175
|
+
const deploymentSegments = [
|
|
176
|
+
...params.deploymentSegments,
|
|
177
|
+
...inlineTranslations.flatMap((t) => t.deploymentSegments)
|
|
178
|
+
];
|
|
179
|
+
const detailByHash = new Map();
|
|
180
|
+
for (const d of segmentDetails) {
|
|
181
|
+
detailByHash.set(d.hash, d);
|
|
182
|
+
}
|
|
183
|
+
const artifactByHash = new Map();
|
|
184
|
+
for (const a of artifacts) {
|
|
185
|
+
artifactByHash.set(a.hash, a);
|
|
186
|
+
}
|
|
187
|
+
const segmentByHash = new Map();
|
|
188
|
+
for (const s of segments) {
|
|
189
|
+
segmentByHash.set(s.hash, s);
|
|
190
|
+
}
|
|
191
|
+
const sortOrderBySegment = new Map();
|
|
192
|
+
for (const ds of deploymentSegments) {
|
|
193
|
+
if (!sortOrderBySegment.has(ds.segmentHash)) {
|
|
194
|
+
sortOrderBySegment.set(ds.segmentHash, ds.sortOrder);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const contentTypeByHash = new Map();
|
|
198
|
+
for (const ref of baseResult.contentRefs.values()) {
|
|
199
|
+
contentTypeByHash.set(ref.hash, ref.contentType);
|
|
200
|
+
}
|
|
201
|
+
const contentType = (hash, artifactType) => contentTypeByHash.get(hash) ?? defaultContentTypeForArtifactType(artifactType);
|
|
202
|
+
// First nav entry per artifact (used to anchor pageId → fullPath/segment).
|
|
203
|
+
const firstNavByArtifact = new Map();
|
|
204
|
+
// Every locale an artifact is reachable from, via the segments navigating to it.
|
|
205
|
+
const localesByArtifact = new Map();
|
|
206
|
+
for (const n of nav) {
|
|
207
|
+
if (!firstNavByArtifact.has(n.artifactHash)) {
|
|
208
|
+
firstNavByArtifact.set(n.artifactHash, n);
|
|
209
|
+
}
|
|
210
|
+
const navLocale = segmentByHash.get(n.segmentHash)?.locale;
|
|
211
|
+
if (navLocale != null) {
|
|
212
|
+
const artifactLocales = localesByArtifact.get(n.artifactHash) ?? new Set();
|
|
213
|
+
artifactLocales.add(navLocale);
|
|
214
|
+
localesByArtifact.set(n.artifactHash, artifactLocales);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// ── segments ──────────────────────────────────────────────────────
|
|
218
|
+
const manifestSegments = segments.map((seg) => ({
|
|
219
|
+
segmentId: seg.hash,
|
|
220
|
+
segmentHash: seg.hash,
|
|
221
|
+
section: seg.section,
|
|
222
|
+
locale: seg.locale,
|
|
223
|
+
sortOrder: sortOrderBySegment.get(seg.hash) ?? 0,
|
|
224
|
+
hidden: seg.hidden,
|
|
225
|
+
metadata: LedgerSegmentMetadataSchema.parse(seg.metadata),
|
|
226
|
+
product: detailToManifest(seg.productHash != null ? detailByHash.get(seg.productHash) : undefined),
|
|
227
|
+
version: detailToManifest(seg.versionHash != null ? detailByHash.get(seg.versionHash) : undefined),
|
|
228
|
+
variant: detailToManifest(seg.variantHash != null ? detailByHash.get(seg.variantHash) : undefined),
|
|
229
|
+
tab: detailToManifest(seg.tabHash != null ? detailByHash.get(seg.tabHash) : undefined)
|
|
230
|
+
}));
|
|
231
|
+
// ── nav (sidebar trees) + routes (routable artifacts) ─────────────
|
|
232
|
+
// Cleaned deployment basepath (no leading/trailing slashes). Sidebar
|
|
233
|
+
// `fullPath`s are stored basepath-RELATIVE so the read-side nav builder
|
|
234
|
+
// re-prefixes them exactly once; see {@link toBasepathRelative}.
|
|
235
|
+
const basePrefix = args.basepath.replace(/^\/+|\/+$/g, "");
|
|
236
|
+
const navBySegment = {};
|
|
237
|
+
// locale → fullPath → route.
|
|
238
|
+
const routesByLocale = {};
|
|
239
|
+
const pagesByLocale = {};
|
|
240
|
+
// Sidebar order is per (segment) by displaySortOrder; build a sortable list first.
|
|
241
|
+
const sidebarEntries = nav
|
|
242
|
+
.filter((n) => n.displaySortOrder != null)
|
|
243
|
+
.sort((a, b) => (a.displaySortOrder ?? 0) - (b.displaySortOrder ?? 0));
|
|
244
|
+
for (const n of sidebarEntries) {
|
|
245
|
+
const artifact = artifactByHash.get(n.artifactHash);
|
|
246
|
+
if (artifact == null) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const { hash: artifactId, contentHash: _contentHash, fileKey: _fileKey, ...artifactRouteFields } = artifact;
|
|
250
|
+
(navBySegment[n.segmentHash] ??= []).push({
|
|
251
|
+
...artifactRouteFields,
|
|
252
|
+
// Basepath-relative: the read-side builder re-joins the basepath.
|
|
253
|
+
// Keeping it absolute here double-prefixes content node slugs on
|
|
254
|
+
// non-empty-basepath sites (→ every page resolves to notFound).
|
|
255
|
+
fullPath: n.fullPath != null ? toBasepathRelative(n.fullPath, basePrefix) : null,
|
|
256
|
+
artifactId,
|
|
257
|
+
// Preserve the absolute sidebar position so the read-side nav builder
|
|
258
|
+
// can interleave leaf pages (displaySortOrder) with subsections
|
|
259
|
+
// (childIndex) in the same coordinate space. Without this the reader
|
|
260
|
+
// falls back to compacted array indices and misorders mixed levels.
|
|
261
|
+
displaySortOrder: n.displaySortOrder
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
// Track fullPaths already claimed by a canonical (sidebar) nav entry so
|
|
265
|
+
// that cross-segment collisions (e.g. a section overview and its child
|
|
266
|
+
// apiReference overview sharing the same slug via skipUrlSlug) are resolved
|
|
267
|
+
// in favour of the first canonical entry — the parent section's artifact.
|
|
268
|
+
const canonicalRoutes = {};
|
|
269
|
+
for (const n of nav) {
|
|
270
|
+
if (n.fullPath == null) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const artifact = artifactByHash.get(n.artifactHash);
|
|
274
|
+
const segment = segmentByHash.get(n.segmentHash);
|
|
275
|
+
if (artifact == null || segment == null) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const routesForLocale = (routesByLocale[segment.locale] ??= {});
|
|
279
|
+
const canonicalForLocale = (canonicalRoutes[segment.locale] ??= new Set());
|
|
280
|
+
if (routesForLocale[n.fullPath] != null) {
|
|
281
|
+
// Route-only alias never overwrites an existing entry.
|
|
282
|
+
if (n.displaySortOrder == null) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// First canonical entry wins — later canonical entries from sibling
|
|
286
|
+
// segments must not overwrite (prevents content resolution picking
|
|
287
|
+
// the wrong overview artifact when two segments share a fullPath).
|
|
288
|
+
if (canonicalForLocale.has(n.fullPath)) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (n.displaySortOrder != null) {
|
|
293
|
+
canonicalForLocale.add(n.fullPath);
|
|
294
|
+
}
|
|
295
|
+
const createdAt = args.artifactHashToCreatedAt?.get(n.artifactHash);
|
|
296
|
+
routesForLocale[n.fullPath] = {
|
|
297
|
+
segmentHash: n.segmentHash,
|
|
298
|
+
sidebarKey: n.segmentHash,
|
|
299
|
+
section: segment.section,
|
|
300
|
+
locale: segment.locale,
|
|
301
|
+
artifactId: n.artifactHash,
|
|
302
|
+
pageId: typeof artifact.metadata?.pageId === "string" ? artifact.metadata.pageId : undefined,
|
|
303
|
+
...manifestRouteArtifactFields(artifact),
|
|
304
|
+
contentHash: artifact.contentHash,
|
|
305
|
+
contentType: artifact.contentHash != null ? contentType(artifact.contentHash, artifact.type) : null,
|
|
306
|
+
...(createdAt != null ? { createdAt } : {})
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// ── pages (pageId → content) ──────────────────────────────────────
|
|
310
|
+
for (const artifact of artifactByHash.values()) {
|
|
311
|
+
const pageId = artifact.metadata?.pageId;
|
|
312
|
+
if (typeof pageId !== "string" || artifact.contentHash == null) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const anchorNav = firstNavByArtifact.get(artifact.hash);
|
|
316
|
+
const anchorSegment = anchorNav != null ? segmentByHash.get(anchorNav.segmentHash) : undefined;
|
|
317
|
+
const pageLocale = anchorSegment?.locale ?? params.deployment.defaultLocale ?? "en";
|
|
318
|
+
const pages = (pagesByLocale[pageLocale] ??= {});
|
|
319
|
+
pages[pageId] = {
|
|
320
|
+
fullPath: anchorNav?.fullPath ?? null,
|
|
321
|
+
segmentHash: anchorNav?.segmentHash ?? "",
|
|
322
|
+
contentHash: artifact.contentHash,
|
|
323
|
+
contentType: contentType(artifact.contentHash, artifact.type)
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const defaultLocale = params.deployment.defaultLocale ?? params.segments[0]?.locale ?? "en";
|
|
327
|
+
// ── API definitions (full + pruned) ───────────────────────────────
|
|
328
|
+
// Full-definition hashes are locale-independent; pruned blobs are NOT. A
|
|
329
|
+
// translated locale's pruned artifacts carry the SAME apiDefinitionId and
|
|
330
|
+
// node ids as the base locale's, so they must be bucketed by the locale of
|
|
331
|
+
// the segment they are navigated from — a single shared map lets one
|
|
332
|
+
// locale's content overwrite every other locale's.
|
|
333
|
+
const apiDefs = {};
|
|
334
|
+
const apiNameToId = {};
|
|
335
|
+
for (const def of params.apiDefinitions) {
|
|
336
|
+
apiDefs[def.apiDefinitionId] = {
|
|
337
|
+
hash: def.contentHash,
|
|
338
|
+
contentType: "application/json",
|
|
339
|
+
prunedHashes: {}
|
|
340
|
+
};
|
|
341
|
+
if (def.apiName != null) {
|
|
342
|
+
apiNameToId[def.apiName] = def.apiDefinitionId;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// locale → apiDefinitionId → `${nodeType}:${nodeId}` → pruned CAS blob.
|
|
346
|
+
const prunedHashesByLocale = {};
|
|
347
|
+
for (const artifact of artifactByHash.values()) {
|
|
348
|
+
const apiDefinitionId = artifact.metadata?.apiDefinitionId;
|
|
349
|
+
if (typeof apiDefinitionId !== "string" || artifact.contentHash == null) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const match = PRUNED_NODE_ID_FIELDS.map(([field, nodeType]) => {
|
|
353
|
+
const nodeId = artifact.metadata?.[field];
|
|
354
|
+
return typeof nodeId === "string" ? { nodeType, nodeId } : undefined;
|
|
355
|
+
}).find((m) => m != null);
|
|
356
|
+
if (match == null) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const ref = {
|
|
360
|
+
hash: artifact.contentHash,
|
|
361
|
+
contentType: contentType(artifact.contentHash, artifact.type)
|
|
362
|
+
};
|
|
363
|
+
// An identical artifact can be navigated from several locales (an
|
|
364
|
+
// untranslated endpoint dedupes to one content hash), so register it
|
|
365
|
+
// under every locale that reaches it rather than just the first.
|
|
366
|
+
for (const artifactLocale of localesByArtifact.get(artifact.hash) ?? [defaultLocale]) {
|
|
367
|
+
const forLocale = (prunedHashesByLocale[artifactLocale] ??= {});
|
|
368
|
+
const forApi = (forLocale[apiDefinitionId] ??= {});
|
|
369
|
+
forApi[`${match.nodeType}:${match.nodeId}`] = ref;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// ── files (docs-bucket file/image artifacts) ──────────────────────
|
|
373
|
+
const bucketByFullPath = new Map();
|
|
374
|
+
for (const f of [...params.files, ...inlineTranslations.flatMap((t) => t.files)]) {
|
|
375
|
+
if (!bucketByFullPath.has(f.fullPath)) {
|
|
376
|
+
bucketByFullPath.set(f.fullPath, f.bucket);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const files = {};
|
|
380
|
+
const defaultBucket = args.isPrivate ? "private_docs" : "public_docs";
|
|
381
|
+
// File bytes are anchored on the canonical fern domain regardless of which
|
|
382
|
+
// URL this manifest is served at (ADR 0010), so files.domain is always the
|
|
383
|
+
// canonical site domain, not the per-URL pointer domain.
|
|
384
|
+
const fileDomain = args.fileDomain ?? params.site.domain;
|
|
385
|
+
for (const fe of baseResult.fileEntries) {
|
|
386
|
+
const isImage = fe.width != null && fe.height != null;
|
|
387
|
+
files[fe.fullPath] = {
|
|
388
|
+
type: isImage ? "image" : "file",
|
|
389
|
+
contentType: fe.contentType,
|
|
390
|
+
contentLength: fe.contentLength,
|
|
391
|
+
filename: fe.filename,
|
|
392
|
+
hash: fe.hash,
|
|
393
|
+
domain: fileDomain,
|
|
394
|
+
bucket: bucketByFullPath.get(fe.fullPath) ?? defaultBucket,
|
|
395
|
+
...(fe.width != null ? { width: fe.width } : {}),
|
|
396
|
+
...(fe.height != null ? { height: fe.height } : {}),
|
|
397
|
+
...(fe.blurDataURL != null ? { blurDataURL: fe.blurDataURL } : {})
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
// Translation-only files carry no dimensions; record them as plain files.
|
|
401
|
+
for (const t of inlineTranslations) {
|
|
402
|
+
for (const f of t.files) {
|
|
403
|
+
if (files[f.fullPath] != null) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
files[f.fullPath] = {
|
|
407
|
+
type: "file",
|
|
408
|
+
contentType: f.contentType,
|
|
409
|
+
filename: f.fullPath.split("/").pop() ?? f.fullPath,
|
|
410
|
+
hash: f.hash,
|
|
411
|
+
domain: fileDomain,
|
|
412
|
+
bucket: f.bucket
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const locales = new Set([defaultLocale]);
|
|
417
|
+
for (const seg of manifestSegments) {
|
|
418
|
+
locales.add(seg.locale);
|
|
419
|
+
}
|
|
420
|
+
for (const locale of Object.keys(routesByLocale)) {
|
|
421
|
+
locales.add(locale);
|
|
422
|
+
}
|
|
423
|
+
for (const locale of Object.keys(pagesByLocale)) {
|
|
424
|
+
locales.add(locale);
|
|
425
|
+
}
|
|
426
|
+
const segmentsForLocale = (locale) => selectLedgerSegmentsForLocale(manifestSegments, locale, defaultLocale).sort((a, b) => a.sortOrder - b.sortOrder);
|
|
427
|
+
const effectiveRoutesForLocale = (locale, segmentsForEffectiveLocale) => {
|
|
428
|
+
// Collect the identities of the effective segments, then allow ANY
|
|
429
|
+
// segment hash that shares one of those identities. This ensures
|
|
430
|
+
// default-locale routes (e.g. landing pages) whose segment was
|
|
431
|
+
// shadowed by a locale-specific segment with the same identity are
|
|
432
|
+
// still included. When a fallback route points at the shadowed segment
|
|
433
|
+
// hash, rewrite it to the effective segment hash so routeContext can
|
|
434
|
+
// recover the translated product/version/tab chrome from the compact
|
|
435
|
+
// pointer's deduped segment list.
|
|
436
|
+
const effectiveSegmentByIdentity = new Map(segmentsForEffectiveLocale.map((segment) => [ledgerSegmentIdentity(segment), segment]));
|
|
437
|
+
const effectiveSegmentHashBySegmentHash = new Map();
|
|
438
|
+
for (const seg of manifestSegments) {
|
|
439
|
+
const effective = effectiveSegmentByIdentity.get(ledgerSegmentIdentity(seg));
|
|
440
|
+
if (effective != null) {
|
|
441
|
+
effectiveSegmentHashBySegmentHash.set(seg.segmentHash, effective.segmentHash);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const addRoute = (path, route, out) => {
|
|
445
|
+
const effectiveSegmentHash = effectiveSegmentHashBySegmentHash.get(route.segmentHash);
|
|
446
|
+
if (effectiveSegmentHash != null) {
|
|
447
|
+
out[path] =
|
|
448
|
+
effectiveSegmentHash === route.segmentHash
|
|
449
|
+
? route
|
|
450
|
+
: {
|
|
451
|
+
...route,
|
|
452
|
+
segmentHash: effectiveSegmentHash,
|
|
453
|
+
sidebarKey: effectiveSegmentHash
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
const routes = {};
|
|
458
|
+
const fallbackRoutes = routesByLocale[defaultLocale] ?? {};
|
|
459
|
+
for (const [path, route] of Object.entries(fallbackRoutes)) {
|
|
460
|
+
addRoute(path, route, routes);
|
|
461
|
+
}
|
|
462
|
+
const requestedRoutes = routesByLocale[locale] ?? {};
|
|
463
|
+
for (const [path, route] of Object.entries(requestedRoutes)) {
|
|
464
|
+
addRoute(path, route, routes);
|
|
465
|
+
}
|
|
466
|
+
return routes;
|
|
467
|
+
};
|
|
468
|
+
/**
|
|
469
|
+
* API defs for one locale: locale-independent full hashes, plus pruned
|
|
470
|
+
* blobs from this locale layered over the default locale's (so endpoints
|
|
471
|
+
* with no translated artifact still resolve to the base content).
|
|
472
|
+
*/
|
|
473
|
+
const apiDefsForLocale = (locale) => {
|
|
474
|
+
const fallbackPruned = prunedHashesByLocale[defaultLocale] ?? {};
|
|
475
|
+
const localePruned = prunedHashesByLocale[locale] ?? {};
|
|
476
|
+
const apiDefinitionIds = new Set([
|
|
477
|
+
...Object.keys(apiDefs),
|
|
478
|
+
...Object.keys(fallbackPruned),
|
|
479
|
+
...Object.keys(localePruned)
|
|
480
|
+
]);
|
|
481
|
+
const result = {};
|
|
482
|
+
for (const apiDefinitionId of apiDefinitionIds) {
|
|
483
|
+
const base = apiDefs[apiDefinitionId];
|
|
484
|
+
result[apiDefinitionId] = {
|
|
485
|
+
...base,
|
|
486
|
+
hash: base?.hash ?? "",
|
|
487
|
+
contentType: base?.contentType ?? "application/json",
|
|
488
|
+
prunedHashes: {
|
|
489
|
+
...(fallbackPruned[apiDefinitionId] ?? {}),
|
|
490
|
+
...(localePruned[apiDefinitionId] ?? {})
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
return result;
|
|
495
|
+
};
|
|
496
|
+
const effectivePagesForLocale = (locale) => ({
|
|
497
|
+
...(pagesByLocale[defaultLocale] ?? {}),
|
|
498
|
+
...(pagesByLocale[locale] ?? {})
|
|
499
|
+
});
|
|
500
|
+
const jsonBlob = (value) => {
|
|
501
|
+
const bytes = Buffer.from(stableStringify(value), "utf-8");
|
|
502
|
+
return {
|
|
503
|
+
hash: stableHash(value),
|
|
504
|
+
bytes,
|
|
505
|
+
contentType: "application/json"
|
|
506
|
+
};
|
|
507
|
+
};
|
|
508
|
+
const generatedText = (hash) => {
|
|
509
|
+
if (hash == null) {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
return baseResult.generatedBlobs.get(hash)?.bytes.toString("utf-8");
|
|
513
|
+
};
|
|
514
|
+
const header = generatedText(baseResult.headerHash);
|
|
515
|
+
const footer = generatedText(baseResult.footerHash);
|
|
516
|
+
const cssInline = generatedText(baseResult.cssInlineHash);
|
|
517
|
+
const jsInline = generatedText(baseResult.jsInlineHash);
|
|
518
|
+
const configJson = baseResult.configJson ?? null;
|
|
519
|
+
const configWithInlineChrome = configJson == null ? null : structuredClone(configJson);
|
|
520
|
+
if (configWithInlineChrome != null) {
|
|
521
|
+
const configRecord = configWithInlineChrome;
|
|
522
|
+
if (header != null) {
|
|
523
|
+
configRecord.header = header;
|
|
524
|
+
}
|
|
525
|
+
if (footer != null) {
|
|
526
|
+
configRecord.footer = footer;
|
|
527
|
+
}
|
|
528
|
+
if (cssInline != null) {
|
|
529
|
+
const css = configRecord.css ?? {};
|
|
530
|
+
configRecord.css = { ...css, inline: cssInline.split("\n") };
|
|
531
|
+
}
|
|
532
|
+
if (jsInline != null) {
|
|
533
|
+
const js = configRecord.js ?? {};
|
|
534
|
+
configRecord.js = { ...js, inline: jsInline.split("\n") };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const localeConfigs = buildLocaleConfigOverrides(configJson, inlineTranslations);
|
|
538
|
+
const config = {
|
|
539
|
+
version: baseResult.version ?? null,
|
|
540
|
+
...(baseResult.git != null
|
|
541
|
+
? {
|
|
542
|
+
git: {
|
|
543
|
+
repoUrl: baseResult.git.repoUrl,
|
|
544
|
+
branch: baseResult.git.branch,
|
|
545
|
+
...(baseResult.git.commitSha != null ? { commitSha: baseResult.git.commitSha } : {})
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
: {}),
|
|
549
|
+
config: configWithInlineChrome,
|
|
550
|
+
...(localeConfigs != null ? { localeConfigs } : {}),
|
|
551
|
+
configRefs: {
|
|
552
|
+
...(baseResult.headerHash != null && header == null
|
|
553
|
+
? {
|
|
554
|
+
header: {
|
|
555
|
+
hash: baseResult.headerHash,
|
|
556
|
+
contentType: "text/markdown"
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
: {}),
|
|
560
|
+
...(baseResult.footerHash != null && footer == null
|
|
561
|
+
? {
|
|
562
|
+
footer: {
|
|
563
|
+
hash: baseResult.footerHash,
|
|
564
|
+
contentType: "text/markdown"
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
: {}),
|
|
568
|
+
...(baseResult.cssInlineHash != null && cssInline == null
|
|
569
|
+
? {
|
|
570
|
+
cssInline: {
|
|
571
|
+
hash: baseResult.cssInlineHash,
|
|
572
|
+
contentType: "text/css"
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
: {}),
|
|
576
|
+
...(baseResult.jsInlineHash != null && jsInline == null
|
|
577
|
+
? {
|
|
578
|
+
jsInline: {
|
|
579
|
+
hash: baseResult.jsInlineHash,
|
|
580
|
+
contentType: "text/javascript"
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
: {}),
|
|
584
|
+
...(baseResult.jsFilesHash != null
|
|
585
|
+
? {
|
|
586
|
+
jsFiles: {
|
|
587
|
+
hash: baseResult.jsFilesHash,
|
|
588
|
+
contentType: "application/javascript"
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
: {})
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const chrome = { apiNameToId };
|
|
595
|
+
const blobs = new Map();
|
|
596
|
+
/** Serialized byte size of a value under the same (stable) rule used to hash it. */
|
|
597
|
+
const byteSize = (value) => Buffer.byteLength(stableStringify(value), "utf-8");
|
|
598
|
+
/** Scope key for a segment: identifies its (product, version, variant, tab) navigation scope. */
|
|
599
|
+
const scopeKeyForSegment = (seg) => `${seg.product?.id ?? ""}|${seg.version?.id ?? ""}|${seg.variant?.id ?? ""}|${seg.tab?.id ?? ""}`;
|
|
600
|
+
// ── File materialization into shards (ADR 0016) ───────────────────
|
|
601
|
+
// Files referenced by config (logo/fonts/OG) and navigation chrome
|
|
602
|
+
// (product images, file:-prefixed product/sidebar icons) are resolved by
|
|
603
|
+
// the bundle via the pointer's `files` map (`fileMetadata`/`fileArtifact`),
|
|
604
|
+
// so they are NEVER moved into a shard and NEVER pruned from the pointer.
|
|
605
|
+
// Everything else a page embeds is materialized into that page's route
|
|
606
|
+
// shard and dropped from the pointer.
|
|
607
|
+
const pointerResolvedFilePaths = new Set([
|
|
608
|
+
...collectConfigFilePaths((baseResult.configJson ?? {})),
|
|
609
|
+
...collectNavigationChromeFilePaths({ segmentDetails, segments, artifacts })
|
|
610
|
+
]);
|
|
611
|
+
const referencedFilesByPageId = args.referencedFilesByPageId ?? {};
|
|
612
|
+
/** Project a full ManifestFile identity into a lean shard entry (drops domain; bucket only when it diverges). */
|
|
613
|
+
const toShardFile = (fullPath, mf) => ({
|
|
614
|
+
hash: mf.hash,
|
|
615
|
+
fullPath,
|
|
616
|
+
contentType: mf.contentType,
|
|
617
|
+
...(mf.contentLength != null ? { contentLength: mf.contentLength } : {}),
|
|
618
|
+
...(mf.width != null ? { width: mf.width } : {}),
|
|
619
|
+
...(mf.height != null ? { height: mf.height } : {}),
|
|
620
|
+
...(mf.blurDataURL != null ? { blurDataURL: mf.blurDataURL } : {}),
|
|
621
|
+
...(mf.bucket !== defaultBucket ? { bucket: mf.bucket } : {})
|
|
622
|
+
});
|
|
623
|
+
const pageIdOfRoute = (route) => {
|
|
624
|
+
const meta = route.artifactMetadata;
|
|
625
|
+
return typeof meta?.pageId === "string" ? meta.pageId : undefined;
|
|
626
|
+
};
|
|
627
|
+
const pointerLocales = {};
|
|
628
|
+
for (const locale of [...locales].sort()) {
|
|
629
|
+
const effectiveSegments = segmentsForLocale(locale);
|
|
630
|
+
const effectiveRoutes = effectiveRoutesForLocale(locale, effectiveSegments);
|
|
631
|
+
const effectivePages = effectivePagesForLocale(locale);
|
|
632
|
+
// segmentHash → scopeKey, for every segment in this effective locale.
|
|
633
|
+
const scopeKeyBySegment = new Map();
|
|
634
|
+
// scopeKey → segments belonging to that scope (every segment lands in exactly one).
|
|
635
|
+
const segmentsByScope = new Map();
|
|
636
|
+
for (const seg of effectiveSegments) {
|
|
637
|
+
const key = scopeKeyForSegment(seg);
|
|
638
|
+
scopeKeyBySegment.set(seg.segmentHash, key);
|
|
639
|
+
(segmentsByScope.get(key) ?? segmentsByScope.set(key, []).get(key)).push(seg);
|
|
640
|
+
}
|
|
641
|
+
// scopeKey → routes whose route.segmentHash belongs to a segment in that scope.
|
|
642
|
+
const routesByScope = new Map();
|
|
643
|
+
for (const [fullPath, route] of Object.entries(effectiveRoutes)) {
|
|
644
|
+
const key = scopeKeyBySegment.get(route.segmentHash);
|
|
645
|
+
if (key == null) {
|
|
646
|
+
// Route references a segment outside this effective locale — skip.
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
(routesByScope.get(key) ?? routesByScope.set(key, {}).get(key))[fullPath] = route;
|
|
650
|
+
}
|
|
651
|
+
const scopes = [];
|
|
652
|
+
for (const [key, segs] of segmentsByScope) {
|
|
653
|
+
const routes = routesByScope.get(key) ?? {};
|
|
654
|
+
const sidebars = {};
|
|
655
|
+
for (const seg of segs) {
|
|
656
|
+
sidebars[seg.segmentHash] = {
|
|
657
|
+
strategy: "inline",
|
|
658
|
+
locale: seg.locale,
|
|
659
|
+
segmentHash: seg.segmentHash,
|
|
660
|
+
routes: navBySegment[seg.segmentHash] ?? []
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const payload = { routes, sidebars };
|
|
664
|
+
scopes.push({ key, payload, size: byteSize(payload) });
|
|
665
|
+
}
|
|
666
|
+
// First-fit-decreasing bin packing: sort scopes largest-first and place
|
|
667
|
+
// each into the first existing shard it still fits in (measuring the merged
|
|
668
|
+
// serialized {schema, locale, routes, sidebars}); else open a new shard. A
|
|
669
|
+
// single scope over the target becomes its own (oversized but sub-1-MB) shard.
|
|
670
|
+
scopes.sort((a, b) => b.size - a.size);
|
|
671
|
+
const shardPayloads = [];
|
|
672
|
+
// scopeKey → shard index, used to populate routing/segmentShard.
|
|
673
|
+
const shardIndexByScope = new Map();
|
|
674
|
+
for (const scope of scopes) {
|
|
675
|
+
let placed = false;
|
|
676
|
+
for (let i = 0; i < shardPayloads.length; i++) {
|
|
677
|
+
const merged = {
|
|
678
|
+
routes: { ...shardPayloads[i].routes, ...scope.payload.routes },
|
|
679
|
+
sidebars: {
|
|
680
|
+
...shardPayloads[i].sidebars,
|
|
681
|
+
...scope.payload.sidebars
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
if (byteSize({
|
|
685
|
+
schema: LEDGER_SHARD_SCHEMA_VERSION,
|
|
686
|
+
locale,
|
|
687
|
+
...merged
|
|
688
|
+
}) <= LEDGER_SHARD_TARGET_BYTES) {
|
|
689
|
+
shardPayloads[i] = merged;
|
|
690
|
+
shardIndexByScope.set(scope.key, i);
|
|
691
|
+
placed = true;
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (!placed) {
|
|
696
|
+
// New shard (a scope larger than the target lands here alone).
|
|
697
|
+
shardPayloads.push({
|
|
698
|
+
routes: { ...scope.payload.routes },
|
|
699
|
+
sidebars: { ...scope.payload.sidebars }
|
|
700
|
+
});
|
|
701
|
+
shardIndexByScope.set(scope.key, shardPayloads.length - 1);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// Emit each shard as a CAS blob; record its hash. Materialize the
|
|
705
|
+
// page→file join: each shard carries the lean identities of every
|
|
706
|
+
// (non-config) file its routes embed, resolved from the global `files`
|
|
707
|
+
// map. Paths placed into a shard are pruned from the pointer below.
|
|
708
|
+
const shards = [];
|
|
709
|
+
const localeShardedFilePaths = new Set();
|
|
710
|
+
for (const payload of shardPayloads) {
|
|
711
|
+
const shardFiles = {};
|
|
712
|
+
for (const route of Object.values(payload.routes)) {
|
|
713
|
+
const pageId = pageIdOfRoute(route);
|
|
714
|
+
if (pageId == null) {
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
for (const fileFullPath of referencedFilesByPageId[pageId] ?? []) {
|
|
718
|
+
if (pointerResolvedFilePaths.has(fileFullPath) || shardFiles[fileFullPath] != null) {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const mf = files[fileFullPath];
|
|
722
|
+
if (mf == null) {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
shardFiles[fileFullPath] = toShardFile(fileFullPath, mf);
|
|
726
|
+
localeShardedFilePaths.add(fileFullPath);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const shardBlob = jsonBlob({
|
|
730
|
+
schema: LEDGER_SHARD_SCHEMA_VERSION,
|
|
731
|
+
locale,
|
|
732
|
+
routes: payload.routes,
|
|
733
|
+
sidebars: payload.sidebars,
|
|
734
|
+
// Omit when empty so file-less shards hash identically to before.
|
|
735
|
+
...(Object.keys(shardFiles).length > 0 ? { files: shardFiles } : {})
|
|
736
|
+
});
|
|
737
|
+
blobs.set(shardBlob.hash, shardBlob);
|
|
738
|
+
shards.push({ hash: shardBlob.hash });
|
|
739
|
+
}
|
|
740
|
+
// Prune the pointer's per-locale `files` map: drop every file that was
|
|
741
|
+
// relocated into a shard. Config / well-known / API-description /
|
|
742
|
+
// orphan files are not page-referenced (or are config-excluded), so
|
|
743
|
+
// they never land in `localeShardedFilePaths` and survive here.
|
|
744
|
+
const prunedFiles = localeShardedFilePaths.size === 0
|
|
745
|
+
? files
|
|
746
|
+
: Object.fromEntries(Object.entries(files).filter(([fp]) => !localeShardedFilePaths.has(fp)));
|
|
747
|
+
// routing: fullPath → shard index (via its scope). segmentShard: segmentHash → shard index.
|
|
748
|
+
const routing = {};
|
|
749
|
+
for (const [fullPath, route] of Object.entries(effectiveRoutes)) {
|
|
750
|
+
const key = scopeKeyBySegment.get(route.segmentHash);
|
|
751
|
+
const idx = key != null ? shardIndexByScope.get(key) : undefined;
|
|
752
|
+
if (idx != null) {
|
|
753
|
+
routing[fullPath] = idx;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const segmentShard = {};
|
|
757
|
+
for (const seg of effectiveSegments) {
|
|
758
|
+
const idx = shardIndexByScope.get(scopeKeyForSegment(seg));
|
|
759
|
+
if (idx != null) {
|
|
760
|
+
segmentShard[seg.segmentHash] = idx;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// versionFirstRoute: versionId → first routable fullPath (insertion order).
|
|
764
|
+
const versionFirstRoute = {};
|
|
765
|
+
const segmentForHash = new Map(effectiveSegments.map((s) => [s.segmentHash, s]));
|
|
766
|
+
for (const [fullPath, route] of Object.entries(effectiveRoutes)) {
|
|
767
|
+
const vid = segmentForHash.get(route.segmentHash)?.version?.id;
|
|
768
|
+
if (vid != null && versionFirstRoute[vid] == null) {
|
|
769
|
+
versionFirstRoute[vid] = fullPath;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
// Pages blob (pageId → ManifestPage) — off the hot path; search/batch only.
|
|
773
|
+
const pagesBlob = jsonBlob(effectivePages);
|
|
774
|
+
blobs.set(pagesBlob.hash, pagesBlob);
|
|
775
|
+
// Deduplicate product/version SegmentDetails. A site with hundreds of
|
|
776
|
+
// segments has only a handful of distinct products/versions; inlining
|
|
777
|
+
// them per segment duplicated ~282 KB on `astronomer`. Catalog them once
|
|
778
|
+
// and reference by index from each compact pointer-segment. Dedup by id
|
|
779
|
+
// (product/version SegmentDetail always carry a stable id).
|
|
780
|
+
const productCatalog = [];
|
|
781
|
+
const versionCatalog = [];
|
|
782
|
+
const productIdxById = new Map();
|
|
783
|
+
const versionIdxById = new Map();
|
|
784
|
+
const catalogIndex = (detail, catalog, idxById) => {
|
|
785
|
+
if (detail == null) {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
const existing = idxById.get(detail.id);
|
|
789
|
+
if (existing != null) {
|
|
790
|
+
return existing;
|
|
791
|
+
}
|
|
792
|
+
const idx = catalog.length;
|
|
793
|
+
catalog.push(detail);
|
|
794
|
+
idxById.set(detail.id, idx);
|
|
795
|
+
return idx;
|
|
796
|
+
};
|
|
797
|
+
const compactSegments = effectiveSegments.map((seg) => ({
|
|
798
|
+
segmentId: seg.segmentId,
|
|
799
|
+
segmentHash: seg.segmentHash,
|
|
800
|
+
section: seg.section,
|
|
801
|
+
locale: seg.locale,
|
|
802
|
+
sortOrder: seg.sortOrder,
|
|
803
|
+
hidden: seg.hidden,
|
|
804
|
+
metadata: seg.metadata,
|
|
805
|
+
productIdx: catalogIndex(seg.product, productCatalog, productIdxById),
|
|
806
|
+
versionIdx: catalogIndex(seg.version, versionCatalog, versionIdxById),
|
|
807
|
+
variant: seg.variant,
|
|
808
|
+
tab: seg.tab
|
|
809
|
+
}));
|
|
810
|
+
pointerLocales[locale] = {
|
|
811
|
+
segments: compactSegments,
|
|
812
|
+
productCatalog,
|
|
813
|
+
versionCatalog,
|
|
814
|
+
routing,
|
|
815
|
+
segmentShard,
|
|
816
|
+
shards,
|
|
817
|
+
versionFirstRoute,
|
|
818
|
+
pagesHash: pagesBlob.hash,
|
|
819
|
+
files: prunedFiles,
|
|
820
|
+
apiDefs: apiDefsForLocale(locale)
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
pointer: {
|
|
825
|
+
schema: LEDGER_MANIFEST_SCHEMA_VERSION,
|
|
826
|
+
orgId: args.orgId,
|
|
827
|
+
domain: args.domain,
|
|
828
|
+
basepath: args.basepath,
|
|
829
|
+
deploymentId: args.deploymentId,
|
|
830
|
+
deploymentHash: args.deploymentHash,
|
|
831
|
+
siteId: args.siteId,
|
|
832
|
+
assignedAt: args.assignedAt,
|
|
833
|
+
assignedBy: args.assignedBy,
|
|
834
|
+
isPreview: args.isPreview,
|
|
835
|
+
isPrivate: args.isPrivate,
|
|
836
|
+
askAiEnabled: args.askAiEnabled,
|
|
837
|
+
...(args.siblingSites != null ? { siblingSites: args.siblingSites } : {}),
|
|
838
|
+
defaultLocale,
|
|
839
|
+
// All file bytes are anchored on the canonical fern domain (ADR
|
|
840
|
+
// 0010); the lean shard file entries omit `domain` and the read
|
|
841
|
+
// client rebuilds URLs from this.
|
|
842
|
+
fileAnchorDomain: fileDomain,
|
|
843
|
+
config,
|
|
844
|
+
chrome,
|
|
845
|
+
locales: pointerLocales
|
|
846
|
+
},
|
|
847
|
+
blobs: [...blobs.values()]
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
const LOCALE_CONFIG_OVERRIDE_KEYS = ["announcement", "navbarLinks"];
|
|
851
|
+
function isRecord(value) {
|
|
852
|
+
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Diff each translation's config against the base-locale config and keep only
|
|
856
|
+
* the overlay-translatable chrome fields that actually differ. Returns
|
|
857
|
+
* `undefined` when no locale diverges so pointers for untranslated sites are
|
|
858
|
+
* byte-identical to before.
|
|
859
|
+
*/
|
|
860
|
+
function buildLocaleConfigOverrides(baseConfig, inlineTranslations) {
|
|
861
|
+
const base = isRecord(baseConfig) ? baseConfig : {};
|
|
862
|
+
const overrides = {};
|
|
863
|
+
for (const translation of inlineTranslations) {
|
|
864
|
+
if (!isRecord(translation.configJson)) {
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
const translated = translation.configJson;
|
|
868
|
+
const override = {};
|
|
869
|
+
for (const key of LOCALE_CONFIG_OVERRIDE_KEYS) {
|
|
870
|
+
const value = translated[key];
|
|
871
|
+
if (value == null || stableStringify(value) === stableStringify(base[key])) {
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
override[key] = value;
|
|
875
|
+
}
|
|
876
|
+
if (Object.keys(override).length > 0) {
|
|
877
|
+
overrides[translation.locale] = LedgerLocaleConfigOverrideSchema.parse(override);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
|
881
|
+
}
|
|
882
|
+
export function buildLedgerManifest(args) {
|
|
883
|
+
return buildLedgerReadModel(args).pointer;
|
|
884
|
+
}
|
|
885
|
+
//# sourceMappingURL=buildLedgerManifest.js.map
|