@fern-api/fdr-sdk 1.2.165-67370398 → 1.2.165-757da63274
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/docs-ledger/buildLedgerManifest.d.ts +80 -0
- package/dist/docs-ledger/buildLedgerManifest.d.ts.map +1 -0
- package/dist/docs-ledger/buildLedgerManifest.js +885 -0
- package/dist/docs-ledger/buildLedgerManifest.js.map +1 -0
- package/dist/docs-ledger/docsLedgerHash.d.ts +80 -0
- package/dist/docs-ledger/docsLedgerHash.d.ts.map +1 -0
- package/dist/docs-ledger/docsLedgerHash.js +110 -0
- package/dist/docs-ledger/docsLedgerHash.js.map +1 -0
- package/dist/docs-ledger/docsPublishTransform.d.ts +178 -0
- package/dist/docs-ledger/docsPublishTransform.d.ts.map +1 -0
- package/dist/docs-ledger/docsPublishTransform.js +2136 -0
- package/dist/docs-ledger/docsPublishTransform.js.map +1 -0
- package/dist/docs-ledger/index.d.ts +15 -0
- package/dist/docs-ledger/index.d.ts.map +1 -0
- package/dist/docs-ledger/index.js +15 -0
- package/dist/docs-ledger/index.js.map +1 -0
- package/dist/docs-ledger/inputTypes.d.ts +236 -0
- package/dist/docs-ledger/inputTypes.d.ts.map +1 -0
- package/dist/docs-ledger/inputTypes.js +2 -0
- package/dist/docs-ledger/inputTypes.js.map +1 -0
- package/dist/docs-ledger/stableHash.d.ts +7 -0
- package/dist/docs-ledger/stableHash.d.ts.map +1 -0
- package/dist/docs-ledger/stableHash.js +24 -0
- package/dist/docs-ledger/stableHash.js.map +1 -0
- package/dist/docs-ledger/toBasepathRelative.d.ts +25 -0
- package/dist/docs-ledger/toBasepathRelative.d.ts.map +1 -0
- package/dist/docs-ledger/toBasepathRelative.js +34 -0
- package/dist/docs-ledger/toBasepathRelative.js.map +1 -0
- package/dist/js/docs-ledger/index.js +20157 -0
- package/dist/js/docs-ledger/index.js.map +1 -0
- package/dist/js/docs-ledger/index.mjs +20113 -0
- package/dist/js/docs-ledger/index.mjs.map +1 -0
- package/dist/types/docs-ledger/buildLedgerManifest.d.ts +80 -0
- package/dist/types/docs-ledger/buildLedgerManifest.d.ts.map +1 -0
- package/dist/types/docs-ledger/docsLedgerHash.d.ts +80 -0
- package/dist/types/docs-ledger/docsLedgerHash.d.ts.map +1 -0
- package/dist/types/docs-ledger/docsPublishTransform.d.ts +178 -0
- package/dist/types/docs-ledger/docsPublishTransform.d.ts.map +1 -0
- package/dist/types/docs-ledger/index.d.ts +15 -0
- package/dist/types/docs-ledger/index.d.ts.map +1 -0
- package/dist/types/docs-ledger/inputTypes.d.ts +236 -0
- package/dist/types/docs-ledger/inputTypes.d.ts.map +1 -0
- package/dist/types/docs-ledger/stableHash.d.ts +7 -0
- package/dist/types/docs-ledger/stableHash.d.ts.map +1 -0
- package/dist/types/docs-ledger/toBasepathRelative.d.ts +25 -0
- package/dist/types/docs-ledger/toBasepathRelative.d.ts.map +1 -0
- package/package.json +8 -1
|
@@ -0,0 +1,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
|