@writedocs/generator 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/astro.config.mjs +37 -3
  2. package/package.json +1 -1
  3. package/src/components/Accordion.astro +22 -5
  4. package/src/components/AppIcon.astro +5 -0
  5. package/src/components/Callout.astro +15 -5
  6. package/src/components/Card.astro +64 -13
  7. package/src/components/Check.astro +13 -0
  8. package/src/components/Color.astro +61 -0
  9. package/src/components/ColorItem.astro +93 -0
  10. package/src/components/ColorRow.astro +39 -0
  11. package/src/components/Columns.astro +11 -0
  12. package/src/components/Frame.astro +10 -1
  13. package/src/components/GitHubRepo.astro +156 -0
  14. package/src/components/Hint.astro +50 -4
  15. package/src/components/Panel.astro +11 -0
  16. package/src/components/ParamField.astro +25 -0
  17. package/src/components/Parameter.astro +41 -4
  18. package/src/components/Prompt.astro +143 -0
  19. package/src/components/ResponseField.astro +18 -0
  20. package/src/components/Step.astro +22 -3
  21. package/src/components/Steps.astro +22 -0
  22. package/src/components/Tab.astro +7 -1
  23. package/src/components/Tabs.astro +27 -4
  24. package/src/components/Tile.astro +65 -0
  25. package/src/components/Tooltip.astro +13 -0
  26. package/src/components/Tree.astro +213 -0
  27. package/src/components/TreeFile.astro +17 -0
  28. package/src/components/TreeFolder.astro +28 -0
  29. package/src/components/Update.astro +171 -0
  30. package/src/components/View.astro +214 -0
  31. package/src/components/Visibility.astro +11 -0
  32. package/src/components/compound.ts +21 -0
  33. package/src/components/index.ts +12 -0
  34. package/src/content.config.ts +68 -2
  35. package/src/layout/components/NavTree.astro +34 -0
  36. package/src/layout/styles/base.css +12 -0
  37. package/src/lib/config.ts +1412 -1306
  38. package/src/lib/inline-markdown.js +29 -0
  39. package/src/lib/mdx-inject-builtins.js +20 -2
  40. package/src/lib/mdx-mintlify.js +99 -0
  41. package/src/lib/mdx-title-anchor-ids.js +7 -2
  42. package/src/lib/shiki-code-block.js +140 -26
  43. package/src/lib/visibility.js +29 -0
  44. package/src/pages/[...slug].astro +110 -7
  45. package/src/pages/[...slug].md.ts +7 -1
  46. package/src/pages/llms-full.txt.ts +5 -1
  47. package/src/pages/llms.txt.ts +3 -0
  48. package/src/styles/global.css +10 -0
