@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,2136 @@
1
+ /**
2
+ * Server-side transform: DocsPublishInput → TransformResult
3
+ *
4
+ * Entry point: {@link transformPublishInput} takes a wire-format DocsPublishInput
5
+ * plus an {@link ApiManifest} and returns a {@link TransformResult} — the flat,
6
+ * hash-addressed entities used by the docs-ledger write pipeline
7
+ * (see `docsLedgerRouter.ts` finish handler and `DocsLedgerDao.persistDeployment`).
8
+ *
9
+ * ## Pipeline overview
10
+ *
11
+ * 1. The caller (finish handler) fetches the API manifest blob from CAS and
12
+ * parses it into an `ApiManifest` (Record<apiDefinitionId, ApiDefinition>).
13
+ * 2. `transformPublishInput` creates a {@link TransformContext} and walks the
14
+ * V1 navigation tree depth-first via `walkRoot` → `walkProductGroup` →
15
+ * `walkVersioned` → `walkTabbed` → `walkSidebarRoot` → `walkSection` /
16
+ * `walkApiSection` / `walkChangelog`.
17
+ * 3. At each leaf, artifact factory methods (`makePageArtifact`,
18
+ * `makeEndpointArtifact`, `makeApiArtifact`, `makeLinkArtifact`,
19
+ * `makeChangelogEntryArtifact`, `makeOverviewArtifact`) produce
20
+ * `CollectedArtifact` entries. API artifacts are pruned inline via
21
+ * `pruneApiContent` → `@fern-api/fdr-sdk/api-definition.prune()`.
22
+ * 4. `createSegment` hashes and stores each segment, then `buildNavEntries`
23
+ * computes fullPath routes (including default-elided variants via
24
+ * `generateFullPaths`).
25
+ * 5. `buildResult` collects global content refs and returns the final
26
+ * `TransformResult`.
27
+ *
28
+ * ## Content model
29
+ *
30
+ * - **Pages** arrive as BlobRefs (hash + contentType + contentLength), not
31
+ * inline markdown. Resolved via `resolvePageBlobRef`.
32
+ * - **API definitions** arrive as a parsed manifest. The transform prunes
33
+ * each API artifact via `pruneApiContent` and serializes the result as a
34
+ * server-generated blob (`trackGeneratedJsonBlob` → `generatedBlobs`).
35
+ * - **Links** are sidebar-only chrome items; they have no CAS content.
36
+ * - **Config/files** are pre-extracted BlobRefs stored as deployment-level globals.
37
+ * - **Redirects** are wired as artifacts (type `"redirect"`) in a dedicated segment.
38
+ * - All structural entities (segment details, artifacts, segments) are hashed
39
+ * inline for CAS dedup. Cross-references use hashes, not array indices.
40
+ * - Nav entries (routing + sidebar ordering) are built inline as segments
41
+ * are created via `buildNavEntries`.
42
+ */
43
+ import { createHash } from "node:crypto";
44
+ import { ORPCError } from "@orpc/client";
45
+ import { kebabCase } from "es-toolkit/string";
46
+ import { prune } from "../api-definition/index.js";
47
+ import { migrate as FernNavigationMigrate, utils as FernNavigationUtils, Slug as LatestSlug, toDefaultSlug, V1 as V1Runtime } from "../navigation/index.js";
48
+ import { GraphQlTypeCategorySchema } from "../orpc-client/index.js";
49
+ import { hashArtifact, hashSegment, hashSegmentDetail } from "./docsLedgerHash.js";
50
+ import { toBasepathRelative } from "./toBasepathRelative.js";
51
+ /**
52
+ * Transform a single locale's content into hash-addressed entities ready for persistence.
53
+ *
54
+ * @param input - One locale entry (content fields) plus the deployment's `domain`.
55
+ * @param apiManifest - Parsed API definitions (fetched from CAS by the caller).
56
+ * Pass an empty object if there are no API references in the nav tree.
57
+ */
58
+ export function transformPublishInput(input, apiManifest = {}) {
59
+ const rawRoot = input.root;
60
+ if (rawRoot == null) {
61
+ throw new ORPCError("BAD_REQUEST", { message: "DocsPublishInput.root (V1 RootNode) is required" });
62
+ }
63
+ if (!isV1RootNode(rawRoot)) {
64
+ const got = describeRootDiscriminants(rawRoot);
65
+ throw new ORPCError("BAD_REQUEST", {
66
+ message: `DocsPublishInput.root must be a V1 RootNode (type="root", version="v1") but got type="${got.type}", version="${got.version}"`
67
+ });
68
+ }
69
+ const ctx = new TransformContext(input, apiManifest);
70
+ ctx.walkRoot(rawRoot);
71
+ return ctx.buildResult(input);
72
+ }
73
+ // ── Helpers ──────────────────────────────────────────────────────────
74
+ /**
75
+ * The shape check the legacy register flow has always performed on the wire
76
+ * root: a `type: "root"`, `version: "v1"` discriminated object. The rest of
77
+ * the V1 tree is intentionally NOT structurally validated here — the wire type
78
+ * is the same `V1.RootNode` the legacy flow stores/serves, and the walk
79
+ * tolerates the same variants.
80
+ */
81
+ function isV1RootNode(value) {
82
+ return (typeof value === "object" &&
83
+ value != null &&
84
+ "type" in value &&
85
+ "version" in value &&
86
+ value.type === "root" &&
87
+ value.version === "v1");
88
+ }
89
+ /** Best-effort read of the `type`/`version` discriminants for error messages. */
90
+ function describeRootDiscriminants(value) {
91
+ if (typeof value !== "object" || value == null) {
92
+ return { type: undefined, version: undefined };
93
+ }
94
+ return {
95
+ type: "type" in value ? value.type : undefined,
96
+ version: "version" in value ? value.version : undefined
97
+ };
98
+ }
99
+ /**
100
+ * Extract the individual slug segment from an accumulated slug.
101
+ *
102
+ * `basePrefix` handles nodes whose published V1 slug omits the site basepath
103
+ * (a node with an explicit `slug:` under a product on a basepath site): the
104
+ * slug still nests under the parent once the basepath is dropped from the
105
+ * parent, so strip it there rather than returning the whole slug — otherwise
106
+ * the read side re-applies the scope prefix on top of a slug that already
107
+ * carries it (`docs/core/core/documentation`).
108
+ */
109
+ function extractSlugSegment(nodeSlug, parentSlug, basePrefix = "") {
110
+ if (!parentSlug) {
111
+ return nodeSlug;
112
+ }
113
+ // skipUrlSlug: node shares its parent's slug (e.g. apiReference that
114
+ // doesn't add a URL segment). Return "" so the section path is not
115
+ // polluted with the full accumulated slug.
116
+ if (nodeSlug === parentSlug) {
117
+ return "";
118
+ }
119
+ const prefix = parentSlug + "/";
120
+ if (nodeSlug.startsWith(prefix)) {
121
+ return nodeSlug.slice(prefix.length);
122
+ }
123
+ const relativeParent = toBasepathRelative(parentSlug, basePrefix);
124
+ if (relativeParent !== "" && relativeParent !== parentSlug) {
125
+ if (nodeSlug === relativeParent) {
126
+ return "";
127
+ }
128
+ if (nodeSlug.startsWith(`${relativeParent}/`)) {
129
+ return nodeSlug.slice(relativeParent.length + 1);
130
+ }
131
+ }
132
+ // Fallback: if the slug isn't accumulated, return as-is
133
+ return nodeSlug;
134
+ }
135
+ /** Join non-empty path parts with '/'. */
136
+ function joinPath(...parts) {
137
+ return parts.filter(Boolean).join("/");
138
+ }
139
+ /** Append a slug segment to a parent section path. */
140
+ function appendSectionPath(parent, slug) {
141
+ if (!slug) {
142
+ return parent;
143
+ }
144
+ return parent ? `${parent}/${slug}` : slug;
145
+ }
146
+ /**
147
+ * Normalise an INTERNAL (root-relative) link `href` to a slug key, or return
148
+ * `null` for external / protocol-relative URLs (which can never be a
149
+ * self-redirect). Used to detect a synthetic link redirect that points at its
150
+ * OWN key — e.g. a home-product link titled "Astro" whose href `/docs/astro`
151
+ * kebab-resolves to the key `docs/astro`, which is also the Astro product's
152
+ * landing slug. Emitting `docs/astro → /docs/astro` both 307-loops and shadows
153
+ * the real product node, so it must be dropped (the node's `pointsTo` then
154
+ * resolves the landing). Mirrors the read path's `canonicalSlugKey` normaliser.
155
+ */
156
+ function internalHrefSlug(href) {
157
+ // Scheme (`https:`) or protocol-relative (`//host`) URLs are external.
158
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) {
159
+ return null;
160
+ }
161
+ const pathOnly = href.split(/[?#]/)[0] ?? href;
162
+ return FernNavigationUtils.canonicalSlugKey(pathOnly.replace(/^\/+|\/+$/g, ""));
163
+ }
164
+ /**
165
+ * Resolve `pointsTo` for a V1 node that has `WithRedirect`.
166
+ * The CLI always sends `pointsTo: undefined`, so we compute it by traversing
167
+ * the node's children to find the first visible page slug.
168
+ */
169
+ function resolvePointsTo(node) {
170
+ const slug = V1Runtime.followRedirect(node);
171
+ return slug != null ? String(slug) : undefined;
172
+ }
173
+ /**
174
+ * Resolve root redirects through the same latest-navigation semantics used by
175
+ * legacy rendering. V1's helper predates page-level auth handling and can point
176
+ * at an authed page; `mutableUpdatePointsTo` on the migrated tree skips authed
177
+ * pages, which is the behavior legacy callers observe.
178
+ */
179
+ function resolveRootPointsTo(root) {
180
+ const latestRoot = FernNavigationMigrate.FernNavigationV1ToLatest.create().root(root);
181
+ FernNavigationUtils.mutableUpdatePointsTo(latestRoot);
182
+ return latestRoot.pointsTo != null ? V1Runtime.Slug(latestRoot.pointsTo) : undefined;
183
+ }
184
+ const EMPTY_SCOPE = {
185
+ productHash: undefined,
186
+ productSlug: undefined,
187
+ productDefault: false,
188
+ versionHash: undefined,
189
+ versionSlug: undefined,
190
+ versionDefault: false,
191
+ variantHash: undefined,
192
+ variantSlug: undefined,
193
+ variantDefault: false,
194
+ tabHash: undefined,
195
+ tabSlug: undefined
196
+ };
197
+ // ── Default-elision route generation ─────────────────────────────────────
198
+ /**
199
+ * Derive all route fullPaths from a node's V1 accumulated slug.
200
+ *
201
+ * The V1 slug is the source of truth for the URL path — it already encodes
202
+ * the correct structure (including custom slug overrides and sections that
203
+ * don't contribute to child URLs). This function strips known default scope
204
+ * prefixes (product, version, variant) from the front of the V1 slug, then
205
+ * generates the power set of elision variants.
206
+ *
207
+ * Replaces the previous reconstruction approach ({@link generateFullPaths})
208
+ * for routing, which broke when `extractSlugSegment` could not strip the
209
+ * section prefix from a custom-slug page's accumulated path.
210
+ *
211
+ * BASEPATH handling (ADR-0014 #6): the `scope.*Slug` entries are basepath-
212
+ * RELATIVE (e.g. `astro/cli`, `v-1-42` — see `extractSlugSegment`), but the V1
213
+ * slug is basepath-ABSOLUTE (begins with the deployment basepath, e.g.
214
+ * `docs/astro/cli/v-1-42/overview`). The prefix checks below match against the
215
+ * relative scope slugs, so on a `/docs` site the leading basepath would never
216
+ * match and NO elision variants would be emitted — leaving the default
217
+ * version's clean URL (`docs/astro/cli/overview`) un-routable (404). To fix
218
+ * this we strip the basepath off the front first, run the elision power-set on
219
+ * the basepath-relative remainder, then re-prepend the basepath to every
220
+ * variant — so the emitted routes stay basepath-ABSOLUTE (matching the `routes`
221
+ * dict / `routing` table) and carry a single `docs/` prefix. Root sites
222
+ * (`basePrefix === ""`) are unaffected (the strip/prepend are no-ops).
223
+ *
224
+ * This is the WRITE half of the #6 fix; it makes the elided routes resolvable
225
+ * in the `routing` table so `getScopedRoot`'s `routeContext` can find their
226
+ * scope. The coordinated READ half lives in `ledger-root-builder.buildVersioned`
227
+ * (the default version node must carry its full versioned slug so
228
+ * `NodeCollector`'s default-version pruning emits the elided alias into the nav
229
+ * `slugMap` that `findNode` gates on).
230
+ */
231
+ function generateFullPathsFromV1Slug(scope, v1Slug, basePrefix) {
232
+ const scopePrefixes = [];
233
+ // The scope slugs are basepath-relative, so strip the basepath off the V1
234
+ // slug before matching them; we re-prepend it to every emitted variant.
235
+ let remaining = toBasepathRelative(v1Slug, basePrefix);
236
+ for (const entry of [
237
+ { slug: scope.productSlug, isDefault: scope.productDefault },
238
+ { slug: scope.versionSlug, isDefault: scope.versionDefault },
239
+ { slug: scope.variantSlug, isDefault: scope.variantDefault }
240
+ ]) {
241
+ if (!entry.slug) {
242
+ continue;
243
+ }
244
+ const prefix = entry.slug + "/";
245
+ if (remaining.startsWith(prefix)) {
246
+ scopePrefixes.push({ slug: entry.slug, isDefault: entry.isDefault });
247
+ remaining = remaining.slice(prefix.length);
248
+ }
249
+ else if (remaining === entry.slug) {
250
+ scopePrefixes.push({ slug: entry.slug, isDefault: entry.isDefault });
251
+ remaining = "";
252
+ break;
253
+ }
254
+ else {
255
+ break;
256
+ }
257
+ }
258
+ const defaultIndices = scopePrefixes.map((p, i) => (p.isDefault ? i : -1)).filter((i) => i >= 0);
259
+ if (defaultIndices.length === 0) {
260
+ return [joinPath(basePrefix, ...scopePrefixes.map((p) => p.slug), remaining)];
261
+ }
262
+ const paths = [];
263
+ for (let mask = 0; mask < 1 << defaultIndices.length; mask++) {
264
+ const elidedSet = new Set();
265
+ for (let j = 0; j < defaultIndices.length; j++) {
266
+ if (mask & (1 << j)) {
267
+ elidedSet.add(defaultIndices[j]);
268
+ }
269
+ }
270
+ const kept = scopePrefixes.filter((_, i) => !elidedSet.has(i)).map((p) => p.slug);
271
+ paths.push(joinPath(basePrefix, ...kept, remaining));
272
+ }
273
+ return paths;
274
+ }
275
+ /**
276
+ * Generate all route fullPaths for an artifact, including default-elided
277
+ * variants. Products, versions, and variants marked as `default` create
278
+ * additional routes with their slug prefix omitted -- the power set of
279
+ * elidable slugs produces all valid path variants.
280
+ *
281
+ * @deprecated Prefer {@link generateFullPathsFromV1Slug} for routing. This
282
+ * function is retained for non-routing uses (link redirect slug computation)
283
+ * where the decomposed scope + section + artifact structure is needed.
284
+ */
285
+ function generateFullPaths(scope, sectionPath, artifactSlug) {
286
+ // Ordered prefix components that may be elidable
287
+ const prefixes = [];
288
+ if (scope.productSlug) {
289
+ prefixes.push({ slug: scope.productSlug, isDefault: scope.productDefault });
290
+ }
291
+ if (scope.versionSlug) {
292
+ prefixes.push({ slug: scope.versionSlug, isDefault: scope.versionDefault });
293
+ }
294
+ if (scope.variantSlug) {
295
+ prefixes.push({ slug: scope.variantSlug, isDefault: scope.variantDefault });
296
+ }
297
+ // Fixed suffix: tab + section + artifact slug (never elided)
298
+ const suffix = joinPath(scope.tabSlug, sectionPath, artifactSlug);
299
+ const defaultIndices = prefixes.map((p, i) => (p.isDefault ? i : -1)).filter((i) => i >= 0);
300
+ if (defaultIndices.length === 0) {
301
+ return [joinPath(...prefixes.map((p) => p.slug), suffix)];
302
+ }
303
+ // Power set over default indices
304
+ const paths = [];
305
+ for (let mask = 0; mask < 1 << defaultIndices.length; mask++) {
306
+ const elidedSet = new Set();
307
+ for (let j = 0; j < defaultIndices.length; j++) {
308
+ if (mask & (1 << j)) {
309
+ elidedSet.add(defaultIndices[j]);
310
+ }
311
+ }
312
+ const kept = prefixes.filter((_, i) => !elidedSet.has(i)).map((p) => p.slug);
313
+ paths.push(joinPath(...kept, suffix));
314
+ }
315
+ return paths;
316
+ }
317
+ // ── Transform context ────────────────────────────────────────────────────
318
+ class TransformContext {
319
+ segmentDetails = [];
320
+ artifacts = [];
321
+ segments = [];
322
+ nav = [];
323
+ siteProducts = [];
324
+ apiDefinitions = [];
325
+ contentRefs = new Map();
326
+ generatedBlobs = new Map();
327
+ fileEntries = [];
328
+ /** Dedup file entries by (hash, fullPath) — see {@link TransformFileEntry}. */
329
+ fileEntryKeys = new Set();
330
+ /** Segment hashes in creation order, for deploymentSegments. */
331
+ segmentOrder = [];
332
+ /**
333
+ * Every real (non-link) routable `fullPath` emitted by any segment.
334
+ * Synthetic `link:` redirects are skipped when they collide with one of
335
+ * these so a real page always wins (write-side dedup, ADR 0013).
336
+ */
337
+ realRoutePaths = new Set();
338
+ /**
339
+ * Synthetic `link:` redirects collected during the walk, materialised in
340
+ * {@link buildResult} once `realRoutePaths` is complete.
341
+ */
342
+ linkRedirectCandidates = [];
343
+ structuralRedirectCandidates = [];
344
+ segmentDetailHashSet = new Set();
345
+ artifactHashSet = new Set();
346
+ apiDefIdSet = new Set();
347
+ pages;
348
+ apiManifest;
349
+ locale;
350
+ assetDomain;
351
+ /**
352
+ * The deployment basepath — the V1 root node's slug, which seeds every
353
+ * accumulated node slug (so real-route `fullPath`s are basepath-ABSOLUTE).
354
+ * Set once in {@link walkRoot}. Synthetic `link:` redirect keys, whose
355
+ * parent slug is reconstructed basepath-RELATIVE via {@link generateFullPaths},
356
+ * are prefixed with this so they match the absolute route keys / `routing`
357
+ * table (see {@link collectSegmentLinkRedirects}).
358
+ */
359
+ basePrefix = "";
360
+ rootPointsTo;
361
+ /**
362
+ * Default-version slug elisions collected during the walk, used to
363
+ * canonicalise the SITE-ROOT structural-redirect target (Postman
364
+ * root-redirect regression; sibling to #13147, #12417). Each
365
+ * entry maps a default version's full accumulated slug (`from`, e.g.
366
+ * `latest-v-12` or `docs/latest-v-12`) to the parent scope prefix it should
367
+ * collapse to (`to`, the enclosing product slug or the deployment basepath).
368
+ *
369
+ * A versioned site's default version can be reached versionlessly (the read
370
+ * path's `toDefaultSlug` / `markDefaultVersionCanonicalSlugs` elision), so
371
+ * the redirect that serves the site root (`/`) must point at that versionless
372
+ * target — otherwise a derived default-version slug (no explicit `slug:`)
373
+ * leaks into the root redirect (`/ → /latest-v-12`) and every version switch
374
+ * from there carries the bad segment. Applied ONLY to the site-root source
375
+ * (`fullPath === basePrefix`) in {@link flushStructuralRedirects}; scoped
376
+ * (product/version/variant) redirect targets keep their full non-elided slug,
377
+ * matching the read-side canonical convention (default product retained,
378
+ * default version elided).
379
+ */
380
+ defaultVersionElisions = [];
381
+ constructor(input, apiManifest) {
382
+ this.pages = input.pages;
383
+ this.apiManifest = apiManifest;
384
+ this.locale = input.locale ?? "en";
385
+ this.assetDomain = input.fileDomain ?? input.domain;
386
+ }
387
+ // ── Generated blob tracking ─────────────────────────────────────
388
+ /** Hash and track a JSON-serializable value as a generated blob for CAS upload. */
389
+ trackGeneratedJsonBlob(value) {
390
+ const json = JSON.stringify(value);
391
+ const bytes = Buffer.from(json, "utf-8");
392
+ const hash = createHash("sha256").update(bytes).digest("hex");
393
+ if (!this.generatedBlobs.has(hash)) {
394
+ this.generatedBlobs.set(hash, {
395
+ ref: { hash, contentType: "application/json", contentLength: bytes.byteLength },
396
+ bytes
397
+ });
398
+ }
399
+ return { hash, contentType: "application/json", contentLength: bytes.byteLength };
400
+ }
401
+ /**
402
+ * Hash and track a raw text value as a generated blob for CAS upload.
403
+ *
404
+ * Used for the four config fields extracted by ADR 0009 (`header`,
405
+ * `footer`, joined `css.inline`, joined `js.inline`) which are raw
406
+ * MDX / CSS / JS strings, not JSON. Stored with `contentType: text/plain`
407
+ * — the loader treats them as opaque byte streams.
408
+ */
409
+ trackGeneratedTextBlob(value) {
410
+ const bytes = Buffer.from(value, "utf-8");
411
+ const hash = createHash("sha256").update(bytes).digest("hex");
412
+ if (!this.generatedBlobs.has(hash)) {
413
+ this.generatedBlobs.set(hash, {
414
+ ref: { hash, contentType: "text/plain", contentLength: bytes.byteLength },
415
+ bytes
416
+ });
417
+ }
418
+ return { hash, contentType: "text/plain", contentLength: bytes.byteLength };
419
+ }
420
+ /**
421
+ * Prune a full API definition for a single node and return a real BlobRef.
422
+ * The pruned JSON bytes are tracked in `generatedBlobs` for the router to upload.
423
+ */
424
+ pruneApiContent(apiDefinitionId, node) {
425
+ const fullDef = this.apiManifest[apiDefinitionId];
426
+ if (fullDef == null) {
427
+ // API definition not in manifest — no content to prune.
428
+ // This happens when the CLI publishes without an apiManifest (e.g. the
429
+ // initial ledger integration that doesn't yet bundle API definitions).
430
+ return null;
431
+ }
432
+ return this.trackGeneratedJsonBlob(prune(fullDef, node));
433
+ }
434
+ /**
435
+ * Serialize a full API definition and track as a generated blob.
436
+ * Returns the content BlobRef for the deployment → API definition join.
437
+ */
438
+ serializeApiDefinition(apiDefinitionId) {
439
+ const fullDef = this.apiManifest[apiDefinitionId];
440
+ if (fullDef == null) {
441
+ return null;
442
+ }
443
+ return this.trackGeneratedJsonBlob(fullDef);
444
+ }
445
+ // ── Deduplicating adders ──────────────────────────────────────────
446
+ /**
447
+ * Deduplicate and store a segment detail (product, version, variant, or tab).
448
+ * Returns the content-addressed hash used as a foreign key in segments.
449
+ */
450
+ addSegmentDetail(detail) {
451
+ const hash = hashSegmentDetail({
452
+ type: detail.type,
453
+ displayName: detail.displayName,
454
+ icon: detail.icon,
455
+ sortOrder: detail.sortOrder,
456
+ metadata: detail.metadata,
457
+ hidden: detail.hidden,
458
+ viewers: detail.viewers,
459
+ orphaned: detail.orphaned,
460
+ featureFlags: detail.featureFlags
461
+ });
462
+ if (!this.segmentDetailHashSet.has(hash)) {
463
+ this.segmentDetailHashSet.add(hash);
464
+ this.segmentDetails.push({ hash, ...detail });
465
+ }
466
+ return hash;
467
+ }
468
+ /**
469
+ * Deduplicate and store an artifact (page, API endpoint, link, changelog
470
+ * entry, file, image, etc.). For CAS-backed content, tracks the BlobRef
471
+ * for upload routing. For file/image artifacts, the caller passes
472
+ * `fileKey` instead — those bytes go to the docs bucket, not CAS.
473
+ *
474
+ * Exactly one of `content` and `fileKey` should be non-null for
475
+ * content-bearing artifacts; both are null for link artifacts.
476
+ *
477
+ * Returns the content-addressed artifact hash.
478
+ */
479
+ addArtifact(artifact) {
480
+ const contentHash = artifact.content?.hash ?? null;
481
+ const fileKey = artifact.fileKey ?? null;
482
+ // Identity hash must distinguish artifacts that resolve to different
483
+ // bytes. We feed the CAS hash for content-backed artifacts and the
484
+ // file hash for file-backed ones (they're both SHA-256 of the bytes,
485
+ // just stored in different tables). For file artifacts, also include
486
+ // `assetDomain` and `fullPath` — the same bytes at different paths or
487
+ // canonical upload domains produce different docs-bucket keys and so
488
+ // must produce different artifacts (the FK points at one specific
489
+ // (domain, hash, fullPath) row).
490
+ const identityContentHash = fileKey != null ? `file:${this.assetDomain}|${fileKey.hash}|${fileKey.fullPath}` : contentHash;
491
+ const hash = hashArtifact({
492
+ type: artifact.type,
493
+ hidden: artifact.hidden,
494
+ metadata: artifact.metadata,
495
+ contentHash: identityContentHash
496
+ });
497
+ if (!this.artifactHashSet.has(hash)) {
498
+ const { content: _content, fileKey: _inputFileKey, ...artifactFields } = artifact;
499
+ this.artifactHashSet.add(hash);
500
+ this.artifacts.push({
501
+ hash,
502
+ ...artifactFields,
503
+ contentHash: fileKey != null ? null : contentHash,
504
+ fileKey
505
+ });
506
+ }
507
+ // Track content reference for CAS — only for CAS-backed artifacts.
508
+ // File/image artifacts land in the docs bucket and are tracked via
509
+ // `fileEntries` instead (see {@link addFileEntry}).
510
+ if (fileKey == null && artifact.content != null && artifact.content.hash !== "") {
511
+ this.contentRefs.set(artifact.content.hash, artifact.content);
512
+ }
513
+ return hash;
514
+ }
515
+ /**
516
+ * Record a file manifest entry for the register/finish handlers.
517
+ * Deduped by `(hash, fullPath)` — the same file at the same path is a
518
+ * single docs_ledger_file row regardless of how many places reference it.
519
+ */
520
+ addFileEntry(entry) {
521
+ const key = `${entry.hash}|${entry.fullPath}`;
522
+ if (this.fileEntryKeys.has(key)) {
523
+ return;
524
+ }
525
+ this.fileEntryKeys.add(key);
526
+ this.fileEntries.push(entry);
527
+ }
528
+ /**
529
+ * Register an API definition for the deployment → API definition join table.
530
+ * Serializes the full definition as a generated blob (deduplicated by ID).
531
+ */
532
+ trackApiDefinition(apiDefinitionId) {
533
+ if (this.apiDefIdSet.has(apiDefinitionId)) {
534
+ return;
535
+ }
536
+ this.apiDefIdSet.add(apiDefinitionId);
537
+ // Serialize the full API definition for the deployment join
538
+ const ref = this.serializeApiDefinition(apiDefinitionId);
539
+ if (ref != null) {
540
+ const apiName = this.apiManifest[apiDefinitionId]?.apiName ?? null;
541
+ this.apiDefinitions.push({ apiDefinitionId, apiName, contentHash: ref.hash });
542
+ this.contentRefs.set(ref.hash, ref);
543
+ }
544
+ }
545
+ // ── Segment creation ──────────────────────────────────────────────
546
+ /**
547
+ * Hash and store a segment — a group of artifacts scoped by product/version/variant/tab
548
+ * at a given section path. Delegates to {@link buildNavEntries} for routing and sidebar
549
+ * ordering, and records the segment in deployment order.
550
+ */
551
+ createSegment(scope, sectionPath, metadata, collected, hidden) {
552
+ const artifactHashes = collected.map((a) => a.artifactHash);
553
+ const displaySortOrder = collected.map((a, order) => ({
554
+ artifactHash: a.artifactHash,
555
+ displaySortOrder: a.childPosition ?? order
556
+ }));
557
+ // Compute routes (skip link artifacts — they're sidebar-only chrome).
558
+ // First-writer-wins: when a fullPath is already claimed by a
559
+ // previously-walked segment (e.g. a parent section's overview), skip
560
+ // the duplicate so the DAO's LIMIT 1 lookup always returns the
561
+ // canonical artifact.
562
+ //
563
+ // Sidebar paths: even when an artifact's routes are all deduped
564
+ // (another segment already claimed the same fullPaths), the sidebar
565
+ // entry still needs a fullPath so the nav link renders the correct
566
+ // href instead of falling back to "/". This matches v2 behavior
567
+ // where multiple nav nodes could share the same target URL.
568
+ const routes = [];
569
+ const sidebarPaths = new Map();
570
+ for (const a of collected) {
571
+ if (a.isLink) {
572
+ continue;
573
+ }
574
+ const paths = a.v1Slug != null
575
+ ? generateFullPathsFromV1Slug(scope, a.v1Slug, this.basePrefix)
576
+ : generateFullPaths(scope, FernNavigationUtils.stripSyntheticSegments(sectionPath), a.slug);
577
+ if (paths.length > 0 && !sidebarPaths.has(a.artifactHash)) {
578
+ sidebarPaths.set(a.artifactHash, paths[0]);
579
+ }
580
+ for (const fullPath of paths) {
581
+ if (this.realRoutePaths.has(fullPath)) {
582
+ continue;
583
+ }
584
+ routes.push({ artifactHash: a.artifactHash, fullPath });
585
+ this.realRoutePaths.add(fullPath);
586
+ }
587
+ }
588
+ const segmentHash = hashSegment({
589
+ locale: this.locale,
590
+ productHash: scope.productHash,
591
+ versionHash: scope.versionHash,
592
+ variantHash: scope.variantHash,
593
+ tabHash: scope.tabHash,
594
+ section: sectionPath,
595
+ metadata,
596
+ hidden,
597
+ artifactHashes,
598
+ displaySortOrder,
599
+ routes
600
+ });
601
+ this.segments.push({
602
+ hash: segmentHash,
603
+ locale: this.locale,
604
+ productHash: scope.productHash,
605
+ versionHash: scope.versionHash,
606
+ variantHash: scope.variantHash,
607
+ tabHash: scope.tabHash,
608
+ section: sectionPath,
609
+ metadata,
610
+ hidden
611
+ });
612
+ // Build nav entries inline
613
+ this.buildNavEntries(segmentHash, collected, routes, sidebarPaths);
614
+ // Collect synthetic `link:` redirects for this segment. The read path
615
+ // historically synthesised `<closest-parent-with-slug>/kebab(title)` →
616
+ // href by walking the reconstructed tree (`collectLinkRedirects`); we
617
+ // compute the same slug here so the route is byte-identical.
618
+ // Materialisation (with real-page-wins dedup) is deferred to
619
+ // `buildResult` once every real route is known.
620
+ this.collectSegmentLinkRedirects(segmentHash, scope, sectionPath, collected);
621
+ this.segmentOrder.push(segmentHash);
622
+ }
623
+ /**
624
+ * Compute the segment's reconstructed node slug — the
625
+ * `closest-parent-with-slug` a `link:` child resolves against in
626
+ * `collectLinkRedirects` over the read-side reconstructed tree
627
+ * (`ledger-root-builder`). Returns the parent slug basepath-RELATIVE
628
+ * (reconstructed from scope/section slugs, which never carry the basepath);
629
+ * {@link collectSegmentLinkRedirects} prefixes `basePrefix` to make the
630
+ * final redirect key absolute and line up with `routeContext` lookups.
631
+ *
632
+ * - **Sectioned segment** (`sectionPath !== ""`): the section node's slug,
633
+ * which the builder takes as the overview route's `fullPath` when an
634
+ * overview exists, else the bare section path
635
+ * (`ledger-root-builder.sectionNode`).
636
+ * - **tabRoot segment** (`sectionPath === ""`): links live directly under
637
+ * the scope's sidebar root, so the closest parent with a slug is the
638
+ * enclosing scope node; its slug is the scope's canonical route prefix
639
+ * (`generateFullPaths(scope, "", "")[0]`, i.e. no default-elision).
640
+ */
641
+ segmentParentSlug(scope, sectionPath, collected) {
642
+ const cleanPath = FernNavigationUtils.stripSyntheticSegments(sectionPath);
643
+ if (cleanPath === "") {
644
+ return generateFullPaths(scope, "", "")[0] ?? "";
645
+ }
646
+ const hasOverview = collected.some((a) => !a.isLink && a.slug === "");
647
+ if (hasOverview) {
648
+ return generateFullPaths(scope, cleanPath, "")[0] ?? cleanPath;
649
+ }
650
+ return cleanPath;
651
+ }
652
+ /**
653
+ * Queue a synthetic `type:"redirect"` route for every `link:` artifact in a
654
+ * segment. Slug derivation mirrors `collectLinkRedirects` exactly: the
655
+ * closest parent's slug + `/` + `kebabCase(title)`, then `canonicalSlugKey`
656
+ * (the read path's percent-decode normaliser). Links with an empty kebab
657
+ * title contribute no route (matching the read-side guard).
658
+ */
659
+ collectSegmentLinkRedirects(segmentHash, scope, sectionPath, collected) {
660
+ const links = collected.filter((a) => a.isLink && a.linkHref != null);
661
+ if (links.length === 0) {
662
+ return;
663
+ }
664
+ const parentSlug = this.segmentParentSlug(scope, sectionPath, collected);
665
+ for (const link of links) {
666
+ const segment = kebabCase(link.linkTitle ?? "");
667
+ if (!segment) {
668
+ continue;
669
+ }
670
+ // `parentSlug` is normally basepath-RELATIVE (reconstructed from
671
+ // scope/section slugs, which don't carry the basepath), but the
672
+ // root/home product (`slug: /`) leaks the basepath into its scope
673
+ // slug. Real route keys and the `routing` table are basepath-
674
+ // ABSOLUTE (derived from each node's V1 accumulated slug, rooted at
675
+ // `root.slug` = the basepath). Normalise the parent to relative,
676
+ // then prefix the basepath exactly once so the synthetic redirect
677
+ // key lines up with both the `routeContext` lookup
678
+ // (`getLinkRedirectHref` queries the absolute request path) and the
679
+ // `realRoutePaths` collision check below — otherwise a `/docs`
680
+ // site's link redirects 404 and the real-page-wins dedup never
681
+ // fires. Root sites (empty basepath) are unaffected: `joinPath`
682
+ // drops the empty prefix.
683
+ const relativeParent = toBasepathRelative(parentSlug, this.basePrefix);
684
+ const fullPath = FernNavigationUtils.canonicalSlugKey(joinPath(this.basePrefix, relativeParent, segment));
685
+ // Drop self-referential redirects (`docs/astro → /docs/astro`): they
686
+ // 307-loop and shadow the real product/version node at that slug.
687
+ // Without the synthetic row, `getLinkRedirectHref` misses and the
688
+ // read path falls through to the node's `pointsTo` (its landing).
689
+ if (internalHrefSlug(link.linkHref) === fullPath) {
690
+ continue;
691
+ }
692
+ this.linkRedirectCandidates.push({
693
+ segmentHash,
694
+ fullPath,
695
+ href: link.linkHref,
696
+ redirectKind: "link"
697
+ });
698
+ }
699
+ }
700
+ /**
701
+ * Emit routable `type:"redirect"` nav rows for every collected
702
+ * {@link LinkRedirectCandidate}, applying ADR 0013's write-side dedup:
703
+ * 1. A real page (or file / config-redirect route) at the same `full_path`
704
+ * always wins — skip the synthetic redirect.
705
+ * 2. Among synthetic redirects, first occurrence at a `full_path` wins
706
+ * (deterministic, matching `collectLinkRedirects`'s `if (!out.has(...))`).
707
+ *
708
+ * Each surviving redirect produces a `type:"redirect"` artifact (no content;
709
+ * `metadata.href` carries the target) attached to its owning segment via a
710
+ * routable nav row — so `routeContext` resolves it inline (page:null,
711
+ * artifactType:"redirect") and the read path issues a 307 to `href`.
712
+ */
713
+ makeRedirectArtifact(candidate) {
714
+ return this.addArtifact({
715
+ type: "redirect",
716
+ hidden: true,
717
+ metadata: stripUndefined({
718
+ href: candidate.href,
719
+ redirectKind: candidate.redirectKind,
720
+ structuralKind: candidate.structuralKind
721
+ }),
722
+ content: null
723
+ });
724
+ }
725
+ emitRedirectRoute(candidate) {
726
+ this.nav.push({
727
+ segmentHash: candidate.segmentHash,
728
+ artifactHash: this.makeRedirectArtifact(candidate),
729
+ fullPath: candidate.fullPath
730
+ });
731
+ this.realRoutePaths.add(candidate.fullPath);
732
+ }
733
+ /**
734
+ * Route keys are basepath-absolute. A `pointsTo` computed from a published
735
+ * V1 slug that omitted the basepath would never match one, silently
736
+ * dropping the node's structural redirect (its landing 404s), so re-apply
737
+ * the prefix when it is missing.
738
+ */
739
+ absolutizeSlug(slug) {
740
+ if (this.basePrefix === "" || slug === this.basePrefix || slug.startsWith(`${this.basePrefix}/`)) {
741
+ return slug;
742
+ }
743
+ return joinPath(this.basePrefix, slug);
744
+ }
745
+ resolveAbsolutePointsTo(node) {
746
+ const pointsTo = resolvePointsTo(node);
747
+ return pointsTo != null ? this.absolutizeSlug(pointsTo) : undefined;
748
+ }
749
+ queueStructuralRedirect(scope, v1Slug, href, overrideExistingRoute, structuralKind) {
750
+ const rawTarget = href != null ? internalHrefSlug(href) : undefined;
751
+ if (href == null || rawTarget == null) {
752
+ return;
753
+ }
754
+ const target = this.absolutizeSlug(rawTarget);
755
+ const resolvedHref = target === rawTarget ? href : target;
756
+ for (const fullPath of generateFullPathsFromV1Slug(scope, v1Slug, this.basePrefix)) {
757
+ if (target !== fullPath) {
758
+ this.structuralRedirectCandidates.push({
759
+ fullPath,
760
+ href: resolvedHref,
761
+ overrideExistingRoute,
762
+ structuralKind
763
+ });
764
+ }
765
+ }
766
+ }
767
+ dropRoutablePath(fullPath) {
768
+ for (let i = this.nav.length - 1; i >= 0; i--) {
769
+ const entry = this.nav[i];
770
+ if (entry.fullPath !== fullPath) {
771
+ continue;
772
+ }
773
+ if (entry.displaySortOrder != null) {
774
+ entry.fullPath = undefined;
775
+ }
776
+ else {
777
+ this.nav.splice(i, 1);
778
+ }
779
+ }
780
+ }
781
+ buildRouteToSegmentHash() {
782
+ const routeToSegmentHash = new Map();
783
+ for (const entry of this.nav) {
784
+ if (entry.fullPath != null) {
785
+ routeToSegmentHash.set(entry.fullPath, entry.segmentHash);
786
+ }
787
+ }
788
+ return routeToSegmentHash;
789
+ }
790
+ /**
791
+ * Collapse default-version slugs out of a SITE-ROOT redirect target so the
792
+ * `/` redirect points at the versionless canonical path (sibling to #13147). Applied
793
+ * only to the site-root source; scoped redirect targets keep their full
794
+ * slug. Idempotent when the target is already elided (`toDefaultSlug` is a
795
+ * no-op once the version prefix is gone), so re-running it on a migrated
796
+ * `rootPointsTo` that already elided the version does nothing.
797
+ */
798
+ canonicalizeRootRedirectTarget(target) {
799
+ let result = target;
800
+ for (const { from, to } of this.defaultVersionElisions) {
801
+ result = toDefaultSlug(LatestSlug(result), LatestSlug(to), LatestSlug(from));
802
+ }
803
+ return result;
804
+ }
805
+ flushStructuralRedirects() {
806
+ const emitted = new Set();
807
+ const routeToSegmentHash = this.buildRouteToSegmentHash();
808
+ for (const candidate of this.structuralRedirectCandidates) {
809
+ const rawTarget = internalHrefSlug(candidate.href);
810
+ // Only the SCOPED aliases (version/product/variant/tab/section) that
811
+ // land on the site-root source (`fullPath === basePrefix`) get their
812
+ // target version-elided: they carry a full, non-elided `pointsTo`, so
813
+ // one elision produces the versionless path. The `"root"` candidate is
814
+ // skipped — its target (`rootPointsTo`) is ALREADY canonical from the
815
+ // migrate/pointsTo resolution, and re-eliding it can double-strip when
816
+ // a non-version segment coincidentally shares the default version's
817
+ // slug (e.g. a section slug equal to the version slug). Scoped sources
818
+ // below basepath keep their full slug so version-scoped URLs still
819
+ // redirect within their own version.
820
+ const canonicalizeTarget = candidate.fullPath === this.basePrefix && candidate.structuralKind !== "root";
821
+ const target = rawTarget != null && canonicalizeTarget ? this.canonicalizeRootRedirectTarget(rawTarget) : rawTarget;
822
+ // A root target that collapses to the root itself is a self-redirect
823
+ // (e.g. `/ → /latest-v-12` where the default version is elided to the
824
+ // basepath). Drop it — the versionless route serves the root directly,
825
+ // or a sibling elided alias fills the slot with a versionless target.
826
+ if (target != null && target === candidate.fullPath) {
827
+ continue;
828
+ }
829
+ const segmentHash = target != null ? routeToSegmentHash.get(target) : undefined;
830
+ if (emitted.has(candidate.fullPath) ||
831
+ segmentHash == null ||
832
+ (!candidate.overrideExistingRoute && this.realRoutePaths.has(candidate.fullPath))) {
833
+ continue;
834
+ }
835
+ emitted.add(candidate.fullPath);
836
+ if (candidate.overrideExistingRoute) {
837
+ this.dropRoutablePath(candidate.fullPath);
838
+ routeToSegmentHash.delete(candidate.fullPath);
839
+ }
840
+ this.emitRedirectRoute({
841
+ segmentHash,
842
+ fullPath: candidate.fullPath,
843
+ // Emit the (possibly elided) target so the 307 Location is
844
+ // versionless for the site root.
845
+ href: canonicalizeTarget && target != null ? target : candidate.href,
846
+ redirectKind: "structural",
847
+ structuralKind: candidate.structuralKind
848
+ });
849
+ routeToSegmentHash.set(candidate.fullPath, segmentHash);
850
+ }
851
+ }
852
+ flushLinkRedirects() {
853
+ const emitted = new Set();
854
+ for (const candidate of this.linkRedirectCandidates) {
855
+ // Real-page-wins: never shadow a routable page/file/config redirect.
856
+ if (this.realRoutePaths.has(candidate.fullPath)) {
857
+ continue;
858
+ }
859
+ // First synthetic redirect at a given full_path wins.
860
+ if (emitted.has(candidate.fullPath)) {
861
+ continue;
862
+ }
863
+ emitted.add(candidate.fullPath);
864
+ this.emitRedirectRoute(candidate);
865
+ }
866
+ }
867
+ /**
868
+ * Create a route-only segment: artifacts are routable by fullPath but have
869
+ * no sidebar presence (no displaySortOrder). Used for infrastructure segments
870
+ * like files that should be findable by path but invisible in nav chrome.
871
+ */
872
+ createRouteOnlySegment(metadata, collected) {
873
+ if (collected.length === 0) {
874
+ return;
875
+ }
876
+ const artifactHashes = collected.map((a) => a.artifactHash);
877
+ const routes = [];
878
+ for (const a of collected) {
879
+ routes.push({ artifactHash: a.artifactHash, fullPath: a.slug });
880
+ this.realRoutePaths.add(a.slug);
881
+ }
882
+ const segmentHash = hashSegment({
883
+ locale: this.locale,
884
+ productHash: undefined,
885
+ versionHash: undefined,
886
+ variantHash: undefined,
887
+ tabHash: undefined,
888
+ section: "",
889
+ metadata,
890
+ hidden: true,
891
+ artifactHashes,
892
+ displaySortOrder: [],
893
+ routes
894
+ });
895
+ this.segments.push({
896
+ hash: segmentHash,
897
+ locale: this.locale,
898
+ section: "",
899
+ metadata,
900
+ hidden: true
901
+ });
902
+ // Route-only nav: fullPath set, no displaySortOrder
903
+ for (const r of routes) {
904
+ this.nav.push({
905
+ segmentHash,
906
+ artifactHash: r.artifactHash,
907
+ fullPath: r.fullPath
908
+ });
909
+ }
910
+ this.segmentOrder.push(segmentHash);
911
+ }
912
+ /**
913
+ * Build nav entries for a segment. Each artifact gets a sidebar entry
914
+ * (with displaySortOrder); each route gets a routable entry (with fullPath).
915
+ * Canonical routes (first per artifact) merge both into a single row.
916
+ *
917
+ * @param navPaths - Maps artifactHash → its first computed fullPath (for
918
+ * sidebar display), even if that path was deduped from the routes array
919
+ * by the cross-segment first-writer-wins guard. This ensures every
920
+ * artifact's sidebar entry gets a valid href.
921
+ *
922
+ * `sidebarPaths` carries the primary fullPath for each artifact computed
923
+ * BEFORE first-writer-wins route dedup. When an artifact's routes are all
924
+ * claimed by a previously-walked segment (e.g. two "Redirects" API
925
+ * subsections sharing the same endpoint slugs), the sidebar entry still
926
+ * receives the correct fullPath so the nav link renders the right href
927
+ * instead of falling back to "/". This matches v2 behavior.
928
+ */
929
+ buildNavEntries(segmentHash, collected, routes, sidebarPaths) {
930
+ // Determine canonical path for each artifact (first route wins)
931
+ const canonicalPaths = new Map();
932
+ for (const r of routes) {
933
+ if (!canonicalPaths.has(r.artifactHash)) {
934
+ canonicalPaths.set(r.artifactHash, r.fullPath);
935
+ }
936
+ }
937
+ const coveredRoutes = new Set();
938
+ // Sidebar entries: one per artifact in order.
939
+ // Canonical routes get both fullPath and displaySortOrder in one row.
940
+ // When an artifact has no canonical route (all deduped), fall back to
941
+ // sidebarPaths so the nav link still has the correct href.
942
+ for (let ai = 0; ai < collected.length; ai++) {
943
+ const a = collected[ai];
944
+ const canonicalPath = canonicalPaths.get(a.artifactHash) ?? sidebarPaths.get(a.artifactHash);
945
+ this.nav.push({
946
+ segmentHash,
947
+ artifactHash: a.artifactHash,
948
+ fullPath: canonicalPath,
949
+ displaySortOrder: a.childPosition ?? ai
950
+ });
951
+ if (canonicalPath != null) {
952
+ coveredRoutes.add(`${segmentHash}:${a.artifactHash}:${canonicalPath}`);
953
+ }
954
+ }
955
+ // Route-only entries: non-canonical routes not already covered
956
+ for (const r of routes) {
957
+ const key = `${segmentHash}:${r.artifactHash}:${r.fullPath}`;
958
+ if (!coveredRoutes.has(key)) {
959
+ this.nav.push({
960
+ segmentHash,
961
+ artifactHash: r.artifactHash,
962
+ fullPath: r.fullPath
963
+ });
964
+ coveredRoutes.add(key);
965
+ }
966
+ }
967
+ }
968
+ // ── Artifact factories ────────────────────────────────────────────
969
+ /**
970
+ * Resolve a page blob ref from the input's pages map.
971
+ * Throws if the pageId is not present — callers must supply all referenced pages.
972
+ */
973
+ resolvePageBlobRef(pageId) {
974
+ const ref = this.pages[pageId];
975
+ if (ref != null) {
976
+ return ref;
977
+ }
978
+ throw new ORPCError("BAD_REQUEST", {
979
+ message: `Page "${pageId}" is referenced in the nav tree but missing from input.pages`
980
+ });
981
+ }
982
+ makePageArtifact(pageId, node, slugPrefix) {
983
+ const slug = extractSlugSegment(String(node.slug), slugPrefix);
984
+ const artifactHash = this.addArtifact({
985
+ type: "markdown",
986
+ hidden: node.hidden ?? false,
987
+ metadata: stripUndefined({
988
+ title: node.title,
989
+ slug,
990
+ icon: node.icon,
991
+ authed: node.authed,
992
+ pageId,
993
+ noindex: node.noindex,
994
+ viewers: node.viewers,
995
+ orphaned: node.orphaned,
996
+ featureFlags: node.featureFlags,
997
+ availability: node.availability
998
+ }),
999
+ content: this.resolvePageBlobRef(pageId)
1000
+ });
1001
+ return { artifactHash, slug, isLink: false, v1Slug: String(node.slug) };
1002
+ }
1003
+ makeOverviewArtifact(pageId, parentNode) {
1004
+ const artifactHash = this.addArtifact({
1005
+ type: "markdown",
1006
+ hidden: parentNode.hidden ?? false,
1007
+ metadata: stripUndefined({
1008
+ title: parentNode.title,
1009
+ slug: "",
1010
+ icon: parentNode.icon,
1011
+ authed: parentNode.authed,
1012
+ pageId,
1013
+ isOverview: true,
1014
+ noindex: parentNode.noindex,
1015
+ viewers: parentNode.viewers,
1016
+ orphaned: parentNode.orphaned,
1017
+ featureFlags: parentNode.featureFlags
1018
+ }),
1019
+ content: this.resolvePageBlobRef(pageId)
1020
+ });
1021
+ // slug "" means the route resolves to the section path itself
1022
+ return { artifactHash, slug: "", isLink: false, v1Slug: String(parentNode.slug) };
1023
+ }
1024
+ /**
1025
+ * Create a CAS artifact for an API node (endpoint, websocket, webhook, grpc, graphql).
1026
+ * All API artifact types share the same structure: extract slug, track API def,
1027
+ * build metadata from the node's common fields + type-specific extras, prune
1028
+ * content. `buildArtifact` receives the common metadata fields and returns the
1029
+ * fully-discriminated `type`/`metadata` pair, so each artifact type's required
1030
+ * metadata is checked against its schema-derived type at the call site.
1031
+ */
1032
+ makeApiArtifact(
1033
+ // `playground` lives on the endpoint/webSocket/apiReference variants but not on
1034
+ // the shared `WithNodeMetadata & WithApiDefinitionId` type, so declare it optionally.
1035
+ node, slugPrefix, buildArtifact, pruneNode) {
1036
+ const slug = extractSlugSegment(String(node.slug), slugPrefix);
1037
+ const apiDefId = String(node.apiDefinitionId);
1038
+ this.trackApiDefinition(apiDefId);
1039
+ const artifactHash = this.addArtifact({
1040
+ ...buildArtifact(stripUndefined({
1041
+ title: node.title,
1042
+ slug,
1043
+ icon: node.icon,
1044
+ authed: node.authed,
1045
+ apiDefinitionId: apiDefId,
1046
+ availability: node.availability,
1047
+ viewers: node.viewers,
1048
+ orphaned: node.orphaned,
1049
+ featureFlags: node.featureFlags,
1050
+ playground: toLedgerPlayground(node.playground),
1051
+ noindex: node.noindex
1052
+ })),
1053
+ hidden: node.hidden ?? false,
1054
+ content: this.pruneApiContent(apiDefId, pruneNode)
1055
+ });
1056
+ return { artifactHash, slug, isLink: false, v1Slug: String(node.slug) };
1057
+ }
1058
+ makeEndpointArtifact(node, slugPrefix, endpointPairKey, isResponseStream) {
1059
+ const resolvedIsResponseStream = isResponseStream ?? node.isResponseStream;
1060
+ const resolvedEndpointPairKey = endpointPairKey ?? node.endpointPairKey;
1061
+ return this.makeApiArtifact(node, slugPrefix, (common) => ({
1062
+ type: "rest",
1063
+ metadata: {
1064
+ ...common,
1065
+ endpointId: String(node.endpointId),
1066
+ method: node.method,
1067
+ ...(resolvedIsResponseStream !== undefined ? { isResponseStream: resolvedIsResponseStream } : {}),
1068
+ ...(resolvedEndpointPairKey != null ? { endpointPairKey: resolvedEndpointPairKey } : {})
1069
+ }
1070
+ }), { type: "endpoint", endpointId: node.endpointId });
1071
+ }
1072
+ makeWebSocketArtifact(node, slugPrefix) {
1073
+ return this.makeApiArtifact(node, slugPrefix, (common) => ({ type: "asyncapi", metadata: { ...common, webSocketId: String(node.webSocketId) } }), { type: "webSocket", webSocketId: node.webSocketId });
1074
+ }
1075
+ makeWebhookArtifact(node, slugPrefix) {
1076
+ return this.makeApiArtifact(node, slugPrefix, (common) => ({
1077
+ type: "webhook",
1078
+ metadata: { ...common, webhookId: String(node.webhookId), method: node.method }
1079
+ }), { type: "webhook", webhookId: node.webhookId });
1080
+ }
1081
+ makeGrpcArtifact(node, slugPrefix) {
1082
+ return this.makeApiArtifact(node, slugPrefix, (common) => ({ type: "grpc", metadata: { ...common, grpcId: String(node.grpcId), method: node.method } }), { type: "grpc", grpcId: node.grpcId });
1083
+ }
1084
+ makeGraphQlArtifact(node, slugPrefix) {
1085
+ // Resolve operationType from the V1 node first, falling back to the
1086
+ // authoritative API definition when the node field is missing at
1087
+ // runtime (e.g. older V1 trees that pre-date the field).
1088
+ const operationType = node.operationType ??
1089
+ this.apiManifest[String(node.apiDefinitionId)]?.graphqlOperations[String(node.graphqlOperationId)]
1090
+ ?.operationType;
1091
+ return this.makeApiArtifact(node, slugPrefix, (common) => ({
1092
+ type: "graphql",
1093
+ metadata: {
1094
+ ...common,
1095
+ graphqlOperationId: String(node.graphqlOperationId),
1096
+ operationType
1097
+ }
1098
+ }), { type: "graphql", graphqlOperationId: node.graphqlOperationId });
1099
+ }
1100
+ makeGraphQlTypeArtifact(node, slugPrefix) {
1101
+ // `typeCategory` cannot be recovered from the API definition — the FDR type
1102
+ // model has no GraphQL kind — and there is no manifest fallback of the sort
1103
+ // `makeGraphQlArtifact` uses for `operationType`. So a missing or misspelled
1104
+ // category has no repair path: reject it here, where the message names the
1105
+ // offending node, rather than letting it reach the ledger and silently drop
1106
+ // the page out of the sidebar at read time (see `apiLeafNodeFromNavRouteInternal`).
1107
+ const typeCategory = GraphQlTypeCategorySchema.safeParse(node.typeCategory);
1108
+ if (!typeCategory.success) {
1109
+ throw new ORPCError("BAD_REQUEST", {
1110
+ message: `graphqlType node "${String(node.slug)}" (typeId "${String(node.typeId)}") has ` +
1111
+ `an invalid typeCategory ${JSON.stringify(node.typeCategory)}; expected one of ` +
1112
+ `${GraphQlTypeCategorySchema.options.join(", ")}`
1113
+ });
1114
+ }
1115
+ return this.makeApiArtifact(node, slugPrefix, (common) => ({
1116
+ type: "graphqlType",
1117
+ metadata: {
1118
+ ...common,
1119
+ typeId: String(node.typeId),
1120
+ typeCategory: typeCategory.data
1121
+ }
1122
+ }), { type: "graphqlType", typeId: node.typeId });
1123
+ }
1124
+ makeLinkArtifact(node) {
1125
+ // Links are sidebar-only chrome — no CAS content, just metadata.
1126
+ const artifactHash = this.addArtifact({
1127
+ type: "link",
1128
+ hidden: false,
1129
+ metadata: stripUndefined({
1130
+ title: node.title,
1131
+ slug: "",
1132
+ icon: node.icon,
1133
+ url: String(node.url),
1134
+ target: node.target
1135
+ }),
1136
+ content: null
1137
+ });
1138
+ // Links don't contribute to their own page route (slug is empty), but
1139
+ // they do get a synthetic `type:"redirect"` route emitted at the end of
1140
+ // the walk — see {@link createSegment} / {@link buildResult}.
1141
+ return { artifactHash, slug: "", isLink: true, linkTitle: node.title, linkHref: String(node.url) };
1142
+ }
1143
+ makeChangelogEntryArtifact(node, slugPrefix) {
1144
+ const pageId = String(node.pageId);
1145
+ const slug = extractSlugSegment(String(node.slug), slugPrefix);
1146
+ const artifactHash = this.addArtifact({
1147
+ type: "changelog",
1148
+ hidden: node.hidden ?? false,
1149
+ metadata: stripUndefined({
1150
+ title: node.title,
1151
+ slug,
1152
+ icon: node.icon,
1153
+ authed: node.authed,
1154
+ pageId,
1155
+ noindex: node.noindex,
1156
+ date: node.date,
1157
+ tags: node.tags,
1158
+ viewers: node.viewers,
1159
+ orphaned: node.orphaned,
1160
+ featureFlags: node.featureFlags
1161
+ }),
1162
+ content: this.resolvePageBlobRef(pageId)
1163
+ });
1164
+ return { artifactHash, slug, isLink: false, v1Slug: String(node.slug) };
1165
+ }
1166
+ /**
1167
+ * Create a file or image artifact from a fileManifest entry.
1168
+ * Files use type "file", images (entries with width/height) use type "image".
1169
+ *
1170
+ * File bytes land in the public/private docs bucket (not CAS); the
1171
+ * artifact's `fileKey` resolves to a `docs_ledger_file` row during
1172
+ * publish. The transform also records a {@link TransformFileEntry} so
1173
+ * the register handler can issue presigned upload URLs and the finish
1174
+ * handler can upsert the file row.
1175
+ */
1176
+ makeFileArtifact(fullPath, entry) {
1177
+ const isImage = entry.width != null && entry.height != null;
1178
+ const type = isImage ? "image" : "file";
1179
+ const metadata = {
1180
+ contentType: entry.contentType,
1181
+ contentLength: entry.contentLength,
1182
+ filename: entry.filename,
1183
+ width: entry.width,
1184
+ height: entry.height,
1185
+ blurDataURL: entry.blurDataURL
1186
+ };
1187
+ this.addFileEntry({
1188
+ fullPath,
1189
+ hash: entry.hash,
1190
+ contentType: entry.contentType,
1191
+ contentLength: entry.contentLength,
1192
+ filename: entry.filename,
1193
+ width: entry.width,
1194
+ height: entry.height,
1195
+ blurDataURL: entry.blurDataURL
1196
+ });
1197
+ const artifactHash = this.addArtifact({
1198
+ type,
1199
+ hidden: true,
1200
+ metadata: stripUndefined(metadata),
1201
+ content: null,
1202
+ fileKey: { hash: entry.hash, fullPath }
1203
+ });
1204
+ // slug is the fullPath itself — nav routing uses fullPath directly
1205
+ return { artifactHash, slug: fullPath, isLink: false };
1206
+ }
1207
+ // ── Tree walking ──────────────────────────────────────────────────
1208
+ //
1209
+ // Depth-first walk of the V1 nav tree. Each level refines the Scope
1210
+ // (product → version → variant → tab) and accumulates artifacts into
1211
+ // segments. The walk order matches sidebar display order.
1212
+ /** Entry point: walk the root node and dispatch to its single child. */
1213
+ walkRoot(root) {
1214
+ const slugPrefix = String(root.slug);
1215
+ // The root slug IS the deployment basepath; remember it so synthetic
1216
+ // link-redirect keys can be made absolute to match real route keys.
1217
+ this.basePrefix = slugPrefix;
1218
+ this.rootPointsTo = root.pointsTo ?? resolveRootPointsTo(root);
1219
+ this.queueStructuralRedirect({ ...EMPTY_SCOPE }, slugPrefix, this.rootPointsTo, false, "root");
1220
+ this.walkRootChild(root.child, { ...EMPTY_SCOPE }, slugPrefix);
1221
+ }
1222
+ walkRootChild(child, scope, slugPrefix) {
1223
+ switch (child.type) {
1224
+ case "productgroup":
1225
+ this.walkProductGroup(child, scope, slugPrefix);
1226
+ break;
1227
+ case "versioned":
1228
+ this.walkVersioned(child, scope, slugPrefix);
1229
+ break;
1230
+ case "unversioned":
1231
+ this.walkUnversioned(child, scope, slugPrefix);
1232
+ break;
1233
+ }
1234
+ }
1235
+ /** Walk a product group: create segment details per product, recurse into each product's children. */
1236
+ walkProductGroup(node, scope, slugPrefix) {
1237
+ // Landing page at the product group level -- uses tabRoot segment type
1238
+ if (node.landingPage) {
1239
+ const artifacts = [this.makePageArtifact(String(node.landingPage.pageId), node.landingPage, slugPrefix)];
1240
+ this.createSegment(scope, "", { type: "tabRoot", landingPage: true }, artifacts, false);
1241
+ }
1242
+ for (let i = 0; i < node.children.length; i++) {
1243
+ const product = node.children[i];
1244
+ if (product.hidden !== true) {
1245
+ this.siteProducts.push(stripUndefined({
1246
+ title: product.title,
1247
+ href: product.type === "productLink" ? String(product.href) : `/${String(product.slug)}/`,
1248
+ subtitle: product.subtitle !== "" ? product.subtitle : undefined
1249
+ }));
1250
+ }
1251
+ if (product.type === "productLink") {
1252
+ // External product -- create segment detail for chrome (product switcher)
1253
+ // and a marker segment so the detail is discoverable via the
1254
+ // segment→detail join in the read path.
1255
+ const detailHash = this.addSegmentDetail({
1256
+ type: "product",
1257
+ displayName: product.title,
1258
+ icon: product.icon,
1259
+ sortOrder: i,
1260
+ metadata: stripUndefined({
1261
+ default: product.default,
1262
+ subtitle: product.subtitle,
1263
+ href: String(product.href),
1264
+ target: product.target,
1265
+ image: product.image ? String(product.image) : undefined
1266
+ }),
1267
+ hidden: product.hidden ?? false,
1268
+ viewers: product.viewers?.map(String) ?? [],
1269
+ orphaned: product.orphaned ?? false,
1270
+ featureFlags: []
1271
+ });
1272
+ const markerScope = {
1273
+ ...scope,
1274
+ productHash: detailHash,
1275
+ productSlug: undefined,
1276
+ productDefault: product.default
1277
+ };
1278
+ this.createSegment(markerScope, "", { type: "productLink" }, [], product.hidden ?? false);
1279
+ continue;
1280
+ }
1281
+ const individualSlug = extractSlugSegment(String(product.slug), slugPrefix, this.basePrefix);
1282
+ const detailHash = this.addSegmentDetail({
1283
+ type: "product",
1284
+ displayName: product.title,
1285
+ icon: product.icon,
1286
+ sortOrder: i,
1287
+ metadata: stripUndefined({
1288
+ slug: individualSlug,
1289
+ default: product.default,
1290
+ subtitle: product.subtitle,
1291
+ image: product.image ? String(product.image) : undefined,
1292
+ announcementText: product.announcement?.text,
1293
+ pointsTo: this.resolveAbsolutePointsTo(product)
1294
+ }),
1295
+ hidden: product.hidden ?? false,
1296
+ viewers: product.viewers?.map(String) ?? [],
1297
+ orphaned: product.orphaned ?? false,
1298
+ featureFlags: product.featureFlags?.map(String) ?? []
1299
+ });
1300
+ const productScope = {
1301
+ ...scope,
1302
+ productHash: detailHash,
1303
+ productSlug: individualSlug,
1304
+ productDefault: product.default
1305
+ };
1306
+ this.queueStructuralRedirect(productScope, String(product.slug), this.resolveAbsolutePointsTo(product), true, "product");
1307
+ this.walkProductChild(product.child, productScope, String(product.slug));
1308
+ }
1309
+ if (node.changelog) {
1310
+ this.walkChangelog(node.changelog, scope, slugPrefix, "");
1311
+ }
1312
+ }
1313
+ walkProductChild(child, scope, slugPrefix) {
1314
+ switch (child.type) {
1315
+ case "versioned":
1316
+ this.walkVersioned(child, scope, slugPrefix);
1317
+ break;
1318
+ case "unversioned":
1319
+ this.walkUnversioned(child, scope, slugPrefix);
1320
+ break;
1321
+ }
1322
+ }
1323
+ /** Walk a versioned node: create segment details per version, handle landing pages, recurse. */
1324
+ walkVersioned(node, scope, slugPrefix) {
1325
+ for (let i = 0; i < node.children.length; i++) {
1326
+ const version = node.children[i];
1327
+ const individualSlug = extractSlugSegment(String(version.slug), slugPrefix, this.basePrefix);
1328
+ const detailHash = this.addSegmentDetail({
1329
+ type: "version",
1330
+ displayName: version.title,
1331
+ icon: version.icon,
1332
+ sortOrder: i,
1333
+ metadata: stripUndefined({
1334
+ slug: individualSlug,
1335
+ default: version.default,
1336
+ availability: version.availability,
1337
+ announcementText: version.announcement?.text,
1338
+ pointsTo: this.resolveAbsolutePointsTo(version)
1339
+ }),
1340
+ hidden: version.hidden ?? false,
1341
+ viewers: version.viewers?.map(String) ?? [],
1342
+ orphaned: version.orphaned ?? false,
1343
+ featureFlags: version.featureFlags?.map(String) ?? []
1344
+ });
1345
+ const versionScope = {
1346
+ ...scope,
1347
+ versionHash: detailHash,
1348
+ versionSlug: individualSlug,
1349
+ versionDefault: version.default
1350
+ };
1351
+ // Record the default version's slug elision so the site-root
1352
+ // redirect target collapses to the versionless path (sibling to #13147).
1353
+ // `slugPrefix` is the enclosing scope (product slug or deployment
1354
+ // basepath), which is exactly the replacement `toDefaultSlug` uses.
1355
+ if (version.default) {
1356
+ this.defaultVersionElisions.push({ from: String(version.slug), to: slugPrefix });
1357
+ }
1358
+ if (!version.landingPage) {
1359
+ this.queueStructuralRedirect(versionScope, String(version.slug), this.resolveAbsolutePointsTo(version), true, "version");
1360
+ }
1361
+ // Version-level landing page -- uses tabRoot segment type
1362
+ if (version.landingPage) {
1363
+ const artifacts = [
1364
+ this.makePageArtifact(String(version.landingPage.pageId), version.landingPage, String(version.slug))
1365
+ ];
1366
+ this.createSegment(versionScope, "", { type: "tabRoot", landingPage: true }, artifacts, false);
1367
+ }
1368
+ this.walkVersionChild(version.child, versionScope, String(version.slug));
1369
+ }
1370
+ }
1371
+ walkUnversioned(node, scope, slugPrefix) {
1372
+ if (node.landingPage) {
1373
+ const artifacts = [this.makePageArtifact(String(node.landingPage.pageId), node.landingPage, slugPrefix)];
1374
+ this.createSegment(scope, "", { type: "tabRoot", landingPage: true }, artifacts, false);
1375
+ }
1376
+ this.walkVersionChild(node.child, scope, slugPrefix);
1377
+ }
1378
+ walkVersionChild(child, scope, slugPrefix) {
1379
+ switch (child.type) {
1380
+ case "tabbed":
1381
+ this.walkTabbed(child, scope, slugPrefix);
1382
+ break;
1383
+ case "sidebarRoot":
1384
+ this.walkSidebarRoot(child, scope, slugPrefix);
1385
+ break;
1386
+ case "varianted":
1387
+ this.walkVarianted(child, scope, slugPrefix);
1388
+ break;
1389
+ }
1390
+ }
1391
+ /** Walk a tabbed node: create segment details per tab, handle tab-level links and changelogs. */
1392
+ walkTabbed(node, scope, slugPrefix) {
1393
+ for (let i = 0; i < node.children.length; i++) {
1394
+ const tabChild = node.children[i];
1395
+ switch (tabChild.type) {
1396
+ case "tab": {
1397
+ const individualSlug = extractSlugSegment(String(tabChild.slug), slugPrefix, this.basePrefix);
1398
+ const detailHash = this.addSegmentDetail({
1399
+ type: "tab",
1400
+ displayName: tabChild.title,
1401
+ icon: tabChild.icon,
1402
+ sortOrder: i,
1403
+ metadata: stripUndefined({
1404
+ slug: individualSlug,
1405
+ pointsTo: this.resolveAbsolutePointsTo(tabChild),
1406
+ tab_type: "tab"
1407
+ }),
1408
+ hidden: tabChild.hidden ?? false,
1409
+ viewers: tabChild.viewers?.map(String) ?? [],
1410
+ orphaned: tabChild.orphaned ?? false,
1411
+ featureFlags: tabChild.featureFlags?.map(String) ?? []
1412
+ });
1413
+ const tabScope = {
1414
+ ...scope,
1415
+ tabHash: detailHash,
1416
+ tabSlug: individualSlug
1417
+ };
1418
+ // A skip-slug tab (slug === enclosing slug) shares the base
1419
+ // path with its sibling tabs and the root. It must NOT emit a
1420
+ // route-OVERRIDING redirect there: doing so drops a real page
1421
+ // that legitimately lives at that path (e.g. the first tab's
1422
+ // landing / root overview). Use a non-destructive redirect so
1423
+ // it only fills the base path when nothing real occupies it.
1424
+ // Tabs that contribute their own slug segment keep the
1425
+ // overriding behavior (their tab-root is theirs to claim).
1426
+ this.queueStructuralRedirect(tabScope, String(tabChild.slug), this.resolveAbsolutePointsTo(tabChild), String(tabChild.slug) !== slugPrefix, "tab");
1427
+ this.walkSidebarRoot(tabChild.child, tabScope, String(tabChild.slug));
1428
+ break;
1429
+ }
1430
+ case "link": {
1431
+ // Tab-level link -- no sidebar content, just a detail for the chrome
1432
+ // and a marker segment so the detail is discoverable via the
1433
+ // segment→detail join in the read path.
1434
+ const linkDetailHash = this.addSegmentDetail({
1435
+ type: "tab",
1436
+ displayName: tabChild.title,
1437
+ icon: tabChild.icon,
1438
+ sortOrder: i,
1439
+ metadata: stripUndefined({
1440
+ url: String(tabChild.url),
1441
+ target: tabChild.target,
1442
+ tab_type: "link"
1443
+ }),
1444
+ hidden: false,
1445
+ viewers: [],
1446
+ orphaned: false,
1447
+ featureFlags: []
1448
+ });
1449
+ const linkTabScope = { ...scope, tabHash: linkDetailHash };
1450
+ this.createSegment(linkTabScope, "", { type: "tabLink" }, [], false);
1451
+ break;
1452
+ }
1453
+ case "changelog": {
1454
+ const individualSlug = extractSlugSegment(String(tabChild.slug), slugPrefix, this.basePrefix);
1455
+ const detailHash = this.addSegmentDetail({
1456
+ type: "tab",
1457
+ displayName: tabChild.title,
1458
+ icon: tabChild.icon,
1459
+ sortOrder: i,
1460
+ metadata: stripUndefined({
1461
+ slug: individualSlug,
1462
+ pointsTo: this.resolveAbsolutePointsTo(tabChild),
1463
+ tab_type: "changelog"
1464
+ }),
1465
+ hidden: tabChild.hidden ?? false,
1466
+ viewers: tabChild.viewers?.map(String) ?? [],
1467
+ orphaned: tabChild.orphaned ?? false,
1468
+ featureFlags: tabChild.featureFlags?.map(String) ?? []
1469
+ });
1470
+ const tabScope = {
1471
+ ...scope,
1472
+ tabHash: detailHash,
1473
+ tabSlug: individualSlug
1474
+ };
1475
+ // See the skip-slug rationale in the "tab" case above.
1476
+ this.queueStructuralRedirect(tabScope, String(tabChild.slug), this.resolveAbsolutePointsTo(tabChild), String(tabChild.slug) !== slugPrefix, "tab");
1477
+ this.walkChangelog(tabChild, tabScope, String(tabChild.slug), "");
1478
+ break;
1479
+ }
1480
+ }
1481
+ }
1482
+ }
1483
+ // ── Variant walking ──────────────────────────────────────────────
1484
+ /**
1485
+ * Walk a varianted node (used both at version-level and inside sidebars).
1486
+ * Each variant becomes a segment detail + scoped child walk.
1487
+ */
1488
+ walkVarianted(node, scope, slugPrefix) {
1489
+ for (let i = 0; i < node.children.length; i++) {
1490
+ const variant = node.children[i];
1491
+ const individualSlug = extractSlugSegment(String(variant.slug), slugPrefix, this.basePrefix);
1492
+ const detailHash = this.addSegmentDetail({
1493
+ type: "variant",
1494
+ displayName: variant.title,
1495
+ icon: variant.icon,
1496
+ sortOrder: i,
1497
+ metadata: stripUndefined({
1498
+ slug: individualSlug,
1499
+ default: variant.default,
1500
+ subtitle: variant.subtitle,
1501
+ image: variant.image ? String(variant.image) : undefined,
1502
+ pointsTo: this.resolveAbsolutePointsTo(variant)
1503
+ }),
1504
+ hidden: variant.hidden ?? false,
1505
+ viewers: variant.viewers?.map(String) ?? [],
1506
+ orphaned: variant.orphaned ?? false,
1507
+ featureFlags: variant.featureFlags?.map(String) ?? []
1508
+ });
1509
+ const variantScope = {
1510
+ ...scope,
1511
+ variantHash: detailHash,
1512
+ variantSlug: individualSlug,
1513
+ variantDefault: variant.default
1514
+ };
1515
+ this.queueStructuralRedirect(variantScope, String(variant.slug), this.resolveAbsolutePointsTo(variant), true, "variant");
1516
+ this.walkVariantChildren(variant.children, variantScope, String(variant.slug));
1517
+ }
1518
+ }
1519
+ walkVariantChildren(children, scope, slugPrefix) {
1520
+ // Pass 1: collect leaf artifacts (pages, links; recurse into sidebar groups).
1521
+ // Use a running offset so childPosition is globally unique across
1522
+ // sidebarGroups (same fix as walkSidebarRoot).
1523
+ const rootArtifacts = [];
1524
+ let offset = 0;
1525
+ for (const child of children) {
1526
+ switch (child.type) {
1527
+ case "page": {
1528
+ const a = this.makePageArtifact(String(child.pageId), child, slugPrefix);
1529
+ a.childPosition = offset;
1530
+ rootArtifacts.push(a);
1531
+ offset++;
1532
+ break;
1533
+ }
1534
+ case "link": {
1535
+ const a = this.makeLinkArtifact(child);
1536
+ a.childPosition = offset;
1537
+ rootArtifacts.push(a);
1538
+ offset++;
1539
+ break;
1540
+ }
1541
+ case "sidebarGroup": {
1542
+ const leaves = this.collectNavLeaves(child.children, slugPrefix);
1543
+ for (const leaf of leaves) {
1544
+ if (leaf.childPosition != null) {
1545
+ leaf.childPosition += offset;
1546
+ }
1547
+ }
1548
+ rootArtifacts.push(...leaves);
1549
+ offset += child.children.length;
1550
+ break;
1551
+ }
1552
+ default:
1553
+ offset++;
1554
+ break;
1555
+ }
1556
+ }
1557
+ if (rootArtifacts.length > 0) {
1558
+ this.createSegment(scope, "", stripUndefined({
1559
+ type: "tabRoot",
1560
+ pointsTo: scope.productHash == null &&
1561
+ scope.versionHash == null &&
1562
+ scope.variantHash == null &&
1563
+ scope.tabHash == null
1564
+ ? this.rootPointsTo
1565
+ : undefined
1566
+ }), rootArtifacts, false);
1567
+ }
1568
+ // Pass 2: walk sub-trees (sections, API refs, changelogs; recurse into sidebar groups).
1569
+ // sidebarDepth=0 marks these as root-level peers so the read side
1570
+ // does not infer parent-child relationships from slug prefixes.
1571
+ //
1572
+ // Direct children (not in a sidebarGroup) with skipUrlSlug
1573
+ // (slug === parent slug) must keep childIndex=undefined so the
1574
+ // read-side hasTrueRootWrapper detection is preserved. Non-skip
1575
+ // children get the global offset for correct interleaving.
1576
+ offset = 0;
1577
+ for (const child of children) {
1578
+ switch (child.type) {
1579
+ case "section": {
1580
+ const skip = String(child.slug) === slugPrefix;
1581
+ this.walkSection(child, scope, slugPrefix, "", skip ? undefined : offset, 0);
1582
+ offset++;
1583
+ break;
1584
+ }
1585
+ case "apiReference": {
1586
+ const skip = String(child.slug) === slugPrefix;
1587
+ this.walkApiSection(child, scope, slugPrefix, "", skip ? undefined : offset, 0);
1588
+ offset++;
1589
+ break;
1590
+ }
1591
+ case "changelog":
1592
+ this.walkChangelog(child, scope, slugPrefix, "");
1593
+ offset++;
1594
+ break;
1595
+ case "sidebarGroup":
1596
+ this.walkNavSubTrees(child.children, scope, slugPrefix, "", 0, offset);
1597
+ offset += child.children.length;
1598
+ break;
1599
+ default:
1600
+ offset++;
1601
+ break;
1602
+ }
1603
+ }
1604
+ }
1605
+ // ── Sidebar walking ───────────────────────────────────────────────
1606
+ /**
1607
+ * Walk the sidebar root: two-pass approach ensures the root segment is
1608
+ * created before child segments (correct sidebar ordering).
1609
+ */
1610
+ walkSidebarRoot(node, scope, slugPrefix) {
1611
+ // Pass 1: collect leaf artifacts from sidebar groups. Sidebar roots
1612
+ // cannot contain pages/links directly in V1, but sidebar-group leaves
1613
+ // still belong to the root segment and participate in root redirect
1614
+ // behavior.
1615
+ //
1616
+ // When non-collapsible sections sit between sidebarGroups at the
1617
+ // sidebarRoot level, groupSidebarRootChildren splits pages into
1618
+ // multiple sidebarGroups. Each group's collectNavLeaves resets
1619
+ // childPosition to 0, causing displaySortOrder collisions in the
1620
+ // tabRoot segment. Track a running offset so every child gets a
1621
+ // globally-unique position that the read-side interleave can sort.
1622
+ const rootArtifacts = [];
1623
+ let offset = 0;
1624
+ for (const child of node.children) {
1625
+ if (child.type === "sidebarGroup") {
1626
+ const leaves = this.collectNavLeaves(child.children, slugPrefix);
1627
+ for (const leaf of leaves) {
1628
+ if (leaf.childPosition != null) {
1629
+ leaf.childPosition += offset;
1630
+ }
1631
+ }
1632
+ rootArtifacts.push(...leaves);
1633
+ offset += child.children.length;
1634
+ }
1635
+ else {
1636
+ offset++;
1637
+ }
1638
+ }
1639
+ if (rootArtifacts.length > 0) {
1640
+ this.createSegment(scope, "", stripUndefined({
1641
+ type: "tabRoot",
1642
+ pointsTo: scope.productHash == null &&
1643
+ scope.versionHash == null &&
1644
+ scope.variantHash == null &&
1645
+ scope.tabHash == null
1646
+ ? this.rootPointsTo
1647
+ : undefined
1648
+ }), rootArtifacts, false);
1649
+ }
1650
+ // Pass 2: walk sub-trees in child order with global offsets so
1651
+ // section childIndex values align with the leaf displaySortOrder
1652
+ // values from Pass 1, enabling correct interleaving on the read side.
1653
+ offset = 0;
1654
+ for (const child of node.children) {
1655
+ switch (child.type) {
1656
+ case "sidebarGroup":
1657
+ this.walkNavSubTrees(child.children, scope, slugPrefix, "", 0, offset);
1658
+ offset += child.children.length;
1659
+ break;
1660
+ case "section": {
1661
+ const skip = String(child.slug) === slugPrefix;
1662
+ this.walkSection(child, scope, slugPrefix, "", skip ? undefined : offset, 0);
1663
+ offset++;
1664
+ break;
1665
+ }
1666
+ case "apiReference": {
1667
+ const skip = String(child.slug) === slugPrefix;
1668
+ this.walkApiSection(child, scope, slugPrefix, "", skip ? undefined : offset, 0);
1669
+ offset++;
1670
+ break;
1671
+ }
1672
+ case "varianted":
1673
+ this.walkVarianted(child, scope, slugPrefix);
1674
+ offset++;
1675
+ break;
1676
+ }
1677
+ }
1678
+ }
1679
+ /** Collect leaf artifacts (pages, links) from navigation children, tracking position. */
1680
+ collectNavLeaves(children, slugPrefix, overviewPageId) {
1681
+ const artifacts = [];
1682
+ for (let i = 0; i < children.length; i++) {
1683
+ const child = children[i];
1684
+ switch (child.type) {
1685
+ case "page": {
1686
+ const pageId = String(child.pageId);
1687
+ if (overviewPageId != null && pageId === overviewPageId) {
1688
+ break;
1689
+ }
1690
+ const a = this.makePageArtifact(pageId, child, slugPrefix);
1691
+ a.childPosition = i;
1692
+ artifacts.push(a);
1693
+ break;
1694
+ }
1695
+ case "link": {
1696
+ const a = this.makeLinkArtifact(child);
1697
+ a.childPosition = i;
1698
+ artifacts.push(a);
1699
+ break;
1700
+ }
1701
+ }
1702
+ }
1703
+ return artifacts;
1704
+ }
1705
+ /** Walk sub-tree navigation children (sections, API refs, changelogs, variants). */
1706
+ walkNavSubTrees(children, scope, slugPrefix, parentSectionPath, sidebarDepth, childIndexOffset) {
1707
+ const base = childIndexOffset ?? 0;
1708
+ for (let i = 0; i < children.length; i++) {
1709
+ const child = children[i];
1710
+ switch (child.type) {
1711
+ case "section":
1712
+ this.walkSection(child, scope, slugPrefix, parentSectionPath, base + i, sidebarDepth);
1713
+ break;
1714
+ case "apiReference":
1715
+ this.walkApiSection(child, scope, slugPrefix, parentSectionPath, base + i, sidebarDepth);
1716
+ break;
1717
+ case "changelog":
1718
+ this.walkChangelog(child, scope, slugPrefix, parentSectionPath, undefined, base + i);
1719
+ break;
1720
+ case "varianted":
1721
+ this.walkVarianted(child, scope, slugPrefix);
1722
+ break;
1723
+ }
1724
+ }
1725
+ }
1726
+ /** Walk a section: collect overview + leaf children, create segment, then walk sub-trees. */
1727
+ walkSection(node, scope, slugPrefix, parentSectionPath, childIndex, sidebarDepth) {
1728
+ const individualSlug = extractSlugSegment(String(node.slug), slugPrefix);
1729
+ const effectiveSlug = individualSlug === "" && childIndex != null
1730
+ ? `${FernNavigationUtils.SKIP_URL_SLUG_PREFIX}${childIndex}`
1731
+ : individualSlug;
1732
+ const sectionPath = appendSectionPath(parentSectionPath, effectiveSlug);
1733
+ const currentPrefix = String(node.slug);
1734
+ const pointsTo = node.pointsTo != null ? String(node.pointsTo) : resolvePointsTo(node);
1735
+ this.queueStructuralRedirect(scope, String(node.slug), pointsTo, false, "section");
1736
+ const ovPageId = node.overviewPageId ? String(node.overviewPageId) : undefined;
1737
+ // Overview occupies position -1 so it sorts before children (whose
1738
+ // childPosition starts at 0) without colliding in displaySortOrder.
1739
+ const sectionArtifacts = [
1740
+ ...(ovPageId != null ? [{ ...this.makeOverviewArtifact(ovPageId, node), childPosition: -1 }] : []),
1741
+ ...this.collectNavLeaves(node.children, currentPrefix, ovPageId)
1742
+ ];
1743
+ const hasSubTrees = node.children.some((c) => c.type === "section" || c.type === "apiReference" || c.type === "changelog");
1744
+ if (sectionArtifacts.length > 0 || hasSubTrees) {
1745
+ this.createSegment(scope, sectionPath, stripUndefined({
1746
+ type: "section",
1747
+ title: node.title,
1748
+ icon: node.icon,
1749
+ authed: node.authed,
1750
+ collapsed: node.collapsed,
1751
+ collapsible: node.collapsible,
1752
+ collapsedByDefault: node.collapsedByDefault,
1753
+ availability: node.availability,
1754
+ overviewPageId: node.overviewPageId ? String(node.overviewPageId) : undefined,
1755
+ noindex: node.noindex,
1756
+ pointsTo,
1757
+ childIndex,
1758
+ sidebarDepth,
1759
+ viewers: node.viewers,
1760
+ orphaned: node.orphaned,
1761
+ featureFlags: node.featureFlags
1762
+ }), sectionArtifacts, node.hidden ?? false);
1763
+ }
1764
+ const nextDepth = sidebarDepth != null ? sidebarDepth + 1 : undefined;
1765
+ this.walkNavSubTrees(node.children, scope, currentPrefix, sectionPath, nextDepth);
1766
+ }
1767
+ // ── API reference / package walking ───────────────────────────────
1768
+ /**
1769
+ * Walk an API reference or API package node. Both share the same structure:
1770
+ * compute section path, track API def, collect overview + leaf children,
1771
+ * create segment, then walk sub-packages and changelog.
1772
+ */
1773
+ walkApiSection(node, scope, slugPrefix, parentSectionPath, childIndex, sidebarDepth) {
1774
+ const individualSlug = extractSlugSegment(String(node.slug), slugPrefix);
1775
+ const effectiveSlug = individualSlug === "" && childIndex != null
1776
+ ? `${FernNavigationUtils.SKIP_URL_SLUG_PREFIX}${childIndex}`
1777
+ : individualSlug;
1778
+ const sectionPath = appendSectionPath(parentSectionPath, effectiveSlug);
1779
+ const currentPrefix = String(node.slug);
1780
+ this.trackApiDefinition(String(node.apiDefinitionId));
1781
+ const pointsTo = node.pointsTo != null ? String(node.pointsTo) : resolvePointsTo(node);
1782
+ this.queueStructuralRedirect(scope, String(node.slug), pointsTo, false, "api-section");
1783
+ // Pass 1: collect leaf API artifacts (endpoints, webhooks, etc.)
1784
+ const artifacts = [];
1785
+ const ovPageId = node.overviewPageId ? String(node.overviewPageId) : undefined;
1786
+ if (ovPageId != null) {
1787
+ // Overview occupies position -1 so it sorts before children (whose
1788
+ // childPosition starts at 0) without colliding in displaySortOrder.
1789
+ artifacts.push({ ...this.makeOverviewArtifact(ovPageId, node), childPosition: -1 });
1790
+ }
1791
+ for (let i = 0; i < node.children.length; i++) {
1792
+ const child = node.children[i];
1793
+ switch (child.type) {
1794
+ case "endpoint":
1795
+ artifacts.push({ ...this.makeEndpointArtifact(child, currentPrefix), childPosition: i });
1796
+ break;
1797
+ case "endpointPair": {
1798
+ const pairKey = String(child.nonStream.endpointId);
1799
+ artifacts.push({
1800
+ ...this.makeEndpointArtifact(child.nonStream, currentPrefix, pairKey, false),
1801
+ childPosition: i
1802
+ });
1803
+ artifacts.push({
1804
+ ...this.makeEndpointArtifact(child.stream, currentPrefix, pairKey, true),
1805
+ childPosition: i
1806
+ });
1807
+ break;
1808
+ }
1809
+ case "webSocket":
1810
+ artifacts.push({ ...this.makeWebSocketArtifact(child, currentPrefix), childPosition: i });
1811
+ break;
1812
+ case "webhook":
1813
+ artifacts.push({ ...this.makeWebhookArtifact(child, currentPrefix), childPosition: i });
1814
+ break;
1815
+ case "grpc":
1816
+ artifacts.push({ ...this.makeGrpcArtifact(child, currentPrefix), childPosition: i });
1817
+ break;
1818
+ case "graphql":
1819
+ artifacts.push({ ...this.makeGraphQlArtifact(child, currentPrefix), childPosition: i });
1820
+ break;
1821
+ case "graphqlType":
1822
+ artifacts.push({ ...this.makeGraphQlTypeArtifact(child, currentPrefix), childPosition: i });
1823
+ break;
1824
+ case "page": {
1825
+ const pageId = String(child.pageId);
1826
+ if (ovPageId != null && pageId === ovPageId) {
1827
+ break;
1828
+ }
1829
+ artifacts.push({
1830
+ ...this.makePageArtifact(pageId, child, currentPrefix),
1831
+ childPosition: i
1832
+ });
1833
+ break;
1834
+ }
1835
+ case "link":
1836
+ artifacts.push({ ...this.makeLinkArtifact(child), childPosition: i });
1837
+ break;
1838
+ }
1839
+ }
1840
+ const metadata = stripUndefined({
1841
+ type: node.type,
1842
+ title: node.title,
1843
+ icon: node.icon,
1844
+ authed: node.authed,
1845
+ collapsed: node.collapsed,
1846
+ apiDefinitionId: String(node.apiDefinitionId),
1847
+ overviewPageId: node.overviewPageId ? String(node.overviewPageId) : undefined,
1848
+ noindex: node.noindex,
1849
+ availability: node.availability,
1850
+ pointsTo,
1851
+ childIndex,
1852
+ sidebarDepth,
1853
+ viewers: node.viewers,
1854
+ orphaned: node.orphaned,
1855
+ featureFlags: node.featureFlags,
1856
+ playground: toLedgerPlayground(node.playground),
1857
+ // ApiReference-specific fields (undefined values stripped for apiPackage)
1858
+ ...(node.type === "apiReference"
1859
+ ? { paginated: node.paginated, showErrors: node.showErrors, hideTitle: node.hideTitle }
1860
+ : {})
1861
+ });
1862
+ const hasApiPackageChildren = node.children.some((c) => c.type === "apiPackage");
1863
+ const hasChangelog = node.type === "apiReference" && node.changelog != null;
1864
+ if (artifacts.length > 0 || hasApiPackageChildren || hasChangelog) {
1865
+ this.createSegment(scope, sectionPath, metadata, artifacts, node.hidden ?? false);
1866
+ }
1867
+ // Pass 2: walk sub-packages (the only sub-tree in API packages)
1868
+ const nextDepth = sidebarDepth != null ? sidebarDepth + 1 : undefined;
1869
+ for (let i = 0; i < node.children.length; i++) {
1870
+ const child = node.children[i];
1871
+ if (child.type === "apiPackage") {
1872
+ this.walkApiSection(child, scope, currentPrefix, sectionPath, i, nextDepth);
1873
+ }
1874
+ }
1875
+ if (node.type === "apiReference" && node.changelog) {
1876
+ this.walkChangelog(node.changelog, scope, currentPrefix, sectionPath, String(node.apiDefinitionId));
1877
+ }
1878
+ }
1879
+ // ── Changelog walking ─────────────────────────────────────────────
1880
+ /** Walk a changelog: flatten year/month/entry hierarchy into a single segment. */
1881
+ walkChangelog(node, scope, slugPrefix, parentSectionPath, apiDefinitionId, childIndex) {
1882
+ const individualSlug = extractSlugSegment(String(node.slug), slugPrefix);
1883
+ const sectionPath = appendSectionPath(parentSectionPath, individualSlug);
1884
+ const currentPrefix = String(node.slug);
1885
+ const artifacts = [];
1886
+ // Overview page — position -1 so it sorts before entries without
1887
+ // colliding in displaySortOrder (entries start at 0).
1888
+ if (node.overviewPageId) {
1889
+ artifacts.push({ ...this.makeOverviewArtifact(String(node.overviewPageId), node), childPosition: -1 });
1890
+ }
1891
+ // Flatten year/month/entry hierarchy. Each entry gets an explicit
1892
+ // childPosition so displaySortOrder doesn't rely on array-index
1893
+ // fallback — keeps ordering deterministic across re-publishes.
1894
+ let entryPos = 0;
1895
+ for (const year of node.children) {
1896
+ for (const month of year.children) {
1897
+ for (const entry of month.children) {
1898
+ artifacts.push({
1899
+ ...this.makeChangelogEntryArtifact(entry, currentPrefix),
1900
+ childPosition: entryPos
1901
+ });
1902
+ entryPos++;
1903
+ }
1904
+ }
1905
+ }
1906
+ if (artifacts.length > 0) {
1907
+ this.createSegment(scope, sectionPath, stripUndefined({
1908
+ type: "changelog",
1909
+ title: node.title,
1910
+ icon: node.icon,
1911
+ overviewPageId: node.overviewPageId ? String(node.overviewPageId) : undefined,
1912
+ noindex: node.noindex,
1913
+ authed: node.authed,
1914
+ viewers: node.viewers,
1915
+ orphaned: node.orphaned,
1916
+ featureFlags: node.featureFlags,
1917
+ apiDefinitionId,
1918
+ childIndex
1919
+ }), artifacts, node.hidden ?? false);
1920
+ }
1921
+ }
1922
+ // ── Final assembly ───────────────────────────────────────────────
1923
+ /**
1924
+ * Build the TransformResult from collected structural data.
1925
+ * Collects global content refs and assembles deployment segments.
1926
+ */
1927
+ buildResult(input) {
1928
+ // ADR 0009: split the config into (a) structural JSONB inlined on
1929
+ // the deployment row, and (b) four extracted content rows for the
1930
+ // variable-length fields (`header`, `footer`, `css.inline`,
1931
+ // `js.inline`). ADR 0016 re-inlines those text fields into the S3
1932
+ // pointer so site chrome does not add page-render reads.
1933
+ let configJson;
1934
+ let headerBlobRef;
1935
+ let footerBlobRef;
1936
+ let cssInlineBlobRef;
1937
+ let jsInlineBlobRef;
1938
+ if (input.config != null) {
1939
+ const { stripped, header, footer, cssInline, jsInline } = splitConfig(input.config);
1940
+ configJson = { ...stripped, rootSlug: this.basePrefix };
1941
+ if (header !== undefined) {
1942
+ headerBlobRef = this.trackGeneratedTextBlob(header);
1943
+ }
1944
+ if (footer !== undefined) {
1945
+ footerBlobRef = this.trackGeneratedTextBlob(footer);
1946
+ }
1947
+ if (cssInline !== undefined) {
1948
+ cssInlineBlobRef = this.trackGeneratedTextBlob(cssInline);
1949
+ }
1950
+ if (jsInline !== undefined) {
1951
+ jsInlineBlobRef = this.trackGeneratedTextBlob(jsInline);
1952
+ }
1953
+ }
1954
+ else {
1955
+ configJson = { rootSlug: this.basePrefix };
1956
+ }
1957
+ // Track global content refs (files, jsFiles, apiManifest)
1958
+ for (const ref of [input.jsFiles, input.apiManifest]) {
1959
+ if (ref != null) {
1960
+ this.contentRefs.set(ref.hash, ref);
1961
+ }
1962
+ }
1963
+ // Wire redirects as a first-class artifact in a dedicated segment.
1964
+ // The segment uses EMPTY_SCOPE (no product/version/variant/tab) and an
1965
+ // empty section path — no sentinel value is needed because the segment
1966
+ // is identified by its content-addressed hash, which is unique to this
1967
+ // combination of scope, metadata ({ type: "redirects" }), and the
1968
+ // redirect artifact's content hash.
1969
+ if (input.redirects != null) {
1970
+ const redirectsRef = input.redirects;
1971
+ this.contentRefs.set(redirectsRef.hash, redirectsRef);
1972
+ const artifactHash = this.addArtifact({
1973
+ type: "redirect",
1974
+ hidden: true,
1975
+ metadata: {},
1976
+ content: redirectsRef
1977
+ });
1978
+ this.createRouteOnlySegment({ type: "redirects" }, [{ artifactHash, slug: "", isLink: false }]);
1979
+ }
1980
+ // Wire file manifest entries as artifacts in a dedicated "files" segment.
1981
+ // Files are route-only (fullPath set, no displaySortOrder) — they must be
1982
+ // findable by path but must not appear in sidebar navigation or nav chrome.
1983
+ if (input.fileManifest != null) {
1984
+ const entries = Object.entries(input.fileManifest);
1985
+ if (entries.length > 0) {
1986
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
1987
+ const fileArtifacts = [];
1988
+ for (const [fullPath, entry] of entries) {
1989
+ fileArtifacts.push(this.makeFileArtifact(fullPath, entry));
1990
+ }
1991
+ this.createRouteOnlySegment({ type: "files" }, fileArtifacts);
1992
+ }
1993
+ }
1994
+ // Materialise structural redirects before synthetic `link:` redirects.
1995
+ // Structural rows are existing flat routes (`type:"redirect"` artifacts)
1996
+ // for implicit product/version/tab URLs whose legacy pointsTo is derived
1997
+ // from the full navigation tree.
1998
+ this.flushStructuralRedirects();
1999
+ // Materialise synthetic `link:` redirects now that every real route is
2000
+ // known (pages + files + config redirects). ADR 0013: each `link:` node
2001
+ // becomes a routable `type:"redirect"` row at
2002
+ // `<closest-parent-with-slug>/kebab(title)` → href, EXCEPT where a real
2003
+ // page already owns that `full_path` (real-page-wins dedup). Emitted
2004
+ // last so the dedup set is complete.
2005
+ this.flushLinkRedirects();
2006
+ // Track generated blobs as content refs too
2007
+ for (const [hash, blob] of this.generatedBlobs) {
2008
+ this.contentRefs.set(hash, blob.ref);
2009
+ }
2010
+ return {
2011
+ segmentDetails: this.segmentDetails,
2012
+ artifacts: this.artifacts,
2013
+ segments: this.segments,
2014
+ nav: this.nav,
2015
+ deploymentSegments: this.segmentOrder.map((segmentHash, sortOrder) => ({ segmentHash, sortOrder })),
2016
+ apiDefinitions: this.apiDefinitions,
2017
+ siteProducts: this.siteProducts,
2018
+ jsFilesHash: input.jsFiles?.hash,
2019
+ configJson,
2020
+ headerHash: headerBlobRef?.hash,
2021
+ footerHash: footerBlobRef?.hash,
2022
+ cssInlineHash: cssInlineBlobRef?.hash,
2023
+ jsInlineHash: jsInlineBlobRef?.hash,
2024
+ domain: input.domain,
2025
+ fileDomain: input.fileDomain ?? input.domain,
2026
+ version: input.version,
2027
+ repo: input.repo,
2028
+ git: input.git,
2029
+ contentRefs: this.contentRefs,
2030
+ generatedBlobs: this.generatedBlobs,
2031
+ fileEntries: this.fileEntries,
2032
+ referencedFilesByPageId: collectReferencedFilesByPageId(input.pages)
2033
+ };
2034
+ }
2035
+ }
2036
+ /**
2037
+ * Project the publish input's `pages` map into `pageId → referencedFiles`,
2038
+ * dropping pages with no embedded files (ADR 0016). Keys mirror the `pages`
2039
+ * record's keys (the `pageId`), which is exactly how artifacts carry
2040
+ * `metadata.pageId`, so the read model can join routes to their files.
2041
+ */
2042
+ function collectReferencedFilesByPageId(pages) {
2043
+ const out = {};
2044
+ for (const [pageId, page] of Object.entries(pages)) {
2045
+ const refs = page?.referencedFiles;
2046
+ if (refs != null && refs.length > 0) {
2047
+ out[pageId] = refs;
2048
+ }
2049
+ }
2050
+ return out;
2051
+ }
2052
+ // ── Utility functions ────────────────────────────────────────────────────
2053
+ /** Remove keys with undefined values from an object. */
2054
+ function stripUndefined(obj) {
2055
+ const result = {};
2056
+ for (const [key, value] of Object.entries(obj)) {
2057
+ if (value !== undefined) {
2058
+ result[key] = value;
2059
+ }
2060
+ }
2061
+ return result;
2062
+ }
2063
+ /**
2064
+ * Coerce a nav node's `playground` (branded Fern types) into the plain ledger
2065
+ * metadata shape written into segment / artifact metadata. `stripUndefined` drops
2066
+ * absent options at runtime, so the resulting object matches the all-optional
2067
+ * schema type — the cast just widens the branded `EnvironmentId`/`Url` to strings.
2068
+ */
2069
+ function toLedgerPlayground(pg) {
2070
+ if (pg == null) {
2071
+ return undefined;
2072
+ }
2073
+ return stripUndefined({
2074
+ hidden: pg.hidden,
2075
+ environments: pg.environments,
2076
+ button: pg.button?.href != null ? { href: String(pg.button.href) } : undefined,
2077
+ "limit-websocket-messages-per-connection": pg["limit-websocket-messages-per-connection"],
2078
+ "send-optional-defaults": pg["send-optional-defaults"]
2079
+ });
2080
+ }
2081
+ /**
2082
+ * Split a {@link LedgerConfig} into the structural portion (to be written
2083
+ * inline as JSONB) and the four variable-length fields that are extracted
2084
+ * to their own CAS blobs (ADR 0009).
2085
+ *
2086
+ * - `header`, `footer`: returned as the raw strings if non-empty.
2087
+ * - `css.inline`, `js.inline`: arrays of strings, joined with `\n` into a
2088
+ * single text blob if non-empty. The `inline` key is removed from `css`
2089
+ * and `js` in the stripped config; any other sibling fields (`js.files`,
2090
+ * `js.remote`) are preserved. `css.inline` is currently the only field
2091
+ * on `css`, so `stripped.css` is dropped in practice — the empty-keys
2092
+ * check is kept for symmetry with `js` and to stay correct if the
2093
+ * schema grows new `css` siblings.
2094
+ *
2095
+ * The returned `stripped` object is a shallow-cloned subset of `config` so
2096
+ * that callers can safely mutate it without affecting the original.
2097
+ *
2098
+ * The input has already been validated against `LedgerConfigSchema` by
2099
+ * Zod upstream — no runtime shape checks are needed here.
2100
+ *
2101
+ * Exported for testing.
2102
+ *
2103
+ * @internal
2104
+ */
2105
+ export function splitConfig(config) {
2106
+ const { header: rawHeader, footer: rawFooter, css: rawCss, js: rawJs, ...rest } = config;
2107
+ const stripped = { ...rest };
2108
+ let cssInline;
2109
+ if (rawCss != null) {
2110
+ const { inline, ...cssRest } = rawCss;
2111
+ if (inline != null && inline.length > 0) {
2112
+ cssInline = inline.join("\n");
2113
+ }
2114
+ if (Object.keys(cssRest).length > 0) {
2115
+ stripped.css = cssRest;
2116
+ }
2117
+ }
2118
+ let jsInline;
2119
+ if (rawJs != null) {
2120
+ const { inline, ...jsRest } = rawJs;
2121
+ if (inline != null && inline.length > 0) {
2122
+ jsInline = inline.join("\n");
2123
+ }
2124
+ if (Object.keys(jsRest).length > 0) {
2125
+ stripped.js = jsRest;
2126
+ }
2127
+ }
2128
+ return {
2129
+ stripped,
2130
+ header: rawHeader != null && rawHeader.length > 0 ? rawHeader : undefined,
2131
+ footer: rawFooter != null && rawFooter.length > 0 ? rawFooter : undefined,
2132
+ cssInline,
2133
+ jsInline
2134
+ };
2135
+ }
2136
+ //# sourceMappingURL=docsPublishTransform.js.map