@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.
Files changed (47) hide show
  1. package/dist/docs-ledger/buildLedgerManifest.d.ts +80 -0
  2. package/dist/docs-ledger/buildLedgerManifest.d.ts.map +1 -0
  3. package/dist/docs-ledger/buildLedgerManifest.js +885 -0
  4. package/dist/docs-ledger/buildLedgerManifest.js.map +1 -0
  5. package/dist/docs-ledger/docsLedgerHash.d.ts +80 -0
  6. package/dist/docs-ledger/docsLedgerHash.d.ts.map +1 -0
  7. package/dist/docs-ledger/docsLedgerHash.js +110 -0
  8. package/dist/docs-ledger/docsLedgerHash.js.map +1 -0
  9. package/dist/docs-ledger/docsPublishTransform.d.ts +178 -0
  10. package/dist/docs-ledger/docsPublishTransform.d.ts.map +1 -0
  11. package/dist/docs-ledger/docsPublishTransform.js +2136 -0
  12. package/dist/docs-ledger/docsPublishTransform.js.map +1 -0
  13. package/dist/docs-ledger/index.d.ts +15 -0
  14. package/dist/docs-ledger/index.d.ts.map +1 -0
  15. package/dist/docs-ledger/index.js +15 -0
  16. package/dist/docs-ledger/index.js.map +1 -0
  17. package/dist/docs-ledger/inputTypes.d.ts +236 -0
  18. package/dist/docs-ledger/inputTypes.d.ts.map +1 -0
  19. package/dist/docs-ledger/inputTypes.js +2 -0
  20. package/dist/docs-ledger/inputTypes.js.map +1 -0
  21. package/dist/docs-ledger/stableHash.d.ts +7 -0
  22. package/dist/docs-ledger/stableHash.d.ts.map +1 -0
  23. package/dist/docs-ledger/stableHash.js +24 -0
  24. package/dist/docs-ledger/stableHash.js.map +1 -0
  25. package/dist/docs-ledger/toBasepathRelative.d.ts +25 -0
  26. package/dist/docs-ledger/toBasepathRelative.d.ts.map +1 -0
  27. package/dist/docs-ledger/toBasepathRelative.js +34 -0
  28. package/dist/docs-ledger/toBasepathRelative.js.map +1 -0
  29. package/dist/js/docs-ledger/index.js +20157 -0
  30. package/dist/js/docs-ledger/index.js.map +1 -0
  31. package/dist/js/docs-ledger/index.mjs +20113 -0
  32. package/dist/js/docs-ledger/index.mjs.map +1 -0
  33. package/dist/types/docs-ledger/buildLedgerManifest.d.ts +80 -0
  34. package/dist/types/docs-ledger/buildLedgerManifest.d.ts.map +1 -0
  35. package/dist/types/docs-ledger/docsLedgerHash.d.ts +80 -0
  36. package/dist/types/docs-ledger/docsLedgerHash.d.ts.map +1 -0
  37. package/dist/types/docs-ledger/docsPublishTransform.d.ts +178 -0
  38. package/dist/types/docs-ledger/docsPublishTransform.d.ts.map +1 -0
  39. package/dist/types/docs-ledger/index.d.ts +15 -0
  40. package/dist/types/docs-ledger/index.d.ts.map +1 -0
  41. package/dist/types/docs-ledger/inputTypes.d.ts +236 -0
  42. package/dist/types/docs-ledger/inputTypes.d.ts.map +1 -0
  43. package/dist/types/docs-ledger/stableHash.d.ts +7 -0
  44. package/dist/types/docs-ledger/stableHash.d.ts.map +1 -0
  45. package/dist/types/docs-ledger/toBasepathRelative.d.ts +25 -0
  46. package/dist/types/docs-ledger/toBasepathRelative.d.ts.map +1 -0
  47. 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