package/src/lib/config.ts CHANGED
@@ -1,1306 +1,1412 @@
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
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import matter from 'gray-matter';
5
+ import { writedocsTempDir } from './writedocs-temp-dir.js';
6
+ import {
7
+ formatValidationIssues,
8
+ validateDocsConfig,
9
+ type DocsConfig,
10
+ type DropdownItem,
11
+ type FontVariant,
12
+ type LanguageItem,
13
+ type NavChildren,
14
+ type NavItem,
15
+ type NavigationConfig,
16
+ type ProductItem,
17
+ type TabItem,
18
+ type VersionItem,
19
+ } from './config-schema.ts';
20
+
21
+ // O schema do writedocs.json mora em ./config-schema.ts desde a extracao (E11):
22
+ // aquele modulo importa so `zod`, sem nada de node:fs, pra que a plataforma
23
+ // possa importa-lo por `@writedocs/generator/config-schema`. Este reexport
24
+ // mantem a superficie deste arquivo exatamente como era - quem importa
25
+ // `docsConfigSchema`, `DocsConfig`, `seoFieldsSchema`, `mergeSeo` etc. de
26
+ // './config.js' continua importando do mesmo lugar, sem mudar uma linha.
27
+ //
28
+ // `.ts` e nao o `./config-schema.js` gerado, de proposito - e a unica coisa no
29
+ // repositorio que ainda aponta pra fonte, e o bin/ e o `exports` apontam pro
30
+ // `.js`. O motivo e que este reexport carrega 24 `export type`/`interface`
31
+ // (DocsConfig, NavItem, Selector, GlobalDropdownView, ContextMenuConfig...) que
32
+ // uma duzia de .astro consome como `import type { X } from '../lib/config'`, e
33
+ // o esbuild apaga todos eles: o `.js` gerado exporta exatamente 5 simbolos de
34
+ // runtime. Este repositorio nao tem tsconfig.json nem o typescript instalado,
35
+ // entao (a) nada configura o mapeamento `.js` -> `.ts` que faria os tipos
36
+ // voltarem e (b) nao existe typecheck que reclamasse - a troca so apagaria os
37
+ // tipos em silencio. Aqui tudo passa pelo transform do Astro/Vite, que le `.ts`
38
+ // nativamente, entao o `.js` nao resolve problema nenhum deste lado.
39
+ //
40
+ // O custo aceito e que o tarball leva as duas copias e um build do Astro pode
41
+ // carregar as duas (o `.ts` por aqui, o `.js` por quem importa o subpath) - duas
42
+ // instancias do schema, inofensivas porque nada compara identidade de schema.
43
+ export * from './config-schema.ts';
44
+
45
+ /** Normalizes writedocs.json's `domain` into a full origin with no trailing
46
+ * slash (e.g. "docs.example.com" -> "https://docs.example.com"), or null
47
+ * if the site hasn't set one. The single source of truth for "does this
48
+ * site have a real deployed URL" - astro.config.mjs (sitemap
49
+ * registration), BaseLayout.astro (canonical/OG/Twitter URLs), and
50
+ * resolveAbsoluteUrl() below all call this rather than reading
51
+ * config.domain directly, so the http(s):// normalization only happens
52
+ * in one place. */
53
+ export function resolveSiteUrl(config: Pick<DocsConfig, 'domain'>): string | null {
54
+ if (!config.domain) return null;
55
+ const withScheme = /^https?:\/\//i.test(config.domain) ? config.domain : `https://${config.domain}`;
56
+ return withScheme.replace(/\/+$/, '');
57
+ }
58
+
59
+ /** Resolves a possibly-relative asset path (e.g. `seo.ogImage: "/card.png"`)
60
+ * against the site's own domain into an absolute URL - social crawlers
61
+ * (Facebook/Twitter/Slack unfurls) generally require an absolute
62
+ * og:image/twitter:image URL, a same-origin relative path isn't reliably
63
+ * respected. Returns the value unchanged if it's already absolute, or if
64
+ * there's no siteUrl to resolve it against (a relative path is still
65
+ * better than nothing in that case - most social crawlers do at least
66
+ * attempt to fetch it relative to the page they scraped). */
67
+ export function resolveAbsoluteUrl(siteUrl: string | null, value: string): string {
68
+ if (/^https?:\/\//i.test(value)) return value;
69
+ if (!siteUrl) return value;
70
+ return `${siteUrl}${value.startsWith('/') ? '' : '/'}${value}`;
71
+ }
72
+
73
+ /** The resolved (never-undefined) Shiki theme names for light/dark code
74
+ * blocks - shared by astro.config.mjs (sitewide MDX code-fence
75
+ * highlighting) and ApiReferencePanel.astro (the API playground's own
76
+ * separate <Code/> usages, which don't inherit markdown.shikiConfig at
77
+ * all - see astro.config.mjs's own comment on that) so both read the
78
+ * same writedocs.json field and fall back to the same defaults instead of
79
+ * each hardcoding its own copy. */
80
+ export function resolveCodeblockTheme(config: DocsConfig): { light: string; dark: string } {
81
+ return {
82
+ light: config.styles.codeblocks?.light ?? 'github-light',
83
+ dark: config.styles.codeblocks?.dark ?? 'github-dark',
84
+ };
85
+ }
86
+
87
+ // mdx is a real, bundled Shiki grammar (@shikijs/langs' mdx.mjs - it does
88
+ // exist, this isn't a "Shiki doesn't know this language" situation), but in
89
+ // practice it tokenizes ```mdx fences into one single run per line with no
90
+ // internal token boundaries at all - every character, JSX tag or not,
91
+ // lands in the exact same TextMate scope and renders in the theme's plain
92
+ // foreground color. Confirmed directly against real built output: every
93
+ // span in a ```mdx block's compiled HTML carries the identical inline
94
+ // color, none of the tag/attribute/string distinction a JS or JSX fence
95
+ // gets. Aliasing to jsx - a close structural match for the JSX-heavy
96
+ // snippets these fences are actually used for in this codebase's own docs
97
+ // (`<Callout>`, `<Card>`, etc.) - actually highlights the tags/attributes,
98
+ // at the cost of not distinctly coloring the markdown-prose portions
99
+ // interleaved between them (jsx's grammar doesn't know about those) - a
100
+ // worthwhile trade given the alternative is no color at all. See
101
+ // astro.config.mjs's own shikiConfig.langAlias for where this actually
102
+ // gets used. */
103
+ const DEFAULT_CODEBLOCK_LANG_ALIAS: Record<string, string> = { mdx: 'jsx' };
104
+
105
+ /** The resolved fence-language alias map - the built-in mdx -> jsx default
106
+ * above, merged with (not replaced by) whatever a site adds under its own
107
+ * writedocs.json styles.codeblocks.langAlias, so a site can extend this list
108
+ * without having to redeclare the built-in entry to keep it. Same
109
+ * "shared resolver, not each consumer re-deriving its own defaults"
110
+ * pattern resolveCodeblockTheme() right above already establishes -
111
+ * though today only astro.config.mjs actually consumes this one, unlike
112
+ * that one, since ApiReferencePanel.astro's own <Code/> usages never
113
+ * render a `mdx`-tagged snippet (API operation samples are curl/js/
114
+ * python/etc., not MDX markup) to begin with. */
115
+ export function resolveCodeblockLangAlias(config: DocsConfig): Record<string, string> {
116
+ return { ...DEFAULT_CODEBLOCK_LANG_ALIAS, ...config.styles.codeblocks?.langAlias };
117
+ }
118
+
119
+ const DEFAULT_FONT_FAMILY = 'Inter';
120
+
121
+ export interface ResolvedFonts {
122
+ base: FontVariant;
123
+ heading: FontVariant | null;
124
+ body: FontVariant | null;
125
+ }
126
+
127
+ /** Resolves writedocs.json's `styles.fonts` into the three font declarations
128
+ * BaseLayout.astro actually needs to render: `base` (the site-wide
129
+ * default - every element gets this unless `heading`/`body` narrows it
130
+ * further), and `heading`/`body`, each `null` when not independently
131
+ * configured (letting BaseLayout fall back to `base` for whichever side
132
+ * wasn't overridden, rather than this function silently copying `base`
133
+ * into both and losing the distinction between "explicitly set to the
134
+ * same font" and "just inheriting the default"). The one hardcoded
135
+ * default in this whole feature lives right here: no `styles.fonts` at
136
+ * all resolves to plain Inter, loaded for real (a Google Fonts `<link>`,
137
+ * not just a name in a fallback stack that only renders correctly for a
138
+ * reader who happens to already have Inter installed - see base.css's
139
+ * old `font-family` rule, which was exactly that, before this feature
140
+ * existed). */
141
+ export function resolveFonts(config: DocsConfig): ResolvedFonts {
142
+ const fonts = config.styles.fonts;
143
+ const base: FontVariant = fonts
144
+ ? { family: fonts.family, weight: fonts.weight, source: fonts.source, format: fonts.format }
145
+ : { family: DEFAULT_FONT_FAMILY };
146
+ return { base, heading: fonts?.heading ?? null, body: fonts?.body ?? null };
147
+ }
148
+
149
+ /** The Google Fonts CSS2 API URL for every font in `fonts` that isn't a
150
+ * `source`-based (local/externally-hosted) font - `null` if there's
151
+ * nothing to load this way at all (every configured font has its own
152
+ * `source`). One request covers every family needed (`&family=` repeated
153
+ * per unique family+weight pair, deduped so the same pair - e.g. `base`
154
+ * and `body` both left at the site default - isn't requested twice)
155
+ * rather than a separate `<link>` per font. `display=swap` avoids an
156
+ * invisible-text flash while the font file loads (renders in the
157
+ * fallback stack immediately, swaps once the real font is ready) -
158
+ * Google's own recommended default for exactly this use case. */
159
+ export function googleFontsHref(fonts: ResolvedFonts): string | null {
160
+ const entries = [fonts.base, fonts.heading, fonts.body].filter(
161
+ (f): f is FontVariant => f !== null && !f.source
162
+ );
163
+ if (entries.length === 0) return null;
164
+ const seen = new Set<string>();
165
+ const params: string[] = [];
166
+ for (const f of entries) {
167
+ const key = `${f.family}|${f.weight ?? ''}`;
168
+ if (seen.has(key)) continue;
169
+ seen.add(key);
170
+ const familyParam = f.family.trim().replace(/\s+/g, '+');
171
+ params.push(f.weight ? `family=${familyParam}:wght@${f.weight}` : `family=${familyParam}`);
172
+ }
173
+ return `https://fonts.googleapis.com/css2?${params.join('&')}&display=swap`;
174
+ }
175
+
176
+ /** A `@font-face` rule for one `source`-based font (local project path or
177
+ * externally-hosted URL), or `''` for a Google Font (no `source` - see
178
+ * googleFontsHref() above, the other half of font loading). `weight`
179
+ * becomes the rule's `font-weight` *descriptor* here - it tells the
180
+ * browser which weight this specific file represents, so a `font-weight`
181
+ * CSS value requested elsewhere (BaseLayout.astro's own
182
+ * `wdFontWeightHeading`/`wdFontWeightBody`, see its comment) picks the
183
+ * real matching file instead of synthetically ("faux") bolding/
184
+ * thinning a mismatched one. This function only ever produces the
185
+ * `@font-face` rule itself - actually applying `font-weight` to any
186
+ * element (h1-h6, body) is BaseLayout's job, not this one's. */
187
+ export function fontFaceRule(font: FontVariant | null): string {
188
+ if (!font || !font.source) return '';
189
+ const weightDecl = font.weight !== undefined ? ` font-weight: ${font.weight};` : '';
190
+ return `@font-face { font-family: '${font.family}'; src: url('${font.source}') format('${font.format ?? 'woff2'}'); font-display: swap;${weightDecl} }`;
191
+ }
192
+
193
+ /** Splits one side of `styles.navbar` (the string-or-object union
194
+ * navbarColorValueSchema allows) into its two parts, filling in the
195
+ * fallback background when the field is unset at all. `accent` stays
196
+ * `undefined` - not defaulted here - for both the bare-string case and
197
+ * the object case where a site set `background` without `accent`;
198
+ * BaseLayout.astro is what turns that `undefined` into "fall back to
199
+ * --wd-primary" for the accent CSS var. There's no `foreground` here at
200
+ * all to resolve - the navbar's text/icon color is never read from
201
+ * writedocs.json, see `navbar`'s own schema comment (stylesSchema) for why. */
202
+ export function resolveNavbarColor(
203
+ value: string | { background: string; accent?: string } | undefined,
204
+ fallbackBackground: string
205
+ ): { background: string; accent: string | undefined } {
206
+ if (!value) return { background: fallbackBackground, accent: undefined };
207
+ if (typeof value === 'string') return { background: value, accent: undefined };
208
+ return { background: value.background, accent: value.accent };
209
+ }
210
+
211
+ /** Picks black or white text for readable contrast against `hexColor`,
212
+ * via the standard relative-luminance formula (ITU-R BT.601 weights -
213
+ * the same "perceived brightness" approximation used all over the web
214
+ * for exactly this "what text color goes on this swatch" problem, not
215
+ * the more expensive WCAG relative-luminance formula, which isn't
216
+ * needed for a binary choose-the-less-bad-option decision like this
217
+ * one). Used two ways in BaseLayout.astro: for `--wd-navbar-accent-text`
218
+ * (the active tab's own fill used to always be `var(--wd-primary)` with
219
+ * hardcoded `color: #fff`, which only actually read fine because every
220
+ * default/example primary color so far has been dark/saturated enough
221
+ * for white text; once a site's navbar accent can be *any* color -
222
+ * `styles.navbar.light.accent`, falling back to `styles.colors.primary`
223
+ * when unset, see resolveNavbarColor() above - that assumption can't
224
+ * hold unconditionally), and for `--wd-navbar-foreground` itself once
225
+ * `styles.navbar` is configured at all (the navbar's plain text/icon
226
+ * color - see `navbar`'s own schema comment for why that's always
227
+ * computed, never a writedocs.json value). Malformed input (not a 6-digit
228
+ * `#rrggbb` hex) falls back to white rather than throwing - same "don't
229
+ * fail a build over a cosmetic color value" posture every other color
230
+ * field here takes (none of them validate hex syntax either). */
231
+ export function contrastTextColor(hexColor: string): '#000000' | '#ffffff' {
232
+ const match = /^#?([0-9a-f]{6})$/i.exec(hexColor.trim());
233
+ if (!match) return '#ffffff';
234
+ const hex = match[1];
235
+ const r = parseInt(hex.slice(0, 2), 16);
236
+ const g = parseInt(hex.slice(2, 4), 16);
237
+ const b = parseInt(hex.slice(4, 6), 16);
238
+ const luminance = (299 * r + 587 * g + 114 * b) / 1000;
239
+ return luminance > 150 ? '#000000' : '#ffffff';
240
+ }
241
+
242
+ /** Whether an href points off-site - has an explicit scheme (`https:`,
243
+ * `mailto:`, `tel:`, ...) or is protocol-relative (`//...`) - versus a
244
+ * same-site path, which this codebase always produces as a single
245
+ * leading slash (hrefForSlug() in [...slug].astro). Used everywhere a
246
+ * nav link is rendered to decide whether it should open in a new tab;
247
+ * deliberately a plain string check rather than a schema-level flag,
248
+ * so it applies uniformly to every href source (topbar.links, a
249
+ * switcher/tab pill pointing at a bare `href` container, a global
250
+ * dropdown's own link, a sidebar link leaf) without threading an
251
+ * `external` field through every one of those call sites. */
252
+ export function isExternalHref(href: string): boolean {
253
+ return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
254
+ }
255
+
256
+ // ---------------------------------------------------------------------
257
+ // Icons - writedocs.json's `icon` fields (TabItem, DropdownItem, ProductItem,
258
+ // Card) are plain strings with no schema-level distinction between "an
259
+ // emoji, paste it verbatim" and "an icon-set name, look it up". Resolved
260
+ // here rather than validated in the schema, since both are valid uses of
261
+ // the same string field and the right rendering only becomes obvious once
262
+ // you look at the value's shape.
263
+ // ---------------------------------------------------------------------
264
+
265
+ export type IconResolution =
266
+ | { kind: 'iconify'; name: string } // ready for astro-icon/components' <Icon name=... />
267
+ | { kind: 'image'; src: string } // an image URL or site path, rendered as <img>
268
+ | { kind: 'text'; value: string }; // literal text/emoji, rendered as-is
269
+
270
+ const DEFAULT_ICON_COLLECTION = 'lucide';
271
+
272
+ // Where a bare icon name is looked up, in order. Lucide stays first so no
273
+ // existing site's icons change; Font Awesome comes after because it's
274
+ // Mintlify's default library - Mintlify content writes `icon="gear"` or
275
+ // `icon="discord"`, names Lucide doesn't have, and astro-icon fails the
276
+ // whole build on a name it can't find. Solid before brands, same as
277
+ // Mintlify's own lookup.
278
+ const BARE_ICON_COLLECTIONS = [DEFAULT_ICON_COLLECTION, 'fa6-solid', 'fa6-brands'];
279
+
280
+ // Loaded lazily, once per collection, from the installed @iconify-json/*
281
+ // packages (the same data astro-icon itself renders from). Resolved
282
+ // against this package, not the site being built - WRITEDOCS_PACKAGE_ROOT
283
+ // is set by run-astro.js; outside that subprocess, this file's own URL
284
+ // still sits inside the package.
285
+ interface IconifyCollection {
286
+ icons?: Record<string, { body: string; width?: number; height?: number }>;
287
+ aliases?: Record<string, { parent: string; width?: number; height?: number }>;
288
+ width?: number;
289
+ height?: number;
290
+ }
291
+ const iconCollections = new Map<string, IconifyCollection | null>();
292
+ function loadIconCollection(collection: string): IconifyCollection | null {
293
+ if (!iconCollections.has(collection)) {
294
+ const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT;
295
+ const require = createRequire(packageRoot ? path.join(packageRoot, 'package.json') : import.meta.url);
296
+ let data: IconifyCollection | null = null;
297
+ try {
298
+ data = require(`@iconify-json/${collection}/icons.json`);
299
+ } catch {
300
+ data = null;
301
+ }
302
+ iconCollections.set(collection, data);
303
+ }
304
+ return iconCollections.get(collection) ?? null;
305
+ }
306
+ function collectionHasIcon(collection: string, name: string): boolean {
307
+ const data = loadIconCollection(collection);
308
+ return Boolean(data && (data.icons?.[name] || data.aliases?.[name]));
309
+ }
310
+
311
+ /** A standalone <svg> for an icon string, or null if it isn't an installed
312
+ * Iconify icon (emoji/text, or a name no collection has). For the few
313
+ * places that build HTML outside an Astro component and so can't use
314
+ * astro-icon's <Icon> - a code block's title bar is built as HAST inside
315
+ * a Shiki transformer (see shiki-code-block.js). Follows one level of
316
+ * Iconify alias, which is all the installed collections use. */
317
+ export function iconSvg(icon: string): string | null {
318
+ const resolved = resolveIcon(icon);
319
+ if (resolved.kind !== 'iconify') return null;
320
+ const [collection, name] = resolved.name.split(':');
321
+ const data = loadIconCollection(collection);
322
+ if (!data) return null;
323
+ const alias = data.aliases?.[name];
324
+ const entry = data.icons?.[alias ? alias.parent : name];
325
+ if (!entry) return null;
326
+ const width = entry.width ?? alias?.width ?? data.width ?? 16;
327
+ const height = entry.height ?? alias?.height ?? data.height ?? 16;
328
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${entry.body}</svg>`;
329
+ }
330
+
331
+ /** Resolves a writedocs.json `icon` string into either an Iconify icon id or
332
+ * literal text, covering three forms:
333
+ * - "collection:icon-name" (e.g. "mdi:server", "simple-icons:github")
334
+ * - used as-is against whatever @iconify-json/* collections are
335
+ * installed (see astro.config.mjs / package.json).
336
+ * - a bare name using only letters/digits/hyphens (e.g. "smartphone",
337
+ * "book-open") - looked up in BARE_ICON_COLLECTIONS order (lucide,
338
+ * then Font Awesome solid, then brands), using the first collection
339
+ * that has it. A name none of them has still resolves to "lucide:...",
340
+ * so the build error names the same collection it always did.
341
+ * - an image URL or path - "https://...", "//...", "/icons/x.svg",
342
+ * "./x.png" - rendered as an <img>. Mintlify accepts these for any
343
+ * icon; before this they were printed as literal text.
344
+ * - anything else (an emoji, a symbol, arbitrary text) - rendered
345
+ * verbatim, preserving the original "just paste an emoji" behavior
346
+ * from before icon-library support existed. */
347
+ export function resolveIcon(icon: string): IconResolution {
348
+ if (/^(?:https?:)?\/\/|^\.{0,2}\//i.test(icon)) return { kind: 'image', src: icon };
349
+ if (/^[a-z0-9-]+:[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: icon };
350
+ if (/^[a-z0-9-]+$/i.test(icon)) {
351
+ const collection = BARE_ICON_COLLECTIONS.find((c) => collectionHasIcon(c, icon)) ?? DEFAULT_ICON_COLLECTION;
352
+ return { kind: 'iconify', name: `${collection}:${icon}` };
353
+ }
354
+ return { kind: 'text', value: icon };
355
+ }
356
+
357
+ // ---------------------------------------------------------------------
358
+ // OpenAPI navigation expansion - turns a group's `openapi: { src, path }`
359
+ // shorthand into a concrete `{ group, pages }` (one sub-group per tag),
360
+ // using the per-spec manifest generate-api-pages.js writes to
361
+ // writedocsTempDir()'s openapi/<path>/manifest.json before Astro starts (both
362
+ // `writedocs dev` and `writedocs build` run it first - see
363
+ // src/cli/dev.js, src/cli/build.js). Applied once, here in
364
+ // loadDocsConfig(), so every other function in this file
365
+ // (resolveSections, flattenNav, buildNavTree, ...) only ever sees plain
366
+ // page-slug strings and ordinary `{ group, pages }` nodes, and never
367
+ // needs to know the `openapi` group shorthand exists. A writedocs.json can
368
+ // have any number of these groups, each pointing at its own spec and
369
+ // mounted under its own `path` - generate-api-pages.js namespaces each
370
+ // spec's manifest/operations under that same `path`, so there's no
371
+ // cross-spec collision as long as every group uses a distinct `path`.
372
+ // ---------------------------------------------------------------------
373
+
374
+ export interface OpenApiManifestEntry {
375
+ slug: string;
376
+ method: string;
377
+ path: string;
378
+ tags: string[];
379
+ title: string;
380
+ generated: boolean;
381
+ }
382
+
383
+ /** `specPath` is the owning group's own `openapi.path` (e.g. "/api") -
384
+ * generate-api-pages.js writes each spec's manifest under a directory
385
+ * named after that same value, so this only ever needs to know which
386
+ * group is asking, not anything about the spec's contents itself. */
387
+ function loadOpenApiManifest(contentDir: string, specPath: string): OpenApiManifestEntry[] | null {
388
+ const normalized = specPath.replace(/^\/+|\/+$/g, '');
389
+ const manifestPath = path.join(writedocsTempDir(contentDir), 'openapi', normalized, 'manifest.json');
390
+ if (!fs.existsSync(manifestPath)) return null;
391
+ try {
392
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
393
+ } catch {
394
+ return null; // stale/partial write from an interrupted previous run - treat as absent
395
+ }
396
+ }
397
+
398
+ /** Expands one group's `openapi: { src, path }` into the `pages` array
399
+ * it stands in for - one sub-group per tag (in first-seen order),
400
+ * untagged operations collected into a trailing "Other" group. Returns
401
+ * an empty array (an empty, harmless group) rather than throwing if the
402
+ * manifest is missing - generate-api-pages.js always runs before this
403
+ * does (see src/cli/dev.js/build.js), so a missing manifest here means
404
+ * generation hasn't happened yet rather than a user error worth
405
+ * crashing the dev server over. */
406
+ function expandOpenApiGroupPages(openapiRef: { src: string; path: string }, contentDir: string): NavItem[] {
407
+ const manifest = loadOpenApiManifest(contentDir, openapiRef.path);
408
+ if (!manifest) return [];
409
+
410
+ const seenTags: string[] = [];
411
+ const byTag = new Map<string, string[]>();
412
+ const untagged: string[] = [];
413
+ for (const op of manifest) {
414
+ const tag = op.tags[0];
415
+ if (!tag) {
416
+ untagged.push(op.slug);
417
+ continue;
418
+ }
419
+ if (!byTag.has(tag)) {
420
+ byTag.set(tag, []);
421
+ seenTags.push(tag);
422
+ }
423
+ byTag.get(tag)!.push(op.slug);
424
+ }
425
+ const groups: NavItem[] = seenTags.map((tag) => ({ group: tag, pages: byTag.get(tag)! }));
426
+ return untagged.length > 0 ? [...groups, { group: 'Other', pages: untagged }] : groups;
427
+ }
428
+
429
+ function expandOpenApiInPages(pages: NavItem[], contentDir: string): NavItem[] {
430
+ return pages.map((item): NavItem => {
431
+ if (typeof item === 'string') return item;
432
+ if ('href' in item) return item;
433
+ if ('openapi' in item) {
434
+ // A group using the { group, openapi: { src, path } } shorthand -
435
+ // replace it with a real { group, pages } node built from that
436
+ // spec's own manifest, so nothing downstream needs to know the
437
+ // shorthand ever existed.
438
+ return { group: item.group, pages: expandOpenApiGroupPages(item.openapi, contentDir) };
439
+ }
440
+ // An ordinary { group, page?, pages } - recurse into its own pages,
441
+ // since an openapi-group can be nested inside a hand-authored group
442
+ // too (e.g. wrapping it to add hand-written pages alongside the
443
+ // auto-generated ones).
444
+ return { ...item, pages: expandOpenApiInPages(item.pages, contentDir) };
445
+ });
446
+ }
447
+
448
+ /** Mirrors walkSections()'s traversal (tabs/versions/languages/
449
+ * dropdowns/products, each bottoming out at `pages`), but rewrites
450
+ * rather than collects - every `pages` array anywhere in the tree gets
451
+ * run through expandOpenApiInPages(). */
452
+ function expandOpenApiInContainer(node: NavChildren, contentDir: string): NavChildren {
453
+ if ('pages' in node) return { pages: expandOpenApiInPages(node.pages, contentDir) };
454
+ if ('href' in node) return node;
455
+ if ('tabs' in node) {
456
+ return { tabs: node.tabs.map((t) => ({ ...t, ...expandOpenApiInContainer(t, contentDir) })) };
457
+ }
458
+ if ('versions' in node) {
459
+ return { versions: node.versions.map((v) => ({ ...v, ...expandOpenApiInContainer(v, contentDir) })) };
460
+ }
461
+ if ('languages' in node) {
462
+ return { languages: node.languages.map((l) => ({ ...l, ...expandOpenApiInContainer(l, contentDir) })) };
463
+ }
464
+ if ('dropdowns' in node) {
465
+ return { dropdowns: node.dropdowns.map((d) => ({ ...d, ...expandOpenApiInContainer(d, contentDir) })) };
466
+ }
467
+ if ('products' in node) {
468
+ return { products: node.products.map((p) => ({ ...p, ...expandOpenApiInContainer(p, contentDir) })) };
469
+ }
470
+ return node;
471
+ }
472
+
473
+ function expandOpenApiInNavigation(navigation: NavigationConfig, contentDir: string): NavigationConfig {
474
+ if (Array.isArray(navigation)) return expandOpenApiInPages(navigation, contentDir);
475
+ const expanded = { ...navigation, ...expandOpenApiInContainer(navigation as Container, contentDir) };
476
+ if (navigation.global?.dropdowns) {
477
+ expanded.global = {
478
+ dropdowns: navigation.global.dropdowns.map(
479
+ (d) => ({ ...d, ...expandOpenApiInContainer(d, contentDir) }) as DropdownItem
480
+ ),
481
+ };
482
+ }
483
+ return expanded as NavigationConfig;
484
+ }
485
+
486
+ export function loadDocsConfig(contentDir: string): DocsConfig {
487
+ const configPath = path.join(contentDir, 'writedocs.json');
488
+ if (!fs.existsSync(configPath)) {
489
+ throw new Error(
490
+ `[writedocs] writedocs.json not found at ${configPath}. Run "writedocs init" to scaffold one.`
491
+ );
492
+ }
493
+ // A validacao em si (JSON.parse incluso) vive em validateDocsConfig
494
+ // (./config-schema.ts), o mesmo ponto de entrada que o `writedocs validate` e
495
+ // a plataforma usam - e o que garante que os tres reportem exatamente os
496
+ // mesmos problemas, com as mesmas palavras.
497
+ //
498
+ // As duas formas de falhar continuam saindo daqui EXATAMENTE como saiam antes
499
+ // desta extracao:
500
+ // - JSON quebrado: relanca o proprio SyntaxError do JSON.parse (mesmo tipo,
501
+ // mesma mensagem que o runtime produz);
502
+ // - schema invalido: o mesmo Error, com o mesmo texto, montado a partir do
503
+ // mesmo formatador que a CLI usa.
504
+ const result = validateDocsConfig(fs.readFileSync(configPath, 'utf-8'));
505
+ if (!result.ok) {
506
+ if (result.parseError) throw result.parseError;
507
+ throw new Error(`[writedocs] writedocs.json failed validation:\n${formatValidationIssues(result.issues)}`);
508
+ }
509
+ // A no-op pass over ordinary navigation trees (no openapi groups) -
510
+ // always run, rather than gated behind a global manifest check, since
511
+ // there's no longer a single global spec to check for.
512
+ result.data.navigation = expandOpenApiInNavigation(result.data.navigation, contentDir);
513
+ return result.data;
514
+ }
515
+
516
+ // --- Locating a page by file id, independent of its effective slug -----
517
+ //
518
+ // writedocs.json's `pages` arrays, and every helper above/below that walks
519
+ // them (flattenNav, firstSlugOf, containerContainsSlug, buildNavTree, ...),
520
+ // always identify a page by its file id - its path relative to the
521
+ // content directory (project root), extension stripped, with a trailing
522
+ // `/index` segment dropped (matching Astro's own default id computation -
523
+ // see the `/index` note below) - regardless of how that page ends up
524
+ // being served. docs/ is not special: a file at `docs/guides/x.mdx` has
525
+ // file id `docs/guides/x`, the exact same rule applied to a file
526
+ // anywhere else in the project.
527
+ //
528
+ // A page's actual URL is a separate question, and Astro's own glob()
529
+ // content loader already has a first-class answer for it: if a page's
530
+ // frontmatter sets `slug`, `entry.id` (and therefore the route Astro
531
+ // builds for it) becomes that value verbatim instead of the file-path
532
+ // default - see astro/dist/content/loaders/glob.js's generateIdDefault:
533
+ // `if (data.slug) return data.slug`. content.config.ts's `slug` schema
534
+ // field is deliberately the same field Astro already recognizes, so
535
+ // nothing here needs to reimplement the override or track it separately -
536
+ // it only needs a way to find a page BY file id (to resolve writedocs.json's
537
+ // references) even once `entry.id` no longer equals it.
538
+ //
539
+ // --- Page discovery -------------------------------------------------
540
+ //
541
+ // Any .md/.mdx file anywhere under the content directory becomes a page
542
+ // candidate the moment it has *any* frontmatter block at all - docs/ has
543
+ // no special status here, it's just a folder like any other (a
544
+ // conventional, recommended place to put most pages, not a requirement).
545
+ // content.config.ts's `pages` collection is the same docsSchema applied
546
+ // to exactly this file set. A file with zero frontmatter (no `---` block
547
+ // whatsoever) is never a page candidate - a snippet (see
548
+ // docs/dev/docs/snippets.mdx) typically has none, which is what lets it
549
+ // live anywhere without tripping schema validation. A file that *does*
550
+ // open a frontmatter block but is missing a required field (`title`)
551
+ // still fails validation exactly as before - only "no frontmatter at
552
+ // all" is new; a genuine mistake (frontmatter present, title forgotten)
553
+ // stays a loud build error rather than being silently treated as
554
+ // non-page content.
555
+ //
556
+ // A handful of directories are never scanned regardless of what's in
557
+ // them - build output, dependencies, and writedocs' own working
558
+ // directories, none of which a site author would ever intend as page
559
+ // content. Only excluded at the content directory's own top level (a
560
+ // hand-authored `guides/dist-notes.mdx` isn't mistaken for the build
561
+ // output directory `dist/` just because a folder two levels down happens
562
+ // to also be named `dist`).
563
+ const EXCLUDED_TOP_LEVEL_DIRS = new Set(['node_modules', 'dist', '.astro', '.writedocs', 'public', '.git']);
564
+
565
+ /** Recursively finds every .md/.mdx file under `contentDir` that has a
566
+ * frontmatter block, skipping the handful of build/dependency
567
+ * directories a real content directory tends to also contain (see
568
+ * EXCLUDED_TOP_LEVEL_DIRS above) - docs/ is scanned exactly like any
569
+ * other folder, no special-casing. Returns POSIX-relative paths (from
570
+ * `contentDir`) suitable to hand straight to Astro's `glob()` loader as
571
+ * a literal `pattern` array - see content.config.ts's `pages`
572
+ * collection, and [...slug].astro, which needs the identical list to
573
+ * decide whether calling `getCollection('pages')` is worth doing at all
574
+ * (see content.config.ts's own comment on why an empty collection still
575
+ * needs to exist, just backed by a no-op loader, to avoid Astro's "does
576
+ * not exist or is empty" warning). Also reused directly by
577
+ * astro.config.mjs's noindex/sitemap scan, so that scan always sees
578
+ * exactly the same file set that actually becomes a page - no risk of
579
+ * the two drifting apart. Synchronous and re-run from scratch wherever
580
+ * it's called rather than cached and shared across modules - consistent
581
+ * with how loadDocsConfig() itself is already called repeatedly across
582
+ * this codebase instead of threaded through as shared state, and cheap
583
+ * enough in practice (a docs site's own file count) not to matter. */
584
+ export function findAllPages(contentDir: string): string[] {
585
+ const results: string[] = [];
586
+ function walk(dir: string, relBase: string) {
587
+ let entries: fs.Dirent[];
588
+ try {
589
+ entries = fs.readdirSync(dir, { withFileTypes: true });
590
+ } catch {
591
+ return;
592
+ }
593
+ for (const entry of entries) {
594
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
595
+ const abs = path.join(dir, entry.name);
596
+ if (entry.isDirectory()) {
597
+ if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
598
+ walk(abs, rel);
599
+ continue;
600
+ }
601
+ if (!entry.isFile() || !/\.mdx?$/i.test(entry.name)) continue;
602
+ let raw: string;
603
+ try {
604
+ raw = fs.readFileSync(abs, 'utf-8');
605
+ } catch {
606
+ continue;
607
+ }
608
+ const { data } = matter(raw);
609
+ if (Object.keys(data).length === 0) continue; // no frontmatter at all - not a page
610
+ results.push(rel);
611
+ }
612
+ }
613
+ walk(contentDir, '');
614
+ return results;
615
+ }
616
+
617
+ /** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
618
+ * `public/` included - auto-loaded site-wide with zero `writedocs.json`
619
+ * config, on top of (not instead of) the explicit `scripts` field
620
+ * (bannerSchema and friends, above). Drop a file in, it loads;
621
+ * there's no field naming which ones to use, matching the same "just
622
+ * works" convention `docs/`'s own file discovery already follows (see
623
+ * findAllPages() above / `content-pipeline.mdx`) - a site author already
624
+ * drops content files in and expects them found, rather than also
625
+ * listing every one in writedocs.json.
626
+ *
627
+ * Two separate walks, because `public/` needs different treatment than
628
+ * everywhere else:
629
+ *
630
+ * - `css`/`js`: every `.css`/`.js` file outside `public/` (root, `docs/`,
631
+ * `snippets/`, any custom folder) - reused as the walk-with-exclusions
632
+ * shape from findAllPages() above, and its exact `EXCLUDED_TOP_LEVEL_DIRS`
633
+ * set (skipped only at the project root, same as there), so `dist/`,
634
+ * `node_modules/`, `.astro/`, `.writedocs/`, `.git/`, and (for this
635
+ * half only - see below) `public/` are never walked into. BaseLayout.astro
636
+ * reads each one's raw content and inlines it as a `<style>`/
637
+ * `<script is:inline>` tag.
638
+ * - `publicCss`/`publicJs`: every `.css`/`.js` file *inside* `public/`,
639
+ * walked separately (starting from `<contentDir>/public` rather than
640
+ * `contentDir` itself, so the same `EXCLUDED_TOP_LEVEL_DIRS` check
641
+ * doesn't apply here - there's no `public/public/` or `public/dist/`
642
+ * convention to guard against). Returned as public-URL-rooted hrefs
643
+ * (a leading `/`, no `public` segment - `public/custom.css` becomes
644
+ * `/custom.css`) rather than content-dir-relative paths, since these
645
+ * files are already served as static assets at exactly that URL once
646
+ * Astro copies `public/` into the build output. BaseLayout.astro
647
+ * renders these as ordinary `<link rel="stylesheet">`/`<script src>`
648
+ * tags pointing at that URL instead of inlining their content -
649
+ * inlining would duplicate every byte (once in the page's own HTML,
650
+ * once more as the independently-fetchable static file at that same
651
+ * URL) for no benefit, where a `<link>`/`<script src>` gets normal
652
+ * browser caching across pages instead of repeating the content on
653
+ * every single page's markup.
654
+ *
655
+ * Both halves are broader than they might sound - a stray `.js` file
656
+ * kept in `docs/`, `snippets/`, or `public/` for an unrelated reason
657
+ * (a snippet's own local helper, an image gallery's lightbox script
658
+ * someone dropped in `public/` to reference from a raw `<script src>`
659
+ * in an .mdx file, say) gets auto-injected sitewide the same as a
660
+ * deliberate one; there's no separate "this one's just tooling" signal
661
+ * to opt out of the convention short of renaming its extension.
662
+ *
663
+ * Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
664
+ * by their respective path for deterministic load order across rebuilds
665
+ * - same reasoning as llms.txt's own alphabetical-by-slug sort (see
666
+ * llms.txt.ts) - filesystem readdir order isn't guaranteed portable
667
+ * across OSes or directory-walk order otherwise.
668
+ *
669
+ * `css`/`js` return POSIX-separated paths relative to `contentDir`, not
670
+ * absolute paths or file contents - BaseLayout.astro (the sole caller)
671
+ * resolves and reads each one's content itself, right before inlining
672
+ * it, so a file's content is always current as of that specific
673
+ * request/build rather than cached here across a `writedocs dev`
674
+ * session. `publicCss`/`publicJs` return the public-URL hrefs described
675
+ * above - nothing to read, Astro's own static-file serving/copy already
676
+ * handles those. */
677
+ export function findRootAssets(
678
+ contentDir: string
679
+ ): { css: string[]; js: string[]; publicCss: string[]; publicJs: string[] } {
680
+ const css: string[] = [];
681
+ const js: string[] = [];
682
+ function walk(dir: string, relBase: string) {
683
+ let entries: fs.Dirent[];
684
+ try {
685
+ entries = fs.readdirSync(dir, { withFileTypes: true });
686
+ } catch {
687
+ return;
688
+ }
689
+ for (const entry of entries) {
690
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
691
+ const abs = path.join(dir, entry.name);
692
+ if (entry.isDirectory()) {
693
+ if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
694
+ walk(abs, rel);
695
+ continue;
696
+ }
697
+ if (!entry.isFile()) continue;
698
+ if (/\.css$/i.test(entry.name)) css.push(rel);
699
+ else if (/\.js$/i.test(entry.name)) js.push(rel);
700
+ }
701
+ }
702
+ walk(contentDir, '');
703
+ css.sort();
704
+ js.sort();
705
+
706
+ const publicCss: string[] = [];
707
+ const publicJs: string[] = [];
708
+ function walkPublic(dir: string, relBase: string) {
709
+ let entries: fs.Dirent[];
710
+ try {
711
+ entries = fs.readdirSync(dir, { withFileTypes: true });
712
+ } catch {
713
+ return;
714
+ }
715
+ for (const entry of entries) {
716
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
717
+ const abs = path.join(dir, entry.name);
718
+ if (entry.isDirectory()) {
719
+ walkPublic(abs, rel);
720
+ continue;
721
+ }
722
+ if (!entry.isFile()) continue;
723
+ if (/\.css$/i.test(entry.name)) publicCss.push(`/${rel}`);
724
+ else if (/\.js$/i.test(entry.name)) publicJs.push(`/${rel}`);
725
+ }
726
+ }
727
+ walkPublic(path.join(contentDir, 'public'), '');
728
+ publicCss.sort();
729
+ publicJs.sort();
730
+
731
+ return { css, js, publicCss, publicJs };
732
+ }
733
+
734
+ /** The root-relative URL paths (leading `/`, e.g. `/images/hero.svg`)
735
+ * referenced by the writedocs.json/styles fields that point at a static asset
736
+ * *by URL* rather than embedding it inline: `styles.favicon`,
737
+ * `styles.logo` (both the plain-string and `{light, dark}` object forms),
738
+ * `styles.background.images.{light,dark}`, and `seo.ogImage`. External
739
+ * URLs (anything not starting with `/` - `https://...`, mainly) are
740
+ * filtered out, since those need no local file resolution at all.
741
+ * Deduped, since e.g. `logo.light` and `background.images.light`
742
+ * coincidentally pointing at the same file shouldn't resolve/copy it
743
+ * twice.
744
+ *
745
+ * Used by `stylesAssetFallback()` (`styles-asset-integration.js`,
746
+ * imported from `astro.config.mjs`) to let every one of these resolve
747
+ * from *anywhere* in the project, not just `public/` - previously,
748
+ * `public/` was the one place `styles.background.images` (etc.) had to
749
+ * live, since Astro's own `publicDir` copy is the only thing that ever
750
+ * served them; everywhere else in this codebase's own "drop a file
751
+ * anywhere, it's found" convention (`findRootAssets()` right above,
752
+ * `findAllPages()` for content) already worked project-wide. See that
753
+ * integration's own comment for the actual resolution mechanism (a dev-time
754
+ * middleware plus a post-build copy step, not a duplicated `publicDir`)
755
+ * and why a straight copy-into-`public/`-on-disk approach was rejected.
756
+ *
757
+ * Logo light/dark resolution here deliberately mirrors BaseLayout.astro's
758
+ * own `logoLight`/`logoDark` derivation exactly (a plain-string `logo`
759
+ * counts as both light and dark) - two independent implementations of
760
+ * that same union-unwrapping would only be one accidental edit away from
761
+ * disagreeing with each other. `footer.logo` (footerSchema) gets the
762
+ * same treatment, independently of `styles.logo` - both are collected
763
+ * unconditionally here (not just whichever one BaseLayout.astro would
764
+ * actually end up using for a given page), since this function has no
765
+ * page context to know which page modes render a footer at all; an
766
+ * unreferenced path collected here that never actually renders anywhere
767
+ * is harmless (nothing copies/resolves a file that's never requested),
768
+ * but a real footer.logo file silently 404ing because this function
769
+ * didn't know to resolve it is exactly the bug this comment is warning
770
+ * future edits away from repeating.
771
+ *
772
+ * Per-page frontmatter `seo.ogImage` overrides are deliberately out of
773
+ * scope - those aren't visible from a `DocsConfig` alone (they live in
774
+ * each page's own frontmatter, merged in per-request by [...slug].astro/
775
+ * BaseLayout.astro), and resolving every page's own override would need
776
+ * a full content scan this function has no reason to also become. A
777
+ * page overriding `seo.ogImage` to something outside `public/` still
778
+ * needs to put it there for now. */
779
+ export function collectConfiguredAssetPaths(config: DocsConfig): string[] {
780
+ const logo = config.styles.logo;
781
+ const logoLight = typeof logo === 'string' ? logo : logo?.light;
782
+ const logoDark = typeof logo === 'string' ? logo : logo?.dark;
783
+ const footerLogo = config.footer.logo;
784
+ const footerLogoLight = typeof footerLogo === 'string' ? footerLogo : footerLogo?.light;
785
+ const footerLogoDark = typeof footerLogo === 'string' ? footerLogo : footerLogo?.dark;
786
+ const fonts = config.styles.fonts;
787
+ const raw: (string | undefined)[] = [
788
+ config.styles.favicon,
789
+ logoLight,
790
+ logoDark,
791
+ footerLogoLight,
792
+ footerLogoDark,
793
+ config.styles.background?.images?.light,
794
+ config.styles.background?.images?.dark,
795
+ config.seo?.ogImage,
796
+ // A `styles.fonts` `source` is only ever handled here when it's a
797
+ // project-relative path (the `p.startsWith('/')` filter below already
798
+ // excludes both Google Font names, which never start with "/", and a
799
+ // full https:// external font URL, which the browser fetches directly
800
+ // - neither needs resolving/copying through this pipeline at all).
801
+ fonts?.source,
802
+ fonts?.heading?.source,
803
+ fonts?.body?.source,
804
+ ];
805
+ const paths = raw.filter((p): p is string => Boolean(p) && p.startsWith('/'));
806
+ return Array.from(new Set(paths));
807
+ }
808
+
809
+ export interface DocsEntryLike {
810
+ id: string;
811
+ filePath?: string;
812
+ }
813
+
814
+ /** Recovers a content entry's writedocs.json-facing file id (its path
815
+ * relative to the content directory, extension stripped, trailing
816
+ * `/index` dropped), independent of any frontmatter `slug` override -
817
+ * `entry.filePath` is always root-relative and POSIX-separated (how
818
+ * Astro's content layer records it, relative to whatever `--root` Astro
819
+ * itself was invoked with - see run-astro.js), so both it and the
820
+ * content directory are resolved to absolute paths before comparing,
821
+ * rather than string-matching a prefix that could be relative,
822
+ * absolute, or platform-separated inconsistently.
823
+ *
824
+ * The trailing-`/index`-drop mirrors Astro's own default id computation
825
+ * exactly (`getContentEntryIdAndSlug()` in astro/dist/content/
826
+ * utils.js: segments are joined then `.replace(/\/index$/, '')`) -
827
+ * without it, a nested index file like `docs/guides/index.mdx` (Astro's
828
+ * own default id: "docs/guides") would recover the wrong file id here
829
+ * ("docs/guides/index"), and a writedocs.json reference written to match the
830
+ * page's real URL would fail to resolve. A single-segment `index.mdx`
831
+ * at the content root is unaffected either way - the regex requires a
832
+ * preceding `/`, which a bare "index" doesn't have (matching Astro's
833
+ * own behavior: a root-level index.mdx keeps file id "index", not "").
834
+ *
835
+ * Full per-segment slugification (github-slugger, applied by Astro to
836
+ * every path segment) is deliberately *not* replicated here - every
837
+ * filename in this codebase's own fixtures and every filename this
838
+ * function needs to have handled correctly is already slug-safe
839
+ * (lowercase, hyphenated, no spaces/unicode), so slugification is
840
+ * always a no-op in practice; only the `/index`-stripping behavior is
841
+ * reproduced, since that's the one part of Astro's algorithm this
842
+ * change newly exercises (a file that used to be a collection's own
843
+ * base-root index, exempt from stripping, and is now nested one level
844
+ * deeper).
845
+ *
846
+ * Entries from the `generatedDocs` collection (auto-generated OpenAPI
847
+ * stub pages - see content.config.ts / generate-api-pages.js) live
848
+ * under writedocsTempDir()'s generated-docs/ directory - an OS temp
849
+ * directory location entirely outside contentDir, not a subdirectory
850
+ * of it - so `relativeToContentDir` for one of these always starts with
851
+ * `..` (walking back out of contentDir to reach it), and the check
852
+ * right below falls back to `entry.id` instead of computing a
853
+ * nonsensical id relative to a directory the file was never actually
854
+ * under. Those entries always set their own `slug` frontmatter
855
+ * explicitly, so there's no independent "file path" identity worth
856
+ * recovering for them the way there is for a hand-written page that
857
+ * might move around - `entry.id` (Astro's already-resolved slug)
858
+ * already IS the exact value generate-api-pages.js's own manifest
859
+ * references them by. */
860
+ export function fileIdForEntry(
861
+ contentDir: string,
862
+ packageRoot: string,
863
+ entry: DocsEntryLike
864
+ ): string {
865
+ if (!entry.filePath) return entry.id;
866
+ const absoluteContentDir = path.resolve(contentDir);
867
+ const absoluteFilePath = path.resolve(packageRoot, entry.filePath);
868
+ const relativeToContentDir = path.relative(absoluteContentDir, absoluteFilePath);
869
+ if (relativeToContentDir.startsWith('..')) return entry.id;
870
+ const posixRelative = relativeToContentDir.split(path.sep).join('/');
871
+ if (EXCLUDED_TOP_LEVEL_DIRS.has(posixRelative.split('/')[0])) return entry.id;
872
+ return posixRelative.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
873
+ }
874
+
875
+ /** Normalizes a raw `entry.id` into the bare, slash-free form every
876
+ * route/href in this codebase assumes. Once a page sets a frontmatter
877
+ * `slug`, `entry.id` becomes that value completely verbatim - Astro's
878
+ * glob loader applies no normalization of its own (see
879
+ * generateIdDefault in astro/dist/content/loaders/glob.js) - so
880
+ * `slug: /` or `slug: /guides/new-name` (both natural things to write,
881
+ * mirroring how every href elsewhere in writedocs.json already has a
882
+ * leading slash) would otherwise produce broken multi-slash hrefs
883
+ * wherever hrefForSlug wraps the value in its own `/${slug}/`, and a
884
+ * bare "/" would additionally fail to be recognized as claiming root,
885
+ * colliding with the synthetic root-redirect route. */
886
+ export function normalizeEntryId(id: string): string {
887
+ const trimmed = id.replace(/^\/+/, '').replace(/\/+$/, '');
888
+ return trimmed === '' ? 'index' : trimmed;
889
+ }
890
+
891
+ export interface FlatNavEntry {
892
+ slug: string;
893
+ group: string | null;
894
+ }
895
+
896
+ export function flattenNav(navigation: NavItem[], group: string | null = null): FlatNavEntry[] {
897
+ return navigation.flatMap((item): FlatNavEntry[] => {
898
+ if (typeof item === 'string') {
899
+ return [{ slug: item, group }];
900
+ }
901
+ if ('href' in item) return []; // external link leaf, not a content page - no route/prev-next entry
902
+ // loadDocsConfig() always expands `{ group, openapi }` shorthand into
903
+ // a real `{ group, pages }` before anything reaches here (see
904
+ // expandOpenApiInNavigation() above) - this is just a defensive
905
+ // no-op for the shouldn't-happen case of an unexpanded node.
906
+ if ('openapi' in item) return [];
907
+ const ownPage: FlatNavEntry[] = item.page ? [{ slug: item.page, group: item.group }] : [];
908
+ return [...ownPage, ...flattenNav(item.pages, item.group)];
909
+ });
910
+ }
911
+
912
+ // --- Sections -------------------------------------------------------
913
+ //
914
+ // A Section is the atomic unit that owns one page tree and therefore one
915
+ // sidebar - every real content page belongs to exactly one Section,
916
+ // whichever `pages` leaf it's listed under, however deep in the
917
+ // tabs/versions/languages/dropdowns/products tree that leaf lives.
918
+ //
919
+ // A Section's `path` records the full chain of containers from the
920
+ // navigation root down to it, one PathSegment per level. This is
921
+ // everything the topbar needs to render the right selector controls
922
+ // (tabs bar, version dropdown, ...) and highlight the right option in
923
+ // each - without the router or layout needing to know how deep or in
924
+ // what order the site author nested things.
925
+
926
+ export type PathSegment =
927
+ | { kind: 'tab'; items: TabItem[]; index: number }
928
+ | { kind: 'version'; items: VersionItem[]; index: number }
929
+ | { kind: 'language'; items: LanguageItem[]; index: number }
930
+ | { kind: 'dropdown'; items: DropdownItem[]; index: number }
931
+ | { kind: 'product'; items: ProductItem[]; index: number };
932
+
933
+ export interface Section {
934
+ pages: NavItem[];
935
+ path: PathSegment[];
936
+ }
937
+
938
+ type Container = NavChildren;
939
+ type NamedContainer = TabItem | VersionItem | LanguageItem | DropdownItem | ProductItem;
940
+
941
+ function walkSections(node: Container, path: PathSegment[], out: Section[]): void {
942
+ if ('pages' in node) {
943
+ out.push({ pages: node.pages, path });
944
+ return;
945
+ }
946
+ if ('href' in node) return; // external link, not a Section
947
+ if ('tabs' in node) {
948
+ node.tabs.forEach((item, index) =>
949
+ walkSections(item, [...path, { kind: 'tab', items: node.tabs, index }], out)
950
+ );
951
+ return;
952
+ }
953
+ if ('versions' in node) {
954
+ node.versions.forEach((item, index) =>
955
+ walkSections(item, [...path, { kind: 'version', items: node.versions, index }], out)
956
+ );
957
+ return;
958
+ }
959
+ if ('languages' in node) {
960
+ node.languages.forEach((item, index) =>
961
+ walkSections(item, [...path, { kind: 'language', items: node.languages, index }], out)
962
+ );
963
+ return;
964
+ }
965
+ if ('dropdowns' in node) {
966
+ node.dropdowns.forEach((item, index) =>
967
+ walkSections(item, [...path, { kind: 'dropdown', items: node.dropdowns, index }], out)
968
+ );
969
+ return;
970
+ }
971
+ if ('products' in node) {
972
+ node.products.forEach((item, index) =>
973
+ walkSections(item, [...path, { kind: 'product', items: node.products, index }], out)
974
+ );
975
+ return;
976
+ }
977
+ }
978
+
979
+ export function resolveSections(navigation: NavigationConfig): Section[] {
980
+ if (Array.isArray(navigation)) return [{ pages: navigation, path: [] }];
981
+ const sections: Section[] = [];
982
+ walkSections(navigation as Container, [], sections);
983
+ // global.dropdowns sits outside the primary pattern, but its pages
984
+ // still need routes generated for them - walk each entry too, with an
985
+ // empty path (they don't participate in the tabs/version/etc.
986
+ // selector chain, only in the always-visible globalDropdowns list).
987
+ for (const dropdown of navigation.global?.dropdowns ?? []) {
988
+ walkSections(dropdown, [], sections);
989
+ }
990
+ return sections;
991
+ }
992
+
993
+ /** Which Section (by index into resolveSections()'s result) a page belongs to. */
994
+ export function findSectionIndexForSlug(sections: Section[], slug: string): number {
995
+ const index = sections.findIndex((s) => flattenNav(s.pages).some((entry) => entry.slug === slug));
996
+ return index === -1 ? 0 : index;
997
+ }
998
+
999
+ /** The always-visible topbar dropdown list - independent of whichever
1000
+ * primary pattern/section is active (Mintlify's equivalent is
1001
+ * `navigation.global.anchors`). */
1002
+ export function resolveGlobalDropdowns(navigation: NavigationConfig): DropdownItem[] {
1003
+ if (Array.isArray(navigation)) return [];
1004
+ return navigation.global?.dropdowns ?? [];
1005
+ }
1006
+
1007
+ /** The first real page slug reachable by descending into a container's
1008
+ * content, however deep - used to compute where a selector option (a
1009
+ * tab, a version, ...) navigates to when chosen. Null for an external
1010
+ * `href` leaf, which the caller renders as a plain link using the
1011
+ * node's own href instead. Versions prefer whichever entry is marked
1012
+ * `default` (falling back to the first), matching Mintlify's version
1013
+ * default rule. */
1014
+ /** Tries `firstSlugOf()` against each item in order, returning the first
1015
+ * non-null result - a plain `items[0]` pick (the previous behavior)
1016
+ * breaks the moment that first sibling happens to be a bare `href` leaf
1017
+ * (returns null, with nothing that tries the next one), which is a real
1018
+ * case now that any container - not just a nested dropdown - can be a
1019
+ * bare external link (e.g. a `tabs` array whose first entry is
1020
+ * `{ tab: "Status", href: "..." }`). */
1021
+ function firstSlugAmong(items: Container[]): string | null {
1022
+ for (const item of items) {
1023
+ const slug = firstSlugOf(item);
1024
+ if (slug !== null) return slug;
1025
+ }
1026
+ return null;
1027
+ }
1028
+
1029
+ export function firstSlugOf(node: Container): string | null {
1030
+ if ('pages' in node) return flattenNav(node.pages)[0]?.slug ?? null;
1031
+ if ('href' in node) return null;
1032
+ if ('tabs' in node) return firstSlugAmong(node.tabs);
1033
+ if ('versions' in node) {
1034
+ // Try the `default`-tagged version(s) first (matching the old
1035
+ // "preferred" pick), then fall through to the rest in order - same
1036
+ // href-leaf-with-no-fallback concern as `tabs` above, just with an
1037
+ // extra preference pass in front of it.
1038
+ const defaults = node.versions.filter((v) => v.default);
1039
+ const rest = node.versions.filter((v) => !v.default);
1040
+ return firstSlugAmong([...defaults, ...rest]);
1041
+ }
1042
+ if ('languages' in node) return firstSlugAmong(node.languages);
1043
+ if ('dropdowns' in node) return firstSlugAmong(node.dropdowns);
1044
+ if ('products' in node) return firstSlugAmong(node.products);
1045
+ return null;
1046
+ }
1047
+
1048
+ /** For a version/language switcher, finds where the reader's current
1049
+ * page would live inside a *different* version/language's own subtree,
1050
+ * so switching keeps them on the same conceptual page instead of always
1051
+ * bouncing to that option's first page (see buildSelectors() below,
1052
+ * which is the only caller). "Same page" is approximated positionally
1053
+ * rather than by name, since nothing guarantees a version/language's
1054
+ * own identifier lines up with its pages' file paths - a version
1055
+ * tagged "2025-09" could just as easily keep its pages under a "v1"
1056
+ * folder with no relation to that string, so there's no naming
1057
+ * convention to key off safely; this heuristic only ever compares tree
1058
+ * position, never path text. (docs.json-examples/00-kitchen-sink/'s own
1059
+ * versions - "2025-09"/"2026-01" under Core Platform - happen to have
1060
+ * folder names that line up with their version strings, so it doesn't
1061
+ * demonstrate the mismatched-folder-name case directly; the guarantee
1062
+ * still doesn't exist regardless of what one fixture happens to do.)
1063
+ *
1064
+ * Two things have to line up for a page to count as "the same" one:
1065
+ * first, `remainingPath` (whatever tab/product/dropdown was chosen
1066
+ * *below* the version/language being switched, on the reader's actual
1067
+ * page) has to exist at the same index in `item`'s own subtree too -
1068
+ * an option isn't guaranteed to nest the same way that many levels
1069
+ * down (a version could add/remove a tab), so any mismatch here bails
1070
+ * out to null immediately rather than guessing. Second, once both
1071
+ * bottom out at a leaf `pages` list, `position` (the reader's own page
1072
+ * index within *its* pages list) has to exist in `item`'s pages list
1073
+ * too - two versions/languages of the same docs are usually authored
1074
+ * with matching page order even when the file paths differ, so this
1075
+ * is a reasonable proxy for "the same page" without relying on names.
1076
+ *
1077
+ * Returns null - meaning "no equivalent page, fall back to
1078
+ * firstSlugOf()" - on any structural mismatch or an out-of-range
1079
+ * position, rather than guessing at a wrong page. */
1080
+ export function equivalentPageIn(
1081
+ item: Container,
1082
+ remainingPath: PathSegment[],
1083
+ position: number
1084
+ ): string | null {
1085
+ if ('pages' in item) return flattenNav(item.pages)[position]?.slug ?? null;
1086
+ if ('href' in item) return null;
1087
+ const [next, ...rest] = remainingPath;
1088
+ if (!next) return null; // the reader's own page didn't go this deep - no basis to pick a branch
1089
+ if ('tabs' in item && next.kind === 'tab') {
1090
+ const target = item.tabs[next.index];
1091
+ return target ? equivalentPageIn(target, rest, position) : null;
1092
+ }
1093
+ if ('versions' in item && next.kind === 'version') {
1094
+ const target = item.versions[next.index];
1095
+ return target ? equivalentPageIn(target, rest, position) : null;
1096
+ }
1097
+ if ('languages' in item && next.kind === 'language') {
1098
+ const target = item.languages[next.index];
1099
+ return target ? equivalentPageIn(target, rest, position) : null;
1100
+ }
1101
+ if ('dropdowns' in item && next.kind === 'dropdown') {
1102
+ const target = item.dropdowns[next.index];
1103
+ return target ? equivalentPageIn(target, rest, position) : null;
1104
+ }
1105
+ if ('products' in item && next.kind === 'product') {
1106
+ const target = item.products[next.index];
1107
+ return target ? equivalentPageIn(target, rest, position) : null;
1108
+ }
1109
+ return null; // structural mismatch - this option nests differently at this depth
1110
+ }
1111
+
1112
+ /** The first real page slug in the whole site, root navigation pattern
1113
+ * included - used to redirect `/` somewhere sensible when nothing in
1114
+ * the navigation happens to be a page literally named "index" (the
1115
+ * usual "docs/index.mdx is the homepage" convention). Mirrors
1116
+ * firstSlugOf()'s per-container descent, plus the flat-array root case
1117
+ * firstSlugOf() alone can't handle since a bare array isn't a Container. */
1118
+ export function firstSlugOfNavigation(navigation: NavigationConfig): string | null {
1119
+ if (Array.isArray(navigation)) return flattenNav(navigation)[0]?.slug ?? null;
1120
+ return firstSlugOf(navigation as Container);
1121
+ }
1122
+
1123
+ /** Whether `slug` is reachable anywhere underneath a container, however
1124
+ * deep - used for computing active state on nodes that aren't part of
1125
+ * the active Section's own `path` (global dropdown entries). */
1126
+ export function containerContainsSlug(node: Container, slug: string): boolean {
1127
+ if ('pages' in node) return flattenNav(node.pages).some((entry) => entry.slug === slug);
1128
+ if ('href' in node) return false;
1129
+ if ('tabs' in node) return node.tabs.some((t) => containerContainsSlug(t, slug));
1130
+ if ('versions' in node) return node.versions.some((v) => containerContainsSlug(v, slug));
1131
+ if ('languages' in node) return node.languages.some((l) => containerContainsSlug(l, slug));
1132
+ if ('dropdowns' in node) return node.dropdowns.some((d) => containerContainsSlug(d, slug));
1133
+ if ('products' in node) return node.products.some((p) => containerContainsSlug(p, slug));
1134
+ return false;
1135
+ }
1136
+
1137
+ function labelOf(item: NamedContainer): string {
1138
+ if ('tab' in item) return item.tab;
1139
+ if ('version' in item) return item.label ?? item.version;
1140
+ if ('language' in item) return item.label ?? item.language;
1141
+ if ('dropdown' in item) return item.dropdown;
1142
+ return item.product;
1143
+ }
1144
+
1145
+ function iconOf(item: NamedContainer): string | undefined {
1146
+ return (item as { icon?: string }).icon;
1147
+ }
1148
+
1149
+ // --- Topbar selectors -------------------------------------------------
1150
+ //
1151
+ // One Selector per PathSegment on the active Section's path. Tabs render
1152
+ // as the horizontal pill bar (all options always visible, exactly one
1153
+ // current); versions/languages/products, and dropdowns used as a nested
1154
+ // path segment, render as a single switcher control instead - the
1155
+ // trigger shows the *current* option, its menu lists the alternatives.
1156
+ // See BaseLayout.astro for the actual markup per kind.
1157
+
1158
+ export interface SelectorOption {
1159
+ label: string;
1160
+ icon?: string;
1161
+ tag?: string;
1162
+ href: string;
1163
+ active: boolean;
1164
+ // Set when this option's own container is a `dropdowns` list (e.g. a
1165
+ // tab whose content is `dropdowns` instead of `pages`) - the tab pill
1166
+ // itself becomes a dropdown-trigger showing these as its menu, rather
1167
+ // than a separate dropdown control rendered alongside it. Only ever
1168
+ // populated one level deep (a dropdown option doesn't itself get a
1169
+ // nested `dropdown` - matches the one level of "a tab/dropdown owns a
1170
+ // dropdowns list" this is meant to cover).
1171
+ dropdown?: SelectorOption[];
1172
+ }
1173
+
1174
+ export interface Selector {
1175
+ kind: PathSegment['kind'];
1176
+ options: SelectorOption[];
1177
+ }
1178
+
1179
+ /** An option's own `dropdowns` menu, if it has one (see SelectorOption.dropdown
1180
+ * above) - `activeSegment` is the *next* PathSegment after this option's own
1181
+ * segment, only passed when this option is the active one on its level, so a
1182
+ * sibling option that also happens to own `dropdowns` doesn't spuriously mark
1183
+ * one of its entries active just because some *other* option is currently
1184
+ * selected. */
1185
+ function dropdownMenuOf(
1186
+ node: NavChildren,
1187
+ hrefForSlug: (slug: string) => string,
1188
+ activeSegment: PathSegment | undefined
1189
+ ): SelectorOption[] | undefined {
1190
+ if (!('dropdowns' in node)) return undefined;
1191
+ return node.dropdowns.map((d, i) => ({
1192
+ label: d.dropdown,
1193
+ icon: iconOf(d),
1194
+ href: 'href' in d ? d.href : hrefForSlug(firstSlugOf(d) ?? 'index'),
1195
+ active: activeSegment?.kind === 'dropdown' && activeSegment.index === i,
1196
+ }));
1197
+ }
1198
+
1199
+ /** `activePagePosition` is the reader's current page's own index within
1200
+ * its Section's flattened pages list (-1 if it can't be found there,
1201
+ * which just disables the position-preserving behavior below) - see
1202
+ * [...slug].astro for how it's computed. Only version/language
1203
+ * switchers try to preserve position across options; tabs/dropdowns/
1204
+ * products keep linking to firstSlugOf() unconditionally, since those
1205
+ * represent genuinely different content (an "API Reference" tab isn't
1206
+ * "the same page" as a "Guides" tab just because they're both first),
1207
+ * unlike a version/language of what's meant to be the same docs. */
1208
+ export function buildSelectors(
1209
+ path: PathSegment[],
1210
+ hrefForSlug: (slug: string) => string,
1211
+ activePagePosition: number = -1
1212
+ ): Selector[] {
1213
+ return path.map((segment, segIndex): Selector => {
1214
+ const preservesPosition =
1215
+ (segment.kind === 'version' || segment.kind === 'language') && activePagePosition >= 0;
1216
+ const remainingPath = path.slice(segIndex + 1);
1217
+ return {
1218
+ kind: segment.kind,
1219
+ options: (segment.items as NamedContainer[]).map((item, i) => {
1220
+ const isActive = i === segment.index;
1221
+ const equivalentSlug = preservesPosition
1222
+ ? equivalentPageIn(item as Container, remainingPath, activePagePosition)
1223
+ : null;
1224
+ const targetSlug = equivalentSlug ?? firstSlugOf(item as Container) ?? 'index';
1225
+ return {
1226
+ label: labelOf(item),
1227
+ icon: iconOf(item),
1228
+ tag: 'tag' in item ? item.tag : undefined,
1229
+ href: 'href' in item ? item.href : hrefForSlug(targetSlug),
1230
+ active: isActive,
1231
+ dropdown: dropdownMenuOf(item, hrefForSlug, isActive ? path[segIndex + 1] : undefined),
1232
+ };
1233
+ }),
1234
+ };
1235
+ });
1236
+ }
1237
+
1238
+ // --- Global dropdowns ---------------------------------------------------
1239
+ //
1240
+ // Unlike path-segment selectors, global dropdowns aren't a mutually
1241
+ // exclusive "pick one" choice - `global.dropdowns` is an array where
1242
+ // *every* entry renders as its own always-visible trigger button
1243
+ // simultaneously (Mintlify's anchors work the same way). A trigger's
1244
+ // menu lists its own direct pages as quick links; a bare-href entry is
1245
+ // just a plain link with no menu; a deeply-nested entry (tabs/versions/
1246
+ // etc. instead of direct pages) falls back to linking straight to its
1247
+ // first page, since building a rich flyout for that combination isn't
1248
+ // worth the complexity for what's meant to be a quick-links affordance.
1249
+
1250
+ export interface GlobalDropdownView {
1251
+ label: string;
1252
+ icon?: string;
1253
+ href: string | null;
1254
+ items: SelectorOption[];
1255
+ }
1256
+
1257
+ export function buildGlobalDropdowns(
1258
+ dropdowns: DropdownItem[],
1259
+ currentSlug: string,
1260
+ titleForSlug: (slug: string) => string,
1261
+ hrefForSlug: (slug: string) => string,
1262
+ metaForSlug?: (slug: string) => NavPageMeta | undefined
1263
+ ): GlobalDropdownView[] {
1264
+ return dropdowns.map((d): GlobalDropdownView => {
1265
+ if ('href' in d) {
1266
+ return { label: d.dropdown, icon: d.icon, href: d.href, items: [] };
1267
+ }
1268
+ if ('pages' in d) {
1269
+ // Same `hidden`/`url` handling as buildNavTree().
1270
+ const items = flattenNav(d.pages)
1271
+ .filter((entry) => !metaForSlug?.(entry.slug)?.hidden)
1272
+ .map((entry) => ({
1273
+ label: titleForSlug(entry.slug),
1274
+ href: metaForSlug?.(entry.slug)?.url ?? hrefForSlug(entry.slug),
1275
+ active: entry.slug === currentSlug,
1276
+ }));
1277
+ return { label: d.dropdown, icon: d.icon, href: null, items };
1278
+ }
1279
+ const slug = firstSlugOf(d);
1280
+ return { label: d.dropdown, icon: d.icon, href: slug ? hrefForSlug(slug) : '#', items: [] };
1281
+ });
1282
+ }
1283
+
1284
+ // --- Sidebar (recursive) -------------------------------------------------
1285
+
1286
+ // A group node's `pageSlug` is the page its own label links to (null if
1287
+ // it's a pure disclosure/label with no page of its own - the group only
1288
+ // groups). NavTree.astro renders depth-0 groups as static, non-collapsible
1289
+ // section titles (linked if `pageSlug` is set, plain text otherwise) and
1290
+ // every deeper group as a collapsible row styled like a page item, with a
1291
+ // chevron, defaulting open when it contains the active page - see
1292
+ // navTreeContainsSlug() below.
1293
+ export type NavTreeNode =
1294
+ | {
1295
+ kind: 'page';
1296
+ slug: string;
1297
+ title: string;
1298
+ method: string | null;
1299
+ icon?: string;
1300
+ tag?: string;
1301
+ deprecated?: boolean;
1302
+ }
1303
+ | { kind: 'group'; label: string; pageSlug: string | null; children: NavTreeNode[] }
1304
+ | { kind: 'link'; label: string; href: string };
1305
+
1306
+ /** Builds the sidebar's view model, preserving group nesting depth.
1307
+ * `methodForSlug` is optional (most sites have no OpenAPI pages at all)
1308
+ * and, when given, returns the HTTP method to badge a page with in the
1309
+ * sidebar (e.g. "GET") or null for an ordinary page - see
1310
+ * NavTree.astro for how that badge renders. */
1311
+ /** Per-page frontmatter that changes how a page shows up in navigation -
1312
+ * Mintlify's `hidden`, `url`, `icon`, `tag` and `deprecated` (see
1313
+ * content.config.ts's docsSchema). */
1314
+ export interface NavPageMeta {
1315
+ hidden?: boolean;
1316
+ url?: string;
1317
+ icon?: string;
1318
+ tag?: string;
1319
+ deprecated?: boolean;
1320
+ }
1321
+
1322
+ export function buildNavTree(
1323
+ navigation: NavItem[],
1324
+ titleForSlug: (slug: string) => string,
1325
+ methodForSlug?: (slug: string) => string | null,
1326
+ metaForSlug?: (slug: string) => NavPageMeta | undefined
1327
+ ): NavTreeNode[] {
1328
+ return navigation.flatMap((item): NavTreeNode[] => {
1329
+ if (typeof item === 'string') {
1330
+ const meta = metaForSlug?.(item);
1331
+ // A `hidden` page keeps its route but gets no sidebar entry.
1332
+ if (meta?.hidden) return [];
1333
+ // A `url` page is an external link in the sidebar, not a page.
1334
+ if (meta?.url) return [{ kind: 'link', label: titleForSlug(item), href: meta.url }];
1335
+ return [
1336
+ {
1337
+ kind: 'page',
1338
+ slug: item,
1339
+ title: titleForSlug(item),
1340
+ method: methodForSlug?.(item) ?? null,
1341
+ icon: meta?.icon,
1342
+ tag: meta?.tag,
1343
+ deprecated: meta?.deprecated,
1344
+ },
1345
+ ];
1346
+ }
1347
+ if ('href' in item) {
1348
+ return [{ kind: 'link', label: item.label, href: item.href }];
1349
+ }
1350
+ // loadDocsConfig() always expands `{ group, openapi }` shorthand into
1351
+ // a real `{ group, pages }` before anything reaches here (see
1352
+ // expandOpenApiInNavigation() in the OpenAPI section above) - this is
1353
+ // just a defensive no-op for the shouldn't-happen case of an
1354
+ // unexpanded node reaching the sidebar builder.
1355
+ if ('openapi' in item) {
1356
+ return [{ kind: 'group', label: item.group, pageSlug: null, children: [] }];
1357
+ }
1358
+ return [
1359
+ {
1360
+ kind: 'group',
1361
+ label: item.group,
1362
+ pageSlug: item.page ?? null,
1363
+ children: buildNavTree(item.pages, titleForSlug, methodForSlug, metaForSlug),
1364
+ },
1365
+ ];
1366
+ });
1367
+ }
1368
+
1369
+ /** Whether `slug` is the group's own attached page, or belongs to any page/group nested inside it. */
1370
+ export function navTreeContainsSlug(nodes: NavTreeNode[], slug: string): boolean {
1371
+ return nodes.some((node) => {
1372
+ if (node.kind === 'page') return node.slug === slug;
1373
+ if (node.kind === 'link') return false;
1374
+ return node.pageSlug === slug || navTreeContainsSlug(node.children, slug);
1375
+ });
1376
+ }
1377
+
1378
+ export interface BreadcrumbCrumb {
1379
+ label: string;
1380
+ href: string | null;
1381
+ }
1382
+
1383
+ /** The chain of ancestor group labels (root first) that `slug` is nested
1384
+ * under within `nodes` - empty when the page sits at the top level of its
1385
+ * Section with no enclosing group at all, in which case the caller should
1386
+ * skip rendering breadcrumbs entirely (there'd be nothing to show but the
1387
+ * fixed home icon Breadcrumbs.astro always renders first).
1388
+ *
1389
+ * Deliberately never includes the current page itself - only the groups
1390
+ * it's nested under (that's already the <h1> right below, repeating it
1391
+ * in the breadcrumb trail would just be noise). A group whose own
1392
+ * attached page (`pageSlug`, see NavTreeNode) *is* `slug` is excluded
1393
+ * from its own trail for the same reason - you're looking at that page,
1394
+ * it doesn't need to also list itself as its own ancestor. A group only
1395
+ * appears here when `slug` is nested *inside* it (its own page, if any,
1396
+ * links to that group's landing page - `href: null` for a label-only
1397
+ * group with no page of its own to link to). */
1398
+ export function ancestorGroupsForSlug(
1399
+ nodes: NavTreeNode[],
1400
+ slug: string,
1401
+ hrefForSlug: (slug: string) => string
1402
+ ): BreadcrumbCrumb[] {
1403
+ for (const node of nodes) {
1404
+ if (node.kind !== 'group') continue;
1405
+ if (node.pageSlug === slug) return [];
1406
+ if (navTreeContainsSlug(node.children, slug)) {
1407
+ const crumb: BreadcrumbCrumb = { label: node.label, href: node.pageSlug ? hrefForSlug(node.pageSlug) : null };
1408
+ return [crumb, ...ancestorGroupsForSlug(node.children, slug, hrefForSlug)];
1409
+ }
1410
+ }
1411
+ return [];
1412
+ }