@writedocs/generator 0.1.0 → 0.2.1

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/src/lib/config.ts CHANGED
@@ -1,2131 +1,1306 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import matter from 'gray-matter';
4
- import { z } from 'astro/zod';
5
- import { writedocsTempDir } from './writedocs-temp-dir.js';
6
-
7
- // ---------------------------------------------------------------------
8
- // Pages & groups - the leaf content any container ultimately bottoms out
9
- // at. A group can nest other groups arbitrarily deep, and can optionally
10
- // link its own label to a page (`page`) independent of expanding or
11
- // collapsing its children - see NavTree.astro for how that renders.
12
- // ---------------------------------------------------------------------
13
-
14
- const navPageSchema = z.string();
15
-
16
- // A group ordinarily lists its own children by hand (`pages`), but can
17
- // instead auto-generate them from an OpenAPI spec via `openapi: { src,
18
- // path }` - resolved by loadDocsConfig() (via expandOpenApiInNavigation()
19
- // below, reading the manifest generate-api-pages.js writes for this exact
20
- // group) into a real `{ group, pages }` node - one sub-group per tag -
21
- // before anything else in this file ever sees it, so
22
- // resolveSections()/flattenNav()/buildNavTree()/etc. only ever need to
23
- // understand the ordinary pages-array shape. `.strict()` on both variants
24
- // is load-bearing (see withChildren()'s own comment on this pattern
25
- // below): without it, a group written with both `pages` and `openapi` at
26
- // once would silently have one dropped instead of rejected.
27
- const navGroupPagesSchema: z.ZodType<{ group: string; page?: string; pages: NavItem[] }> = z.lazy(() =>
28
- z
29
- .object({
30
- group: z.string(),
31
- page: z.string().optional(),
32
- pages: z.array(navItemSchema),
33
- })
34
- .strict()
35
- );
36
-
37
- const navGroupOpenApiSchema = z.object({
38
- group: z.string(),
39
- openapi: z
40
- .object({
41
- // Path to an OpenAPI 3.x spec, relative to the content directory
42
- // (alongside writedocs.json) - same convention the old top-level
43
- // `openapi` field used. See generate-api-pages.js (the pre-Astro
44
- // build/dev step that actually parses this and writes the
45
- // manifest/operation JSON this group's pages are expanded from).
46
- src: z.string(),
47
- // Base URL path every page generated from this spec is namespaced
48
- // under, e.g. "/api" -> pages served at /api/<tag>/<operation>/.
49
- // Also doubles as this spec's own unique key under
50
- // writedocsTempDir()'s openapi/<path>/ directory (manifest.json + operations/*.json),
51
- // so two openapi groups in the same writedocs.json must use different
52
- // `path` values - generate-api-pages.js throws a clear error if
53
- // they collide.
54
- path: z.string(),
55
- })
56
- .strict(),
57
- }).strict();
58
-
59
- export type GroupNavItem =
60
- | { group: string; page?: string; pages: NavItem[] }
61
- | { group: string; openapi: { src: string; path: string } };
62
-
63
- const navGroupSchema: z.ZodType<GroupNavItem> = z.union([navGroupPagesSchema, navGroupOpenApiSchema]);
64
-
65
- // A bare external link sitting directly in a `pages` array, alongside
66
- // page slugs and groups - e.g. a "Support" link to an external help
67
- // desk shown right in the sidebar, rather than only reachable via
68
- // navigation.global.dropdowns. Discriminated from navGroupSchema by
69
- // its required `label`/`href` keys (vs `group`/`pages`/`openapi`), so a
70
- // plain (non-strict at the union level) schema is enough for Zod to pick
71
- // the right branch; `.strict()` here still catches a typo'd extra key.
72
- const navLinkSchema = z.object({ label: z.string(), href: z.string() }).strict();
73
-
74
- const navItemSchema: z.ZodType<NavItem> = z.lazy(() =>
75
- z.union([navPageSchema, navGroupSchema, navLinkSchema])
76
- );
77
-
78
- export type NavItem = string | GroupNavItem | { label: string; href: string };
79
-
80
- // ---------------------------------------------------------------------
81
- // Containers: tabs, versions, languages, dropdowns, products.
82
- //
83
- // This schema is modeled directly on Mintlify's writedocs.json
84
- // (https://mintlify.com/writedocs.json / mintlify.com/docs/organize/navigation):
85
- // all five container kinds are structurally identical except for their
86
- // own identifying field. Each owns *exactly one* of {pages, tabs,
87
- // versions, languages, dropdowns, products} as its content, or is a bare
88
- // external `href` link with no content of its own. That symmetry is what
89
- // lets any of them nest inside any other - a tab can contain versions, a
90
- // version can contain languages, a language can contain tabs, and so on,
91
- // bottoming out at a `pages` array. `withChildren()` is the single place
92
- // that shape is defined, instead of hand-writing the union five times.
93
- // ---------------------------------------------------------------------
94
-
95
- export type NavChildren =
96
- | { pages: NavItem[] }
97
- | { tabs: TabItem[] }
98
- | { versions: VersionItem[] }
99
- | { languages: LanguageItem[] }
100
- | { dropdowns: DropdownItem[] }
101
- | { products: ProductItem[] }
102
- | { href: string };
103
-
104
- export type TabItem = { tab: string; icon?: string } & NavChildren;
105
- export type VersionItem = { version: string; label?: string; tag?: string; default?: boolean } & NavChildren;
106
- export type LanguageItem = { language: string; label?: string } & NavChildren;
107
- export type DropdownItem = { dropdown: string; icon?: string } & NavChildren;
108
- export type ProductItem = { product: string; icon?: string; description?: string } & NavChildren;
109
-
110
- // `.strict()` on every variant is load-bearing, not decoration: Zod
111
- // objects silently strip unrecognized keys by default, so without it a
112
- // node written with two children fields at once (e.g. both `pages` and
113
- // `versions`) would just have the second one dropped instead of
114
- // rejected - defeating the entire "exactly one child kind per level"
115
- // rule this schema exists to enforce (mirroring Mintlify's own "a tab
116
- // cannot contain both anchors and groups at the same level").
117
- function withChildren<Base extends z.ZodRawShape>(base: Base) {
118
- return z.union([
119
- z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
120
- z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
121
- z.object({ ...base, versions: z.array(versionSchema).min(1) }).strict(),
122
- z.object({ ...base, languages: z.array(languageSchema).min(1) }).strict(),
123
- z.object({ ...base, dropdowns: z.array(dropdownSchema).min(1) }).strict(),
124
- z.object({ ...base, products: z.array(productSchema).min(1) }).strict(),
125
- z.object({ ...base, href: z.string() }).strict(),
126
- ]);
127
- }
128
-
129
- // Each of these five is defined via z.lazy() because withChildren()
130
- // references all five by name (mutual recursion) - safe because none of
131
- // them are actually *called* until a real writedocs.json is parsed, by which
132
- // point every const below has been assigned. The same pattern already
133
- // used for navItemSchema/navGroupSchema above.
134
- const tabSchema: z.ZodType<TabItem> = z.lazy(() =>
135
- withChildren({ tab: z.string(), icon: z.string().optional() })
136
- );
137
-
138
- const versionSchema: z.ZodType<VersionItem> = z.lazy(() =>
139
- withChildren({
140
- version: z.string(),
141
- label: z.string().optional(),
142
- tag: z.string().optional(),
143
- default: z.boolean().optional(),
144
- })
145
- );
146
-
147
- const languageSchema: z.ZodType<LanguageItem> = z.lazy(() =>
148
- withChildren({ language: z.string(), label: z.string().optional() })
149
- );
150
-
151
- const dropdownSchema: z.ZodType<DropdownItem> = z.lazy(() =>
152
- withChildren({ dropdown: z.string(), icon: z.string().optional() })
153
- );
154
-
155
- const productSchema: z.ZodType<ProductItem> = z.lazy(() =>
156
- withChildren({ product: z.string(), icon: z.string().optional(), description: z.string().optional() })
157
- );
158
-
159
- // ---------------------------------------------------------------------
160
- // Root navigation: a flat array (shorthand for one implicit section - the
161
- // common case) or an object choosing exactly one primary organizational
162
- // pattern, per Mintlify's convention ("choose one primary organizational
163
- // pattern at the root level of your navigation").
164
- //
165
- // `global.dropdowns` is the one exception to "exactly one pattern": it's
166
- // not a primary pattern, it's a persistent set of topbar dropdown
167
- // triggers that render on every page regardless of which tab/version/
168
- // language/product is active - equivalent to Mintlify's
169
- // `navigation.global.anchors`. This replaces the earlier `{tabs,
170
- // dropdowns}` shape (both present at once), which doesn't generalize
171
- // once versions/languages/products are also root-level choices.
172
- // ---------------------------------------------------------------------
173
-
174
- export type NavigationConfig =
175
- | NavItem[]
176
- | { global?: { dropdowns: DropdownItem[] }; tabs: TabItem[] }
177
- | { global?: { dropdowns: DropdownItem[] }; versions: VersionItem[] }
178
- | { global?: { dropdowns: DropdownItem[] }; languages: LanguageItem[] }
179
- | { global?: { dropdowns: DropdownItem[] }; dropdowns: DropdownItem[] }
180
- | { global?: { dropdowns: DropdownItem[] }; products: ProductItem[] };
181
-
182
- const globalSchema = z.object({ dropdowns: z.array(dropdownSchema).min(1) });
183
-
184
- const navigationSchema = z.union([
185
- z.array(navItemSchema),
186
- z.object({ global: globalSchema.optional(), tabs: z.array(tabSchema).min(1) }).strict(),
187
- z.object({ global: globalSchema.optional(), versions: z.array(versionSchema).min(1) }).strict(),
188
- z.object({ global: globalSchema.optional(), languages: z.array(languageSchema).min(1) }).strict(),
189
- z.object({ global: globalSchema.optional(), dropdowns: z.array(dropdownSchema).min(1) }).strict(),
190
- z.object({ global: globalSchema.optional(), products: z.array(productSchema).min(1) }).strict(),
191
- ]) as z.ZodType<NavigationConfig>;
192
-
193
- const DEFAULT_PRIMARY = '#6366f1';
194
-
195
- // A logo is either one path used for both color modes, or a
196
- // { light, dark, label } object - each image shown only while that mode
197
- // is active (see BaseLayout.astro's .wd-logo-light/.wd-logo-dark CSS).
198
- // The topbar shows *only* the image by default - no site name text next
199
- // to it, since a real logo asset usually already is a wordmark. `label`
200
- // is the opt-in way to add text back next to the image (e.g. a product
201
- // name alongside a symbol-only mark); it only exists on the object form,
202
- // since a single shared image path is just as easy to bake the label
203
- // into directly.
204
- //
205
- // `light`/`dark` are themselves optional (unlike the string form, which
206
- // is inherently "both"): a site with no logo *images* at all can still
207
- // set `styles.logo: { label: "..." }` to override the topbar's fallback
208
- // text without providing any image - see BaseLayout.astro's
209
- // hasLogoImage/logoLabel/brandLabel derivation, where an object with
210
- // only `label` set already resolves correctly (brandLabel = logoLabel,
211
- // same as if an image were also present) without needing any change
212
- // there; only this schema needed loosening; light/dark were previously
213
- // both required whenever the object form was used at all.
214
- //
215
- // Standalone const (rather than inlined into stylesSchema.logo below) so
216
- // footer.logo further down can share the exact same union instead of an
217
- // independently-written copy - see footerSchema's own comment on why a
218
- // footer logo needs this at all.
219
- const logoSchema = z.union([
220
- z.string(),
221
- z.object({ light: z.string().optional(), dark: z.string().optional(), label: z.string().optional() }).strict(),
222
- ]);
223
-
224
- // Same {light, dark}-with-fallback shape applies to `colors.dark`: any
225
- // of primary/text left unset there falls back to the light-mode value
226
- // already given above, so a site only has to specify what actually
227
- // differs in dark mode. There used to be a `background`/`dark.background`
228
- // pair here too, doing effectively the same job as `background.colors`
229
- // further down (see that field's own comment) - two writedocs.json fields
230
- // both named "background" that visually looked interchangeable but
231
- // weren't quite (one drove --wd-background site-wide, the other only
232
- // the <body> canvas), which was confusing to author against and easy to
233
- // half-configure by setting only one. Removed in favor of `background`
234
- // alone being the single place to set any background color - see that
235
- // field's comment for what it now covers.
236
- // One side of `styles.navbar` (light or dark) - either a bare background
237
- // color (the original shape) or `{ background, accent? }` once a site
238
- // wants its own accent color (the active-tab fill / hover underline)
239
- // inside the navbar specifically, independent of the sitewide
240
- // `styles.colors.primary`. Deliberately does NOT carry a text/icon color
241
- // field at all - see `navbar`'s own comment below (stylesSchema) for why
242
- // that's resolved automatically instead of being configurable. Kept as a
243
- // standalone const rather than inlined so `light`/`dark` share the exact
244
- // same union rather than two independently-written (and possibly
245
- // drifting) copies of it.
246
- const navbarColorValueSchema = z.union([
247
- z.string(),
248
- z
249
- .object({
250
- background: z.string(),
251
- accent: z.string().optional(),
252
- })
253
- .strict(),
254
- ]);
255
-
256
- // One font declaration - almost exactly Mintlify's own `fonts` shape (see
257
- // docs/dev/docs/site-config.mdx's own "Fonts" section for the research this
258
- // was based on): a bare Google Fonts family name (`family` alone - Google
259
- // Fonts loads it automatically, see googleFontsHref() below), or a local/
260
- // externally-hosted font file (`source` + `format` together; `source` is
261
- // either a project-relative path like the other five asset-fallback fields
262
- // this schema already has - see collectConfiguredAssetPaths() - or a full
263
- // https:// URL to an externally-hosted font). `weight` does two things at
264
- // once, both driven by BaseLayout.astro: it narrows which file actually
265
- // loads (folded into the Google Fonts URL's `:wght@` axis, or the
266
- // generated `@font-face` rule's own `font-weight` descriptor for a
267
- // `source` font - see googleFontsHref()/fontFaceRule() below), *and* it's
268
- // applied as a real CSS `font-weight` on h1-h6 (for `heading`) or `body`
269
- // (for `body`) - see BaseLayout's own `wdFontWeightHeading`/
270
- // `wdFontWeightBody`. Both halves matter: loading only the 400-weight file
271
- // while leaving h1-h6 at the browser's UA-default `font-weight: bold`
272
- // faux-bolds that file back to looking bold anyway, with no visible
273
- // change from configuring a lighter weight at all - the bug this second
274
- // half fixes (caught from real user-reported behavior, not anticipated
275
- // up front). Left unset anywhere in `styles.fonts` renders no font-weight
276
- // rule at all, keeping today's plain browser-default bold headings/normal
277
- // body text unchanged for a site that hasn't touched this field.
278
- const fontVariantSchema = z
279
- .object({
280
- family: z.string(),
281
- weight: z.number().optional(),
282
- source: z.string().optional(),
283
- format: z.enum(['woff', 'woff2']).optional(),
284
- })
285
- .strict();
286
-
287
- export type FontVariant = z.infer<typeof fontVariantSchema>;
288
-
289
- // The top-level shape adds `heading`/`body`: each independently optional,
290
- // each falling back to the top-level family/weight/source/format when
291
- // unset (see resolveFonts() below) - so a site can set one font for
292
- // everything (`fonts.family` alone), or split headings and body text
293
- // without needing to repeat itself for whichever side isn't changing.
294
- const fontsSchema = z
295
- .object({
296
- family: z.string(),
297
- weight: z.number().optional(),
298
- source: z.string().optional(),
299
- format: z.enum(['woff', 'woff2']).optional(),
300
- heading: fontVariantSchema.optional(),
301
- body: fontVariantSchema.optional(),
302
- })
303
- .strict();
304
-
305
- export type FontsConfig = z.infer<typeof fontsSchema>;
306
-
307
- const stylesSchema = z
308
- .object({
309
- colors: z
310
- .object({
311
- primary: z.string().default(DEFAULT_PRIMARY),
312
- text: z.string().optional(),
313
- dark: z
314
- .object({
315
- primary: z.string().optional(),
316
- text: z.string().optional(),
317
- })
318
- .optional(),
319
- })
320
- .default({ primary: DEFAULT_PRIMARY }),
321
- logo: logoSchema.optional(),
322
- favicon: z.string().optional(),
323
- // No default value here (unlike `colors.primary`'s DEFAULT_PRIMARY) -
324
- // resolveFonts() below is where "Inter" actually gets applied as the
325
- // site-wide default whenever this field is left unset entirely, so
326
- // that fallback lives in one place alongside the rest of the
327
- // resolution logic rather than being duplicated as a schema default
328
- // *and* a resolver fallback.
329
- fonts: fontsSchema.optional(),
330
- // The Shiki theme *name* (not a full theme object/JSON - see
331
- // https://shiki.style/themes for the built-in list) each fenced
332
- // (```) MDX code block, and the API playground's request/response
333
- // snippets, use in light/dark mode - read out of writedocs.json by both
334
- // astro.config.mjs (Shiki's dual-theme markdown config) and
335
- // ApiReferencePanel.astro (its own separate <Code/> usages, which
336
- // don't inherit markdown.shikiConfig) via resolveCodeblockTheme()
337
- // below, the single source of truth for the 'github-light'/
338
- // 'github-dark' fallback. Left per-key optional (not defaulted in
339
- // the schema itself) so a site can override just one side (e.g.
340
- // only `dark`) and still get the ordinary default for the other.
341
- codeblocks: z
342
- .object({
343
- light: z.string().optional(),
344
- dark: z.string().optional(),
345
- // Maps a fenced (```) block's own language tag to whichever real
346
- // Shiki grammar actually highlights it, before that tag ever
347
- // reaches Shiki - see resolveCodeblockLangAlias() below for the
348
- // one built-in entry (mdx -> jsx) this ships with by default and
349
- // why. A site can add its own entries for any other language tag
350
- // that either doesn't tokenize well under its own grammar, or
351
- // that a site wants to write under a shorter/friendlier fence tag
352
- // than Shiki's own bundled id (e.g. `groovy: 'java'` for a close-
353
- // enough approximation Shiki doesn't ship a dedicated grammar for
354
- // at all) - merged with, not replacing, the built-in default, so
355
- // adding one of a site's own doesn't require re-declaring mdx.
356
- langAlias: z.record(z.string(), z.string()).optional(),
357
- })
358
- .strict()
359
- .optional(),
360
- // The topbar's own background, independent of `background` below (the
361
- // page's) - a site may want its navbar to stand out (a dark or
362
- // brand-colored bar over a light page) rather than blend into the
363
- // page, which is the default when this is left unset. Same {light,
364
- // dark} shape (and same per-key-optional, not defaulted here) as
365
- // `codeblocks` right above, rather than nested under `colors` - it's a
366
- // topbar-specific override, not a third general-purpose page color, so
367
- // it reads more like "one more themed surface" alongside codeblocks
368
- // than a sibling of primary/text. BaseLayout.astro's own
369
- // lightNavbarBg/darkNavbarBg resolution is what falls each side back
370
- // to the page background when unset.
371
- //
372
- // Each side (`light`/`dark`) is either a bare string - just the
373
- // background color, exactly the original shape, kept for backwards
374
- // compatibility - or `{ background, accent? }`, for when a site also
375
- // wants the navbar's own active-tab-fill/hover-underline color to
376
- // differ from the sitewide `styles.colors.primary` (`accent`'s only
377
- // job - see BaseLayout.astro's lightNavbarAccent). Deliberately no
378
- // text/icon color field here at all: BaseLayout.astro always resolves
379
- // the navbar's plain text/icon color itself, picking black or white by
380
- // contrast against whatever `background` resolves to
381
- // (contrastTextColor() below) the moment this field is configured at
382
- // all (string or object) - never a value read from writedocs.json. A
383
- // manually-set text color is a legibility footgun a site author can
384
- // get wrong (or drift out of sync after later changing `background`
385
- // without remembering to update it too) in a way "compute it
386
- // correctly, every time" simply can't - there's no legitimate reason
387
- // to want *illegible* navbar text, unlike `accent`, which is a real
388
- // aesthetic choice. Real bug this fixes: a site setting `navbar.light`
389
- // to the same value as `colors.primary` (a plausible thing to reach
390
- // for - "brand-colored navbar") made every topbar label/icon/link
391
- // render in the default near-black text color against that same
392
- // saturated background, all but unreadable - and an earlier version of
393
- // this feature that let a writedocs.json-supplied color double as the text
394
- // color reintroduced the identical bug one level down, just requiring
395
- // one more (still guessable-wrong) field to trigger it.
396
- navbar: z
397
- .object({
398
- light: navbarColorValueSchema.optional(),
399
- dark: navbarColorValueSchema.optional(),
400
- })
401
- .strict()
402
- .optional(),
403
- // The single place to configure any background color: `colors` is a
404
- // solid color (falls back to '#ffffff'/'#0b1120' when unset - see
405
- // BaseLayout.astro's lightBackground/darkBackground resolution), and
406
- // `images` layers an image on top of it (or is the whole background
407
- // by itself, if `colors` is left at its default). This one pair now
408
- // drives everything background-related site-wide - --wd-background
409
- // (dropdowns/modals/kbd chips/footer/topbar-fallback/etc., see every
410
- // `var(--wd-background)` call site) *and* the <body> canvas behind the
411
- // main/toc columns - rather than the two being independently
412
- // configurable fields that happened to look alike but didn't affect
413
- // the same things (see this schema's own comment on `colors.dark`
414
- // above for why that split was removed). Both are per-mode optional,
415
- // same "only specify what differs" pattern as codeblocks/navbar above.
416
- // The topbar and footer still get their own explicit opaque
417
- // background (topbar.css/footer.css) specifically so an `images`
418
- // background doesn't show through them; see those files' own
419
- // comments. The sidebar column has no background of its own (base.css)
420
- // and lets it show through, same as the main/toc columns.
421
- background: z
422
- .object({
423
- colors: z
424
- .object({
425
- light: z.string().optional(),
426
- dark: z.string().optional(),
427
- })
428
- .strict()
429
- .optional(),
430
- images: z
431
- .object({
432
- light: z.string().optional(),
433
- dark: z.string().optional(),
434
- })
435
- .strict()
436
- .optional(),
437
- })
438
- .strict()
439
- .optional(),
440
- })
441
- .default({ colors: { primary: DEFAULT_PRIMARY } });
442
-
443
- // `href` is always required - a link with nowhere to go isn't a link.
444
- // `label`/`icon` are each individually optional, but not both at once
445
- // (enforced by the .refine() below, same shape as scriptEntrySchema's own
446
- // exactly-one-of check above) - a link needs *something* visible to render,
447
- // whichever of the two, and can have both (icon rendered to the left of the
448
- // label - see TopBar.astro/SiteFooter.astro, the two renderers of this
449
- // shape). An icon-only link (no label) is the common case for something
450
- // like a bare GitHub mark in the topbar; label-only (no icon) is every
451
- // plain text link this already supported before `icon` existed - both
452
- // still need to keep working unchanged, hence neither field being outright
453
- // required on its own.
454
- const topbarLinkSchema = z
455
- .object({
456
- label: z.string().optional(),
457
- href: z.string(),
458
- // Same string shape as every other writedocs.json `icon` field (a plain
459
- // Lucide name, or the explicit "collection:icon-name" form for
460
- // anything else - see resolveIcon()/AppIcon.astro) - not documented
461
- // again here since that convention is already established elsewhere
462
- // in this file (tabs/switchers/socials all take the identical shape).
463
- icon: z.string().optional(),
464
- })
465
- .strict()
466
- .refine((link) => Boolean(link.label) || Boolean(link.icon), {
467
- message: 'Each topbar.links/footer column link needs a "label", an "icon", or both - a link with neither has nothing to render.',
468
- });
469
-
470
- // One column of the footer (BaseLayout.astro's <footer class="wd-footer">) -
471
- // an optional heading (e.g. "Resources", "Community") plus a list of links,
472
- // reusing the exact same {label, icon, href} shape as topbar.links above (a
473
- // footer link is the same thing rendered in a different place, no reason
474
- // for a second, identical-but-differently-named schema). `title` is
475
- // optional so a single-column footer with no heading (just a bare list of
476
- // links) is valid too.
477
- const footerColumnSchema = z
478
- .object({
479
- title: z.string().optional(),
480
- links: z.array(topbarLinkSchema).default([]),
481
- })
482
- .strict();
483
-
484
- // Off by default (empty `columns`, same convention as `topbar` above - a
485
- // footer with nothing configured just doesn't render at all, see
486
- // BaseLayout.astro's `hasFooterContent` check) rather than `.optional()`
487
- // like contextMenu: unlike that field, an empty/default footer has no
488
- // build-output or routing implications to gate, it's purely visual, so
489
- // there's no reason to distinguish "unset" from "set to nothing" the way
490
- // contextMenu needs to.
491
- // Same {light, dark, label} union as `styles.logo` (via the shared
492
- // logoSchema above) - unset by default, in which case the footer just
493
- // shows the sitewide `styles.logo` (BaseLayout.astro's existing
494
- // logoLight/logoDark/logoLabel, already threaded into SiteFooter as-is).
495
- // Set here, it replaces that entirely rather than filling in only the
496
- // missing side of it - a footer.logo with only `dark` set shows *just*
497
- // the dark-mode image, not styles.logo's light image plus this dark one -
498
- // the same all-or-nothing-relative-to-styles.logo resolution BaseLayout.astro
499
- // already applies.
500
- const footerSchema = z
501
- .object({
502
- columns: z.array(footerColumnSchema).default([]),
503
- logo: logoSchema.optional(),
504
- })
505
- .strict()
506
- .default({ columns: [] });
507
-
508
- export type FooterColumn = z.infer<typeof footerColumnSchema>;
509
- export type FooterConfig = z.infer<typeof footerSchema>;
510
-
511
- // Shared by both writedocs.json's top-level `seo` (site-wide defaults) and a
512
- // page's own frontmatter `seo` (per-page overrides) - see
513
- // content.config.ts's docsSchema, which imports this exact schema rather
514
- // than redeclaring the same shape a second time. mergeSeo() below is what
515
- // actually combines the two, field by field, at render time.
516
- export const seoFieldsSchema = z
517
- .object({
518
- // Open Graph / Twitter card image - a relative path (resolved against
519
- // `domain` below into an absolute URL, since most social crawlers
520
- // require one) or an already-absolute https:// URL.
521
- ogImage: z.string().optional(),
522
- // og:type - "website" for most pages, "article" for blog-post-shaped
523
- // content, etc. Defaults to "website" if never set anywhere.
524
- ogType: z.string().optional(),
525
- twitterCard: z.enum(['summary', 'summary_large_image']).optional(),
526
- keywords: z.array(z.string()).optional(),
527
- // Renders <meta name="robots" content="noindex, nofollow" /> and
528
- // (see astro.config.mjs's sitemap `filter`) excludes the page from
529
- // sitemap.xml entirely - both driven off this one flag, since listing
530
- // a page in the sitemap while also telling crawlers not to index it
531
- // would be self-contradictory.
532
- noindex: z.boolean().optional(),
533
- })
534
- .strict();
535
-
536
- export type SeoFields = z.infer<typeof seoFieldsSchema>;
537
-
538
- /** Field-by-field merge of a page's own `seo` frontmatter over writedocs.json's
539
- * site-wide `seo` defaults - a page only overrides the specific fields it
540
- * sets, falling back to the site default for everything else, rather than
541
- * a page's (possibly partial) `seo` object replacing the site's wholesale. */
542
- export function mergeSeo(site: SeoFields, page?: SeoFields): SeoFields {
543
- if (!page) return site;
544
- return {
545
- ogImage: page.ogImage ?? site.ogImage,
546
- ogType: page.ogType ?? site.ogType,
547
- twitterCard: page.twitterCard ?? site.twitterCard,
548
- keywords: page.keywords ?? site.keywords,
549
- noindex: page.noindex ?? site.noindex,
550
- };
551
- }
552
-
553
- // Controls for the OpenAPI API playground's "Try it" modal - separate
554
- // from a group's own `openapi: { src, path }` field (which spec to use,
555
- // and where) since this is about how a *request* it sends behaves, not
556
- // about the spec itself.
557
- const apiSchema = z
558
- .object({
559
- // Whether the Try-it modal's Send button routes its request through
560
- // writedocs' own CORS proxy (https://proxy.writechoice.io/) rather
561
- // than calling the API directly from the browser. Almost no
562
- // real-world API sends back Access-Control-Allow-Origin headers
563
- // permitting an arbitrary docs site's origin, so a direct browser
564
- // fetch() from the Try-it modal fails for most real APIs without
565
- // this - see PROXY_BASE_URL in ApiReferencePanel.astro for the
566
- // actual request-forwarding logic. Defaults to enabled; a site can
567
- // set this to `false` to always call the API directly instead (the
568
- // API is already CORS-permissive, same-origin in some deployments,
569
- // or the site owner doesn't want requests routed through a third
570
- // party at all).
571
- proxy: z.boolean().default(true),
572
- })
573
- .strict()
574
- .default({ proxy: true });
575
-
576
- // The "Copy page" dropdown shown next to a page's title - copy the raw
577
- // Markdown to the clipboard, open the raw Markdown in a new tab, or
578
- // deep-link into an AI assistant with a prompt pointing at it. Opt-in:
579
- // undefined (the field simply absent from writedocs.json) means the feature
580
- // is off entirely - no menu rendered, no .md routes generated at build
581
- // time either (see [...slug].md.ts) - rather than defaulting to on,
582
- // since it changes both the UI and the build output. Once a site does
583
- // write a `contextMenu` key (even `{}`), copying/viewing-as-Markdown are
584
- // always included (they need nothing but the page's own content); only
585
- // `openIn` (the AI-assistant deep-links, which need an absolute URL to
586
- // point the assistant at) is independently configurable.
587
- const contextMenuSchema = z
588
- .object({
589
- openIn: z.array(z.enum(['chatgpt', 'claude', 'perplexity'])).default(['chatgpt', 'claude', 'perplexity']),
590
- })
591
- .strict();
592
-
593
- export type ContextMenuConfig = z.infer<typeof contextMenuSchema>;
594
-
595
- // One entry in writedocs.json's `redirects` array - wired almost directly into
596
- // Astro's own `redirects` config option (astro.config.mjs), which is what
597
- // actually generates the redirect pages. `permanent` is deliberately not
598
- // part of this schema: with no server/adapter (this project always builds
599
- // `output: 'static'` with none installed), Astro's own docs say a static
600
- // redirect "does not support status codes" at all - it's always a client-
601
- // side `<meta http-equiv="refresh">` page, full stop. Accepting a
602
- // `permanent` field here that silently did nothing would be worse than
603
- // not offering it.
604
- const redirectSchema = z
605
- .object({
606
- source: z.string(),
607
- destination: z.string(),
608
- })
609
- .strict();
610
-
611
- export type RedirectConfig = z.infer<typeof redirectSchema>;
612
-
613
- // A site-wide banner rendered above the topbar on every page (except
614
- // `mode: blank`, which drops all site chrome including this - see
615
- // BaseLayout.astro). Optional/undefined by default, same convention as
616
- // `contextMenu` above - a site with no banner configured gets no banner
617
- // markup at all, not an empty one.
618
- const bannerSchema = z
619
- .object({
620
- content: z.string(),
621
- // Persisted via localStorage once dismissed (see BaseLayout.astro's
622
- // initBanner()) - a static site has no server-side session to track
623
- // this against, so "dismissed" only ever means "dismissed in this
624
- // browser". A banner without `dismissible` reappears on every visit,
625
- // appropriate for something that should stay visible until the site
626
- // author removes it from writedocs.json themselves (e.g. "this version is
627
- // deprecated"), not something a reader can permanently clear.
628
- dismissible: z.boolean().default(false),
629
- type: z.enum(['info', 'warning', 'critical']).default('info'),
630
- })
631
- .strict();
632
-
633
- export type BannerConfig = z.infer<typeof bannerSchema>;
634
-
635
- // Content for the custom 404 page (src/pages/404.astro) - Astro's own
636
- // file-based convention (a static build emits this as 404.html at the
637
- // site root; most static hosts pick it up automatically for unmatched
638
- // routes). Both fields are optional with hardcoded fallbacks in 404.astro
639
- // itself, so a site doesn't have to set either to get a reasonably
640
- // branded 404 page (still rendered inside the normal BaseLayout/topbar
641
- // chrome, just with placeholder content).
642
- const notFoundSchema = z
643
- .object({
644
- title: z.string().optional(),
645
- description: z.string().optional(),
646
- })
647
- .strict()
648
- .default({});
649
-
650
- export type NotFoundConfig = z.infer<typeof notFoundSchema>;
651
-
652
- // One <script> tag to inject - exactly one of `src` (an external/local
653
- // file, rendered as `<script src="...">`) or `content` (inline JS,
654
- // rendered via `<script is:inline set:html="...">`) has to be set, not
655
- // both and not neither - enforced by the `.refine()` below rather than
656
- // leaving both optional and silently rendering an empty tag if a site
657
- // author gets this wrong.
658
- const scriptEntrySchema = z
659
- .object({
660
- src: z.string().optional(),
661
- content: z.string().optional(),
662
- })
663
- .strict()
664
- .refine((v) => (v.src ? !v.content : !!v.content), {
665
- message: 'Each scripts.head/scripts.body entry needs exactly one of "src" or "content", not both or neither.',
666
- });
667
-
668
- export type ScriptEntry = z.infer<typeof scriptEntrySchema>;
669
-
670
- // Raw third-party script injection - analytics snippets, chat widgets,
671
- // anything that needs a real <script> tag writedocs.json's own structured
672
- // fields (styles, integrations-style config, ...) don't have a dedicated
673
- // slot for yet. `head` renders right before </head>; `body` renders right
674
- // before </body> (after everything else has already loaded/hydrated -
675
- // see BaseLayout.astro), matching where a third-party script's own
676
- // install instructions usually say to put it.
677
- const scriptsSchema = z
678
- .object({
679
- head: z.array(scriptEntrySchema).default([]),
680
- body: z.array(scriptEntrySchema).default([]),
681
- })
682
- .strict()
683
- .default({ head: [], body: [] });
684
-
685
- export type ScriptsConfig = z.infer<typeof scriptsSchema>;
686
-
687
- // writedocs.json's `integrations` - a curated, high-value subset of analytics
688
- // providers, each getting its own minimal config object (just the id(s)
689
- // it actually needs) rather than requiring a site author to hand-write
690
- // the provider's own install snippet through the generic `scripts` field
691
- // above. This is deliberately its own thing, not built as sugar over
692
- // `scripts` the way the roadmap once implied it might be - most of these
693
- // providers' real snippets need `defer`/`async`/`data-*` attributes
694
- // ScriptEntry's plain `{ src?, content? }` shape has no way to express,
695
- // so BaseLayout.astro renders each provider with its own bespoke markup
696
- // instead of generating a ScriptEntry array to feed through the generic
697
- // renderer. Every snippet shape below was sourced from that provider's
698
- // own current official docs (fetched directly, not recalled from
699
- // training data) while building this feature - see
700
- // docs/dev/docs/integrations.mdx for links and dates.
701
- //
702
- // Every provider field is optional and independent - a site can turn on
703
- // any subset (including all of them at once, for a migration period) or
704
- // none. No "the" analytics field; a site picks whichever provider(s) it
705
- // actually uses.
706
-
707
- const ga4IntegrationSchema = z
708
- .object({
709
- // A GA4 "Measurement ID", always shaped like "G-XXXXXXXXXX" - not
710
- // validated against that shape here, since Google could change the
711
- // prefix/length without this schema needing to track it.
712
- measurementId: z.string(),
713
- })
714
- .strict();
715
-
716
- const googleTagManagerIntegrationSchema = z
717
- .object({
718
- // A GTM container id, shaped like "GTM-XXXXXXX".
719
- containerId: z.string(),
720
- })
721
- .strict();
722
-
723
- const plausibleIntegrationSchema = z
724
- .object({
725
- domain: z.string(),
726
- // Plausible's own hosted script URL by default - override for a
727
- // self-hosted instance, a reverse-proxy path (Plausible's own
728
- // documented way to dodge ad-blockers), or one of Plausible's
729
- // documented script *extensions* (e.g.
730
- // "https://plausible.io/js/script.hash.outbound-links.js").
731
- src: z.string().default('https://plausible.io/js/script.js'),
732
- })
733
- .strict();
734
-
735
- const fathomIntegrationSchema = z
736
- .object({
737
- // Fathom's own short "Site ID", not a full URL or measurement id.
738
- siteId: z.string(),
739
- })
740
- .strict();
741
-
742
- const posthogIntegrationSchema = z
743
- .object({
744
- // PostHog calls this a "project API key" (starts with "phc_") in its
745
- // own docs - `apiKey` here rather than that exact name, matching this
746
- // schema's own plainer naming convention for the equivalent id on
747
- // every other provider above.
748
- apiKey: z.string(),
749
- // PostHog is region-sharded (US/EU) with no single universal default
750
- // - "https://us.i.posthog.com" is PostHog's own default for a new
751
- // project, but a site on their EU cloud (or a self-hosted instance)
752
- // must override this to the host shown in their own project
753
- // settings, or events silently go nowhere.
754
- apiHost: z.string().default('https://us.i.posthog.com'),
755
- })
756
- .strict();
757
-
758
- const umamiIntegrationSchema = z
759
- .object({
760
- websiteId: z.string(),
761
- // Unlike Plausible/PostHog, Umami has no single hosted default that
762
- // works for most users - it's commonly self-hosted, and even Umami
763
- // Cloud users get a per-region script URL. Defaults to Umami Cloud's
764
- // own documented script URL, which only happens to be correct for a
765
- // Cloud user on that specific region; anyone self-hosting (the more
766
- // common case for this particular provider) must override it to
767
- // their own instance's own /script.js path.
768
- src: z.string().default('https://cloud.umami.is/script.js'),
769
- })
770
- .strict();
771
-
772
- // AI chat over the site's own docs content, via a third-party provider
773
- // (DocsBot, currently the only one wired up) rather than a self-hosted
774
- // RAG pipeline - see the roadmap's own "AI chat over docs content" line
775
- // in docs/dev/docs/roadmap.mdx, which this fulfills via integration
776
- // rather than by building the retrieval/generation stack in-house.
777
- // Deliberately named `askAi` here, not `docsbot` - this is the
778
- // writedocs.json-facing, provider-neutral name for the feature (matching
779
- // the "Ask AI" label this kind of button/widget is conventionally
780
- // given in docs sites generally), independent of which vendor happens
781
- // to sit behind it today. `id` matches DocsBot's own embed config
782
- // field name exactly (`DocsBotAI.init({ id: 'teamId/botId' })`) - it's
783
- // a single opaque "teamId/botId" string DocsBot treats as one value,
784
- // not two separate ids, so this schema doesn't split it either.
785
- const askAiIntegrationSchema = z
786
- .object({
787
- id: z.string(),
788
- })
789
- .strict();
790
-
791
- const integrationsSchema = z
792
- .object({
793
- ga4: ga4IntegrationSchema.optional(),
794
- googleTagManager: googleTagManagerIntegrationSchema.optional(),
795
- plausible: plausibleIntegrationSchema.optional(),
796
- fathom: fathomIntegrationSchema.optional(),
797
- posthog: posthogIntegrationSchema.optional(),
798
- umami: umamiIntegrationSchema.optional(),
799
- askAi: askAiIntegrationSchema.optional(),
800
- })
801
- .strict()
802
- .default({});
803
-
804
- export type IntegrationsConfig = z.infer<typeof integrationsSchema>;
805
-
806
- export const docsConfigSchema = z.object({
807
- name: z.string(),
808
- description: z.string().optional(),
809
- styles: stylesSchema,
810
- navigation: navigationSchema,
811
- // Platform name -> profile/page URL (e.g. `{ "github": "https://
812
- // github.com/...", "twitter": "https://twitter.com/..." }`), free-form
813
- // rather than an enum of known platforms - the key doubles as the icon
814
- // reference BaseLayout.astro's footer resolves via resolveIcon() below
815
- // (bare `"github"` -> `lucide:github`; use an explicit
816
- // `"simple-icons:whatever"` key instead when the bare lucide name isn't
817
- // right for a given platform). Rendered as a row of icon links in the
818
- // footer - see hasFooterContent/`.wd-footer-socials` in BaseLayout.astro.
819
- socials: z.record(z.string(), z.string()).default({}),
820
- topbar: z
821
- .object({ links: z.array(topbarLinkSchema).default([]) })
822
- .default({ links: [] }),
823
- // Columns of links below the page content - see footerSchema/
824
- // footerColumnSchema above. Renders alongside `socials` above (as a row
825
- // of icon links) in the same <footer> - see BaseLayout.astro.
826
- footer: footerSchema,
827
- api: apiSchema,
828
- // The site's own deployed domain, e.g. "docs.example.com" or
829
- // "https://docs.example.com" (the scheme is optional - resolveSiteUrl()
830
- // below normalizes either form to a full https:// origin). Powers three
831
- // things that all need an absolute origin to work: sitemap.xml
832
- // generation (astro.config.mjs only registers @astrojs/sitemap when this
833
- // is set - a sitemap of relative URLs isn't meaningful), the canonical
834
- // <link> and og:url/twitter meta tags (BaseLayout.astro), and resolving
835
- // a relative `seo.ogImage` into an absolute URL for social crawlers.
836
- // Left optional and simply skipped (with a log message, not an error) at
837
- // every one of those call sites when unset - most fixtures/local builds
838
- // have no real deployed domain yet.
839
- domain: z.string().optional(),
840
- // Site-wide meta tag defaults - see seoFieldsSchema above. A page's own
841
- // frontmatter `seo` (content.config.ts's docsSchema) overrides these
842
- // field by field via mergeSeo(), rather than needing to repeat every
843
- // field on every page.
844
- seo: seoFieldsSchema.default({}),
845
- // The "Copy page" dropdown - see contextMenuSchema above. Absent by
846
- // default (no menu, no .md routes).
847
- contextMenu: contextMenuSchema.optional(),
848
- // See redirectSchema above. Wired directly into Astro's own `redirects`
849
- // config option in astro.config.mjs.
850
- redirects: z.array(redirectSchema).default([]),
851
- // Site-wide `[[key]]` substitution values, applied to page prose text
852
- // at build time (see the remarkSubstituteVariables plugin wired into
853
- // astro.config.mjs) - e.g. `{ "productName": "Acme" }` lets every page
854
- // write `[[productName]]` once instead of hardcoding it everywhere.
855
- // Double square brackets, not the more familiar `{{key}}` - MDX
856
- // reserves single curly braces for embedded JS expressions, so
857
- // `{{productName}}` in an .mdx file parses as a JS object-literal
858
- // expression (`{productName}`, shorthand syntax) rather than literal
859
- // text, and throws `ReferenceError: productName is not defined` at
860
- // render time - see remarkSubstituteVariables' own comment in
861
- // mdx-substitute-variables.js for the full explanation. Deliberately a
862
- // flat string map, not typed values - this is text substitution into
863
- // prose, not a templating language.
864
- variables: z.record(z.string(), z.string()).default({}),
865
- // See bannerSchema above. Absent by default (no banner rendered).
866
- banner: bannerSchema.optional(),
867
- // See notFoundSchema above.
868
- notFound: notFoundSchema,
869
- // See scriptsSchema above.
870
- scripts: scriptsSchema,
871
- // See integrationsSchema above.
872
- integrations: integrationsSchema,
873
- });
874
-
875
- export type DocsConfig = z.infer<typeof docsConfigSchema>;
876
-
877
- /** Normalizes writedocs.json's `domain` into a full origin with no trailing
878
- * slash (e.g. "docs.example.com" -> "https://docs.example.com"), or null
879
- * if the site hasn't set one. The single source of truth for "does this
880
- * site have a real deployed URL" - astro.config.mjs (sitemap
881
- * registration), BaseLayout.astro (canonical/OG/Twitter URLs), and
882
- * resolveAbsoluteUrl() below all call this rather than reading
883
- * config.domain directly, so the http(s):// normalization only happens
884
- * in one place. */
885
- export function resolveSiteUrl(config: Pick<DocsConfig, 'domain'>): string | null {
886
- if (!config.domain) return null;
887
- const withScheme = /^https?:\/\//i.test(config.domain) ? config.domain : `https://${config.domain}`;
888
- return withScheme.replace(/\/+$/, '');
889
- }
890
-
891
- /** Resolves a possibly-relative asset path (e.g. `seo.ogImage: "/card.png"`)
892
- * against the site's own domain into an absolute URL - social crawlers
893
- * (Facebook/Twitter/Slack unfurls) generally require an absolute
894
- * og:image/twitter:image URL, a same-origin relative path isn't reliably
895
- * respected. Returns the value unchanged if it's already absolute, or if
896
- * there's no siteUrl to resolve it against (a relative path is still
897
- * better than nothing in that case - most social crawlers do at least
898
- * attempt to fetch it relative to the page they scraped). */
899
- export function resolveAbsoluteUrl(siteUrl: string | null, value: string): string {
900
- if (/^https?:\/\//i.test(value)) return value;
901
- if (!siteUrl) return value;
902
- return `${siteUrl}${value.startsWith('/') ? '' : '/'}${value}`;
903
- }
904
-
905
- /** The resolved (never-undefined) Shiki theme names for light/dark code
906
- * blocks - shared by astro.config.mjs (sitewide MDX code-fence
907
- * highlighting) and ApiReferencePanel.astro (the API playground's own
908
- * separate <Code/> usages, which don't inherit markdown.shikiConfig at
909
- * all - see astro.config.mjs's own comment on that) so both read the
910
- * same writedocs.json field and fall back to the same defaults instead of
911
- * each hardcoding its own copy. */
912
- export function resolveCodeblockTheme(config: DocsConfig): { light: string; dark: string } {
913
- return {
914
- light: config.styles.codeblocks?.light ?? 'github-light',
915
- dark: config.styles.codeblocks?.dark ?? 'github-dark',
916
- };
917
- }
918
-
919
- // mdx is a real, bundled Shiki grammar (@shikijs/langs' mdx.mjs - it does
920
- // exist, this isn't a "Shiki doesn't know this language" situation), but in
921
- // practice it tokenizes ```mdx fences into one single run per line with no
922
- // internal token boundaries at all - every character, JSX tag or not,
923
- // lands in the exact same TextMate scope and renders in the theme's plain
924
- // foreground color. Confirmed directly against real built output: every
925
- // span in a ```mdx block's compiled HTML carries the identical inline
926
- // color, none of the tag/attribute/string distinction a JS or JSX fence
927
- // gets. Aliasing to jsx - a close structural match for the JSX-heavy
928
- // snippets these fences are actually used for in this codebase's own docs
929
- // (`<Callout>`, `<Card>`, etc.) - actually highlights the tags/attributes,
930
- // at the cost of not distinctly coloring the markdown-prose portions
931
- // interleaved between them (jsx's grammar doesn't know about those) - a
932
- // worthwhile trade given the alternative is no color at all. See
933
- // astro.config.mjs's own shikiConfig.langAlias for where this actually
934
- // gets used. */
935
- const DEFAULT_CODEBLOCK_LANG_ALIAS: Record<string, string> = { mdx: 'jsx' };
936
-
937
- /** The resolved fence-language alias map - the built-in mdx -> jsx default
938
- * above, merged with (not replaced by) whatever a site adds under its own
939
- * writedocs.json styles.codeblocks.langAlias, so a site can extend this list
940
- * without having to redeclare the built-in entry to keep it. Same
941
- * "shared resolver, not each consumer re-deriving its own defaults"
942
- * pattern resolveCodeblockTheme() right above already establishes -
943
- * though today only astro.config.mjs actually consumes this one, unlike
944
- * that one, since ApiReferencePanel.astro's own <Code/> usages never
945
- * render a `mdx`-tagged snippet (API operation samples are curl/js/
946
- * python/etc., not MDX markup) to begin with. */
947
- export function resolveCodeblockLangAlias(config: DocsConfig): Record<string, string> {
948
- return { ...DEFAULT_CODEBLOCK_LANG_ALIAS, ...config.styles.codeblocks?.langAlias };
949
- }
950
-
951
- const DEFAULT_FONT_FAMILY = 'Inter';
952
-
953
- export interface ResolvedFonts {
954
- base: FontVariant;
955
- heading: FontVariant | null;
956
- body: FontVariant | null;
957
- }
958
-
959
- /** Resolves writedocs.json's `styles.fonts` into the three font declarations
960
- * BaseLayout.astro actually needs to render: `base` (the site-wide
961
- * default - every element gets this unless `heading`/`body` narrows it
962
- * further), and `heading`/`body`, each `null` when not independently
963
- * configured (letting BaseLayout fall back to `base` for whichever side
964
- * wasn't overridden, rather than this function silently copying `base`
965
- * into both and losing the distinction between "explicitly set to the
966
- * same font" and "just inheriting the default"). The one hardcoded
967
- * default in this whole feature lives right here: no `styles.fonts` at
968
- * all resolves to plain Inter, loaded for real (a Google Fonts `<link>`,
969
- * not just a name in a fallback stack that only renders correctly for a
970
- * reader who happens to already have Inter installed - see base.css's
971
- * old `font-family` rule, which was exactly that, before this feature
972
- * existed). */
973
- export function resolveFonts(config: DocsConfig): ResolvedFonts {
974
- const fonts = config.styles.fonts;
975
- const base: FontVariant = fonts
976
- ? { family: fonts.family, weight: fonts.weight, source: fonts.source, format: fonts.format }
977
- : { family: DEFAULT_FONT_FAMILY };
978
- return { base, heading: fonts?.heading ?? null, body: fonts?.body ?? null };
979
- }
980
-
981
- /** The Google Fonts CSS2 API URL for every font in `fonts` that isn't a
982
- * `source`-based (local/externally-hosted) font - `null` if there's
983
- * nothing to load this way at all (every configured font has its own
984
- * `source`). One request covers every family needed (`&family=` repeated
985
- * per unique family+weight pair, deduped so the same pair - e.g. `base`
986
- * and `body` both left at the site default - isn't requested twice)
987
- * rather than a separate `<link>` per font. `display=swap` avoids an
988
- * invisible-text flash while the font file loads (renders in the
989
- * fallback stack immediately, swaps once the real font is ready) -
990
- * Google's own recommended default for exactly this use case. */
991
- export function googleFontsHref(fonts: ResolvedFonts): string | null {
992
- const entries = [fonts.base, fonts.heading, fonts.body].filter(
993
- (f): f is FontVariant => f !== null && !f.source
994
- );
995
- if (entries.length === 0) return null;
996
- const seen = new Set<string>();
997
- const params: string[] = [];
998
- for (const f of entries) {
999
- const key = `${f.family}|${f.weight ?? ''}`;
1000
- if (seen.has(key)) continue;
1001
- seen.add(key);
1002
- const familyParam = f.family.trim().replace(/\s+/g, '+');
1003
- params.push(f.weight ? `family=${familyParam}:wght@${f.weight}` : `family=${familyParam}`);
1004
- }
1005
- return `https://fonts.googleapis.com/css2?${params.join('&')}&display=swap`;
1006
- }
1007
-
1008
- /** A `@font-face` rule for one `source`-based font (local project path or
1009
- * externally-hosted URL), or `''` for a Google Font (no `source` - see
1010
- * googleFontsHref() above, the other half of font loading). `weight`
1011
- * becomes the rule's `font-weight` *descriptor* here - it tells the
1012
- * browser which weight this specific file represents, so a `font-weight`
1013
- * CSS value requested elsewhere (BaseLayout.astro's own
1014
- * `wdFontWeightHeading`/`wdFontWeightBody`, see its comment) picks the
1015
- * real matching file instead of synthetically ("faux") bolding/
1016
- * thinning a mismatched one. This function only ever produces the
1017
- * `@font-face` rule itself - actually applying `font-weight` to any
1018
- * element (h1-h6, body) is BaseLayout's job, not this one's. */
1019
- export function fontFaceRule(font: FontVariant | null): string {
1020
- if (!font || !font.source) return '';
1021
- const weightDecl = font.weight !== undefined ? ` font-weight: ${font.weight};` : '';
1022
- return `@font-face { font-family: '${font.family}'; src: url('${font.source}') format('${font.format ?? 'woff2'}'); font-display: swap;${weightDecl} }`;
1023
- }
1024
-
1025
- /** Splits one side of `styles.navbar` (the string-or-object union
1026
- * navbarColorValueSchema allows) into its two parts, filling in the
1027
- * fallback background when the field is unset at all. `accent` stays
1028
- * `undefined` - not defaulted here - for both the bare-string case and
1029
- * the object case where a site set `background` without `accent`;
1030
- * BaseLayout.astro is what turns that `undefined` into "fall back to
1031
- * --wd-primary" for the accent CSS var. There's no `foreground` here at
1032
- * all to resolve - the navbar's text/icon color is never read from
1033
- * writedocs.json, see `navbar`'s own schema comment (stylesSchema) for why. */
1034
- export function resolveNavbarColor(
1035
- value: string | { background: string; accent?: string } | undefined,
1036
- fallbackBackground: string
1037
- ): { background: string; accent: string | undefined } {
1038
- if (!value) return { background: fallbackBackground, accent: undefined };
1039
- if (typeof value === 'string') return { background: value, accent: undefined };
1040
- return { background: value.background, accent: value.accent };
1041
- }
1042
-
1043
- /** Picks black or white text for readable contrast against `hexColor`,
1044
- * via the standard relative-luminance formula (ITU-R BT.601 weights -
1045
- * the same "perceived brightness" approximation used all over the web
1046
- * for exactly this "what text color goes on this swatch" problem, not
1047
- * the more expensive WCAG relative-luminance formula, which isn't
1048
- * needed for a binary choose-the-less-bad-option decision like this
1049
- * one). Used two ways in BaseLayout.astro: for `--wd-navbar-accent-text`
1050
- * (the active tab's own fill used to always be `var(--wd-primary)` with
1051
- * hardcoded `color: #fff`, which only actually read fine because every
1052
- * default/example primary color so far has been dark/saturated enough
1053
- * for white text; once a site's navbar accent can be *any* color -
1054
- * `styles.navbar.light.accent`, falling back to `styles.colors.primary`
1055
- * when unset, see resolveNavbarColor() above - that assumption can't
1056
- * hold unconditionally), and for `--wd-navbar-foreground` itself once
1057
- * `styles.navbar` is configured at all (the navbar's plain text/icon
1058
- * color - see `navbar`'s own schema comment for why that's always
1059
- * computed, never a writedocs.json value). Malformed input (not a 6-digit
1060
- * `#rrggbb` hex) falls back to white rather than throwing - same "don't
1061
- * fail a build over a cosmetic color value" posture every other color
1062
- * field here takes (none of them validate hex syntax either). */
1063
- export function contrastTextColor(hexColor: string): '#000000' | '#ffffff' {
1064
- const match = /^#?([0-9a-f]{6})$/i.exec(hexColor.trim());
1065
- if (!match) return '#ffffff';
1066
- const hex = match[1];
1067
- const r = parseInt(hex.slice(0, 2), 16);
1068
- const g = parseInt(hex.slice(2, 4), 16);
1069
- const b = parseInt(hex.slice(4, 6), 16);
1070
- const luminance = (299 * r + 587 * g + 114 * b) / 1000;
1071
- return luminance > 150 ? '#000000' : '#ffffff';
1072
- }
1073
-
1074
- /** Whether an href points off-site - has an explicit scheme (`https:`,
1075
- * `mailto:`, `tel:`, ...) or is protocol-relative (`//...`) - versus a
1076
- * same-site path, which this codebase always produces as a single
1077
- * leading slash (hrefForSlug() in [...slug].astro). Used everywhere a
1078
- * nav link is rendered to decide whether it should open in a new tab;
1079
- * deliberately a plain string check rather than a schema-level flag,
1080
- * so it applies uniformly to every href source (topbar.links, a
1081
- * switcher/tab pill pointing at a bare `href` container, a global
1082
- * dropdown's own link, a sidebar link leaf) without threading an
1083
- * `external` field through every one of those call sites. */
1084
- export function isExternalHref(href: string): boolean {
1085
- return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
1086
- }
1087
-
1088
- // ---------------------------------------------------------------------
1089
- // Icons - writedocs.json's `icon` fields (TabItem, DropdownItem, ProductItem,
1090
- // Card) are plain strings with no schema-level distinction between "an
1091
- // emoji, paste it verbatim" and "an icon-set name, look it up". Resolved
1092
- // here rather than validated in the schema, since both are valid uses of
1093
- // the same string field and the right rendering only becomes obvious once
1094
- // you look at the value's shape.
1095
- // ---------------------------------------------------------------------
1096
-
1097
- export type IconResolution =
1098
- | { kind: 'iconify'; name: string } // ready for astro-icon/components' <Icon name=... />
1099
- | { kind: 'text'; value: string }; // literal text/emoji, rendered as-is
1100
-
1101
- const DEFAULT_ICON_COLLECTION = 'lucide';
1102
-
1103
- /** Resolves a writedocs.json `icon` string into either an Iconify icon id or
1104
- * literal text, covering three forms:
1105
- * - "collection:icon-name" (e.g. "mdi:server", "simple-icons:github")
1106
- * - used as-is against whatever @iconify-json/* collections are
1107
- * installed (see astro.config.mjs / package.json).
1108
- * - a bare name using only letters/digits/hyphens (e.g. "smartphone",
1109
- * "book-open") - defaults to the "lucide" collection, matching what
1110
- * docs.json-examples/ already assumes for plain icon-name strings.
1111
- * - anything else (an emoji, a symbol, arbitrary text) - rendered
1112
- * verbatim, preserving the original "just paste an emoji" behavior
1113
- * from before icon-library support existed. */
1114
- export function resolveIcon(icon: string): IconResolution {
1115
- if (/^[a-z0-9-]+:[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: icon };
1116
- if (/^[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: `${DEFAULT_ICON_COLLECTION}:${icon}` };
1117
- return { kind: 'text', value: icon };
1118
- }
1119
-
1120
- // ---------------------------------------------------------------------
1121
- // OpenAPI navigation expansion - turns a group's `openapi: { src, path }`
1122
- // shorthand into a concrete `{ group, pages }` (one sub-group per tag),
1123
- // using the per-spec manifest generate-api-pages.js writes to
1124
- // writedocsTempDir()'s openapi/<path>/manifest.json before Astro starts (both
1125
- // `writedocs dev` and `writedocs build` run it first - see
1126
- // src/cli/dev.js, src/cli/build.js). Applied once, here in
1127
- // loadDocsConfig(), so every other function in this file
1128
- // (resolveSections, flattenNav, buildNavTree, ...) only ever sees plain
1129
- // page-slug strings and ordinary `{ group, pages }` nodes, and never
1130
- // needs to know the `openapi` group shorthand exists. A writedocs.json can
1131
- // have any number of these groups, each pointing at its own spec and
1132
- // mounted under its own `path` - generate-api-pages.js namespaces each
1133
- // spec's manifest/operations under that same `path`, so there's no
1134
- // cross-spec collision as long as every group uses a distinct `path`.
1135
- // ---------------------------------------------------------------------
1136
-
1137
- export interface OpenApiManifestEntry {
1138
- slug: string;
1139
- method: string;
1140
- path: string;
1141
- tags: string[];
1142
- title: string;
1143
- generated: boolean;
1144
- }
1145
-
1146
- /** `specPath` is the owning group's own `openapi.path` (e.g. "/api") -
1147
- * generate-api-pages.js writes each spec's manifest under a directory
1148
- * named after that same value, so this only ever needs to know which
1149
- * group is asking, not anything about the spec's contents itself. */
1150
- function loadOpenApiManifest(contentDir: string, specPath: string): OpenApiManifestEntry[] | null {
1151
- const normalized = specPath.replace(/^\/+|\/+$/g, '');
1152
- const manifestPath = path.join(writedocsTempDir(contentDir), 'openapi', normalized, 'manifest.json');
1153
- if (!fs.existsSync(manifestPath)) return null;
1154
- try {
1155
- return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
1156
- } catch {
1157
- return null; // stale/partial write from an interrupted previous run - treat as absent
1158
- }
1159
- }
1160
-
1161
- /** Expands one group's `openapi: { src, path }` into the `pages` array
1162
- * it stands in for - one sub-group per tag (in first-seen order),
1163
- * untagged operations collected into a trailing "Other" group. Returns
1164
- * an empty array (an empty, harmless group) rather than throwing if the
1165
- * manifest is missing - generate-api-pages.js always runs before this
1166
- * does (see src/cli/dev.js/build.js), so a missing manifest here means
1167
- * generation hasn't happened yet rather than a user error worth
1168
- * crashing the dev server over. */
1169
- function expandOpenApiGroupPages(openapiRef: { src: string; path: string }, contentDir: string): NavItem[] {
1170
- const manifest = loadOpenApiManifest(contentDir, openapiRef.path);
1171
- if (!manifest) return [];
1172
-
1173
- const seenTags: string[] = [];
1174
- const byTag = new Map<string, string[]>();
1175
- const untagged: string[] = [];
1176
- for (const op of manifest) {
1177
- const tag = op.tags[0];
1178
- if (!tag) {
1179
- untagged.push(op.slug);
1180
- continue;
1181
- }
1182
- if (!byTag.has(tag)) {
1183
- byTag.set(tag, []);
1184
- seenTags.push(tag);
1185
- }
1186
- byTag.get(tag)!.push(op.slug);
1187
- }
1188
- const groups: NavItem[] = seenTags.map((tag) => ({ group: tag, pages: byTag.get(tag)! }));
1189
- return untagged.length > 0 ? [...groups, { group: 'Other', pages: untagged }] : groups;
1190
- }
1191
-
1192
- function expandOpenApiInPages(pages: NavItem[], contentDir: string): NavItem[] {
1193
- return pages.map((item): NavItem => {
1194
- if (typeof item === 'string') return item;
1195
- if ('href' in item) return item;
1196
- if ('openapi' in item) {
1197
- // A group using the { group, openapi: { src, path } } shorthand -
1198
- // replace it with a real { group, pages } node built from that
1199
- // spec's own manifest, so nothing downstream needs to know the
1200
- // shorthand ever existed.
1201
- return { group: item.group, pages: expandOpenApiGroupPages(item.openapi, contentDir) };
1202
- }
1203
- // An ordinary { group, page?, pages } - recurse into its own pages,
1204
- // since an openapi-group can be nested inside a hand-authored group
1205
- // too (e.g. wrapping it to add hand-written pages alongside the
1206
- // auto-generated ones).
1207
- return { ...item, pages: expandOpenApiInPages(item.pages, contentDir) };
1208
- });
1209
- }
1210
-
1211
- /** Mirrors walkSections()'s traversal (tabs/versions/languages/
1212
- * dropdowns/products, each bottoming out at `pages`), but rewrites
1213
- * rather than collects - every `pages` array anywhere in the tree gets
1214
- * run through expandOpenApiInPages(). */
1215
- function expandOpenApiInContainer(node: NavChildren, contentDir: string): NavChildren {
1216
- if ('pages' in node) return { pages: expandOpenApiInPages(node.pages, contentDir) };
1217
- if ('href' in node) return node;
1218
- if ('tabs' in node) {
1219
- return { tabs: node.tabs.map((t) => ({ ...t, ...expandOpenApiInContainer(t, contentDir) })) };
1220
- }
1221
- if ('versions' in node) {
1222
- return { versions: node.versions.map((v) => ({ ...v, ...expandOpenApiInContainer(v, contentDir) })) };
1223
- }
1224
- if ('languages' in node) {
1225
- return { languages: node.languages.map((l) => ({ ...l, ...expandOpenApiInContainer(l, contentDir) })) };
1226
- }
1227
- if ('dropdowns' in node) {
1228
- return { dropdowns: node.dropdowns.map((d) => ({ ...d, ...expandOpenApiInContainer(d, contentDir) })) };
1229
- }
1230
- if ('products' in node) {
1231
- return { products: node.products.map((p) => ({ ...p, ...expandOpenApiInContainer(p, contentDir) })) };
1232
- }
1233
- return node;
1234
- }
1235
-
1236
- function expandOpenApiInNavigation(navigation: NavigationConfig, contentDir: string): NavigationConfig {
1237
- if (Array.isArray(navigation)) return expandOpenApiInPages(navigation, contentDir);
1238
- const expanded = { ...navigation, ...expandOpenApiInContainer(navigation as Container, contentDir) };
1239
- if (navigation.global?.dropdowns) {
1240
- expanded.global = {
1241
- dropdowns: navigation.global.dropdowns.map(
1242
- (d) => ({ ...d, ...expandOpenApiInContainer(d, contentDir) }) as DropdownItem
1243
- ),
1244
- };
1245
- }
1246
- return expanded as NavigationConfig;
1247
- }
1248
-
1249
- export function loadDocsConfig(contentDir: string): DocsConfig {
1250
- const configPath = path.join(contentDir, 'writedocs.json');
1251
- if (!fs.existsSync(configPath)) {
1252
- throw new Error(
1253
- `[writedocs] writedocs.json not found at ${configPath}. Run "writedocs init" to scaffold one.`
1254
- );
1255
- }
1256
- const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1257
- const result = docsConfigSchema.safeParse(raw);
1258
- if (!result.success) {
1259
- const issues = result.error.issues
1260
- .map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
1261
- .join('\n');
1262
- throw new Error(`[writedocs] writedocs.json failed validation:\n${issues}`);
1263
- }
1264
- // A no-op pass over ordinary navigation trees (no openapi groups) -
1265
- // always run, rather than gated behind a global manifest check, since
1266
- // there's no longer a single global spec to check for.
1267
- result.data.navigation = expandOpenApiInNavigation(result.data.navigation, contentDir);
1268
- return result.data;
1269
- }
1270
-
1271
- // --- Locating a page by file id, independent of its effective slug -----
1272
- //
1273
- // writedocs.json's `pages` arrays, and every helper above/below that walks
1274
- // them (flattenNav, firstSlugOf, containerContainsSlug, buildNavTree, ...),
1275
- // always identify a page by its file id - its path relative to the
1276
- // content directory (project root), extension stripped, with a trailing
1277
- // `/index` segment dropped (matching Astro's own default id computation -
1278
- // see the `/index` note below) - regardless of how that page ends up
1279
- // being served. docs/ is not special: a file at `docs/guides/x.mdx` has
1280
- // file id `docs/guides/x`, the exact same rule applied to a file
1281
- // anywhere else in the project.
1282
- //
1283
- // A page's actual URL is a separate question, and Astro's own glob()
1284
- // content loader already has a first-class answer for it: if a page's
1285
- // frontmatter sets `slug`, `entry.id` (and therefore the route Astro
1286
- // builds for it) becomes that value verbatim instead of the file-path
1287
- // default - see astro/dist/content/loaders/glob.js's generateIdDefault:
1288
- // `if (data.slug) return data.slug`. content.config.ts's `slug` schema
1289
- // field is deliberately the same field Astro already recognizes, so
1290
- // nothing here needs to reimplement the override or track it separately -
1291
- // it only needs a way to find a page BY file id (to resolve writedocs.json's
1292
- // references) even once `entry.id` no longer equals it.
1293
- //
1294
- // --- Page discovery -------------------------------------------------
1295
- //
1296
- // Any .md/.mdx file anywhere under the content directory becomes a page
1297
- // candidate the moment it has *any* frontmatter block at all - docs/ has
1298
- // no special status here, it's just a folder like any other (a
1299
- // conventional, recommended place to put most pages, not a requirement).
1300
- // content.config.ts's `pages` collection is the same docsSchema applied
1301
- // to exactly this file set. A file with zero frontmatter (no `---` block
1302
- // whatsoever) is never a page candidate - a snippet (see
1303
- // docs/dev/docs/snippets.mdx) typically has none, which is what lets it
1304
- // live anywhere without tripping schema validation. A file that *does*
1305
- // open a frontmatter block but is missing a required field (`title`)
1306
- // still fails validation exactly as before - only "no frontmatter at
1307
- // all" is new; a genuine mistake (frontmatter present, title forgotten)
1308
- // stays a loud build error rather than being silently treated as
1309
- // non-page content.
1310
- //
1311
- // A handful of directories are never scanned regardless of what's in
1312
- // them - build output, dependencies, and writedocs' own working
1313
- // directories, none of which a site author would ever intend as page
1314
- // content. Only excluded at the content directory's own top level (a
1315
- // hand-authored `guides/dist-notes.mdx` isn't mistaken for the build
1316
- // output directory `dist/` just because a folder two levels down happens
1317
- // to also be named `dist`).
1318
- const EXCLUDED_TOP_LEVEL_DIRS = new Set(['node_modules', 'dist', '.astro', '.writedocs', 'public', '.git']);
1319
-
1320
- /** Recursively finds every .md/.mdx file under `contentDir` that has a
1321
- * frontmatter block, skipping the handful of build/dependency
1322
- * directories a real content directory tends to also contain (see
1323
- * EXCLUDED_TOP_LEVEL_DIRS above) - docs/ is scanned exactly like any
1324
- * other folder, no special-casing. Returns POSIX-relative paths (from
1325
- * `contentDir`) suitable to hand straight to Astro's `glob()` loader as
1326
- * a literal `pattern` array - see content.config.ts's `pages`
1327
- * collection, and [...slug].astro, which needs the identical list to
1328
- * decide whether calling `getCollection('pages')` is worth doing at all
1329
- * (see content.config.ts's own comment on why an empty collection still
1330
- * needs to exist, just backed by a no-op loader, to avoid Astro's "does
1331
- * not exist or is empty" warning). Also reused directly by
1332
- * astro.config.mjs's noindex/sitemap scan, so that scan always sees
1333
- * exactly the same file set that actually becomes a page - no risk of
1334
- * the two drifting apart. Synchronous and re-run from scratch wherever
1335
- * it's called rather than cached and shared across modules - consistent
1336
- * with how loadDocsConfig() itself is already called repeatedly across
1337
- * this codebase instead of threaded through as shared state, and cheap
1338
- * enough in practice (a docs site's own file count) not to matter. */
1339
- export function findAllPages(contentDir: string): string[] {
1340
- const results: string[] = [];
1341
- function walk(dir: string, relBase: string) {
1342
- let entries: fs.Dirent[];
1343
- try {
1344
- entries = fs.readdirSync(dir, { withFileTypes: true });
1345
- } catch {
1346
- return;
1347
- }
1348
- for (const entry of entries) {
1349
- const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
1350
- const abs = path.join(dir, entry.name);
1351
- if (entry.isDirectory()) {
1352
- if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
1353
- walk(abs, rel);
1354
- continue;
1355
- }
1356
- if (!entry.isFile() || !/\.mdx?$/i.test(entry.name)) continue;
1357
- let raw: string;
1358
- try {
1359
- raw = fs.readFileSync(abs, 'utf-8');
1360
- } catch {
1361
- continue;
1362
- }
1363
- const { data } = matter(raw);
1364
- if (Object.keys(data).length === 0) continue; // no frontmatter at all - not a page
1365
- results.push(rel);
1366
- }
1367
- }
1368
- walk(contentDir, '');
1369
- return results;
1370
- }
1371
-
1372
- /** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
1373
- * `public/` included - auto-loaded site-wide with zero `writedocs.json`
1374
- * config, on top of (not instead of) the explicit `scripts` field
1375
- * (bannerSchema and friends, above). Drop a file in, it loads;
1376
- * there's no field naming which ones to use, matching the same "just
1377
- * works" convention `docs/`'s own file discovery already follows (see
1378
- * findAllPages() above / `content-pipeline.mdx`) - a site author already
1379
- * drops content files in and expects them found, rather than also
1380
- * listing every one in writedocs.json.
1381
- *
1382
- * Two separate walks, because `public/` needs different treatment than
1383
- * everywhere else:
1384
- *
1385
- * - `css`/`js`: every `.css`/`.js` file outside `public/` (root, `docs/`,
1386
- * `snippets/`, any custom folder) - reused as the walk-with-exclusions
1387
- * shape from findAllPages() above, and its exact `EXCLUDED_TOP_LEVEL_DIRS`
1388
- * set (skipped only at the project root, same as there), so `dist/`,
1389
- * `node_modules/`, `.astro/`, `.writedocs/`, `.git/`, and (for this
1390
- * half only - see below) `public/` are never walked into. BaseLayout.astro
1391
- * reads each one's raw content and inlines it as a `<style>`/
1392
- * `<script is:inline>` tag.
1393
- * - `publicCss`/`publicJs`: every `.css`/`.js` file *inside* `public/`,
1394
- * walked separately (starting from `<contentDir>/public` rather than
1395
- * `contentDir` itself, so the same `EXCLUDED_TOP_LEVEL_DIRS` check
1396
- * doesn't apply here - there's no `public/public/` or `public/dist/`
1397
- * convention to guard against). Returned as public-URL-rooted hrefs
1398
- * (a leading `/`, no `public` segment - `public/custom.css` becomes
1399
- * `/custom.css`) rather than content-dir-relative paths, since these
1400
- * files are already served as static assets at exactly that URL once
1401
- * Astro copies `public/` into the build output. BaseLayout.astro
1402
- * renders these as ordinary `<link rel="stylesheet">`/`<script src>`
1403
- * tags pointing at that URL instead of inlining their content -
1404
- * inlining would duplicate every byte (once in the page's own HTML,
1405
- * once more as the independently-fetchable static file at that same
1406
- * URL) for no benefit, where a `<link>`/`<script src>` gets normal
1407
- * browser caching across pages instead of repeating the content on
1408
- * every single page's markup.
1409
- *
1410
- * Both halves are broader than they might sound - a stray `.js` file
1411
- * kept in `docs/`, `snippets/`, or `public/` for an unrelated reason
1412
- * (a snippet's own local helper, an image gallery's lightbox script
1413
- * someone dropped in `public/` to reference from a raw `<script src>`
1414
- * in an .mdx file, say) gets auto-injected sitewide the same as a
1415
- * deliberate one; there's no separate "this one's just tooling" signal
1416
- * to opt out of the convention short of renaming its extension.
1417
- *
1418
- * Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
1419
- * by their respective path for deterministic load order across rebuilds
1420
- * - same reasoning as llms.txt's own alphabetical-by-slug sort (see
1421
- * llms.txt.ts) - filesystem readdir order isn't guaranteed portable
1422
- * across OSes or directory-walk order otherwise.
1423
- *
1424
- * `css`/`js` return POSIX-separated paths relative to `contentDir`, not
1425
- * absolute paths or file contents - BaseLayout.astro (the sole caller)
1426
- * resolves and reads each one's content itself, right before inlining
1427
- * it, so a file's content is always current as of that specific
1428
- * request/build rather than cached here across a `writedocs dev`
1429
- * session. `publicCss`/`publicJs` return the public-URL hrefs described
1430
- * above - nothing to read, Astro's own static-file serving/copy already
1431
- * handles those. */
1432
- export function findRootAssets(
1433
- contentDir: string
1434
- ): { css: string[]; js: string[]; publicCss: string[]; publicJs: string[] } {
1435
- const css: string[] = [];
1436
- const js: string[] = [];
1437
- function walk(dir: string, relBase: string) {
1438
- let entries: fs.Dirent[];
1439
- try {
1440
- entries = fs.readdirSync(dir, { withFileTypes: true });
1441
- } catch {
1442
- return;
1443
- }
1444
- for (const entry of entries) {
1445
- const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
1446
- const abs = path.join(dir, entry.name);
1447
- if (entry.isDirectory()) {
1448
- if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
1449
- walk(abs, rel);
1450
- continue;
1451
- }
1452
- if (!entry.isFile()) continue;
1453
- if (/\.css$/i.test(entry.name)) css.push(rel);
1454
- else if (/\.js$/i.test(entry.name)) js.push(rel);
1455
- }
1456
- }
1457
- walk(contentDir, '');
1458
- css.sort();
1459
- js.sort();
1460
-
1461
- const publicCss: string[] = [];
1462
- const publicJs: string[] = [];
1463
- function walkPublic(dir: string, relBase: string) {
1464
- let entries: fs.Dirent[];
1465
- try {
1466
- entries = fs.readdirSync(dir, { withFileTypes: true });
1467
- } catch {
1468
- return;
1469
- }
1470
- for (const entry of entries) {
1471
- const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
1472
- const abs = path.join(dir, entry.name);
1473
- if (entry.isDirectory()) {
1474
- walkPublic(abs, rel);
1475
- continue;
1476
- }
1477
- if (!entry.isFile()) continue;
1478
- if (/\.css$/i.test(entry.name)) publicCss.push(`/${rel}`);
1479
- else if (/\.js$/i.test(entry.name)) publicJs.push(`/${rel}`);
1480
- }
1481
- }
1482
- walkPublic(path.join(contentDir, 'public'), '');
1483
- publicCss.sort();
1484
- publicJs.sort();
1485
-
1486
- return { css, js, publicCss, publicJs };
1487
- }
1488
-
1489
- /** The root-relative URL paths (leading `/`, e.g. `/images/hero.svg`)
1490
- * referenced by the writedocs.json/styles fields that point at a static asset
1491
- * *by URL* rather than embedding it inline: `styles.favicon`,
1492
- * `styles.logo` (both the plain-string and `{light, dark}` object forms),
1493
- * `styles.background.images.{light,dark}`, and `seo.ogImage`. External
1494
- * URLs (anything not starting with `/` - `https://...`, mainly) are
1495
- * filtered out, since those need no local file resolution at all.
1496
- * Deduped, since e.g. `logo.light` and `background.images.light`
1497
- * coincidentally pointing at the same file shouldn't resolve/copy it
1498
- * twice.
1499
- *
1500
- * Used by `stylesAssetFallback()` (`styles-asset-integration.js`,
1501
- * imported from `astro.config.mjs`) to let every one of these resolve
1502
- * from *anywhere* in the project, not just `public/` - previously,
1503
- * `public/` was the one place `styles.background.images` (etc.) had to
1504
- * live, since Astro's own `publicDir` copy is the only thing that ever
1505
- * served them; everywhere else in this codebase's own "drop a file
1506
- * anywhere, it's found" convention (`findRootAssets()` right above,
1507
- * `findAllPages()` for content) already worked project-wide. See that
1508
- * integration's own comment for the actual resolution mechanism (a dev-time
1509
- * middleware plus a post-build copy step, not a duplicated `publicDir`)
1510
- * and why a straight copy-into-`public/`-on-disk approach was rejected.
1511
- *
1512
- * Logo light/dark resolution here deliberately mirrors BaseLayout.astro's
1513
- * own `logoLight`/`logoDark` derivation exactly (a plain-string `logo`
1514
- * counts as both light and dark) - two independent implementations of
1515
- * that same union-unwrapping would only be one accidental edit away from
1516
- * disagreeing with each other. `footer.logo` (footerSchema) gets the
1517
- * same treatment, independently of `styles.logo` - both are collected
1518
- * unconditionally here (not just whichever one BaseLayout.astro would
1519
- * actually end up using for a given page), since this function has no
1520
- * page context to know which page modes render a footer at all; an
1521
- * unreferenced path collected here that never actually renders anywhere
1522
- * is harmless (nothing copies/resolves a file that's never requested),
1523
- * but a real footer.logo file silently 404ing because this function
1524
- * didn't know to resolve it is exactly the bug this comment is warning
1525
- * future edits away from repeating.
1526
- *
1527
- * Per-page frontmatter `seo.ogImage` overrides are deliberately out of
1528
- * scope - those aren't visible from a `DocsConfig` alone (they live in
1529
- * each page's own frontmatter, merged in per-request by [...slug].astro/
1530
- * BaseLayout.astro), and resolving every page's own override would need
1531
- * a full content scan this function has no reason to also become. A
1532
- * page overriding `seo.ogImage` to something outside `public/` still
1533
- * needs to put it there for now. */
1534
- export function collectConfiguredAssetPaths(config: DocsConfig): string[] {
1535
- const logo = config.styles.logo;
1536
- const logoLight = typeof logo === 'string' ? logo : logo?.light;
1537
- const logoDark = typeof logo === 'string' ? logo : logo?.dark;
1538
- const footerLogo = config.footer.logo;
1539
- const footerLogoLight = typeof footerLogo === 'string' ? footerLogo : footerLogo?.light;
1540
- const footerLogoDark = typeof footerLogo === 'string' ? footerLogo : footerLogo?.dark;
1541
- const fonts = config.styles.fonts;
1542
- const raw: (string | undefined)[] = [
1543
- config.styles.favicon,
1544
- logoLight,
1545
- logoDark,
1546
- footerLogoLight,
1547
- footerLogoDark,
1548
- config.styles.background?.images?.light,
1549
- config.styles.background?.images?.dark,
1550
- config.seo?.ogImage,
1551
- // A `styles.fonts` `source` is only ever handled here when it's a
1552
- // project-relative path (the `p.startsWith('/')` filter below already
1553
- // excludes both Google Font names, which never start with "/", and a
1554
- // full https:// external font URL, which the browser fetches directly
1555
- // - neither needs resolving/copying through this pipeline at all).
1556
- fonts?.source,
1557
- fonts?.heading?.source,
1558
- fonts?.body?.source,
1559
- ];
1560
- const paths = raw.filter((p): p is string => Boolean(p) && p.startsWith('/'));
1561
- return Array.from(new Set(paths));
1562
- }
1563
-
1564
- export interface DocsEntryLike {
1565
- id: string;
1566
- filePath?: string;
1567
- }
1568
-
1569
- /** Recovers a content entry's writedocs.json-facing file id (its path
1570
- * relative to the content directory, extension stripped, trailing
1571
- * `/index` dropped), independent of any frontmatter `slug` override -
1572
- * `entry.filePath` is always root-relative and POSIX-separated (how
1573
- * Astro's content layer records it, relative to whatever `--root` Astro
1574
- * itself was invoked with - see run-astro.js), so both it and the
1575
- * content directory are resolved to absolute paths before comparing,
1576
- * rather than string-matching a prefix that could be relative,
1577
- * absolute, or platform-separated inconsistently.
1578
- *
1579
- * The trailing-`/index`-drop mirrors Astro's own default id computation
1580
- * exactly (`getContentEntryIdAndSlug()` in astro/dist/content/
1581
- * utils.js: segments are joined then `.replace(/\/index$/, '')`) -
1582
- * without it, a nested index file like `docs/guides/index.mdx` (Astro's
1583
- * own default id: "docs/guides") would recover the wrong file id here
1584
- * ("docs/guides/index"), and a writedocs.json reference written to match the
1585
- * page's real URL would fail to resolve. A single-segment `index.mdx`
1586
- * at the content root is unaffected either way - the regex requires a
1587
- * preceding `/`, which a bare "index" doesn't have (matching Astro's
1588
- * own behavior: a root-level index.mdx keeps file id "index", not "").
1589
- *
1590
- * Full per-segment slugification (github-slugger, applied by Astro to
1591
- * every path segment) is deliberately *not* replicated here - every
1592
- * filename in this codebase's own fixtures and every filename this
1593
- * function needs to have handled correctly is already slug-safe
1594
- * (lowercase, hyphenated, no spaces/unicode), so slugification is
1595
- * always a no-op in practice; only the `/index`-stripping behavior is
1596
- * reproduced, since that's the one part of Astro's algorithm this
1597
- * change newly exercises (a file that used to be a collection's own
1598
- * base-root index, exempt from stripping, and is now nested one level
1599
- * deeper).
1600
- *
1601
- * Entries from the `generatedDocs` collection (auto-generated OpenAPI
1602
- * stub pages - see content.config.ts / generate-api-pages.js) live
1603
- * under writedocsTempDir()'s generated-docs/ directory - an OS temp
1604
- * directory location entirely outside contentDir, not a subdirectory
1605
- * of it - so `relativeToContentDir` for one of these always starts with
1606
- * `..` (walking back out of contentDir to reach it), and the check
1607
- * right below falls back to `entry.id` instead of computing a
1608
- * nonsensical id relative to a directory the file was never actually
1609
- * under. Those entries always set their own `slug` frontmatter
1610
- * explicitly, so there's no independent "file path" identity worth
1611
- * recovering for them the way there is for a hand-written page that
1612
- * might move around - `entry.id` (Astro's already-resolved slug)
1613
- * already IS the exact value generate-api-pages.js's own manifest
1614
- * references them by. */
1615
- export function fileIdForEntry(
1616
- contentDir: string,
1617
- packageRoot: string,
1618
- entry: DocsEntryLike
1619
- ): string {
1620
- if (!entry.filePath) return entry.id;
1621
- const absoluteContentDir = path.resolve(contentDir);
1622
- const absoluteFilePath = path.resolve(packageRoot, entry.filePath);
1623
- const relativeToContentDir = path.relative(absoluteContentDir, absoluteFilePath);
1624
- if (relativeToContentDir.startsWith('..')) return entry.id;
1625
- const posixRelative = relativeToContentDir.split(path.sep).join('/');
1626
- if (EXCLUDED_TOP_LEVEL_DIRS.has(posixRelative.split('/')[0])) return entry.id;
1627
- return posixRelative.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
1628
- }
1629
-
1630
- /** Normalizes a raw `entry.id` into the bare, slash-free form every
1631
- * route/href in this codebase assumes. Once a page sets a frontmatter
1632
- * `slug`, `entry.id` becomes that value completely verbatim - Astro's
1633
- * glob loader applies no normalization of its own (see
1634
- * generateIdDefault in astro/dist/content/loaders/glob.js) - so
1635
- * `slug: /` or `slug: /guides/new-name` (both natural things to write,
1636
- * mirroring how every href elsewhere in writedocs.json already has a
1637
- * leading slash) would otherwise produce broken multi-slash hrefs
1638
- * wherever hrefForSlug wraps the value in its own `/${slug}/`, and a
1639
- * bare "/" would additionally fail to be recognized as claiming root,
1640
- * colliding with the synthetic root-redirect route. */
1641
- export function normalizeEntryId(id: string): string {
1642
- const trimmed = id.replace(/^\/+/, '').replace(/\/+$/, '');
1643
- return trimmed === '' ? 'index' : trimmed;
1644
- }
1645
-
1646
- export interface FlatNavEntry {
1647
- slug: string;
1648
- group: string | null;
1649
- }
1650
-
1651
- export function flattenNav(navigation: NavItem[], group: string | null = null): FlatNavEntry[] {
1652
- return navigation.flatMap((item): FlatNavEntry[] => {
1653
- if (typeof item === 'string') {
1654
- return [{ slug: item, group }];
1655
- }
1656
- if ('href' in item) return []; // external link leaf, not a content page - no route/prev-next entry
1657
- // loadDocsConfig() always expands `{ group, openapi }` shorthand into
1658
- // a real `{ group, pages }` before anything reaches here (see
1659
- // expandOpenApiInNavigation() above) - this is just a defensive
1660
- // no-op for the shouldn't-happen case of an unexpanded node.
1661
- if ('openapi' in item) return [];
1662
- const ownPage: FlatNavEntry[] = item.page ? [{ slug: item.page, group: item.group }] : [];
1663
- return [...ownPage, ...flattenNav(item.pages, item.group)];
1664
- });
1665
- }
1666
-
1667
- // --- Sections -------------------------------------------------------
1668
- //
1669
- // A Section is the atomic unit that owns one page tree and therefore one
1670
- // sidebar - every real content page belongs to exactly one Section,
1671
- // whichever `pages` leaf it's listed under, however deep in the
1672
- // tabs/versions/languages/dropdowns/products tree that leaf lives.
1673
- //
1674
- // A Section's `path` records the full chain of containers from the
1675
- // navigation root down to it, one PathSegment per level. This is
1676
- // everything the topbar needs to render the right selector controls
1677
- // (tabs bar, version dropdown, ...) and highlight the right option in
1678
- // each - without the router or layout needing to know how deep or in
1679
- // what order the site author nested things.
1680
-
1681
- export type PathSegment =
1682
- | { kind: 'tab'; items: TabItem[]; index: number }
1683
- | { kind: 'version'; items: VersionItem[]; index: number }
1684
- | { kind: 'language'; items: LanguageItem[]; index: number }
1685
- | { kind: 'dropdown'; items: DropdownItem[]; index: number }
1686
- | { kind: 'product'; items: ProductItem[]; index: number };
1687
-
1688
- export interface Section {
1689
- pages: NavItem[];
1690
- path: PathSegment[];
1691
- }
1692
-
1693
- type Container = NavChildren;
1694
- type NamedContainer = TabItem | VersionItem | LanguageItem | DropdownItem | ProductItem;
1695
-
1696
- function walkSections(node: Container, path: PathSegment[], out: Section[]): void {
1697
- if ('pages' in node) {
1698
- out.push({ pages: node.pages, path });
1699
- return;
1700
- }
1701
- if ('href' in node) return; // external link, not a Section
1702
- if ('tabs' in node) {
1703
- node.tabs.forEach((item, index) =>
1704
- walkSections(item, [...path, { kind: 'tab', items: node.tabs, index }], out)
1705
- );
1706
- return;
1707
- }
1708
- if ('versions' in node) {
1709
- node.versions.forEach((item, index) =>
1710
- walkSections(item, [...path, { kind: 'version', items: node.versions, index }], out)
1711
- );
1712
- return;
1713
- }
1714
- if ('languages' in node) {
1715
- node.languages.forEach((item, index) =>
1716
- walkSections(item, [...path, { kind: 'language', items: node.languages, index }], out)
1717
- );
1718
- return;
1719
- }
1720
- if ('dropdowns' in node) {
1721
- node.dropdowns.forEach((item, index) =>
1722
- walkSections(item, [...path, { kind: 'dropdown', items: node.dropdowns, index }], out)
1723
- );
1724
- return;
1725
- }
1726
- if ('products' in node) {
1727
- node.products.forEach((item, index) =>
1728
- walkSections(item, [...path, { kind: 'product', items: node.products, index }], out)
1729
- );
1730
- return;
1731
- }
1732
- }
1733
-
1734
- export function resolveSections(navigation: NavigationConfig): Section[] {
1735
- if (Array.isArray(navigation)) return [{ pages: navigation, path: [] }];
1736
- const sections: Section[] = [];
1737
- walkSections(navigation as Container, [], sections);
1738
- // global.dropdowns sits outside the primary pattern, but its pages
1739
- // still need routes generated for them - walk each entry too, with an
1740
- // empty path (they don't participate in the tabs/version/etc.
1741
- // selector chain, only in the always-visible globalDropdowns list).
1742
- for (const dropdown of navigation.global?.dropdowns ?? []) {
1743
- walkSections(dropdown, [], sections);
1744
- }
1745
- return sections;
1746
- }
1747
-
1748
- /** Which Section (by index into resolveSections()'s result) a page belongs to. */
1749
- export function findSectionIndexForSlug(sections: Section[], slug: string): number {
1750
- const index = sections.findIndex((s) => flattenNav(s.pages).some((entry) => entry.slug === slug));
1751
- return index === -1 ? 0 : index;
1752
- }
1753
-
1754
- /** The always-visible topbar dropdown list - independent of whichever
1755
- * primary pattern/section is active (Mintlify's equivalent is
1756
- * `navigation.global.anchors`). */
1757
- export function resolveGlobalDropdowns(navigation: NavigationConfig): DropdownItem[] {
1758
- if (Array.isArray(navigation)) return [];
1759
- return navigation.global?.dropdowns ?? [];
1760
- }
1761
-
1762
- /** The first real page slug reachable by descending into a container's
1763
- * content, however deep - used to compute where a selector option (a
1764
- * tab, a version, ...) navigates to when chosen. Null for an external
1765
- * `href` leaf, which the caller renders as a plain link using the
1766
- * node's own href instead. Versions prefer whichever entry is marked
1767
- * `default` (falling back to the first), matching Mintlify's version
1768
- * default rule. */
1769
- /** Tries `firstSlugOf()` against each item in order, returning the first
1770
- * non-null result - a plain `items[0]` pick (the previous behavior)
1771
- * breaks the moment that first sibling happens to be a bare `href` leaf
1772
- * (returns null, with nothing that tries the next one), which is a real
1773
- * case now that any container - not just a nested dropdown - can be a
1774
- * bare external link (e.g. a `tabs` array whose first entry is
1775
- * `{ tab: "Status", href: "..." }`). */
1776
- function firstSlugAmong(items: Container[]): string | null {
1777
- for (const item of items) {
1778
- const slug = firstSlugOf(item);
1779
- if (slug !== null) return slug;
1780
- }
1781
- return null;
1782
- }
1783
-
1784
- export function firstSlugOf(node: Container): string | null {
1785
- if ('pages' in node) return flattenNav(node.pages)[0]?.slug ?? null;
1786
- if ('href' in node) return null;
1787
- if ('tabs' in node) return firstSlugAmong(node.tabs);
1788
- if ('versions' in node) {
1789
- // Try the `default`-tagged version(s) first (matching the old
1790
- // "preferred" pick), then fall through to the rest in order - same
1791
- // href-leaf-with-no-fallback concern as `tabs` above, just with an
1792
- // extra preference pass in front of it.
1793
- const defaults = node.versions.filter((v) => v.default);
1794
- const rest = node.versions.filter((v) => !v.default);
1795
- return firstSlugAmong([...defaults, ...rest]);
1796
- }
1797
- if ('languages' in node) return firstSlugAmong(node.languages);
1798
- if ('dropdowns' in node) return firstSlugAmong(node.dropdowns);
1799
- if ('products' in node) return firstSlugAmong(node.products);
1800
- return null;
1801
- }
1802
-
1803
- /** For a version/language switcher, finds where the reader's current
1804
- * page would live inside a *different* version/language's own subtree,
1805
- * so switching keeps them on the same conceptual page instead of always
1806
- * bouncing to that option's first page (see buildSelectors() below,
1807
- * which is the only caller). "Same page" is approximated positionally
1808
- * rather than by name, since nothing guarantees a version/language's
1809
- * own identifier lines up with its pages' file paths - a version
1810
- * tagged "2025-09" could just as easily keep its pages under a "v1"
1811
- * folder with no relation to that string, so there's no naming
1812
- * convention to key off safely; this heuristic only ever compares tree
1813
- * position, never path text. (docs.json-examples/00-kitchen-sink/'s own
1814
- * versions - "2025-09"/"2026-01" under Core Platform - happen to have
1815
- * folder names that line up with their version strings, so it doesn't
1816
- * demonstrate the mismatched-folder-name case directly; the guarantee
1817
- * still doesn't exist regardless of what one fixture happens to do.)
1818
- *
1819
- * Two things have to line up for a page to count as "the same" one:
1820
- * first, `remainingPath` (whatever tab/product/dropdown was chosen
1821
- * *below* the version/language being switched, on the reader's actual
1822
- * page) has to exist at the same index in `item`'s own subtree too -
1823
- * an option isn't guaranteed to nest the same way that many levels
1824
- * down (a version could add/remove a tab), so any mismatch here bails
1825
- * out to null immediately rather than guessing. Second, once both
1826
- * bottom out at a leaf `pages` list, `position` (the reader's own page
1827
- * index within *its* pages list) has to exist in `item`'s pages list
1828
- * too - two versions/languages of the same docs are usually authored
1829
- * with matching page order even when the file paths differ, so this
1830
- * is a reasonable proxy for "the same page" without relying on names.
1831
- *
1832
- * Returns null - meaning "no equivalent page, fall back to
1833
- * firstSlugOf()" - on any structural mismatch or an out-of-range
1834
- * position, rather than guessing at a wrong page. */
1835
- export function equivalentPageIn(
1836
- item: Container,
1837
- remainingPath: PathSegment[],
1838
- position: number
1839
- ): string | null {
1840
- if ('pages' in item) return flattenNav(item.pages)[position]?.slug ?? null;
1841
- if ('href' in item) return null;
1842
- const [next, ...rest] = remainingPath;
1843
- if (!next) return null; // the reader's own page didn't go this deep - no basis to pick a branch
1844
- if ('tabs' in item && next.kind === 'tab') {
1845
- const target = item.tabs[next.index];
1846
- return target ? equivalentPageIn(target, rest, position) : null;
1847
- }
1848
- if ('versions' in item && next.kind === 'version') {
1849
- const target = item.versions[next.index];
1850
- return target ? equivalentPageIn(target, rest, position) : null;
1851
- }
1852
- if ('languages' in item && next.kind === 'language') {
1853
- const target = item.languages[next.index];
1854
- return target ? equivalentPageIn(target, rest, position) : null;
1855
- }
1856
- if ('dropdowns' in item && next.kind === 'dropdown') {
1857
- const target = item.dropdowns[next.index];
1858
- return target ? equivalentPageIn(target, rest, position) : null;
1859
- }
1860
- if ('products' in item && next.kind === 'product') {
1861
- const target = item.products[next.index];
1862
- return target ? equivalentPageIn(target, rest, position) : null;
1863
- }
1864
- return null; // structural mismatch - this option nests differently at this depth
1865
- }
1866
-
1867
- /** The first real page slug in the whole site, root navigation pattern
1868
- * included - used to redirect `/` somewhere sensible when nothing in
1869
- * the navigation happens to be a page literally named "index" (the
1870
- * usual "docs/index.mdx is the homepage" convention). Mirrors
1871
- * firstSlugOf()'s per-container descent, plus the flat-array root case
1872
- * firstSlugOf() alone can't handle since a bare array isn't a Container. */
1873
- export function firstSlugOfNavigation(navigation: NavigationConfig): string | null {
1874
- if (Array.isArray(navigation)) return flattenNav(navigation)[0]?.slug ?? null;
1875
- return firstSlugOf(navigation as Container);
1876
- }
1877
-
1878
- /** Whether `slug` is reachable anywhere underneath a container, however
1879
- * deep - used for computing active state on nodes that aren't part of
1880
- * the active Section's own `path` (global dropdown entries). */
1881
- export function containerContainsSlug(node: Container, slug: string): boolean {
1882
- if ('pages' in node) return flattenNav(node.pages).some((entry) => entry.slug === slug);
1883
- if ('href' in node) return false;
1884
- if ('tabs' in node) return node.tabs.some((t) => containerContainsSlug(t, slug));
1885
- if ('versions' in node) return node.versions.some((v) => containerContainsSlug(v, slug));
1886
- if ('languages' in node) return node.languages.some((l) => containerContainsSlug(l, slug));
1887
- if ('dropdowns' in node) return node.dropdowns.some((d) => containerContainsSlug(d, slug));
1888
- if ('products' in node) return node.products.some((p) => containerContainsSlug(p, slug));
1889
- return false;
1890
- }
1891
-
1892
- function labelOf(item: NamedContainer): string {
1893
- if ('tab' in item) return item.tab;
1894
- if ('version' in item) return item.label ?? item.version;
1895
- if ('language' in item) return item.label ?? item.language;
1896
- if ('dropdown' in item) return item.dropdown;
1897
- return item.product;
1898
- }
1899
-
1900
- function iconOf(item: NamedContainer): string | undefined {
1901
- return (item as { icon?: string }).icon;
1902
- }
1903
-
1904
- // --- Topbar selectors -------------------------------------------------
1905
- //
1906
- // One Selector per PathSegment on the active Section's path. Tabs render
1907
- // as the horizontal pill bar (all options always visible, exactly one
1908
- // current); versions/languages/products, and dropdowns used as a nested
1909
- // path segment, render as a single switcher control instead - the
1910
- // trigger shows the *current* option, its menu lists the alternatives.
1911
- // See BaseLayout.astro for the actual markup per kind.
1912
-
1913
- export interface SelectorOption {
1914
- label: string;
1915
- icon?: string;
1916
- tag?: string;
1917
- href: string;
1918
- active: boolean;
1919
- // Set when this option's own container is a `dropdowns` list (e.g. a
1920
- // tab whose content is `dropdowns` instead of `pages`) - the tab pill
1921
- // itself becomes a dropdown-trigger showing these as its menu, rather
1922
- // than a separate dropdown control rendered alongside it. Only ever
1923
- // populated one level deep (a dropdown option doesn't itself get a
1924
- // nested `dropdown` - matches the one level of "a tab/dropdown owns a
1925
- // dropdowns list" this is meant to cover).
1926
- dropdown?: SelectorOption[];
1927
- }
1928
-
1929
- export interface Selector {
1930
- kind: PathSegment['kind'];
1931
- options: SelectorOption[];
1932
- }
1933
-
1934
- /** An option's own `dropdowns` menu, if it has one (see SelectorOption.dropdown
1935
- * above) - `activeSegment` is the *next* PathSegment after this option's own
1936
- * segment, only passed when this option is the active one on its level, so a
1937
- * sibling option that also happens to own `dropdowns` doesn't spuriously mark
1938
- * one of its entries active just because some *other* option is currently
1939
- * selected. */
1940
- function dropdownMenuOf(
1941
- node: NavChildren,
1942
- hrefForSlug: (slug: string) => string,
1943
- activeSegment: PathSegment | undefined
1944
- ): SelectorOption[] | undefined {
1945
- if (!('dropdowns' in node)) return undefined;
1946
- return node.dropdowns.map((d, i) => ({
1947
- label: d.dropdown,
1948
- icon: iconOf(d),
1949
- href: 'href' in d ? d.href : hrefForSlug(firstSlugOf(d) ?? 'index'),
1950
- active: activeSegment?.kind === 'dropdown' && activeSegment.index === i,
1951
- }));
1952
- }
1953
-
1954
- /** `activePagePosition` is the reader's current page's own index within
1955
- * its Section's flattened pages list (-1 if it can't be found there,
1956
- * which just disables the position-preserving behavior below) - see
1957
- * [...slug].astro for how it's computed. Only version/language
1958
- * switchers try to preserve position across options; tabs/dropdowns/
1959
- * products keep linking to firstSlugOf() unconditionally, since those
1960
- * represent genuinely different content (an "API Reference" tab isn't
1961
- * "the same page" as a "Guides" tab just because they're both first),
1962
- * unlike a version/language of what's meant to be the same docs. */
1963
- export function buildSelectors(
1964
- path: PathSegment[],
1965
- hrefForSlug: (slug: string) => string,
1966
- activePagePosition: number = -1
1967
- ): Selector[] {
1968
- return path.map((segment, segIndex): Selector => {
1969
- const preservesPosition =
1970
- (segment.kind === 'version' || segment.kind === 'language') && activePagePosition >= 0;
1971
- const remainingPath = path.slice(segIndex + 1);
1972
- return {
1973
- kind: segment.kind,
1974
- options: (segment.items as NamedContainer[]).map((item, i) => {
1975
- const isActive = i === segment.index;
1976
- const equivalentSlug = preservesPosition
1977
- ? equivalentPageIn(item as Container, remainingPath, activePagePosition)
1978
- : null;
1979
- const targetSlug = equivalentSlug ?? firstSlugOf(item as Container) ?? 'index';
1980
- return {
1981
- label: labelOf(item),
1982
- icon: iconOf(item),
1983
- tag: 'tag' in item ? item.tag : undefined,
1984
- href: 'href' in item ? item.href : hrefForSlug(targetSlug),
1985
- active: isActive,
1986
- dropdown: dropdownMenuOf(item, hrefForSlug, isActive ? path[segIndex + 1] : undefined),
1987
- };
1988
- }),
1989
- };
1990
- });
1991
- }
1992
-
1993
- // --- Global dropdowns ---------------------------------------------------
1994
- //
1995
- // Unlike path-segment selectors, global dropdowns aren't a mutually
1996
- // exclusive "pick one" choice - `global.dropdowns` is an array where
1997
- // *every* entry renders as its own always-visible trigger button
1998
- // simultaneously (Mintlify's anchors work the same way). A trigger's
1999
- // menu lists its own direct pages as quick links; a bare-href entry is
2000
- // just a plain link with no menu; a deeply-nested entry (tabs/versions/
2001
- // etc. instead of direct pages) falls back to linking straight to its
2002
- // first page, since building a rich flyout for that combination isn't
2003
- // worth the complexity for what's meant to be a quick-links affordance.
2004
-
2005
- export interface GlobalDropdownView {
2006
- label: string;
2007
- icon?: string;
2008
- href: string | null;
2009
- items: SelectorOption[];
2010
- }
2011
-
2012
- export function buildGlobalDropdowns(
2013
- dropdowns: DropdownItem[],
2014
- currentSlug: string,
2015
- titleForSlug: (slug: string) => string,
2016
- hrefForSlug: (slug: string) => string
2017
- ): GlobalDropdownView[] {
2018
- return dropdowns.map((d): GlobalDropdownView => {
2019
- if ('href' in d) {
2020
- return { label: d.dropdown, icon: d.icon, href: d.href, items: [] };
2021
- }
2022
- if ('pages' in d) {
2023
- const items = flattenNav(d.pages).map((entry) => ({
2024
- label: titleForSlug(entry.slug),
2025
- href: hrefForSlug(entry.slug),
2026
- active: entry.slug === currentSlug,
2027
- }));
2028
- return { label: d.dropdown, icon: d.icon, href: null, items };
2029
- }
2030
- const slug = firstSlugOf(d);
2031
- return { label: d.dropdown, icon: d.icon, href: slug ? hrefForSlug(slug) : '#', items: [] };
2032
- });
2033
- }
2034
-
2035
- // --- Sidebar (recursive) -------------------------------------------------
2036
-
2037
- // A group node's `pageSlug` is the page its own label links to (null if
2038
- // it's a pure disclosure/label with no page of its own - the group only
2039
- // groups). NavTree.astro renders depth-0 groups as static, non-collapsible
2040
- // section titles (linked if `pageSlug` is set, plain text otherwise) and
2041
- // every deeper group as a collapsible row styled like a page item, with a
2042
- // chevron, defaulting open when it contains the active page - see
2043
- // navTreeContainsSlug() below.
2044
- export type NavTreeNode =
2045
- | { kind: 'page'; slug: string; title: string; method: string | null }
2046
- | { kind: 'group'; label: string; pageSlug: string | null; children: NavTreeNode[] }
2047
- | { kind: 'link'; label: string; href: string };
2048
-
2049
- /** Builds the sidebar's view model, preserving group nesting depth.
2050
- * `methodForSlug` is optional (most sites have no OpenAPI pages at all)
2051
- * and, when given, returns the HTTP method to badge a page with in the
2052
- * sidebar (e.g. "GET") or null for an ordinary page - see
2053
- * NavTree.astro for how that badge renders. */
2054
- export function buildNavTree(
2055
- navigation: NavItem[],
2056
- titleForSlug: (slug: string) => string,
2057
- methodForSlug?: (slug: string) => string | null
2058
- ): NavTreeNode[] {
2059
- return navigation.map((item): NavTreeNode => {
2060
- if (typeof item === 'string') {
2061
- return {
2062
- kind: 'page',
2063
- slug: item,
2064
- title: titleForSlug(item),
2065
- method: methodForSlug?.(item) ?? null,
2066
- };
2067
- }
2068
- if ('href' in item) {
2069
- return { kind: 'link', label: item.label, href: item.href };
2070
- }
2071
- // loadDocsConfig() always expands `{ group, openapi }` shorthand into
2072
- // a real `{ group, pages }` before anything reaches here (see
2073
- // expandOpenApiInNavigation() in the OpenAPI section above) - this is
2074
- // just a defensive no-op for the shouldn't-happen case of an
2075
- // unexpanded node reaching the sidebar builder.
2076
- if ('openapi' in item) {
2077
- return { kind: 'group', label: item.group, pageSlug: null, children: [] };
2078
- }
2079
- return {
2080
- kind: 'group',
2081
- label: item.group,
2082
- pageSlug: item.page ?? null,
2083
- children: buildNavTree(item.pages, titleForSlug, methodForSlug),
2084
- };
2085
- });
2086
- }
2087
-
2088
- /** Whether `slug` is the group's own attached page, or belongs to any page/group nested inside it. */
2089
- export function navTreeContainsSlug(nodes: NavTreeNode[], slug: string): boolean {
2090
- return nodes.some((node) => {
2091
- if (node.kind === 'page') return node.slug === slug;
2092
- if (node.kind === 'link') return false;
2093
- return node.pageSlug === slug || navTreeContainsSlug(node.children, slug);
2094
- });
2095
- }
2096
-
2097
- export interface BreadcrumbCrumb {
2098
- label: string;
2099
- href: string | null;
2100
- }
2101
-
2102
- /** The chain of ancestor group labels (root first) that `slug` is nested
2103
- * under within `nodes` - empty when the page sits at the top level of its
2104
- * Section with no enclosing group at all, in which case the caller should
2105
- * skip rendering breadcrumbs entirely (there'd be nothing to show but the
2106
- * fixed home icon Breadcrumbs.astro always renders first).
2107
- *
2108
- * Deliberately never includes the current page itself - only the groups
2109
- * it's nested under (that's already the <h1> right below, repeating it
2110
- * in the breadcrumb trail would just be noise). A group whose own
2111
- * attached page (`pageSlug`, see NavTreeNode) *is* `slug` is excluded
2112
- * from its own trail for the same reason - you're looking at that page,
2113
- * it doesn't need to also list itself as its own ancestor. A group only
2114
- * appears here when `slug` is nested *inside* it (its own page, if any,
2115
- * links to that group's landing page - `href: null` for a label-only
2116
- * group with no page of its own to link to). */
2117
- export function ancestorGroupsForSlug(
2118
- nodes: NavTreeNode[],
2119
- slug: string,
2120
- hrefForSlug: (slug: string) => string
2121
- ): BreadcrumbCrumb[] {
2122
- for (const node of nodes) {
2123
- if (node.kind !== 'group') continue;
2124
- if (node.pageSlug === slug) return [];
2125
- if (navTreeContainsSlug(node.children, slug)) {
2126
- const crumb: BreadcrumbCrumb = { label: node.label, href: node.pageSlug ? hrefForSlug(node.pageSlug) : null };
2127
- return [crumb, ...ancestorGroupsForSlug(node.children, slug, hrefForSlug)];
2128
- }
2129
- }
2130
- return [];
2131
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { writedocsTempDir } from './writedocs-temp-dir.js';
5
+ import {
6
+ formatValidationIssues,
7
+ validateDocsConfig,
8
+ type DocsConfig,
9
+ type DropdownItem,
10
+ type FontVariant,
11
+ type LanguageItem,
12
+ type NavChildren,
13
+ type NavItem,
14
+ type NavigationConfig,
15
+ type ProductItem,
16
+ type TabItem,
17
+ type VersionItem,
18
+ } from './config-schema.ts';
19
+
20
+ // O schema do writedocs.json mora em ./config-schema.ts desde a extracao (E11):
21
+ // aquele modulo importa so `zod`, sem nada de node:fs, pra que a plataforma
22
+ // possa importa-lo por `@writedocs/generator/config-schema`. Este reexport
23
+ // mantem a superficie deste arquivo exatamente como era - quem importa
24
+ // `docsConfigSchema`, `DocsConfig`, `seoFieldsSchema`, `mergeSeo` etc. de
25
+ // './config.js' continua importando do mesmo lugar, sem mudar uma linha.
26
+ //
27
+ // `.ts` e nao o `./config-schema.js` gerado, de proposito - e a unica coisa no
28
+ // repositorio que ainda aponta pra fonte, e o bin/ e o `exports` apontam pro
29
+ // `.js`. O motivo e que este reexport carrega 24 `export type`/`interface`
30
+ // (DocsConfig, NavItem, Selector, GlobalDropdownView, ContextMenuConfig...) que
31
+ // uma duzia de .astro consome como `import type { X } from '../lib/config'`, e
32
+ // o esbuild apaga todos eles: o `.js` gerado exporta exatamente 5 simbolos de
33
+ // runtime. Este repositorio nao tem tsconfig.json nem o typescript instalado,
34
+ // entao (a) nada configura o mapeamento `.js` -> `.ts` que faria os tipos
35
+ // voltarem e (b) nao existe typecheck que reclamasse - a troca so apagaria os
36
+ // tipos em silencio. Aqui tudo passa pelo transform do Astro/Vite, que le `.ts`
37
+ // nativamente, entao o `.js` nao resolve problema nenhum deste lado.
38
+ //
39
+ // O custo aceito e que o tarball leva as duas copias e um build do Astro pode
40
+ // carregar as duas (o `.ts` por aqui, o `.js` por quem importa o subpath) - duas
41
+ // instancias do schema, inofensivas porque nada compara identidade de schema.
42
+ export * from './config-schema.ts';
43
+
44
+ /** Normalizes writedocs.json's `domain` into a full origin with no trailing
45
+ * slash (e.g. "docs.example.com" -> "https://docs.example.com"), or null
46
+ * if the site hasn't set one. The single source of truth for "does this
47
+ * site have a real deployed URL" - astro.config.mjs (sitemap
48
+ * registration), BaseLayout.astro (canonical/OG/Twitter URLs), and
49
+ * resolveAbsoluteUrl() below all call this rather than reading
50
+ * config.domain directly, so the http(s):// normalization only happens
51
+ * in one place. */
52
+ export function resolveSiteUrl(config: Pick<DocsConfig, 'domain'>): string | null {
53
+ if (!config.domain) return null;
54
+ const withScheme = /^https?:\/\//i.test(config.domain) ? config.domain : `https://${config.domain}`;
55
+ return withScheme.replace(/\/+$/, '');
56
+ }
57
+
58
+ /** Resolves a possibly-relative asset path (e.g. `seo.ogImage: "/card.png"`)
59
+ * against the site's own domain into an absolute URL - social crawlers
60
+ * (Facebook/Twitter/Slack unfurls) generally require an absolute
61
+ * og:image/twitter:image URL, a same-origin relative path isn't reliably
62
+ * respected. Returns the value unchanged if it's already absolute, or if
63
+ * there's no siteUrl to resolve it against (a relative path is still
64
+ * better than nothing in that case - most social crawlers do at least
65
+ * attempt to fetch it relative to the page they scraped). */
66
+ export function resolveAbsoluteUrl(siteUrl: string | null, value: string): string {
67
+ if (/^https?:\/\//i.test(value)) return value;
68
+ if (!siteUrl) return value;
69
+ return `${siteUrl}${value.startsWith('/') ? '' : '/'}${value}`;
70
+ }
71
+
72
+ /** The resolved (never-undefined) Shiki theme names for light/dark code
73
+ * blocks - shared by astro.config.mjs (sitewide MDX code-fence
74
+ * highlighting) and ApiReferencePanel.astro (the API playground's own
75
+ * separate <Code/> usages, which don't inherit markdown.shikiConfig at
76
+ * all - see astro.config.mjs's own comment on that) so both read the
77
+ * same writedocs.json field and fall back to the same defaults instead of
78
+ * each hardcoding its own copy. */
79
+ export function resolveCodeblockTheme(config: DocsConfig): { light: string; dark: string } {
80
+ return {
81
+ light: config.styles.codeblocks?.light ?? 'github-light',
82
+ dark: config.styles.codeblocks?.dark ?? 'github-dark',
83
+ };
84
+ }
85
+
86
+ // mdx is a real, bundled Shiki grammar (@shikijs/langs' mdx.mjs - it does
87
+ // exist, this isn't a "Shiki doesn't know this language" situation), but in
88
+ // practice it tokenizes ```mdx fences into one single run per line with no
89
+ // internal token boundaries at all - every character, JSX tag or not,
90
+ // lands in the exact same TextMate scope and renders in the theme's plain
91
+ // foreground color. Confirmed directly against real built output: every
92
+ // span in a ```mdx block's compiled HTML carries the identical inline
93
+ // color, none of the tag/attribute/string distinction a JS or JSX fence
94
+ // gets. Aliasing to jsx - a close structural match for the JSX-heavy
95
+ // snippets these fences are actually used for in this codebase's own docs
96
+ // (`<Callout>`, `<Card>`, etc.) - actually highlights the tags/attributes,
97
+ // at the cost of not distinctly coloring the markdown-prose portions
98
+ // interleaved between them (jsx's grammar doesn't know about those) - a
99
+ // worthwhile trade given the alternative is no color at all. See
100
+ // astro.config.mjs's own shikiConfig.langAlias for where this actually
101
+ // gets used. */
102
+ const DEFAULT_CODEBLOCK_LANG_ALIAS: Record<string, string> = { mdx: 'jsx' };
103
+
104
+ /** The resolved fence-language alias map - the built-in mdx -> jsx default
105
+ * above, merged with (not replaced by) whatever a site adds under its own
106
+ * writedocs.json styles.codeblocks.langAlias, so a site can extend this list
107
+ * without having to redeclare the built-in entry to keep it. Same
108
+ * "shared resolver, not each consumer re-deriving its own defaults"
109
+ * pattern resolveCodeblockTheme() right above already establishes -
110
+ * though today only astro.config.mjs actually consumes this one, unlike
111
+ * that one, since ApiReferencePanel.astro's own <Code/> usages never
112
+ * render a `mdx`-tagged snippet (API operation samples are curl/js/
113
+ * python/etc., not MDX markup) to begin with. */
114
+ export function resolveCodeblockLangAlias(config: DocsConfig): Record<string, string> {
115
+ return { ...DEFAULT_CODEBLOCK_LANG_ALIAS, ...config.styles.codeblocks?.langAlias };
116
+ }
117
+
118
+ const DEFAULT_FONT_FAMILY = 'Inter';
119
+
120
+ export interface ResolvedFonts {
121
+ base: FontVariant;
122
+ heading: FontVariant | null;
123
+ body: FontVariant | null;
124
+ }
125
+
126
+ /** Resolves writedocs.json's `styles.fonts` into the three font declarations
127
+ * BaseLayout.astro actually needs to render: `base` (the site-wide
128
+ * default - every element gets this unless `heading`/`body` narrows it
129
+ * further), and `heading`/`body`, each `null` when not independently
130
+ * configured (letting BaseLayout fall back to `base` for whichever side
131
+ * wasn't overridden, rather than this function silently copying `base`
132
+ * into both and losing the distinction between "explicitly set to the
133
+ * same font" and "just inheriting the default"). The one hardcoded
134
+ * default in this whole feature lives right here: no `styles.fonts` at
135
+ * all resolves to plain Inter, loaded for real (a Google Fonts `<link>`,
136
+ * not just a name in a fallback stack that only renders correctly for a
137
+ * reader who happens to already have Inter installed - see base.css's
138
+ * old `font-family` rule, which was exactly that, before this feature
139
+ * existed). */
140
+ export function resolveFonts(config: DocsConfig): ResolvedFonts {
141
+ const fonts = config.styles.fonts;
142
+ const base: FontVariant = fonts
143
+ ? { family: fonts.family, weight: fonts.weight, source: fonts.source, format: fonts.format }
144
+ : { family: DEFAULT_FONT_FAMILY };
145
+ return { base, heading: fonts?.heading ?? null, body: fonts?.body ?? null };
146
+ }
147
+
148
+ /** The Google Fonts CSS2 API URL for every font in `fonts` that isn't a
149
+ * `source`-based (local/externally-hosted) font - `null` if there's
150
+ * nothing to load this way at all (every configured font has its own
151
+ * `source`). One request covers every family needed (`&family=` repeated
152
+ * per unique family+weight pair, deduped so the same pair - e.g. `base`
153
+ * and `body` both left at the site default - isn't requested twice)
154
+ * rather than a separate `<link>` per font. `display=swap` avoids an
155
+ * invisible-text flash while the font file loads (renders in the
156
+ * fallback stack immediately, swaps once the real font is ready) -
157
+ * Google's own recommended default for exactly this use case. */
158
+ export function googleFontsHref(fonts: ResolvedFonts): string | null {
159
+ const entries = [fonts.base, fonts.heading, fonts.body].filter(
160
+ (f): f is FontVariant => f !== null && !f.source
161
+ );
162
+ if (entries.length === 0) return null;
163
+ const seen = new Set<string>();
164
+ const params: string[] = [];
165
+ for (const f of entries) {
166
+ const key = `${f.family}|${f.weight ?? ''}`;
167
+ if (seen.has(key)) continue;
168
+ seen.add(key);
169
+ const familyParam = f.family.trim().replace(/\s+/g, '+');
170
+ params.push(f.weight ? `family=${familyParam}:wght@${f.weight}` : `family=${familyParam}`);
171
+ }
172
+ return `https://fonts.googleapis.com/css2?${params.join('&')}&display=swap`;
173
+ }
174
+
175
+ /** A `@font-face` rule for one `source`-based font (local project path or
176
+ * externally-hosted URL), or `''` for a Google Font (no `source` - see
177
+ * googleFontsHref() above, the other half of font loading). `weight`
178
+ * becomes the rule's `font-weight` *descriptor* here - it tells the
179
+ * browser which weight this specific file represents, so a `font-weight`
180
+ * CSS value requested elsewhere (BaseLayout.astro's own
181
+ * `wdFontWeightHeading`/`wdFontWeightBody`, see its comment) picks the
182
+ * real matching file instead of synthetically ("faux") bolding/
183
+ * thinning a mismatched one. This function only ever produces the
184
+ * `@font-face` rule itself - actually applying `font-weight` to any
185
+ * element (h1-h6, body) is BaseLayout's job, not this one's. */
186
+ export function fontFaceRule(font: FontVariant | null): string {
187
+ if (!font || !font.source) return '';
188
+ const weightDecl = font.weight !== undefined ? ` font-weight: ${font.weight};` : '';
189
+ return `@font-face { font-family: '${font.family}'; src: url('${font.source}') format('${font.format ?? 'woff2'}'); font-display: swap;${weightDecl} }`;
190
+ }
191
+
192
+ /** Splits one side of `styles.navbar` (the string-or-object union
193
+ * navbarColorValueSchema allows) into its two parts, filling in the
194
+ * fallback background when the field is unset at all. `accent` stays
195
+ * `undefined` - not defaulted here - for both the bare-string case and
196
+ * the object case where a site set `background` without `accent`;
197
+ * BaseLayout.astro is what turns that `undefined` into "fall back to
198
+ * --wd-primary" for the accent CSS var. There's no `foreground` here at
199
+ * all to resolve - the navbar's text/icon color is never read from
200
+ * writedocs.json, see `navbar`'s own schema comment (stylesSchema) for why. */
201
+ export function resolveNavbarColor(
202
+ value: string | { background: string; accent?: string } | undefined,
203
+ fallbackBackground: string
204
+ ): { background: string; accent: string | undefined } {
205
+ if (!value) return { background: fallbackBackground, accent: undefined };
206
+ if (typeof value === 'string') return { background: value, accent: undefined };
207
+ return { background: value.background, accent: value.accent };
208
+ }
209
+
210
+ /** Picks black or white text for readable contrast against `hexColor`,
211
+ * via the standard relative-luminance formula (ITU-R BT.601 weights -
212
+ * the same "perceived brightness" approximation used all over the web
213
+ * for exactly this "what text color goes on this swatch" problem, not
214
+ * the more expensive WCAG relative-luminance formula, which isn't
215
+ * needed for a binary choose-the-less-bad-option decision like this
216
+ * one). Used two ways in BaseLayout.astro: for `--wd-navbar-accent-text`
217
+ * (the active tab's own fill used to always be `var(--wd-primary)` with
218
+ * hardcoded `color: #fff`, which only actually read fine because every
219
+ * default/example primary color so far has been dark/saturated enough
220
+ * for white text; once a site's navbar accent can be *any* color -
221
+ * `styles.navbar.light.accent`, falling back to `styles.colors.primary`
222
+ * when unset, see resolveNavbarColor() above - that assumption can't
223
+ * hold unconditionally), and for `--wd-navbar-foreground` itself once
224
+ * `styles.navbar` is configured at all (the navbar's plain text/icon
225
+ * color - see `navbar`'s own schema comment for why that's always
226
+ * computed, never a writedocs.json value). Malformed input (not a 6-digit
227
+ * `#rrggbb` hex) falls back to white rather than throwing - same "don't
228
+ * fail a build over a cosmetic color value" posture every other color
229
+ * field here takes (none of them validate hex syntax either). */
230
+ export function contrastTextColor(hexColor: string): '#000000' | '#ffffff' {
231
+ const match = /^#?([0-9a-f]{6})$/i.exec(hexColor.trim());
232
+ if (!match) return '#ffffff';
233
+ const hex = match[1];
234
+ const r = parseInt(hex.slice(0, 2), 16);
235
+ const g = parseInt(hex.slice(2, 4), 16);
236
+ const b = parseInt(hex.slice(4, 6), 16);
237
+ const luminance = (299 * r + 587 * g + 114 * b) / 1000;
238
+ return luminance > 150 ? '#000000' : '#ffffff';
239
+ }
240
+
241
+ /** Whether an href points off-site - has an explicit scheme (`https:`,
242
+ * `mailto:`, `tel:`, ...) or is protocol-relative (`//...`) - versus a
243
+ * same-site path, which this codebase always produces as a single
244
+ * leading slash (hrefForSlug() in [...slug].astro). Used everywhere a
245
+ * nav link is rendered to decide whether it should open in a new tab;
246
+ * deliberately a plain string check rather than a schema-level flag,
247
+ * so it applies uniformly to every href source (topbar.links, a
248
+ * switcher/tab pill pointing at a bare `href` container, a global
249
+ * dropdown's own link, a sidebar link leaf) without threading an
250
+ * `external` field through every one of those call sites. */
251
+ export function isExternalHref(href: string): boolean {
252
+ return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
253
+ }
254
+
255
+ // ---------------------------------------------------------------------
256
+ // Icons - writedocs.json's `icon` fields (TabItem, DropdownItem, ProductItem,
257
+ // Card) are plain strings with no schema-level distinction between "an
258
+ // emoji, paste it verbatim" and "an icon-set name, look it up". Resolved
259
+ // here rather than validated in the schema, since both are valid uses of
260
+ // the same string field and the right rendering only becomes obvious once
261
+ // you look at the value's shape.
262
+ // ---------------------------------------------------------------------
263
+
264
+ export type IconResolution =
265
+ | { kind: 'iconify'; name: string } // ready for astro-icon/components' <Icon name=... />
266
+ | { kind: 'text'; value: string }; // literal text/emoji, rendered as-is
267
+
268
+ const DEFAULT_ICON_COLLECTION = 'lucide';
269
+
270
+ /** Resolves a writedocs.json `icon` string into either an Iconify icon id or
271
+ * literal text, covering three forms:
272
+ * - "collection:icon-name" (e.g. "mdi:server", "simple-icons:github")
273
+ * - used as-is against whatever @iconify-json/* collections are
274
+ * installed (see astro.config.mjs / package.json).
275
+ * - a bare name using only letters/digits/hyphens (e.g. "smartphone",
276
+ * "book-open") - defaults to the "lucide" collection, matching what
277
+ * docs.json-examples/ already assumes for plain icon-name strings.
278
+ * - anything else (an emoji, a symbol, arbitrary text) - rendered
279
+ * verbatim, preserving the original "just paste an emoji" behavior
280
+ * from before icon-library support existed. */
281
+ export function resolveIcon(icon: string): IconResolution {
282
+ if (/^[a-z0-9-]+:[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: icon };
283
+ if (/^[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: `${DEFAULT_ICON_COLLECTION}:${icon}` };
284
+ return { kind: 'text', value: icon };
285
+ }
286
+
287
+ // ---------------------------------------------------------------------
288
+ // OpenAPI navigation expansion - turns a group's `openapi: { src, path }`
289
+ // shorthand into a concrete `{ group, pages }` (one sub-group per tag),
290
+ // using the per-spec manifest generate-api-pages.js writes to
291
+ // writedocsTempDir()'s openapi/<path>/manifest.json before Astro starts (both
292
+ // `writedocs dev` and `writedocs build` run it first - see
293
+ // src/cli/dev.js, src/cli/build.js). Applied once, here in
294
+ // loadDocsConfig(), so every other function in this file
295
+ // (resolveSections, flattenNav, buildNavTree, ...) only ever sees plain
296
+ // page-slug strings and ordinary `{ group, pages }` nodes, and never
297
+ // needs to know the `openapi` group shorthand exists. A writedocs.json can
298
+ // have any number of these groups, each pointing at its own spec and
299
+ // mounted under its own `path` - generate-api-pages.js namespaces each
300
+ // spec's manifest/operations under that same `path`, so there's no
301
+ // cross-spec collision as long as every group uses a distinct `path`.
302
+ // ---------------------------------------------------------------------
303
+
304
+ export interface OpenApiManifestEntry {
305
+ slug: string;
306
+ method: string;
307
+ path: string;
308
+ tags: string[];
309
+ title: string;
310
+ generated: boolean;
311
+ }
312
+
313
+ /** `specPath` is the owning group's own `openapi.path` (e.g. "/api") -
314
+ * generate-api-pages.js writes each spec's manifest under a directory
315
+ * named after that same value, so this only ever needs to know which
316
+ * group is asking, not anything about the spec's contents itself. */
317
+ function loadOpenApiManifest(contentDir: string, specPath: string): OpenApiManifestEntry[] | null {
318
+ const normalized = specPath.replace(/^\/+|\/+$/g, '');
319
+ const manifestPath = path.join(writedocsTempDir(contentDir), 'openapi', normalized, 'manifest.json');
320
+ if (!fs.existsSync(manifestPath)) return null;
321
+ try {
322
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
323
+ } catch {
324
+ return null; // stale/partial write from an interrupted previous run - treat as absent
325
+ }
326
+ }
327
+
328
+ /** Expands one group's `openapi: { src, path }` into the `pages` array
329
+ * it stands in for - one sub-group per tag (in first-seen order),
330
+ * untagged operations collected into a trailing "Other" group. Returns
331
+ * an empty array (an empty, harmless group) rather than throwing if the
332
+ * manifest is missing - generate-api-pages.js always runs before this
333
+ * does (see src/cli/dev.js/build.js), so a missing manifest here means
334
+ * generation hasn't happened yet rather than a user error worth
335
+ * crashing the dev server over. */
336
+ function expandOpenApiGroupPages(openapiRef: { src: string; path: string }, contentDir: string): NavItem[] {
337
+ const manifest = loadOpenApiManifest(contentDir, openapiRef.path);
338
+ if (!manifest) return [];
339
+
340
+ const seenTags: string[] = [];
341
+ const byTag = new Map<string, string[]>();
342
+ const untagged: string[] = [];
343
+ for (const op of manifest) {
344
+ const tag = op.tags[0];
345
+ if (!tag) {
346
+ untagged.push(op.slug);
347
+ continue;
348
+ }
349
+ if (!byTag.has(tag)) {
350
+ byTag.set(tag, []);
351
+ seenTags.push(tag);
352
+ }
353
+ byTag.get(tag)!.push(op.slug);
354
+ }
355
+ const groups: NavItem[] = seenTags.map((tag) => ({ group: tag, pages: byTag.get(tag)! }));
356
+ return untagged.length > 0 ? [...groups, { group: 'Other', pages: untagged }] : groups;
357
+ }
358
+
359
+ function expandOpenApiInPages(pages: NavItem[], contentDir: string): NavItem[] {
360
+ return pages.map((item): NavItem => {
361
+ if (typeof item === 'string') return item;
362
+ if ('href' in item) return item;
363
+ if ('openapi' in item) {
364
+ // A group using the { group, openapi: { src, path } } shorthand -
365
+ // replace it with a real { group, pages } node built from that
366
+ // spec's own manifest, so nothing downstream needs to know the
367
+ // shorthand ever existed.
368
+ return { group: item.group, pages: expandOpenApiGroupPages(item.openapi, contentDir) };
369
+ }
370
+ // An ordinary { group, page?, pages } - recurse into its own pages,
371
+ // since an openapi-group can be nested inside a hand-authored group
372
+ // too (e.g. wrapping it to add hand-written pages alongside the
373
+ // auto-generated ones).
374
+ return { ...item, pages: expandOpenApiInPages(item.pages, contentDir) };
375
+ });
376
+ }
377
+
378
+ /** Mirrors walkSections()'s traversal (tabs/versions/languages/
379
+ * dropdowns/products, each bottoming out at `pages`), but rewrites
380
+ * rather than collects - every `pages` array anywhere in the tree gets
381
+ * run through expandOpenApiInPages(). */
382
+ function expandOpenApiInContainer(node: NavChildren, contentDir: string): NavChildren {
383
+ if ('pages' in node) return { pages: expandOpenApiInPages(node.pages, contentDir) };
384
+ if ('href' in node) return node;
385
+ if ('tabs' in node) {
386
+ return { tabs: node.tabs.map((t) => ({ ...t, ...expandOpenApiInContainer(t, contentDir) })) };
387
+ }
388
+ if ('versions' in node) {
389
+ return { versions: node.versions.map((v) => ({ ...v, ...expandOpenApiInContainer(v, contentDir) })) };
390
+ }
391
+ if ('languages' in node) {
392
+ return { languages: node.languages.map((l) => ({ ...l, ...expandOpenApiInContainer(l, contentDir) })) };
393
+ }
394
+ if ('dropdowns' in node) {
395
+ return { dropdowns: node.dropdowns.map((d) => ({ ...d, ...expandOpenApiInContainer(d, contentDir) })) };
396
+ }
397
+ if ('products' in node) {
398
+ return { products: node.products.map((p) => ({ ...p, ...expandOpenApiInContainer(p, contentDir) })) };
399
+ }
400
+ return node;
401
+ }
402
+
403
+ function expandOpenApiInNavigation(navigation: NavigationConfig, contentDir: string): NavigationConfig {
404
+ if (Array.isArray(navigation)) return expandOpenApiInPages(navigation, contentDir);
405
+ const expanded = { ...navigation, ...expandOpenApiInContainer(navigation as Container, contentDir) };
406
+ if (navigation.global?.dropdowns) {
407
+ expanded.global = {
408
+ dropdowns: navigation.global.dropdowns.map(
409
+ (d) => ({ ...d, ...expandOpenApiInContainer(d, contentDir) }) as DropdownItem
410
+ ),
411
+ };
412
+ }
413
+ return expanded as NavigationConfig;
414
+ }
415
+
416
+ export function loadDocsConfig(contentDir: string): DocsConfig {
417
+ const configPath = path.join(contentDir, 'writedocs.json');
418
+ if (!fs.existsSync(configPath)) {
419
+ throw new Error(
420
+ `[writedocs] writedocs.json not found at ${configPath}. Run "writedocs init" to scaffold one.`
421
+ );
422
+ }
423
+ // A validacao em si (JSON.parse incluso) vive em validateDocsConfig
424
+ // (./config-schema.ts), o mesmo ponto de entrada que o `writedocs validate` e
425
+ // a plataforma usam - e o que garante que os tres reportem exatamente os
426
+ // mesmos problemas, com as mesmas palavras.
427
+ //
428
+ // As duas formas de falhar continuam saindo daqui EXATAMENTE como saiam antes
429
+ // desta extracao:
430
+ // - JSON quebrado: relanca o proprio SyntaxError do JSON.parse (mesmo tipo,
431
+ // mesma mensagem que o runtime produz);
432
+ // - schema invalido: o mesmo Error, com o mesmo texto, montado a partir do
433
+ // mesmo formatador que a CLI usa.
434
+ const result = validateDocsConfig(fs.readFileSync(configPath, 'utf-8'));
435
+ if (!result.ok) {
436
+ if (result.parseError) throw result.parseError;
437
+ throw new Error(`[writedocs] writedocs.json failed validation:\n${formatValidationIssues(result.issues)}`);
438
+ }
439
+ // A no-op pass over ordinary navigation trees (no openapi groups) -
440
+ // always run, rather than gated behind a global manifest check, since
441
+ // there's no longer a single global spec to check for.
442
+ result.data.navigation = expandOpenApiInNavigation(result.data.navigation, contentDir);
443
+ return result.data;
444
+ }
445
+
446
+ // --- Locating a page by file id, independent of its effective slug -----
447
+ //
448
+ // writedocs.json's `pages` arrays, and every helper above/below that walks
449
+ // them (flattenNav, firstSlugOf, containerContainsSlug, buildNavTree, ...),
450
+ // always identify a page by its file id - its path relative to the
451
+ // content directory (project root), extension stripped, with a trailing
452
+ // `/index` segment dropped (matching Astro's own default id computation -
453
+ // see the `/index` note below) - regardless of how that page ends up
454
+ // being served. docs/ is not special: a file at `docs/guides/x.mdx` has
455
+ // file id `docs/guides/x`, the exact same rule applied to a file
456
+ // anywhere else in the project.
457
+ //
458
+ // A page's actual URL is a separate question, and Astro's own glob()
459
+ // content loader already has a first-class answer for it: if a page's
460
+ // frontmatter sets `slug`, `entry.id` (and therefore the route Astro
461
+ // builds for it) becomes that value verbatim instead of the file-path
462
+ // default - see astro/dist/content/loaders/glob.js's generateIdDefault:
463
+ // `if (data.slug) return data.slug`. content.config.ts's `slug` schema
464
+ // field is deliberately the same field Astro already recognizes, so
465
+ // nothing here needs to reimplement the override or track it separately -
466
+ // it only needs a way to find a page BY file id (to resolve writedocs.json's
467
+ // references) even once `entry.id` no longer equals it.
468
+ //
469
+ // --- Page discovery -------------------------------------------------
470
+ //
471
+ // Any .md/.mdx file anywhere under the content directory becomes a page
472
+ // candidate the moment it has *any* frontmatter block at all - docs/ has
473
+ // no special status here, it's just a folder like any other (a
474
+ // conventional, recommended place to put most pages, not a requirement).
475
+ // content.config.ts's `pages` collection is the same docsSchema applied
476
+ // to exactly this file set. A file with zero frontmatter (no `---` block
477
+ // whatsoever) is never a page candidate - a snippet (see
478
+ // docs/dev/docs/snippets.mdx) typically has none, which is what lets it
479
+ // live anywhere without tripping schema validation. A file that *does*
480
+ // open a frontmatter block but is missing a required field (`title`)
481
+ // still fails validation exactly as before - only "no frontmatter at
482
+ // all" is new; a genuine mistake (frontmatter present, title forgotten)
483
+ // stays a loud build error rather than being silently treated as
484
+ // non-page content.
485
+ //
486
+ // A handful of directories are never scanned regardless of what's in
487
+ // them - build output, dependencies, and writedocs' own working
488
+ // directories, none of which a site author would ever intend as page
489
+ // content. Only excluded at the content directory's own top level (a
490
+ // hand-authored `guides/dist-notes.mdx` isn't mistaken for the build
491
+ // output directory `dist/` just because a folder two levels down happens
492
+ // to also be named `dist`).
493
+ const EXCLUDED_TOP_LEVEL_DIRS = new Set(['node_modules', 'dist', '.astro', '.writedocs', 'public', '.git']);
494
+
495
+ /** Recursively finds every .md/.mdx file under `contentDir` that has a
496
+ * frontmatter block, skipping the handful of build/dependency
497
+ * directories a real content directory tends to also contain (see
498
+ * EXCLUDED_TOP_LEVEL_DIRS above) - docs/ is scanned exactly like any
499
+ * other folder, no special-casing. Returns POSIX-relative paths (from
500
+ * `contentDir`) suitable to hand straight to Astro's `glob()` loader as
501
+ * a literal `pattern` array - see content.config.ts's `pages`
502
+ * collection, and [...slug].astro, which needs the identical list to
503
+ * decide whether calling `getCollection('pages')` is worth doing at all
504
+ * (see content.config.ts's own comment on why an empty collection still
505
+ * needs to exist, just backed by a no-op loader, to avoid Astro's "does
506
+ * not exist or is empty" warning). Also reused directly by
507
+ * astro.config.mjs's noindex/sitemap scan, so that scan always sees
508
+ * exactly the same file set that actually becomes a page - no risk of
509
+ * the two drifting apart. Synchronous and re-run from scratch wherever
510
+ * it's called rather than cached and shared across modules - consistent
511
+ * with how loadDocsConfig() itself is already called repeatedly across
512
+ * this codebase instead of threaded through as shared state, and cheap
513
+ * enough in practice (a docs site's own file count) not to matter. */
514
+ export function findAllPages(contentDir: string): string[] {
515
+ const results: string[] = [];
516
+ function walk(dir: string, relBase: string) {
517
+ let entries: fs.Dirent[];
518
+ try {
519
+ entries = fs.readdirSync(dir, { withFileTypes: true });
520
+ } catch {
521
+ return;
522
+ }
523
+ for (const entry of entries) {
524
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
525
+ const abs = path.join(dir, entry.name);
526
+ if (entry.isDirectory()) {
527
+ if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
528
+ walk(abs, rel);
529
+ continue;
530
+ }
531
+ if (!entry.isFile() || !/\.mdx?$/i.test(entry.name)) continue;
532
+ let raw: string;
533
+ try {
534
+ raw = fs.readFileSync(abs, 'utf-8');
535
+ } catch {
536
+ continue;
537
+ }
538
+ const { data } = matter(raw);
539
+ if (Object.keys(data).length === 0) continue; // no frontmatter at all - not a page
540
+ results.push(rel);
541
+ }
542
+ }
543
+ walk(contentDir, '');
544
+ return results;
545
+ }
546
+
547
+ /** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
548
+ * `public/` included - auto-loaded site-wide with zero `writedocs.json`
549
+ * config, on top of (not instead of) the explicit `scripts` field
550
+ * (bannerSchema and friends, above). Drop a file in, it loads;
551
+ * there's no field naming which ones to use, matching the same "just
552
+ * works" convention `docs/`'s own file discovery already follows (see
553
+ * findAllPages() above / `content-pipeline.mdx`) - a site author already
554
+ * drops content files in and expects them found, rather than also
555
+ * listing every one in writedocs.json.
556
+ *
557
+ * Two separate walks, because `public/` needs different treatment than
558
+ * everywhere else:
559
+ *
560
+ * - `css`/`js`: every `.css`/`.js` file outside `public/` (root, `docs/`,
561
+ * `snippets/`, any custom folder) - reused as the walk-with-exclusions
562
+ * shape from findAllPages() above, and its exact `EXCLUDED_TOP_LEVEL_DIRS`
563
+ * set (skipped only at the project root, same as there), so `dist/`,
564
+ * `node_modules/`, `.astro/`, `.writedocs/`, `.git/`, and (for this
565
+ * half only - see below) `public/` are never walked into. BaseLayout.astro
566
+ * reads each one's raw content and inlines it as a `<style>`/
567
+ * `<script is:inline>` tag.
568
+ * - `publicCss`/`publicJs`: every `.css`/`.js` file *inside* `public/`,
569
+ * walked separately (starting from `<contentDir>/public` rather than
570
+ * `contentDir` itself, so the same `EXCLUDED_TOP_LEVEL_DIRS` check
571
+ * doesn't apply here - there's no `public/public/` or `public/dist/`
572
+ * convention to guard against). Returned as public-URL-rooted hrefs
573
+ * (a leading `/`, no `public` segment - `public/custom.css` becomes
574
+ * `/custom.css`) rather than content-dir-relative paths, since these
575
+ * files are already served as static assets at exactly that URL once
576
+ * Astro copies `public/` into the build output. BaseLayout.astro
577
+ * renders these as ordinary `<link rel="stylesheet">`/`<script src>`
578
+ * tags pointing at that URL instead of inlining their content -
579
+ * inlining would duplicate every byte (once in the page's own HTML,
580
+ * once more as the independently-fetchable static file at that same
581
+ * URL) for no benefit, where a `<link>`/`<script src>` gets normal
582
+ * browser caching across pages instead of repeating the content on
583
+ * every single page's markup.
584
+ *
585
+ * Both halves are broader than they might sound - a stray `.js` file
586
+ * kept in `docs/`, `snippets/`, or `public/` for an unrelated reason
587
+ * (a snippet's own local helper, an image gallery's lightbox script
588
+ * someone dropped in `public/` to reference from a raw `<script src>`
589
+ * in an .mdx file, say) gets auto-injected sitewide the same as a
590
+ * deliberate one; there's no separate "this one's just tooling" signal
591
+ * to opt out of the convention short of renaming its extension.
592
+ *
593
+ * Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
594
+ * by their respective path for deterministic load order across rebuilds
595
+ * - same reasoning as llms.txt's own alphabetical-by-slug sort (see
596
+ * llms.txt.ts) - filesystem readdir order isn't guaranteed portable
597
+ * across OSes or directory-walk order otherwise.
598
+ *
599
+ * `css`/`js` return POSIX-separated paths relative to `contentDir`, not
600
+ * absolute paths or file contents - BaseLayout.astro (the sole caller)
601
+ * resolves and reads each one's content itself, right before inlining
602
+ * it, so a file's content is always current as of that specific
603
+ * request/build rather than cached here across a `writedocs dev`
604
+ * session. `publicCss`/`publicJs` return the public-URL hrefs described
605
+ * above - nothing to read, Astro's own static-file serving/copy already
606
+ * handles those. */
607
+ export function findRootAssets(
608
+ contentDir: string
609
+ ): { css: string[]; js: string[]; publicCss: string[]; publicJs: string[] } {
610
+ const css: string[] = [];
611
+ const js: string[] = [];
612
+ function walk(dir: string, relBase: string) {
613
+ let entries: fs.Dirent[];
614
+ try {
615
+ entries = fs.readdirSync(dir, { withFileTypes: true });
616
+ } catch {
617
+ return;
618
+ }
619
+ for (const entry of entries) {
620
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
621
+ const abs = path.join(dir, entry.name);
622
+ if (entry.isDirectory()) {
623
+ if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
624
+ walk(abs, rel);
625
+ continue;
626
+ }
627
+ if (!entry.isFile()) continue;
628
+ if (/\.css$/i.test(entry.name)) css.push(rel);
629
+ else if (/\.js$/i.test(entry.name)) js.push(rel);
630
+ }
631
+ }
632
+ walk(contentDir, '');
633
+ css.sort();
634
+ js.sort();
635
+
636
+ const publicCss: string[] = [];
637
+ const publicJs: string[] = [];
638
+ function walkPublic(dir: string, relBase: string) {
639
+ let entries: fs.Dirent[];
640
+ try {
641
+ entries = fs.readdirSync(dir, { withFileTypes: true });
642
+ } catch {
643
+ return;
644
+ }
645
+ for (const entry of entries) {
646
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
647
+ const abs = path.join(dir, entry.name);
648
+ if (entry.isDirectory()) {
649
+ walkPublic(abs, rel);
650
+ continue;
651
+ }
652
+ if (!entry.isFile()) continue;
653
+ if (/\.css$/i.test(entry.name)) publicCss.push(`/${rel}`);
654
+ else if (/\.js$/i.test(entry.name)) publicJs.push(`/${rel}`);
655
+ }
656
+ }
657
+ walkPublic(path.join(contentDir, 'public'), '');
658
+ publicCss.sort();
659
+ publicJs.sort();
660
+
661
+ return { css, js, publicCss, publicJs };
662
+ }
663
+
664
+ /** The root-relative URL paths (leading `/`, e.g. `/images/hero.svg`)
665
+ * referenced by the writedocs.json/styles fields that point at a static asset
666
+ * *by URL* rather than embedding it inline: `styles.favicon`,
667
+ * `styles.logo` (both the plain-string and `{light, dark}` object forms),
668
+ * `styles.background.images.{light,dark}`, and `seo.ogImage`. External
669
+ * URLs (anything not starting with `/` - `https://...`, mainly) are
670
+ * filtered out, since those need no local file resolution at all.
671
+ * Deduped, since e.g. `logo.light` and `background.images.light`
672
+ * coincidentally pointing at the same file shouldn't resolve/copy it
673
+ * twice.
674
+ *
675
+ * Used by `stylesAssetFallback()` (`styles-asset-integration.js`,
676
+ * imported from `astro.config.mjs`) to let every one of these resolve
677
+ * from *anywhere* in the project, not just `public/` - previously,
678
+ * `public/` was the one place `styles.background.images` (etc.) had to
679
+ * live, since Astro's own `publicDir` copy is the only thing that ever
680
+ * served them; everywhere else in this codebase's own "drop a file
681
+ * anywhere, it's found" convention (`findRootAssets()` right above,
682
+ * `findAllPages()` for content) already worked project-wide. See that
683
+ * integration's own comment for the actual resolution mechanism (a dev-time
684
+ * middleware plus a post-build copy step, not a duplicated `publicDir`)
685
+ * and why a straight copy-into-`public/`-on-disk approach was rejected.
686
+ *
687
+ * Logo light/dark resolution here deliberately mirrors BaseLayout.astro's
688
+ * own `logoLight`/`logoDark` derivation exactly (a plain-string `logo`
689
+ * counts as both light and dark) - two independent implementations of
690
+ * that same union-unwrapping would only be one accidental edit away from
691
+ * disagreeing with each other. `footer.logo` (footerSchema) gets the
692
+ * same treatment, independently of `styles.logo` - both are collected
693
+ * unconditionally here (not just whichever one BaseLayout.astro would
694
+ * actually end up using for a given page), since this function has no
695
+ * page context to know which page modes render a footer at all; an
696
+ * unreferenced path collected here that never actually renders anywhere
697
+ * is harmless (nothing copies/resolves a file that's never requested),
698
+ * but a real footer.logo file silently 404ing because this function
699
+ * didn't know to resolve it is exactly the bug this comment is warning
700
+ * future edits away from repeating.
701
+ *
702
+ * Per-page frontmatter `seo.ogImage` overrides are deliberately out of
703
+ * scope - those aren't visible from a `DocsConfig` alone (they live in
704
+ * each page's own frontmatter, merged in per-request by [...slug].astro/
705
+ * BaseLayout.astro), and resolving every page's own override would need
706
+ * a full content scan this function has no reason to also become. A
707
+ * page overriding `seo.ogImage` to something outside `public/` still
708
+ * needs to put it there for now. */
709
+ export function collectConfiguredAssetPaths(config: DocsConfig): string[] {
710
+ const logo = config.styles.logo;
711
+ const logoLight = typeof logo === 'string' ? logo : logo?.light;
712
+ const logoDark = typeof logo === 'string' ? logo : logo?.dark;
713
+ const footerLogo = config.footer.logo;
714
+ const footerLogoLight = typeof footerLogo === 'string' ? footerLogo : footerLogo?.light;
715
+ const footerLogoDark = typeof footerLogo === 'string' ? footerLogo : footerLogo?.dark;
716
+ const fonts = config.styles.fonts;
717
+ const raw: (string | undefined)[] = [
718
+ config.styles.favicon,
719
+ logoLight,
720
+ logoDark,
721
+ footerLogoLight,
722
+ footerLogoDark,
723
+ config.styles.background?.images?.light,
724
+ config.styles.background?.images?.dark,
725
+ config.seo?.ogImage,
726
+ // A `styles.fonts` `source` is only ever handled here when it's a
727
+ // project-relative path (the `p.startsWith('/')` filter below already
728
+ // excludes both Google Font names, which never start with "/", and a
729
+ // full https:// external font URL, which the browser fetches directly
730
+ // - neither needs resolving/copying through this pipeline at all).
731
+ fonts?.source,
732
+ fonts?.heading?.source,
733
+ fonts?.body?.source,
734
+ ];
735
+ const paths = raw.filter((p): p is string => Boolean(p) && p.startsWith('/'));
736
+ return Array.from(new Set(paths));
737
+ }
738
+
739
+ export interface DocsEntryLike {
740
+ id: string;
741
+ filePath?: string;
742
+ }
743
+
744
+ /** Recovers a content entry's writedocs.json-facing file id (its path
745
+ * relative to the content directory, extension stripped, trailing
746
+ * `/index` dropped), independent of any frontmatter `slug` override -
747
+ * `entry.filePath` is always root-relative and POSIX-separated (how
748
+ * Astro's content layer records it, relative to whatever `--root` Astro
749
+ * itself was invoked with - see run-astro.js), so both it and the
750
+ * content directory are resolved to absolute paths before comparing,
751
+ * rather than string-matching a prefix that could be relative,
752
+ * absolute, or platform-separated inconsistently.
753
+ *
754
+ * The trailing-`/index`-drop mirrors Astro's own default id computation
755
+ * exactly (`getContentEntryIdAndSlug()` in astro/dist/content/
756
+ * utils.js: segments are joined then `.replace(/\/index$/, '')`) -
757
+ * without it, a nested index file like `docs/guides/index.mdx` (Astro's
758
+ * own default id: "docs/guides") would recover the wrong file id here
759
+ * ("docs/guides/index"), and a writedocs.json reference written to match the
760
+ * page's real URL would fail to resolve. A single-segment `index.mdx`
761
+ * at the content root is unaffected either way - the regex requires a
762
+ * preceding `/`, which a bare "index" doesn't have (matching Astro's
763
+ * own behavior: a root-level index.mdx keeps file id "index", not "").
764
+ *
765
+ * Full per-segment slugification (github-slugger, applied by Astro to
766
+ * every path segment) is deliberately *not* replicated here - every
767
+ * filename in this codebase's own fixtures and every filename this
768
+ * function needs to have handled correctly is already slug-safe
769
+ * (lowercase, hyphenated, no spaces/unicode), so slugification is
770
+ * always a no-op in practice; only the `/index`-stripping behavior is
771
+ * reproduced, since that's the one part of Astro's algorithm this
772
+ * change newly exercises (a file that used to be a collection's own
773
+ * base-root index, exempt from stripping, and is now nested one level
774
+ * deeper).
775
+ *
776
+ * Entries from the `generatedDocs` collection (auto-generated OpenAPI
777
+ * stub pages - see content.config.ts / generate-api-pages.js) live
778
+ * under writedocsTempDir()'s generated-docs/ directory - an OS temp
779
+ * directory location entirely outside contentDir, not a subdirectory
780
+ * of it - so `relativeToContentDir` for one of these always starts with
781
+ * `..` (walking back out of contentDir to reach it), and the check
782
+ * right below falls back to `entry.id` instead of computing a
783
+ * nonsensical id relative to a directory the file was never actually
784
+ * under. Those entries always set their own `slug` frontmatter
785
+ * explicitly, so there's no independent "file path" identity worth
786
+ * recovering for them the way there is for a hand-written page that
787
+ * might move around - `entry.id` (Astro's already-resolved slug)
788
+ * already IS the exact value generate-api-pages.js's own manifest
789
+ * references them by. */
790
+ export function fileIdForEntry(
791
+ contentDir: string,
792
+ packageRoot: string,
793
+ entry: DocsEntryLike
794
+ ): string {
795
+ if (!entry.filePath) return entry.id;
796
+ const absoluteContentDir = path.resolve(contentDir);
797
+ const absoluteFilePath = path.resolve(packageRoot, entry.filePath);
798
+ const relativeToContentDir = path.relative(absoluteContentDir, absoluteFilePath);
799
+ if (relativeToContentDir.startsWith('..')) return entry.id;
800
+ const posixRelative = relativeToContentDir.split(path.sep).join('/');
801
+ if (EXCLUDED_TOP_LEVEL_DIRS.has(posixRelative.split('/')[0])) return entry.id;
802
+ return posixRelative.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
803
+ }
804
+
805
+ /** Normalizes a raw `entry.id` into the bare, slash-free form every
806
+ * route/href in this codebase assumes. Once a page sets a frontmatter
807
+ * `slug`, `entry.id` becomes that value completely verbatim - Astro's
808
+ * glob loader applies no normalization of its own (see
809
+ * generateIdDefault in astro/dist/content/loaders/glob.js) - so
810
+ * `slug: /` or `slug: /guides/new-name` (both natural things to write,
811
+ * mirroring how every href elsewhere in writedocs.json already has a
812
+ * leading slash) would otherwise produce broken multi-slash hrefs
813
+ * wherever hrefForSlug wraps the value in its own `/${slug}/`, and a
814
+ * bare "/" would additionally fail to be recognized as claiming root,
815
+ * colliding with the synthetic root-redirect route. */
816
+ export function normalizeEntryId(id: string): string {
817
+ const trimmed = id.replace(/^\/+/, '').replace(/\/+$/, '');
818
+ return trimmed === '' ? 'index' : trimmed;
819
+ }
820
+
821
+ export interface FlatNavEntry {
822
+ slug: string;
823
+ group: string | null;
824
+ }
825
+
826
+ export function flattenNav(navigation: NavItem[], group: string | null = null): FlatNavEntry[] {
827
+ return navigation.flatMap((item): FlatNavEntry[] => {
828
+ if (typeof item === 'string') {
829
+ return [{ slug: item, group }];
830
+ }
831
+ if ('href' in item) return []; // external link leaf, not a content page - no route/prev-next entry
832
+ // loadDocsConfig() always expands `{ group, openapi }` shorthand into
833
+ // a real `{ group, pages }` before anything reaches here (see
834
+ // expandOpenApiInNavigation() above) - this is just a defensive
835
+ // no-op for the shouldn't-happen case of an unexpanded node.
836
+ if ('openapi' in item) return [];
837
+ const ownPage: FlatNavEntry[] = item.page ? [{ slug: item.page, group: item.group }] : [];
838
+ return [...ownPage, ...flattenNav(item.pages, item.group)];
839
+ });
840
+ }
841
+
842
+ // --- Sections -------------------------------------------------------
843
+ //
844
+ // A Section is the atomic unit that owns one page tree and therefore one
845
+ // sidebar - every real content page belongs to exactly one Section,
846
+ // whichever `pages` leaf it's listed under, however deep in the
847
+ // tabs/versions/languages/dropdowns/products tree that leaf lives.
848
+ //
849
+ // A Section's `path` records the full chain of containers from the
850
+ // navigation root down to it, one PathSegment per level. This is
851
+ // everything the topbar needs to render the right selector controls
852
+ // (tabs bar, version dropdown, ...) and highlight the right option in
853
+ // each - without the router or layout needing to know how deep or in
854
+ // what order the site author nested things.
855
+
856
+ export type PathSegment =
857
+ | { kind: 'tab'; items: TabItem[]; index: number }
858
+ | { kind: 'version'; items: VersionItem[]; index: number }
859
+ | { kind: 'language'; items: LanguageItem[]; index: number }
860
+ | { kind: 'dropdown'; items: DropdownItem[]; index: number }
861
+ | { kind: 'product'; items: ProductItem[]; index: number };
862
+
863
+ export interface Section {
864
+ pages: NavItem[];
865
+ path: PathSegment[];
866
+ }
867
+
868
+ type Container = NavChildren;
869
+ type NamedContainer = TabItem | VersionItem | LanguageItem | DropdownItem | ProductItem;
870
+
871
+ function walkSections(node: Container, path: PathSegment[], out: Section[]): void {
872
+ if ('pages' in node) {
873
+ out.push({ pages: node.pages, path });
874
+ return;
875
+ }
876
+ if ('href' in node) return; // external link, not a Section
877
+ if ('tabs' in node) {
878
+ node.tabs.forEach((item, index) =>
879
+ walkSections(item, [...path, { kind: 'tab', items: node.tabs, index }], out)
880
+ );
881
+ return;
882
+ }
883
+ if ('versions' in node) {
884
+ node.versions.forEach((item, index) =>
885
+ walkSections(item, [...path, { kind: 'version', items: node.versions, index }], out)
886
+ );
887
+ return;
888
+ }
889
+ if ('languages' in node) {
890
+ node.languages.forEach((item, index) =>
891
+ walkSections(item, [...path, { kind: 'language', items: node.languages, index }], out)
892
+ );
893
+ return;
894
+ }
895
+ if ('dropdowns' in node) {
896
+ node.dropdowns.forEach((item, index) =>
897
+ walkSections(item, [...path, { kind: 'dropdown', items: node.dropdowns, index }], out)
898
+ );
899
+ return;
900
+ }
901
+ if ('products' in node) {
902
+ node.products.forEach((item, index) =>
903
+ walkSections(item, [...path, { kind: 'product', items: node.products, index }], out)
904
+ );
905
+ return;
906
+ }
907
+ }
908
+
909
+ export function resolveSections(navigation: NavigationConfig): Section[] {
910
+ if (Array.isArray(navigation)) return [{ pages: navigation, path: [] }];
911
+ const sections: Section[] = [];
912
+ walkSections(navigation as Container, [], sections);
913
+ // global.dropdowns sits outside the primary pattern, but its pages
914
+ // still need routes generated for them - walk each entry too, with an
915
+ // empty path (they don't participate in the tabs/version/etc.
916
+ // selector chain, only in the always-visible globalDropdowns list).
917
+ for (const dropdown of navigation.global?.dropdowns ?? []) {
918
+ walkSections(dropdown, [], sections);
919
+ }
920
+ return sections;
921
+ }
922
+
923
+ /** Which Section (by index into resolveSections()'s result) a page belongs to. */
924
+ export function findSectionIndexForSlug(sections: Section[], slug: string): number {
925
+ const index = sections.findIndex((s) => flattenNav(s.pages).some((entry) => entry.slug === slug));
926
+ return index === -1 ? 0 : index;
927
+ }
928
+
929
+ /** The always-visible topbar dropdown list - independent of whichever
930
+ * primary pattern/section is active (Mintlify's equivalent is
931
+ * `navigation.global.anchors`). */
932
+ export function resolveGlobalDropdowns(navigation: NavigationConfig): DropdownItem[] {
933
+ if (Array.isArray(navigation)) return [];
934
+ return navigation.global?.dropdowns ?? [];
935
+ }
936
+
937
+ /** The first real page slug reachable by descending into a container's
938
+ * content, however deep - used to compute where a selector option (a
939
+ * tab, a version, ...) navigates to when chosen. Null for an external
940
+ * `href` leaf, which the caller renders as a plain link using the
941
+ * node's own href instead. Versions prefer whichever entry is marked
942
+ * `default` (falling back to the first), matching Mintlify's version
943
+ * default rule. */
944
+ /** Tries `firstSlugOf()` against each item in order, returning the first
945
+ * non-null result - a plain `items[0]` pick (the previous behavior)
946
+ * breaks the moment that first sibling happens to be a bare `href` leaf
947
+ * (returns null, with nothing that tries the next one), which is a real
948
+ * case now that any container - not just a nested dropdown - can be a
949
+ * bare external link (e.g. a `tabs` array whose first entry is
950
+ * `{ tab: "Status", href: "..." }`). */
951
+ function firstSlugAmong(items: Container[]): string | null {
952
+ for (const item of items) {
953
+ const slug = firstSlugOf(item);
954
+ if (slug !== null) return slug;
955
+ }
956
+ return null;
957
+ }
958
+
959
+ export function firstSlugOf(node: Container): string | null {
960
+ if ('pages' in node) return flattenNav(node.pages)[0]?.slug ?? null;
961
+ if ('href' in node) return null;
962
+ if ('tabs' in node) return firstSlugAmong(node.tabs);
963
+ if ('versions' in node) {
964
+ // Try the `default`-tagged version(s) first (matching the old
965
+ // "preferred" pick), then fall through to the rest in order - same
966
+ // href-leaf-with-no-fallback concern as `tabs` above, just with an
967
+ // extra preference pass in front of it.
968
+ const defaults = node.versions.filter((v) => v.default);
969
+ const rest = node.versions.filter((v) => !v.default);
970
+ return firstSlugAmong([...defaults, ...rest]);
971
+ }
972
+ if ('languages' in node) return firstSlugAmong(node.languages);
973
+ if ('dropdowns' in node) return firstSlugAmong(node.dropdowns);
974
+ if ('products' in node) return firstSlugAmong(node.products);
975
+ return null;
976
+ }
977
+
978
+ /** For a version/language switcher, finds where the reader's current
979
+ * page would live inside a *different* version/language's own subtree,
980
+ * so switching keeps them on the same conceptual page instead of always
981
+ * bouncing to that option's first page (see buildSelectors() below,
982
+ * which is the only caller). "Same page" is approximated positionally
983
+ * rather than by name, since nothing guarantees a version/language's
984
+ * own identifier lines up with its pages' file paths - a version
985
+ * tagged "2025-09" could just as easily keep its pages under a "v1"
986
+ * folder with no relation to that string, so there's no naming
987
+ * convention to key off safely; this heuristic only ever compares tree
988
+ * position, never path text. (docs.json-examples/00-kitchen-sink/'s own
989
+ * versions - "2025-09"/"2026-01" under Core Platform - happen to have
990
+ * folder names that line up with their version strings, so it doesn't
991
+ * demonstrate the mismatched-folder-name case directly; the guarantee
992
+ * still doesn't exist regardless of what one fixture happens to do.)
993
+ *
994
+ * Two things have to line up for a page to count as "the same" one:
995
+ * first, `remainingPath` (whatever tab/product/dropdown was chosen
996
+ * *below* the version/language being switched, on the reader's actual
997
+ * page) has to exist at the same index in `item`'s own subtree too -
998
+ * an option isn't guaranteed to nest the same way that many levels
999
+ * down (a version could add/remove a tab), so any mismatch here bails
1000
+ * out to null immediately rather than guessing. Second, once both
1001
+ * bottom out at a leaf `pages` list, `position` (the reader's own page
1002
+ * index within *its* pages list) has to exist in `item`'s pages list
1003
+ * too - two versions/languages of the same docs are usually authored
1004
+ * with matching page order even when the file paths differ, so this
1005
+ * is a reasonable proxy for "the same page" without relying on names.
1006
+ *
1007
+ * Returns null - meaning "no equivalent page, fall back to
1008
+ * firstSlugOf()" - on any structural mismatch or an out-of-range
1009
+ * position, rather than guessing at a wrong page. */
1010
+ export function equivalentPageIn(
1011
+ item: Container,
1012
+ remainingPath: PathSegment[],
1013
+ position: number
1014
+ ): string | null {
1015
+ if ('pages' in item) return flattenNav(item.pages)[position]?.slug ?? null;
1016
+ if ('href' in item) return null;
1017
+ const [next, ...rest] = remainingPath;
1018
+ if (!next) return null; // the reader's own page didn't go this deep - no basis to pick a branch
1019
+ if ('tabs' in item && next.kind === 'tab') {
1020
+ const target = item.tabs[next.index];
1021
+ return target ? equivalentPageIn(target, rest, position) : null;
1022
+ }
1023
+ if ('versions' in item && next.kind === 'version') {
1024
+ const target = item.versions[next.index];
1025
+ return target ? equivalentPageIn(target, rest, position) : null;
1026
+ }
1027
+ if ('languages' in item && next.kind === 'language') {
1028
+ const target = item.languages[next.index];
1029
+ return target ? equivalentPageIn(target, rest, position) : null;
1030
+ }
1031
+ if ('dropdowns' in item && next.kind === 'dropdown') {
1032
+ const target = item.dropdowns[next.index];
1033
+ return target ? equivalentPageIn(target, rest, position) : null;
1034
+ }
1035
+ if ('products' in item && next.kind === 'product') {
1036
+ const target = item.products[next.index];
1037
+ return target ? equivalentPageIn(target, rest, position) : null;
1038
+ }
1039
+ return null; // structural mismatch - this option nests differently at this depth
1040
+ }
1041
+
1042
+ /** The first real page slug in the whole site, root navigation pattern
1043
+ * included - used to redirect `/` somewhere sensible when nothing in
1044
+ * the navigation happens to be a page literally named "index" (the
1045
+ * usual "docs/index.mdx is the homepage" convention). Mirrors
1046
+ * firstSlugOf()'s per-container descent, plus the flat-array root case
1047
+ * firstSlugOf() alone can't handle since a bare array isn't a Container. */
1048
+ export function firstSlugOfNavigation(navigation: NavigationConfig): string | null {
1049
+ if (Array.isArray(navigation)) return flattenNav(navigation)[0]?.slug ?? null;
1050
+ return firstSlugOf(navigation as Container);
1051
+ }
1052
+
1053
+ /** Whether `slug` is reachable anywhere underneath a container, however
1054
+ * deep - used for computing active state on nodes that aren't part of
1055
+ * the active Section's own `path` (global dropdown entries). */
1056
+ export function containerContainsSlug(node: Container, slug: string): boolean {
1057
+ if ('pages' in node) return flattenNav(node.pages).some((entry) => entry.slug === slug);
1058
+ if ('href' in node) return false;
1059
+ if ('tabs' in node) return node.tabs.some((t) => containerContainsSlug(t, slug));
1060
+ if ('versions' in node) return node.versions.some((v) => containerContainsSlug(v, slug));
1061
+ if ('languages' in node) return node.languages.some((l) => containerContainsSlug(l, slug));
1062
+ if ('dropdowns' in node) return node.dropdowns.some((d) => containerContainsSlug(d, slug));
1063
+ if ('products' in node) return node.products.some((p) => containerContainsSlug(p, slug));
1064
+ return false;
1065
+ }
1066
+
1067
+ function labelOf(item: NamedContainer): string {
1068
+ if ('tab' in item) return item.tab;
1069
+ if ('version' in item) return item.label ?? item.version;
1070
+ if ('language' in item) return item.label ?? item.language;
1071
+ if ('dropdown' in item) return item.dropdown;
1072
+ return item.product;
1073
+ }
1074
+
1075
+ function iconOf(item: NamedContainer): string | undefined {
1076
+ return (item as { icon?: string }).icon;
1077
+ }
1078
+
1079
+ // --- Topbar selectors -------------------------------------------------
1080
+ //
1081
+ // One Selector per PathSegment on the active Section's path. Tabs render
1082
+ // as the horizontal pill bar (all options always visible, exactly one
1083
+ // current); versions/languages/products, and dropdowns used as a nested
1084
+ // path segment, render as a single switcher control instead - the
1085
+ // trigger shows the *current* option, its menu lists the alternatives.
1086
+ // See BaseLayout.astro for the actual markup per kind.
1087
+
1088
+ export interface SelectorOption {
1089
+ label: string;
1090
+ icon?: string;
1091
+ tag?: string;
1092
+ href: string;
1093
+ active: boolean;
1094
+ // Set when this option's own container is a `dropdowns` list (e.g. a
1095
+ // tab whose content is `dropdowns` instead of `pages`) - the tab pill
1096
+ // itself becomes a dropdown-trigger showing these as its menu, rather
1097
+ // than a separate dropdown control rendered alongside it. Only ever
1098
+ // populated one level deep (a dropdown option doesn't itself get a
1099
+ // nested `dropdown` - matches the one level of "a tab/dropdown owns a
1100
+ // dropdowns list" this is meant to cover).
1101
+ dropdown?: SelectorOption[];
1102
+ }
1103
+
1104
+ export interface Selector {
1105
+ kind: PathSegment['kind'];
1106
+ options: SelectorOption[];
1107
+ }
1108
+
1109
+ /** An option's own `dropdowns` menu, if it has one (see SelectorOption.dropdown
1110
+ * above) - `activeSegment` is the *next* PathSegment after this option's own
1111
+ * segment, only passed when this option is the active one on its level, so a
1112
+ * sibling option that also happens to own `dropdowns` doesn't spuriously mark
1113
+ * one of its entries active just because some *other* option is currently
1114
+ * selected. */
1115
+ function dropdownMenuOf(
1116
+ node: NavChildren,
1117
+ hrefForSlug: (slug: string) => string,
1118
+ activeSegment: PathSegment | undefined
1119
+ ): SelectorOption[] | undefined {
1120
+ if (!('dropdowns' in node)) return undefined;
1121
+ return node.dropdowns.map((d, i) => ({
1122
+ label: d.dropdown,
1123
+ icon: iconOf(d),
1124
+ href: 'href' in d ? d.href : hrefForSlug(firstSlugOf(d) ?? 'index'),
1125
+ active: activeSegment?.kind === 'dropdown' && activeSegment.index === i,
1126
+ }));
1127
+ }
1128
+
1129
+ /** `activePagePosition` is the reader's current page's own index within
1130
+ * its Section's flattened pages list (-1 if it can't be found there,
1131
+ * which just disables the position-preserving behavior below) - see
1132
+ * [...slug].astro for how it's computed. Only version/language
1133
+ * switchers try to preserve position across options; tabs/dropdowns/
1134
+ * products keep linking to firstSlugOf() unconditionally, since those
1135
+ * represent genuinely different content (an "API Reference" tab isn't
1136
+ * "the same page" as a "Guides" tab just because they're both first),
1137
+ * unlike a version/language of what's meant to be the same docs. */
1138
+ export function buildSelectors(
1139
+ path: PathSegment[],
1140
+ hrefForSlug: (slug: string) => string,
1141
+ activePagePosition: number = -1
1142
+ ): Selector[] {
1143
+ return path.map((segment, segIndex): Selector => {
1144
+ const preservesPosition =
1145
+ (segment.kind === 'version' || segment.kind === 'language') && activePagePosition >= 0;
1146
+ const remainingPath = path.slice(segIndex + 1);
1147
+ return {
1148
+ kind: segment.kind,
1149
+ options: (segment.items as NamedContainer[]).map((item, i) => {
1150
+ const isActive = i === segment.index;
1151
+ const equivalentSlug = preservesPosition
1152
+ ? equivalentPageIn(item as Container, remainingPath, activePagePosition)
1153
+ : null;
1154
+ const targetSlug = equivalentSlug ?? firstSlugOf(item as Container) ?? 'index';
1155
+ return {
1156
+ label: labelOf(item),
1157
+ icon: iconOf(item),
1158
+ tag: 'tag' in item ? item.tag : undefined,
1159
+ href: 'href' in item ? item.href : hrefForSlug(targetSlug),
1160
+ active: isActive,
1161
+ dropdown: dropdownMenuOf(item, hrefForSlug, isActive ? path[segIndex + 1] : undefined),
1162
+ };
1163
+ }),
1164
+ };
1165
+ });
1166
+ }
1167
+
1168
+ // --- Global dropdowns ---------------------------------------------------
1169
+ //
1170
+ // Unlike path-segment selectors, global dropdowns aren't a mutually
1171
+ // exclusive "pick one" choice - `global.dropdowns` is an array where
1172
+ // *every* entry renders as its own always-visible trigger button
1173
+ // simultaneously (Mintlify's anchors work the same way). A trigger's
1174
+ // menu lists its own direct pages as quick links; a bare-href entry is
1175
+ // just a plain link with no menu; a deeply-nested entry (tabs/versions/
1176
+ // etc. instead of direct pages) falls back to linking straight to its
1177
+ // first page, since building a rich flyout for that combination isn't
1178
+ // worth the complexity for what's meant to be a quick-links affordance.
1179
+
1180
+ export interface GlobalDropdownView {
1181
+ label: string;
1182
+ icon?: string;
1183
+ href: string | null;
1184
+ items: SelectorOption[];
1185
+ }
1186
+
1187
+ export function buildGlobalDropdowns(
1188
+ dropdowns: DropdownItem[],
1189
+ currentSlug: string,
1190
+ titleForSlug: (slug: string) => string,
1191
+ hrefForSlug: (slug: string) => string
1192
+ ): GlobalDropdownView[] {
1193
+ return dropdowns.map((d): GlobalDropdownView => {
1194
+ if ('href' in d) {
1195
+ return { label: d.dropdown, icon: d.icon, href: d.href, items: [] };
1196
+ }
1197
+ if ('pages' in d) {
1198
+ const items = flattenNav(d.pages).map((entry) => ({
1199
+ label: titleForSlug(entry.slug),
1200
+ href: hrefForSlug(entry.slug),
1201
+ active: entry.slug === currentSlug,
1202
+ }));
1203
+ return { label: d.dropdown, icon: d.icon, href: null, items };
1204
+ }
1205
+ const slug = firstSlugOf(d);
1206
+ return { label: d.dropdown, icon: d.icon, href: slug ? hrefForSlug(slug) : '#', items: [] };
1207
+ });
1208
+ }
1209
+
1210
+ // --- Sidebar (recursive) -------------------------------------------------
1211
+
1212
+ // A group node's `pageSlug` is the page its own label links to (null if
1213
+ // it's a pure disclosure/label with no page of its own - the group only
1214
+ // groups). NavTree.astro renders depth-0 groups as static, non-collapsible
1215
+ // section titles (linked if `pageSlug` is set, plain text otherwise) and
1216
+ // every deeper group as a collapsible row styled like a page item, with a
1217
+ // chevron, defaulting open when it contains the active page - see
1218
+ // navTreeContainsSlug() below.
1219
+ export type NavTreeNode =
1220
+ | { kind: 'page'; slug: string; title: string; method: string | null }
1221
+ | { kind: 'group'; label: string; pageSlug: string | null; children: NavTreeNode[] }
1222
+ | { kind: 'link'; label: string; href: string };
1223
+
1224
+ /** Builds the sidebar's view model, preserving group nesting depth.
1225
+ * `methodForSlug` is optional (most sites have no OpenAPI pages at all)
1226
+ * and, when given, returns the HTTP method to badge a page with in the
1227
+ * sidebar (e.g. "GET") or null for an ordinary page - see
1228
+ * NavTree.astro for how that badge renders. */
1229
+ export function buildNavTree(
1230
+ navigation: NavItem[],
1231
+ titleForSlug: (slug: string) => string,
1232
+ methodForSlug?: (slug: string) => string | null
1233
+ ): NavTreeNode[] {
1234
+ return navigation.map((item): NavTreeNode => {
1235
+ if (typeof item === 'string') {
1236
+ return {
1237
+ kind: 'page',
1238
+ slug: item,
1239
+ title: titleForSlug(item),
1240
+ method: methodForSlug?.(item) ?? null,
1241
+ };
1242
+ }
1243
+ if ('href' in item) {
1244
+ return { kind: 'link', label: item.label, href: item.href };
1245
+ }
1246
+ // loadDocsConfig() always expands `{ group, openapi }` shorthand into
1247
+ // a real `{ group, pages }` before anything reaches here (see
1248
+ // expandOpenApiInNavigation() in the OpenAPI section above) - this is
1249
+ // just a defensive no-op for the shouldn't-happen case of an
1250
+ // unexpanded node reaching the sidebar builder.
1251
+ if ('openapi' in item) {
1252
+ return { kind: 'group', label: item.group, pageSlug: null, children: [] };
1253
+ }
1254
+ return {
1255
+ kind: 'group',
1256
+ label: item.group,
1257
+ pageSlug: item.page ?? null,
1258
+ children: buildNavTree(item.pages, titleForSlug, methodForSlug),
1259
+ };
1260
+ });
1261
+ }
1262
+
1263
+ /** Whether `slug` is the group's own attached page, or belongs to any page/group nested inside it. */
1264
+ export function navTreeContainsSlug(nodes: NavTreeNode[], slug: string): boolean {
1265
+ return nodes.some((node) => {
1266
+ if (node.kind === 'page') return node.slug === slug;
1267
+ if (node.kind === 'link') return false;
1268
+ return node.pageSlug === slug || navTreeContainsSlug(node.children, slug);
1269
+ });
1270
+ }
1271
+
1272
+ export interface BreadcrumbCrumb {
1273
+ label: string;
1274
+ href: string | null;
1275
+ }
1276
+
1277
+ /** The chain of ancestor group labels (root first) that `slug` is nested
1278
+ * under within `nodes` - empty when the page sits at the top level of its
1279
+ * Section with no enclosing group at all, in which case the caller should
1280
+ * skip rendering breadcrumbs entirely (there'd be nothing to show but the
1281
+ * fixed home icon Breadcrumbs.astro always renders first).
1282
+ *
1283
+ * Deliberately never includes the current page itself - only the groups
1284
+ * it's nested under (that's already the <h1> right below, repeating it
1285
+ * in the breadcrumb trail would just be noise). A group whose own
1286
+ * attached page (`pageSlug`, see NavTreeNode) *is* `slug` is excluded
1287
+ * from its own trail for the same reason - you're looking at that page,
1288
+ * it doesn't need to also list itself as its own ancestor. A group only
1289
+ * appears here when `slug` is nested *inside* it (its own page, if any,
1290
+ * links to that group's landing page - `href: null` for a label-only
1291
+ * group with no page of its own to link to). */
1292
+ export function ancestorGroupsForSlug(
1293
+ nodes: NavTreeNode[],
1294
+ slug: string,
1295
+ hrefForSlug: (slug: string) => string
1296
+ ): BreadcrumbCrumb[] {
1297
+ for (const node of nodes) {
1298
+ if (node.kind !== 'group') continue;
1299
+ if (node.pageSlug === slug) return [];
1300
+ if (navTreeContainsSlug(node.children, slug)) {
1301
+ const crumb: BreadcrumbCrumb = { label: node.label, href: node.pageSlug ? hrefForSlug(node.pageSlug) : null };
1302
+ return [crumb, ...ancestorGroupsForSlug(node.children, slug, hrefForSlug)];
1303
+ }
1304
+ }
1305
+ return [];
1306
+ }