@writedocs/generator 0.4.7 → 0.4.9

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.
@@ -1,1437 +1,1574 @@
1
- // O schema do writedocs.json, e nada alem disso.
2
- //
3
- // Este arquivo era as primeiras 875 linhas do config.ts (que continua
4
- // reexportando tudo daqui, entao nada que importa de './config.js' mudou). A
5
- // separacao existe por um motivo concreto: o config.ts importa node:fs,
6
- // node:path e gray-matter no topo pras funcoes que leem o disco, e o
7
- // package.json passou a exportar './config-schema' pra que a plataforma
8
- // (writedocs-application) possa importar o schema DE VERDADE - o mesmo que o
9
- // build usa - em vez de manter uma copia que diverge em algumas semanas. Um
10
- // Worker da Cloudflare nao consegue importar um modulo que traz node:fs junto.
11
- //
12
- // Por isso a unica dependencia aqui e o zod. Nada neste arquivo pode passar a
13
- // tocar o sistema de arquivos: se precisar de fs/path, o lugar e o config.ts.
14
- //
15
- // ESTE arquivo e a fonte de verdade, mas NAO e o que o pacote publica: o script
16
- // `prepare` (package.json) transpila ele pra ./config-schema.js, e e o `.js` que
17
- // o `exports` e o bin/writedocs.js apontam. Sem isso o `writedocs validate`
18
- // quebra pra quem instala o pacote - o Node recusa type stripping sob
19
- // node_modules. Editou aqui, rode `npm run build:schema` (ou qualquer
20
- // `npm install`) antes de testar o caminho da CLI ou da plataforma.
21
- //
22
- // (`zod` e nao `astro/zod`: as duas especificacoes resolvem pra mesma instalacao
23
- // - node_modules/zod 4.4.3, que e o que astro@7.2.2 tambem pede via `^4.3.6` -
24
- // entao a troca nao muda comportamento nenhum, so tira o astro do caminho de
25
- // quem importa isto de fora do gerador.)
26
- import { z } from 'zod';
27
- // WD-063: `parseTree` + `findNodeAtLocation` traduzem o caminho de um erro
28
- // (`navigation.products.0.product`) para o offset dele no TEXTO, que e o que
29
- // vira linha. E o parser de JSON do VS Code: zero dependencias, nada de `node:`,
30
- // entao continua bundlavel pro Worker da plataforma. Escrever o scanner a mao
31
- // foi tentado e deu errado (perdia todo caminho aninhado, que e justamente o que
32
- // importa aqui) - nao repita.
33
- import { findNodeAtLocation, parseTree } from 'jsonc-parser';
34
-
35
- // ---------------------------------------------------------------------
36
- // Pages & groups - the leaf content any container ultimately bottoms out
37
- // at. A group can nest other groups arbitrarily deep, and can optionally
38
- // link its own label to a page (`page`) independent of expanding or
39
- // collapsing its children - see NavTree.astro for how that renders.
40
- // ---------------------------------------------------------------------
41
-
42
- const navPageSchema = z.string();
43
-
44
- // A group ordinarily lists its own children by hand (`pages`), but can
45
- // instead auto-generate them from an OpenAPI spec via `openapi: { src,
46
- // path }` - resolved by loadDocsConfig() (via expandOpenApiInNavigation()
47
- // below, reading the manifest generate-api-pages.js writes for this exact
48
- // group) into a real `{ group, pages }` node - one sub-group per tag -
49
- // before anything else in this file ever sees it, so
50
- // resolveSections()/flattenNav()/buildNavTree()/etc. only ever need to
51
- // understand the ordinary pages-array shape. `.strict()` on both variants
52
- // is load-bearing (see withChildren()'s own comment on this pattern
53
- // below): without it, a group written with both `pages` and `openapi` at
54
- // once would silently have one dropped instead of rejected.
55
- const navGroupPagesSchema: z.ZodType<{ group: string; page?: string; pages: NavItem[] }> = z.lazy(() =>
56
- z
57
- .object({
58
- group: z.string(),
59
- page: z.string().optional(),
60
- pages: z.array(navItemSchema),
61
- })
62
- .strict()
63
- );
64
-
65
- const navGroupOpenApiSchema = z.object({
66
- group: z.string(),
67
- openapi: z
68
- .object({
69
- // Path to an OpenAPI 3.x spec, relative to the content directory
70
- // (alongside writedocs.json) - same convention the old top-level
71
- // `openapi` field used. See generate-api-pages.js (the pre-Astro
72
- // build/dev step that actually parses this and writes the
73
- // manifest/operation JSON this group's pages are expanded from).
74
- src: z.string(),
75
- // Base URL path every page generated from this spec is namespaced
76
- // under, e.g. "/api" -> pages served at /api/<tag>/<operation>/.
77
- // Also doubles as this spec's own unique key under
78
- // writedocsTempDir()'s openapi/<path>/ directory (manifest.json + operations/*.json),
79
- // so two openapi groups in the same writedocs.json must use different
80
- // `path` values - generate-api-pages.js throws a clear error if
81
- // they collide.
82
- path: z.string(),
83
- })
84
- .strict(),
85
- }).strict();
86
-
87
- export type GroupNavItem =
88
- | { group: string; page?: string; pages: NavItem[] }
89
- | { group: string; openapi: { src: string; path: string } };
90
-
91
- const navGroupSchema: z.ZodType<GroupNavItem> = z.union([navGroupPagesSchema, navGroupOpenApiSchema]);
92
-
93
- // A bare external link sitting directly in a `pages` array, alongside
94
- // page slugs and groups - e.g. a "Support" link to an external help
95
- // desk shown right in the sidebar, rather than only reachable via
96
- // navigation.global.dropdowns. Discriminated from navGroupSchema by
97
- // its required `label`/`href` keys (vs `group`/`pages`/`openapi`), so a
98
- // plain (non-strict at the union level) schema is enough for Zod to pick
99
- // the right branch; `.strict()` here still catches a typo'd extra key.
100
- const navLinkSchema = z.object({ label: z.string(), href: z.string() }).strict();
101
-
102
- const navItemSchema: z.ZodType<NavItem> = z.lazy(() =>
103
- z.union([navPageSchema, navGroupSchema, navLinkSchema])
104
- );
105
-
106
- export type NavItem = string | GroupNavItem | { label: string; href: string };
107
-
108
- // ---------------------------------------------------------------------
109
- // Containers: tabs, versions, languages, dropdowns, products.
110
- //
111
- // This schema is modeled directly on Mintlify's writedocs.json
112
- // (https://mintlify.com/writedocs.json / mintlify.com/docs/organize/navigation):
113
- // all five container kinds are structurally identical except for their
114
- // own identifying field. Each owns *exactly one* of {pages, tabs,
115
- // versions, languages, dropdowns, products} as its content, or is a bare
116
- // external `href` link with no content of its own. That symmetry is what
117
- // lets any of them nest inside any other - a tab can contain versions, a
118
- // version can contain languages, a language can contain tabs, and so on,
119
- // bottoming out at a `pages` array. `withChildren()` is the single place
120
- // that shape is defined, instead of hand-writing the union five times.
121
- // ---------------------------------------------------------------------
122
-
123
- export type NavChildren =
124
- | { pages: NavItem[] }
125
- | { tabs: TabItem[] }
126
- | { versions: VersionItem[] }
127
- | { languages: LanguageItem[] }
128
- | { dropdowns: DropdownItem[] }
129
- | { products: ProductItem[] }
130
- | { href: string };
131
-
132
- export type TabItem = { tab: string; icon?: string } & NavChildren;
133
- export type VersionItem = { version: string; label?: string; tag?: string; default?: boolean } & NavChildren;
134
- export type LanguageItem = { language: string; label?: string } & NavChildren;
135
- export type DropdownItem = { dropdown: string; icon?: string } & NavChildren;
136
- export type ProductItem = { product: string; icon?: string; description?: string } & NavChildren;
137
-
138
- // `.strict()` on every variant is load-bearing, not decoration: Zod
139
- // objects silently strip unrecognized keys by default, so without it a
140
- // node written with two children fields at once (e.g. both `pages` and
141
- // `versions`) would just have the second one dropped instead of
142
- // rejected - defeating the entire "exactly one child kind per level"
143
- // rule this schema exists to enforce (mirroring Mintlify's own "a tab
144
- // cannot contain both anchors and groups at the same level").
145
- function withChildren<Base extends z.ZodRawShape>(base: Base) {
146
- return z.union([
147
- z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
148
- z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
149
- z.object({ ...base, versions: z.array(versionSchema).min(1) }).strict(),
150
- z.object({ ...base, languages: z.array(languageSchema).min(1) }).strict(),
151
- z.object({ ...base, dropdowns: z.array(dropdownSchema).min(1) }).strict(),
152
- z.object({ ...base, products: z.array(productSchema).min(1) }).strict(),
153
- z.object({ ...base, href: z.string() }).strict(),
154
- ]);
155
- }
156
-
157
- // Each of these five is defined via z.lazy() because withChildren()
158
- // references all five by name (mutual recursion) - safe because none of
159
- // them are actually *called* until a real writedocs.json is parsed, by which
160
- // point every const below has been assigned. The same pattern already
161
- // used for navItemSchema/navGroupSchema above.
162
- const tabSchema: z.ZodType<TabItem> = z.lazy(() =>
163
- withChildren({ tab: z.string(), icon: z.string().optional() })
164
- );
165
-
166
- const versionSchema: z.ZodType<VersionItem> = z.lazy(() =>
167
- withChildren({
168
- version: z.string(),
169
- label: z.string().optional(),
170
- tag: z.string().optional(),
171
- default: z.boolean().optional(),
172
- })
173
- );
174
-
175
- const languageSchema: z.ZodType<LanguageItem> = z.lazy(() =>
176
- withChildren({ language: z.string(), label: z.string().optional() })
177
- );
178
-
179
- const dropdownSchema: z.ZodType<DropdownItem> = z.lazy(() =>
180
- withChildren({ dropdown: z.string(), icon: z.string().optional() })
181
- );
182
-
183
- const productSchema: z.ZodType<ProductItem> = z.lazy(() =>
184
- withChildren({ product: z.string(), icon: z.string().optional(), description: z.string().optional() })
185
- );
186
-
187
- // ---------------------------------------------------------------------
188
- // Root navigation: a flat array (shorthand for one implicit section - the
189
- // common case) or an object choosing exactly one primary organizational
190
- // pattern, per Mintlify's convention ("choose one primary organizational
191
- // pattern at the root level of your navigation").
192
- //
193
- // `global.dropdowns` is the one exception to "exactly one pattern": it's
194
- // not a primary pattern, it's a persistent set of topbar dropdown
195
- // triggers that render on every page regardless of which tab/version/
196
- // language/product is active - equivalent to Mintlify's
197
- // `navigation.global.anchors`. This replaces the earlier `{tabs,
198
- // dropdowns}` shape (both present at once), which doesn't generalize
199
- // once versions/languages/products are also root-level choices.
200
- // ---------------------------------------------------------------------
201
-
202
- export type NavigationConfig =
203
- | NavItem[]
204
- | { global?: { dropdowns: DropdownItem[] }; tabs: TabItem[] }
205
- | { global?: { dropdowns: DropdownItem[] }; versions: VersionItem[] }
206
- | { global?: { dropdowns: DropdownItem[] }; languages: LanguageItem[] }
207
- | { global?: { dropdowns: DropdownItem[] }; dropdowns: DropdownItem[] }
208
- | { global?: { dropdowns: DropdownItem[] }; products: ProductItem[] };
209
-
210
- const globalSchema = z.object({ dropdowns: z.array(dropdownSchema).min(1) });
211
-
212
- const navigationSchema = z.union([
213
- z.array(navItemSchema),
214
- z.object({ global: globalSchema.optional(), tabs: z.array(tabSchema).min(1) }).strict(),
215
- z.object({ global: globalSchema.optional(), versions: z.array(versionSchema).min(1) }).strict(),
216
- z.object({ global: globalSchema.optional(), languages: z.array(languageSchema).min(1) }).strict(),
217
- z.object({ global: globalSchema.optional(), dropdowns: z.array(dropdownSchema).min(1) }).strict(),
218
- z.object({ global: globalSchema.optional(), products: z.array(productSchema).min(1) }).strict(),
219
- ]) as z.ZodType<NavigationConfig>;
220
-
221
- const DEFAULT_PRIMARY = '#6366f1';
222
-
223
- // A logo is either one path used for both color modes, or a
224
- // { light, dark, label } object - each image shown only while that mode
225
- // is active (see BaseLayout.astro's .wd-logo-light/.wd-logo-dark CSS).
226
- // The topbar shows *only* the image by default - no site name text next
227
- // to it, since a real logo asset usually already is a wordmark. `label`
228
- // is the opt-in way to add text back next to the image (e.g. a product
229
- // name alongside a symbol-only mark); it only exists on the object form,
230
- // since a single shared image path is just as easy to bake the label
231
- // into directly.
232
- //
233
- // `light`/`dark` are themselves optional (unlike the string form, which
234
- // is inherently "both"): a site with no logo *images* at all can still
235
- // set `styles.logo: { label: "..." }` to override the topbar's fallback
236
- // text without providing any image - see BaseLayout.astro's
237
- // hasLogoImage/logoLabel/brandLabel derivation, where an object with
238
- // only `label` set already resolves correctly (brandLabel = logoLabel,
239
- // same as if an image were also present) without needing any change
240
- // there; only this schema needed loosening; light/dark were previously
241
- // both required whenever the object form was used at all.
242
- //
243
- // Standalone const (rather than inlined into stylesSchema.logo below) so
244
- // footer.logo further down can share the exact same union instead of an
245
- // independently-written copy - see footerSchema's own comment on why a
246
- // footer logo needs this at all.
247
- const logoSchema = z.union([
248
- z.string(),
249
- z.object({ light: z.string().optional(), dark: z.string().optional(), label: z.string().optional() }).strict(),
250
- ]);
251
-
252
- // Same {light, dark}-with-fallback shape applies to `colors.dark`: any
253
- // of primary/text left unset there falls back to the light-mode value
254
- // already given above, so a site only has to specify what actually
255
- // differs in dark mode. There used to be a `background`/`dark.background`
256
- // pair here too, doing effectively the same job as `background.colors`
257
- // further down (see that field's own comment) - two writedocs.json fields
258
- // both named "background" that visually looked interchangeable but
259
- // weren't quite (one drove --wd-background site-wide, the other only
260
- // the <body> canvas), which was confusing to author against and easy to
261
- // half-configure by setting only one. Removed in favor of `background`
262
- // alone being the single place to set any background color - see that
263
- // field's comment for what it now covers.
264
- // One side of `styles.navbar` (light or dark) - either a bare background
265
- // color (the original shape) or `{ background, accent? }` once a site
266
- // wants its own accent color (the active-tab fill / hover underline)
267
- // inside the navbar specifically, independent of the sitewide
268
- // `styles.colors.primary`. Deliberately does NOT carry a text/icon color
269
- // field at all - see `navbar`'s own comment below (stylesSchema) for why
270
- // that's resolved automatically instead of being configurable. Kept as a
271
- // standalone const rather than inlined so `light`/`dark` share the exact
272
- // same union rather than two independently-written (and possibly
273
- // drifting) copies of it.
274
- const navbarColorValueSchema = z.union([
275
- z.string(),
276
- z
277
- .object({
278
- background: z.string(),
279
- accent: z.string().optional(),
280
- })
281
- .strict(),
282
- ]);
283
-
284
- // One font declaration - almost exactly Mintlify's own `fonts` shape (see
285
- // docs/dev/docs/site-config.mdx's own "Fonts" section for the research this
286
- // was based on): a bare Google Fonts family name (`family` alone - Google
287
- // Fonts loads it automatically, see googleFontsHref() below), or a local/
288
- // externally-hosted font file (`source` + `format` together; `source` is
289
- // either a project-relative path like the other five asset-fallback fields
290
- // this schema already has - see collectConfiguredAssetPaths() - or a full
291
- // https:// URL to an externally-hosted font). `weight` does two things at
292
- // once, both driven by BaseLayout.astro: it narrows which file actually
293
- // loads (folded into the Google Fonts URL's `:wght@` axis, or the
294
- // generated `@font-face` rule's own `font-weight` descriptor for a
295
- // `source` font - see googleFontsHref()/fontFaceRule() below), *and* it's
296
- // applied as a real CSS `font-weight` on h1-h6 (for `heading`) or `body`
297
- // (for `body`) - see BaseLayout's own `wdFontWeightHeading`/
298
- // `wdFontWeightBody`. Both halves matter: loading only the 400-weight file
299
- // while leaving h1-h6 at the browser's UA-default `font-weight: bold`
300
- // faux-bolds that file back to looking bold anyway, with no visible
301
- // change from configuring a lighter weight at all - the bug this second
302
- // half fixes (caught from real user-reported behavior, not anticipated
303
- // up front). Left unset anywhere in `styles.fonts` renders no font-weight
304
- // rule at all, keeping today's plain browser-default bold headings/normal
305
- // body text unchanged for a site that hasn't touched this field.
306
- const fontVariantSchema = z
307
- .object({
308
- family: z.string(),
309
- weight: z.number().optional(),
310
- source: z.string().optional(),
311
- format: z.enum(['woff', 'woff2']).optional(),
312
- })
313
- .strict();
314
-
315
- export type FontVariant = z.infer<typeof fontVariantSchema>;
316
-
317
- // The top-level shape adds `heading`/`body`: each independently optional,
318
- // each falling back to the top-level family/weight/source/format when
319
- // unset (see resolveFonts() below) - so a site can set one font for
320
- // everything (`fonts.family` alone), or split headings and body text
321
- // without needing to repeat itself for whichever side isn't changing.
322
- const fontsSchema = z
323
- .object({
324
- family: z.string(),
325
- weight: z.number().optional(),
326
- source: z.string().optional(),
327
- format: z.enum(['woff', 'woff2']).optional(),
328
- heading: fontVariantSchema.optional(),
329
- body: fontVariantSchema.optional(),
330
- })
331
- .strict();
332
-
333
- export type FontsConfig = z.infer<typeof fontsSchema>;
334
-
335
- const stylesSchema = z
336
- .object({
337
- colors: z
338
- .object({
339
- primary: z.string().default(DEFAULT_PRIMARY),
340
- text: z.string().optional(),
341
- dark: z
342
- .object({
343
- primary: z.string().optional(),
344
- text: z.string().optional(),
345
- })
346
- .optional(),
347
- })
348
- .default({ primary: DEFAULT_PRIMARY }),
349
- logo: logoSchema.optional(),
350
- favicon: z.string().optional(),
351
- // No default value here (unlike `colors.primary`'s DEFAULT_PRIMARY) -
352
- // resolveFonts() below is where "Inter" actually gets applied as the
353
- // site-wide default whenever this field is left unset entirely, so
354
- // that fallback lives in one place alongside the rest of the
355
- // resolution logic rather than being duplicated as a schema default
356
- // *and* a resolver fallback.
357
- fonts: fontsSchema.optional(),
358
- // The Shiki theme *name* (not a full theme object/JSON - see
359
- // https://shiki.style/themes for the built-in list) each fenced
360
- // (```) MDX code block, and the API playground's request/response
361
- // snippets, use in light/dark mode - read out of writedocs.json by both
362
- // astro.config.mjs (Shiki's dual-theme markdown config) and
363
- // ApiReferencePanel.astro (its own separate <Code/> usages, which
364
- // don't inherit markdown.shikiConfig) via resolveCodeblockTheme()
365
- // below, the single source of truth for the 'github-light'/
366
- // 'github-dark' fallback. Left per-key optional (not defaulted in
367
- // the schema itself) so a site can override just one side (e.g.
368
- // only `dark`) and still get the ordinary default for the other.
369
- codeblocks: z
370
- .object({
371
- light: z.string().optional(),
372
- dark: z.string().optional(),
373
- // Maps a fenced (```) block's own language tag to whichever real
374
- // Shiki grammar actually highlights it, before that tag ever
375
- // reaches Shiki - see resolveCodeblockLangAlias() below for the
376
- // one built-in entry (mdx -> jsx) this ships with by default and
377
- // why. A site can add its own entries for any other language tag
378
- // that either doesn't tokenize well under its own grammar, or
379
- // that a site wants to write under a shorter/friendlier fence tag
380
- // than Shiki's own bundled id (e.g. `groovy: 'java'` for a close-
381
- // enough approximation Shiki doesn't ship a dedicated grammar for
382
- // at all) - merged with, not replacing, the built-in default, so
383
- // adding one of a site's own doesn't require re-declaring mdx.
384
- langAlias: z.record(z.string(), z.string()).optional(),
385
- })
386
- .strict()
387
- .optional(),
388
- // The topbar's own background, independent of `background` below (the
389
- // page's) - a site may want its navbar to stand out (a dark or
390
- // brand-colored bar over a light page) rather than blend into the
391
- // page, which is the default when this is left unset. Same {light,
392
- // dark} shape (and same per-key-optional, not defaulted here) as
393
- // `codeblocks` right above, rather than nested under `colors` - it's a
394
- // topbar-specific override, not a third general-purpose page color, so
395
- // it reads more like "one more themed surface" alongside codeblocks
396
- // than a sibling of primary/text. BaseLayout.astro's own
397
- // lightNavbarBg/darkNavbarBg resolution is what falls each side back
398
- // to the page background when unset.
399
- //
400
- // Each side (`light`/`dark`) is either a bare string - just the
401
- // background color, exactly the original shape, kept for backwards
402
- // compatibility - or `{ background, accent? }`, for when a site also
403
- // wants the navbar's own active-tab-fill/hover-underline color to
404
- // differ from the sitewide `styles.colors.primary` (`accent`'s only
405
- // job - see BaseLayout.astro's lightNavbarAccent). Deliberately no
406
- // text/icon color field here at all: BaseLayout.astro always resolves
407
- // the navbar's plain text/icon color itself, picking black or white by
408
- // contrast against whatever `background` resolves to
409
- // (contrastTextColor() below) the moment this field is configured at
410
- // all (string or object) - never a value read from writedocs.json. A
411
- // manually-set text color is a legibility footgun a site author can
412
- // get wrong (or drift out of sync after later changing `background`
413
- // without remembering to update it too) in a way "compute it
414
- // correctly, every time" simply can't - there's no legitimate reason
415
- // to want *illegible* navbar text, unlike `accent`, which is a real
416
- // aesthetic choice. Real bug this fixes: a site setting `navbar.light`
417
- // to the same value as `colors.primary` (a plausible thing to reach
418
- // for - "brand-colored navbar") made every topbar label/icon/link
419
- // render in the default near-black text color against that same
420
- // saturated background, all but unreadable - and an earlier version of
421
- // this feature that let a writedocs.json-supplied color double as the text
422
- // color reintroduced the identical bug one level down, just requiring
423
- // one more (still guessable-wrong) field to trigger it.
424
- navbar: z
425
- .object({
426
- light: navbarColorValueSchema.optional(),
427
- dark: navbarColorValueSchema.optional(),
428
- })
429
- .strict()
430
- .optional(),
431
- // The single place to configure any background color: `colors` is a
432
- // solid color (falls back to '#ffffff'/'#0b1120' when unset - see
433
- // BaseLayout.astro's lightBackground/darkBackground resolution), and
434
- // `images` layers an image on top of it (or is the whole background
435
- // by itself, if `colors` is left at its default). This one pair now
436
- // drives everything background-related site-wide - --wd-background
437
- // (dropdowns/modals/kbd chips/footer/topbar-fallback/etc., see every
438
- // `var(--wd-background)` call site) *and* the <body> canvas behind the
439
- // main/toc columns - rather than the two being independently
440
- // configurable fields that happened to look alike but didn't affect
441
- // the same things (see this schema's own comment on `colors.dark`
442
- // above for why that split was removed). Both are per-mode optional,
443
- // same "only specify what differs" pattern as codeblocks/navbar above.
444
- // The topbar and footer still get their own explicit opaque
445
- // background (topbar.css/footer.css) specifically so an `images`
446
- // background doesn't show through them; see those files' own
447
- // comments. The sidebar column has no background of its own (base.css)
448
- // and lets it show through, same as the main/toc columns.
449
- background: z
450
- .object({
451
- colors: z
452
- .object({
453
- light: z.string().optional(),
454
- dark: z.string().optional(),
455
- })
456
- .strict()
457
- .optional(),
458
- images: z
459
- .object({
460
- light: z.string().optional(),
461
- dark: z.string().optional(),
462
- })
463
- .strict()
464
- .optional(),
465
- })
466
- .strict()
467
- .optional(),
468
- })
469
- .default({ colors: { primary: DEFAULT_PRIMARY } });
470
-
471
- // `href` is always required - a link with nowhere to go isn't a link.
472
- // `label`/`icon` are each individually optional, but not both at once
473
- // (enforced by the .refine() below, same shape as scriptEntrySchema's own
474
- // exactly-one-of check above) - a link needs *something* visible to render,
475
- // whichever of the two, and can have both (icon rendered to the left of the
476
- // label - see TopBar.astro/SiteFooter.astro, the two renderers of this
477
- // shape). An icon-only link (no label) is the common case for something
478
- // like a bare GitHub mark in the topbar; label-only (no icon) is every
479
- // plain text link this already supported before `icon` existed - both
480
- // still need to keep working unchanged, hence neither field being outright
481
- // required on its own.
482
- const topbarLinkSchema = z
483
- .object({
484
- label: z.string().optional(),
485
- href: z.string(),
486
- // Same string shape as every other writedocs.json `icon` field (a plain
487
- // Lucide name, or the explicit "collection:icon-name" form for
488
- // anything else - see resolveIcon()/AppIcon.astro) - not documented
489
- // again here since that convention is already established elsewhere
490
- // in this file (tabs/switchers/socials all take the identical shape).
491
- icon: z.string().optional(),
492
- })
493
- .strict()
494
- .refine((link) => Boolean(link.label) || Boolean(link.icon), {
495
- message: 'Each topbar.links/footer column link needs a "label", an "icon", or both - a link with neither has nothing to render.',
496
- });
497
-
498
- // One column of the footer (BaseLayout.astro's <footer class="wd-footer">) -
499
- // an optional heading (e.g. "Resources", "Community") plus a list of links,
500
- // reusing the exact same {label, icon, href} shape as topbar.links above (a
501
- // footer link is the same thing rendered in a different place, no reason
502
- // for a second, identical-but-differently-named schema). `title` is
503
- // optional so a single-column footer with no heading (just a bare list of
504
- // links) is valid too.
505
- const footerColumnSchema = z
506
- .object({
507
- title: z.string().optional(),
508
- links: z.array(topbarLinkSchema).default([]),
509
- })
510
- .strict();
511
-
512
- // Off by default (empty `columns`, same convention as `topbar` above - a
513
- // footer with nothing configured just doesn't render at all, see
514
- // BaseLayout.astro's `hasFooterContent` check) rather than `.optional()`
515
- // like contextMenu: unlike that field, an empty/default footer has no
516
- // build-output or routing implications to gate, it's purely visual, so
517
- // there's no reason to distinguish "unset" from "set to nothing" the way
518
- // contextMenu needs to.
519
- // Same {light, dark, label} union as `styles.logo` (via the shared
520
- // logoSchema above) - unset by default, in which case the footer just
521
- // shows the sitewide `styles.logo` (BaseLayout.astro's existing
522
- // logoLight/logoDark/logoLabel, already threaded into SiteFooter as-is).
523
- // Set here, it replaces that entirely rather than filling in only the
524
- // missing side of it - a footer.logo with only `dark` set shows *just*
525
- // the dark-mode image, not styles.logo's light image plus this dark one -
526
- // the same all-or-nothing-relative-to-styles.logo resolution BaseLayout.astro
527
- // already applies.
528
- const footerSchema = z
529
- .object({
530
- columns: z.array(footerColumnSchema).default([]),
531
- logo: logoSchema.optional(),
532
- })
533
- .strict()
534
- .default({ columns: [] });
535
-
536
- export type FooterColumn = z.infer<typeof footerColumnSchema>;
537
- export type FooterConfig = z.infer<typeof footerSchema>;
538
-
539
- // Shared by both writedocs.json's top-level `seo` (site-wide defaults) and a
540
- // page's own frontmatter `seo` (per-page overrides) - see
541
- // content.config.ts's docsSchema, which imports this exact schema rather
542
- // than redeclaring the same shape a second time. mergeSeo() below is what
543
- // actually combines the two, field by field, at render time.
544
- export const seoFieldsSchema = z
545
- .object({
546
- // Open Graph / Twitter card image - a relative path (resolved against
547
- // `domain` below into an absolute URL, since most social crawlers
548
- // require one) or an already-absolute https:// URL.
549
- ogImage: z.string().optional(),
550
- // og:type - "website" for most pages, "article" for blog-post-shaped
551
- // content, etc. Defaults to "website" if never set anywhere.
552
- ogType: z.string().optional(),
553
- twitterCard: z.enum(['summary', 'summary_large_image']).optional(),
554
- keywords: z.array(z.string()).optional(),
555
- // Renders <meta name="robots" content="noindex, nofollow" /> and
556
- // (see astro.config.mjs's sitemap `filter`) excludes the page from
557
- // sitemap.xml entirely - both driven off this one flag, since listing
558
- // a page in the sitemap while also telling crawlers not to index it
559
- // would be self-contradictory.
560
- noindex: z.boolean().optional(),
561
- })
562
- .strict();
563
-
564
- export type SeoFields = z.infer<typeof seoFieldsSchema>;
565
-
566
- /** Field-by-field merge of a page's own `seo` frontmatter over writedocs.json's
567
- * site-wide `seo` defaults - a page only overrides the specific fields it
568
- * sets, falling back to the site default for everything else, rather than
569
- * a page's (possibly partial) `seo` object replacing the site's wholesale. */
570
- export function mergeSeo(site: SeoFields, page?: SeoFields): SeoFields {
571
- if (!page) return site;
572
- return {
573
- ogImage: page.ogImage ?? site.ogImage,
574
- ogType: page.ogType ?? site.ogType,
575
- twitterCard: page.twitterCard ?? site.twitterCard,
576
- keywords: page.keywords ?? site.keywords,
577
- noindex: page.noindex ?? site.noindex,
578
- };
579
- }
580
-
581
- // Controls for the OpenAPI API playground's "Try it" modal - separate
582
- // from a group's own `openapi: { src, path }` field (which spec to use,
583
- // and where) since this is about how a *request* it sends behaves, not
584
- // about the spec itself.
585
- const apiSchema = z
586
- .object({
587
- // Whether the Try-it modal's Send button routes its request through
588
- // writedocs' own CORS proxy (https://proxy.writechoice.io/) rather
589
- // than calling the API directly from the browser. Almost no
590
- // real-world API sends back Access-Control-Allow-Origin headers
591
- // permitting an arbitrary docs site's origin, so a direct browser
592
- // fetch() from the Try-it modal fails for most real APIs without
593
- // this - see PROXY_BASE_URL in ApiReferencePanel.astro for the
594
- // actual request-forwarding logic. Defaults to enabled; a site can
595
- // set this to `false` to always call the API directly instead (the
596
- // API is already CORS-permissive, same-origin in some deployments,
597
- // or the site owner doesn't want requests routed through a third
598
- // party at all).
599
- proxy: z.boolean().default(true),
600
- })
601
- .strict()
602
- .default({ proxy: true });
603
-
604
- // The "Copy page" dropdown shown next to a page's title - copy the raw
605
- // Markdown to the clipboard, open the raw Markdown in a new tab, or
606
- // deep-link into an AI assistant with a prompt pointing at it. Opt-in:
607
- // undefined (the field simply absent from writedocs.json) means the feature
608
- // is off entirely - no menu rendered, no .md routes generated at build
609
- // time either (see [...slug].md.ts) - rather than defaulting to on,
610
- // since it changes both the UI and the build output. Once a site does
611
- // write a `contextMenu` key (even `{}`), copying/viewing-as-Markdown are
612
- // always included (they need nothing but the page's own content); only
613
- // `openIn` (the AI-assistant deep-links, which need an absolute URL to
614
- // point the assistant at) is independently configurable.
615
- const contextMenuSchema = z
616
- .object({
617
- openIn: z.array(z.enum(['chatgpt', 'claude', 'perplexity'])).default(['chatgpt', 'claude', 'perplexity']),
618
- })
619
- .strict();
620
-
621
- export type ContextMenuConfig = z.infer<typeof contextMenuSchema>;
622
-
623
- // One entry in writedocs.json's `redirects` array - wired almost directly into
624
- // Astro's own `redirects` config option (astro.config.mjs), which is what
625
- // actually generates the redirect pages. `permanent` is deliberately not
626
- // part of this schema: with no server/adapter (this project always builds
627
- // `output: 'static'` with none installed), Astro's own docs say a static
628
- // redirect "does not support status codes" at all - it's always a client-
629
- // side `<meta http-equiv="refresh">` page, full stop. Accepting a
630
- // `permanent` field here that silently did nothing would be worse than
631
- // not offering it.
632
- const redirectSchema = z
633
- .object({
634
- source: z.string(),
635
- destination: z.string(),
636
- })
637
- .strict();
638
-
639
- export type RedirectConfig = z.infer<typeof redirectSchema>;
640
-
641
- // A site-wide banner rendered above the topbar on every page (except
642
- // `mode: blank`, which drops all site chrome including this - see
643
- // BaseLayout.astro). Optional/undefined by default, same convention as
644
- // `contextMenu` above - a site with no banner configured gets no banner
645
- // markup at all, not an empty one.
646
- const bannerSchema = z
647
- .object({
648
- content: z.string(),
649
- // Persisted via localStorage once dismissed (see BaseLayout.astro's
650
- // initBanner()) - a static site has no server-side session to track
651
- // this against, so "dismissed" only ever means "dismissed in this
652
- // browser". A banner without `dismissible` reappears on every visit,
653
- // appropriate for something that should stay visible until the site
654
- // author removes it from writedocs.json themselves (e.g. "this version is
655
- // deprecated"), not something a reader can permanently clear.
656
- dismissible: z.boolean().default(false),
657
- type: z.enum(['info', 'warning', 'critical']).default('info'),
658
- })
659
- .strict();
660
-
661
- export type BannerConfig = z.infer<typeof bannerSchema>;
662
-
663
- // Content for the custom 404 page (src/pages/404.astro) - Astro's own
664
- // file-based convention (a static build emits this as 404.html at the
665
- // site root; most static hosts pick it up automatically for unmatched
666
- // routes). Both fields are optional with hardcoded fallbacks in 404.astro
667
- // itself, so a site doesn't have to set either to get a reasonably
668
- // branded 404 page (still rendered inside the normal BaseLayout/topbar
669
- // chrome, just with placeholder content).
670
- const notFoundSchema = z
671
- .object({
672
- title: z.string().optional(),
673
- description: z.string().optional(),
674
- })
675
- .strict()
676
- .default({});
677
-
678
- export type NotFoundConfig = z.infer<typeof notFoundSchema>;
679
-
680
- // One <script> tag to inject - exactly one of `src` (an external/local
681
- // file, rendered as `<script src="...">`) or `content` (inline JS,
682
- // rendered via `<script is:inline set:html="...">`) has to be set, not
683
- // both and not neither - enforced by the `.refine()` below rather than
684
- // leaving both optional and silently rendering an empty tag if a site
685
- // author gets this wrong.
686
- const scriptEntrySchema = z
687
- .object({
688
- src: z.string().optional(),
689
- content: z.string().optional(),
690
- })
691
- .strict()
692
- .refine((v) => (v.src ? !v.content : !!v.content), {
693
- message: 'Each scripts.head/scripts.body entry needs exactly one of "src" or "content", not both or neither.',
694
- });
695
-
696
- export type ScriptEntry = z.infer<typeof scriptEntrySchema>;
697
-
698
- // Raw third-party script injection - analytics snippets, chat widgets,
699
- // anything that needs a real <script> tag writedocs.json's own structured
700
- // fields (styles, integrations-style config, ...) don't have a dedicated
701
- // slot for yet. `head` renders right before </head>; `body` renders right
702
- // before </body> (after everything else has already loaded/hydrated -
703
- // see BaseLayout.astro), matching where a third-party script's own
704
- // install instructions usually say to put it.
705
- const scriptsSchema = z
706
- .object({
707
- head: z.array(scriptEntrySchema).default([]),
708
- body: z.array(scriptEntrySchema).default([]),
709
- })
710
- .strict()
711
- .default({ head: [], body: [] });
712
-
713
- export type ScriptsConfig = z.infer<typeof scriptsSchema>;
714
-
715
- // writedocs.json's `integrations` - a curated, high-value subset of analytics
716
- // providers, each getting its own minimal config object (just the id(s)
717
- // it actually needs) rather than requiring a site author to hand-write
718
- // the provider's own install snippet through the generic `scripts` field
719
- // above. This is deliberately its own thing, not built as sugar over
720
- // `scripts` the way the roadmap once implied it might be - most of these
721
- // providers' real snippets need `defer`/`async`/`data-*` attributes
722
- // ScriptEntry's plain `{ src?, content? }` shape has no way to express,
723
- // so BaseLayout.astro renders each provider with its own bespoke markup
724
- // instead of generating a ScriptEntry array to feed through the generic
725
- // renderer. Every snippet shape below was sourced from that provider's
726
- // own current official docs (fetched directly, not recalled from
727
- // training data) while building this feature - see
728
- // docs/dev/docs/integrations.mdx for links and dates.
729
- //
730
- // Every provider field is optional and independent - a site can turn on
731
- // any subset (including all of them at once, for a migration period) or
732
- // none. No "the" analytics field; a site picks whichever provider(s) it
733
- // actually uses.
734
-
735
- const ga4IntegrationSchema = z
736
- .object({
737
- // A GA4 "Measurement ID", always shaped like "G-XXXXXXXXXX" - not
738
- // validated against that shape here, since Google could change the
739
- // prefix/length without this schema needing to track it.
740
- measurementId: z.string(),
741
- })
742
- .strict();
743
-
744
- const googleTagManagerIntegrationSchema = z
745
- .object({
746
- // A GTM container id, shaped like "GTM-XXXXXXX".
747
- containerId: z.string(),
748
- })
749
- .strict();
750
-
751
- const plausibleIntegrationSchema = z
752
- .object({
753
- domain: z.string(),
754
- // Plausible's own hosted script URL by default - override for a
755
- // self-hosted instance, a reverse-proxy path (Plausible's own
756
- // documented way to dodge ad-blockers), or one of Plausible's
757
- // documented script *extensions* (e.g.
758
- // "https://plausible.io/js/script.hash.outbound-links.js").
759
- src: z.string().default('https://plausible.io/js/script.js'),
760
- })
761
- .strict();
762
-
763
- const fathomIntegrationSchema = z
764
- .object({
765
- // Fathom's own short "Site ID", not a full URL or measurement id.
766
- siteId: z.string(),
767
- })
768
- .strict();
769
-
770
- const posthogIntegrationSchema = z
771
- .object({
772
- // PostHog calls this a "project API key" (starts with "phc_") in its
773
- // own docs - `apiKey` here rather than that exact name, matching this
774
- // schema's own plainer naming convention for the equivalent id on
775
- // every other provider above.
776
- apiKey: z.string(),
777
- // PostHog is region-sharded (US/EU) with no single universal default
778
- // - "https://us.i.posthog.com" is PostHog's own default for a new
779
- // project, but a site on their EU cloud (or a self-hosted instance)
780
- // must override this to the host shown in their own project
781
- // settings, or events silently go nowhere.
782
- apiHost: z.string().default('https://us.i.posthog.com'),
783
- })
784
- .strict();
785
-
786
- const umamiIntegrationSchema = z
787
- .object({
788
- websiteId: z.string(),
789
- // Unlike Plausible/PostHog, Umami has no single hosted default that
790
- // works for most users - it's commonly self-hosted, and even Umami
791
- // Cloud users get a per-region script URL. Defaults to Umami Cloud's
792
- // own documented script URL, which only happens to be correct for a
793
- // Cloud user on that specific region; anyone self-hosting (the more
794
- // common case for this particular provider) must override it to
795
- // their own instance's own /script.js path.
796
- src: z.string().default('https://cloud.umami.is/script.js'),
797
- })
798
- .strict();
799
-
800
- // AI chat over the site's own docs content, via a third-party provider
801
- // (DocsBot, currently the only one wired up) rather than a self-hosted
802
- // RAG pipeline - see the roadmap's own "AI chat over docs content" line
803
- // in docs/dev/docs/roadmap.mdx, which this fulfills via integration
804
- // rather than by building the retrieval/generation stack in-house.
805
- // Deliberately named `askAi` here, not `docsbot` - this is the
806
- // writedocs.json-facing, provider-neutral name for the feature (matching
807
- // the "Ask AI" label this kind of button/widget is conventionally
808
- // given in docs sites generally), independent of which vendor happens
809
- // to sit behind it today. `id` matches DocsBot's own embed config
810
- // field name exactly (`DocsBotAI.init({ id: 'teamId/botId' })`) - it's
811
- // a single opaque "teamId/botId" string DocsBot treats as one value,
812
- // not two separate ids, so this schema doesn't split it either.
813
- const askAiIntegrationSchema = z
814
- .object({
815
- id: z.string(),
816
- })
817
- .strict();
818
-
819
- const integrationsSchema = z
820
- .object({
821
- ga4: ga4IntegrationSchema.optional(),
822
- googleTagManager: googleTagManagerIntegrationSchema.optional(),
823
- plausible: plausibleIntegrationSchema.optional(),
824
- fathom: fathomIntegrationSchema.optional(),
825
- posthog: posthogIntegrationSchema.optional(),
826
- umami: umamiIntegrationSchema.optional(),
827
- askAi: askAiIntegrationSchema.optional(),
828
- })
829
- .strict()
830
- .default({});
831
-
832
- export type IntegrationsConfig = z.infer<typeof integrationsSchema>;
833
-
834
- export const docsConfigSchema = z.object({
835
- name: z.string(),
836
- description: z.string().optional(),
837
- styles: stylesSchema,
838
- navigation: navigationSchema,
839
- // Platform name -> profile/page URL (e.g. `{ "github": "https://
840
- // github.com/...", "twitter": "https://twitter.com/..." }`), free-form
841
- // rather than an enum of known platforms - the key doubles as the icon
842
- // reference BaseLayout.astro's footer resolves via resolveIcon() below
843
- // (bare `"github"` -> `lucide:github`; use an explicit
844
- // `"simple-icons:whatever"` key instead when the bare lucide name isn't
845
- // right for a given platform). Rendered as a row of icon links in the
846
- // footer - see hasFooterContent/`.wd-footer-socials` in BaseLayout.astro.
847
- socials: z.record(z.string(), z.string()).default({}),
848
- topbar: z
849
- .object({ links: z.array(topbarLinkSchema).default([]) })
850
- .default({ links: [] }),
851
- // Columns of links below the page content - see footerSchema/
852
- // footerColumnSchema above. Renders alongside `socials` above (as a row
853
- // of icon links) in the same <footer> - see BaseLayout.astro.
854
- footer: footerSchema,
855
- api: apiSchema,
856
- // The site's own deployed domain, e.g. "docs.example.com" or
857
- // "https://docs.example.com" (the scheme is optional - resolveSiteUrl()
858
- // below normalizes either form to a full https:// origin). Powers three
859
- // things that all need an absolute origin to work: sitemap.xml
860
- // generation (astro.config.mjs only registers @astrojs/sitemap when this
861
- // is set - a sitemap of relative URLs isn't meaningful), the canonical
862
- // <link> and og:url/twitter meta tags (BaseLayout.astro), and resolving
863
- // a relative `seo.ogImage` into an absolute URL for social crawlers.
864
- // Left optional and simply skipped (with a log message, not an error) at
865
- // every one of those call sites when unset - most fixtures/local builds
866
- // have no real deployed domain yet.
867
- domain: z.string().optional(),
868
- // Site-wide meta tag defaults - see seoFieldsSchema above. A page's own
869
- // frontmatter `seo` (content.config.ts's docsSchema) overrides these
870
- // field by field via mergeSeo(), rather than needing to repeat every
871
- // field on every page.
872
- seo: seoFieldsSchema.default({}),
873
- // The "Copy page" dropdown - see contextMenuSchema above. Absent by
874
- // default (no menu, no .md routes).
875
- contextMenu: contextMenuSchema.optional(),
876
- // See redirectSchema above. Wired directly into Astro's own `redirects`
877
- // config option in astro.config.mjs.
878
- redirects: z.array(redirectSchema).default([]),
879
- // Site-wide `[[key]]` substitution values, applied to page prose text
880
- // at build time (see the remarkSubstituteVariables plugin wired into
881
- // astro.config.mjs) - e.g. `{ "productName": "Acme" }` lets every page
882
- // write `[[productName]]` once instead of hardcoding it everywhere.
883
- // Double square brackets, not the more familiar `{{key}}` - MDX
884
- // reserves single curly braces for embedded JS expressions, so
885
- // `{{productName}}` in an .mdx file parses as a JS object-literal
886
- // expression (`{productName}`, shorthand syntax) rather than literal
887
- // text, and throws `ReferenceError: productName is not defined` at
888
- // render time - see remarkSubstituteVariables' own comment in
889
- // mdx-substitute-variables.js for the full explanation. Deliberately a
890
- // flat string map, not typed values - this is text substitution into
891
- // prose, not a templating language.
892
- variables: z.record(z.string(), z.string()).default({}),
893
- // See bannerSchema above. Absent by default (no banner rendered).
894
- banner: bannerSchema.optional(),
895
- // See notFoundSchema above.
896
- notFound: notFoundSchema,
897
- // See scriptsSchema above.
898
- scripts: scriptsSchema,
899
- // See integrationsSchema above.
900
- integrations: integrationsSchema,
901
- });
902
-
903
- export type DocsConfig = z.infer<typeof docsConfigSchema>;
904
-
905
- // ---------------------------------------------------------------------
906
- // Ponto de entrada unico de validacao
907
- //
908
- // Existe pra que o gerador (`writedocs validate` e o `loadDocsConfig` que o
909
- // build usa) e a plataforma validem pelo MESMO codigo, com as MESMAS palavras -
910
- // o criterio de aceite do WD-066 e do WD-062. Se cada lado formatasse os erros
911
- // por conta propria, o cliente veria a plataforma aceitar um writedocs.json que
912
- // o build depois recusa, que e exatamente a classe de bug que a E11 existe pra
913
- // impedir.
914
- //
915
- // Recebe TEXTO CRU, e faz o JSON.parse por conta propria, por tres motivos:
916
- // 1. um JSON sintaticamente quebrado passa a ser um resultado de validacao
917
- // como qualquer outro, em vez de uma excecao solta do runtime;
918
- // 2. linha/coluna so existem no texto - e o WD-063 precisa reportar linha;
919
- // 3. do lado da plataforma, importProjectConfig ja tem os bytes crus em maos
920
- // antes de parsear, entao encaixa sem ginastica nenhuma.
921
- //
922
- // IMPORTANTE, e deliberado: as mensagens aqui sao as do Zod, VERBATIM. Melhorar
923
- // a linguagem delas e o WD-063, que precisa deste comportamento como ponto de
924
- // partida pra medir a melhora. Nada aqui reescreve, traduz ou embeleza mensagem.
925
- // ---------------------------------------------------------------------
926
-
927
- /** Um problema encontrado na configuracao. `path` e o caminho do campo em
928
- * notacao de ponto (`navigation.tabs.0.label`), ou `(root)` quando o problema e
929
- * do documento inteiro - o mesmo texto que a mensagem de erro do build ja
930
- * imprime hoje. `line`/`column` so vem quando da pra saber (hoje: erro de JSON;
931
- * no WD-063, tambem para erros de schema). */
932
- export type ValidationIssue = {
933
- path: string;
934
- message: string;
935
- /** Codigo do Zod (`invalid_type`, `unrecognized_keys`, ...) ou `invalid_json`. */
936
- code?: string;
937
- line?: number;
938
- column?: number;
939
- /** WD-063: a mesma coisa que `message`, dita para gente. ADICIONAL, nunca
940
- * substituto - `message` continua sendo o texto do Zod verbatim, que e o que
941
- * o `writedocs build` imprime e o que o WD-062 grava em `validation_issues`.
942
- * Quem exibe escolhe qual dos dois mostrar. */
943
- humanMessage?: string;
944
- /** O que fazer a respeito, quando da pra dizer algo concreto. */
945
- suggestion?: string;
946
- /** Chave ESTAVEL de documentacao (`config.styles`), nunca uma URL: uma URL
947
- * cravada num pacote npm fica quebrada em toda instalacao ate a proxima
948
- * release, e o mesmo erro e exibido pela CLI e pelo dashboard, que podem
949
- * querer destinos diferentes. O mapa chave -> URL mora do lado de quem
950
- * renderiza. */
951
- docKey?: string;
952
- };
953
-
954
- /** Sucesso carrega os dados ja validados (com os defaults do schema aplicados);
955
- * falha carrega os problemas e diz de que tipo ela foi. `parseError` so aparece
956
- * quando o JSON nao pode nem ser parseado, e existe para o chamador que quiser
957
- * relancar exatamente o erro original (e o que o loadDocsConfig faz, pra nao
958
- * mudar uma virgula do que o `writedocs build` ja imprime hoje). */
959
- export type ValidationResult =
960
- | { ok: true; data: DocsConfig; issues: ValidationIssue[] }
961
- | {
962
- ok: false;
963
- data: null;
964
- kind: 'invalid_json' | 'schema';
965
- issues: ValidationIssue[];
966
- parseError?: SyntaxError;
967
- };
968
-
969
- /** "at position 22 (line 1 column 23)" -> { line: 1, column: 23 }.
970
- * O formato da mensagem de erro do JSON.parse e do V8, nao do padrao, entao
971
- * isto e melhor-esforco: se a mensagem nao trouxer posicao, devolve null e o
972
- * problema simplesmente fica sem linha. */
973
- function positionFromJsonError(message: string, rawText: string): { line: number; column: number } | null {
974
- const comLinha = message.match(/line (\d+) column (\d+)/);
975
- if (comLinha) return { line: Number(comLinha[1]), column: Number(comLinha[2]) };
976
- const comPosicao = message.match(/position (\d+)/);
977
- if (comPosicao) {
978
- const pos = Math.min(Number(comPosicao[1]), rawText.length);
979
- const antes = rawText.slice(0, pos);
980
- const line = antes.split('\n').length;
981
- return { line, column: pos - antes.lastIndexOf('\n') };
982
- }
983
- return null;
984
- }
985
-
986
- /** O corpo da mensagem de erro, uma linha por problema - exatamente o formato
987
- * que o `writedocs build` imprime desde sempre (` - campo: mensagem`). Fica
988
- * aqui, e nao em cada chamador, pra que CLI e plataforma nao possam divergir.
989
- *
990
- * NAO mudou no WD-063, de proposito: continua imprimindo `message` (o texto do
991
- * Zod). O que melhorou foi o CONTEUDO dos issues - a uniao desembrulhada faz o
992
- * `path` apontar pro campo real em vez de parar em `navigation`. Quem quiser a
993
- * versao com frase humana e linha usa formatValidationIssuesDetailed. */
994
- export function formatValidationIssues(issues: ValidationIssue[]): string {
995
- return issues.map((i) => ` - ${i.path}: ${i.message}`).join('\n');
996
- }
997
-
998
- /** A versao longa, para quem tem uma tela inteira (a CLI hoje, o dashboard do
999
- * WD-065 se quiser): caminho, linha, frase humana, sugestao, e a mensagem do
1000
- * Zod por ultimo, entre parenteses, pra quem precisa do texto exato.
1001
- *
1002
- * Mora aqui pelo mesmo motivo que a irma curta: se cada consumidor montasse a
1003
- * sua, a CLI e a plataforma divergiriam na primeira mudanca - que e exatamente
1004
- * o que o AC do WD-066 proibe. `fileName` so entra na referencia de linha. */
1005
- export function formatValidationIssuesDetailed(
1006
- issues: ValidationIssue[],
1007
- { fileName = 'writedocs.json' }: { fileName?: string } = {}
1008
- ): string {
1009
- return issues
1010
- .map((i) => {
1011
- const temCampo = i.path !== '(root)';
1012
- const local = i.line ? `${fileName}:${i.line}` : i.path;
1013
- const linhas = [` ${local}${i.line && temCampo ? ` (${i.path})` : ''}`];
1014
- linhas.push(` ${i.humanMessage ?? i.message}`);
1015
- if (i.suggestion) linhas.push(` ${i.suggestion}`);
1016
- if (i.humanMessage && i.message !== i.humanMessage) linhas.push(` (${i.message})`);
1017
- return linhas.join('\n');
1018
- })
1019
- .join('\n\n');
1020
- }
1021
-
1022
- // ---------------------------------------------------------------------
1023
- // WD-063 — do issue do Zod para "onde esta o erro, e o que fazer"
1024
- //
1025
- // Tres transformacoes, nesta ordem:
1026
- // 1. desembrulhar `invalid_union` (senao todo erro dentro da navigation vira
1027
- // `navigation: "Invalid input"`, que e inutil num arquivo de 10 KB);
1028
- // 2. achar a linha no texto, pelo caminho ja desembrulhado;
1029
- // 3. escrever a frase humana, a sugestao e a docKey - POR CODIGO do Zod, nao
1030
- // por campo: um mapa por campo teria 17 entradas so na raiz e centenas no
1031
- // total, e envelheceria a cada campo novo do schema.
1032
- // ---------------------------------------------------------------------
1033
-
1034
- /** Chaves que a raiz aceita sem ser campo do schema, e que por isso NAO devem
1035
- * gerar aviso de chave desconhecida. Deliberadamente minuscula: a raiz nao e
1036
- * `.strict()` justamente pra nao quebrar configs existentes, e `$schema` foi um
1037
- * dos argumentos dessa decisao - avisar sobre ele contradiria o que o protegeu.
1038
- * Se esta lista passar de dois ou tres nomes, o problema e outro e a raiz
1039
- * precisa de outra conversa. */
1040
- export const ROOT_ALLOWED_EXTRA_KEYS: readonly string[] = Object.freeze(['$schema']);
1041
-
1042
- type IssueCru = {
1043
- code?: string;
1044
- path?: (string | number)[];
1045
- message: string;
1046
- expected?: string;
1047
- values?: unknown[];
1048
- keys?: string[];
1049
- minimum?: number;
1050
- maximum?: number;
1051
- origin?: string;
1052
- format?: string;
1053
- errors?: unknown[];
1054
- unionErrors?: unknown[];
1055
- };
1056
-
1057
- type IssuePlano = IssueCru & { caminho: (string | number)[] };
1058
-
1059
- function issuesDoRamo(ramo: unknown): IssueCru[] {
1060
- if (Array.isArray(ramo)) return ramo as IssueCru[];
1061
- const comIssues = ramo as { issues?: IssueCru[] };
1062
- return comIssues?.issues ?? [];
1063
- }
1064
-
1065
- /** O maior valor de `f` sobre a lista, ou 0 se ela for vazia. Um `for` e nao
1066
- * `Math.max(0, ...lista.map(f))`: o spread vira argumentos de chamada, e uma
1067
- * lista grande o bastante (config com dezenas de milhares de erros) estoura o
1068
- * limite de argumentos com RangeError em vez de devolver um numero. */
1069
- function maiorProfundidade(lista: IssuePlano[]): number {
1070
- let maior = 0;
1071
- for (const issue of lista) {
1072
- const p = issue.caminho.length + (issue.code === 'unrecognized_keys' ? 1 : 0);
1073
- if (p > maior) maior = p;
1074
- }
1075
- return maior;
1076
- }
1077
-
1078
- /** Plano ACHATADO de um ramo, memoizado por identidade do ramo.
1079
- *
1080
- * A memoizacao nao e otimizacao, e o que torna o achatamento viavel. Pontuar um
1081
- * ramo exige desembrulhar as unioes DENTRO dele, e cada uma dessas tem os seus
1082
- * proprios ramos - sem cache, a pontuacao visita o mesmo sub-ramo uma vez por
1083
- * caminho que leva ate ele, o que e exponencial na profundidade do aninhamento.
1084
- * Medido com grupos aninhados e um erro no fundo: 3,7 ms com 4 niveis, 281 ms
1085
- * com 8, 69 SEGUNDOS com 12. Com o cache, os mesmos casos ficam em 0,2 ms,
1086
- * porque cada no da arvore de erro do Zod e achatado uma vez so.
1087
- *
1088
- * Se alguem remover este WeakMap por parecer supérfluo, o `writedocs validate`
1089
- * passa a travar em configs profundas. Nao remova. */
1090
- const planoPorRamo = new WeakMap<object, IssuePlano[]>();
1091
-
1092
- function planoDoRamo(ramo: unknown): IssuePlano[] {
1093
- const lista = issuesDoRamo(ramo);
1094
- if (typeof ramo !== 'object' || ramo === null) return desembrulharUnioes(lista);
1095
- const memoizado = planoPorRamo.get(ramo);
1096
- if (memoizado) return memoizado;
1097
- // Prefixo vazio de proposito: a pontuacao compara ramos entre si, entao a
1098
- // profundidade tem que ser relativa ao proprio ramo, nao ao documento.
1099
- const plano = desembrulharUnioes(lista);
1100
- planoPorRamo.set(ramo, plano);
1101
- return plano;
1102
- }
1103
-
1104
- /** Escolhe qual ramo de uma uniao o cliente PROVAVELMENTE quis.
1105
- *
1106
- * Criterio: caminho mais fundo primeiro; empate resolvido por menos issues. A
1107
- * intuicao e a do Gabriel na spec - o ramo certo e o que nao reclama da chave
1108
- * que o cliente usou -, e a profundidade importa porque um ramo que chegou
1109
- * fundo antes de falhar (`products.0.product`) reconheceu a forma, enquanto um
1110
- * que falha na raiz (`expected array, received object`) so recusou o shape.
1111
- *
1112
- * DUAS CORRECOES sobre a primeira versao (WD-063), as duas medidas contra o
1113
- * `00-kitchen-sink` real - ver o relato de revisao 43:
1114
- *
1115
- * 1. PONTUA O RAMO ACHATADO, nao a lista crua. Um ramo que e ele proprio uma
1116
- * uniao aparece como UM issue (`invalid_union`) no caminho vazio - ou seja,
1117
- * com a MENOR contagem e a MENOR profundidade possiveis, sem que isso diga
1118
- * nada sobre o quanto ele reconheceu. Era o caso mais comum que existe: um
1119
- * item de `pages` e `string | grupo | link`, e o grupo e outra uniao, entao
1120
- * todo erro estrutural dentro de um grupo empatava com o ramo `string` e
1121
- * perdia pela ordem de declaracao. O cliente que escrevia
1122
- * `{ "group": "G", "pages": 42 }` recebia "expected string, received
1123
- * object" - conselho errado, nao so inutil. Achatando primeiro, o mesmo caso
1124
- * vira `pages.0.pages: expected array, received number`.
1125
- *
1126
- * 2. PROFUNDIDADE EFETIVA: `unrecognized_keys` reporta no caminho do PAI, com a
1127
- * chave ofensora em `keys`, entao a profundidade dele sai subestimada em 1.
1128
- * Sem a correcao, um ramo que identificou a chave errada perde para um que
1129
- * so exigiu um campo ausente. (`validateDocsConfig` ja fazia a mesma conta
1130
- * para achar a LINHA; aqui ela entra tambem na pontuacao.)
1131
- *
1132
- * Medido sobre as 60 folhas da `navigation` do `00-kitchen-sink`, trocando o
1133
- * tipo de cada uma: a versao anterior apontava o campo exato em 42/60 e PARAVA
1134
- * RASO em 18/60 (sempre os mesmos 18: os que ficam sob um `pages`); esta aponta
1135
- * 60/60 para valor escalar errado e nunca para raso. Para um objeto vazio ou
1136
- * parcial no lugar de uma pagina, as duas ficam tecnicamente empatadas (41
1137
- * contra 42): a anterior diz "expected string", esta lista os campos que
1138
- * faltam para ser um grupo. E o custo aceito desta versao, e substitui o antigo
1139
- * (`tabs` e `products` escritos juntos, que agora sai como
1140
- * `Unrecognized key: "products"`).
1141
- *
1142
- * EMPATE TOTAL (mesma profundidade E mesmo numero de issues): vence o ramo
1143
- * declarado primeiro no schema. `Array.prototype.sort` e estavel desde a
1144
- * ES2019, entao ordenar e pegar o [0] ja entrega isso - se alguem trocar por
1145
- * uma ordenacao instavel, o desempate vira sorteio. Duas razoes para "o
1146
- * primeiro" em vez de "todos os empatados":
1147
- * - um engano do cliente tem que virar UMA mensagem. Emitir os 5 ramos
1148
- * empatados de um `"navigation": {}` produz cinco exigencias que se
1149
- * contradizem ("tabs e obrigatorio", "versions e obrigatorio", ...);
1150
- * - determinismo: a mesma config sempre da a mesma mensagem. Um palpite
1151
- * instavel seria pior que um palpite ruim.
1152
- * E nada se perde no palpite errado: `message` continua sendo o texto do Zod. */
1153
- function melhorRamo(ramos: unknown[]): IssueCru[] {
1154
- return ramos
1155
- .map((ramo) => {
1156
- const plano = planoDoRamo(ramo);
1157
- return {
1158
- lista: issuesDoRamo(ramo),
1159
- n: plano.length,
1160
- profundidade: maiorProfundidade(plano),
1161
- };
1162
- })
1163
- .sort((a, b) => b.profundidade - a.profundidade || a.n - b.n)[0].lista;
1164
- }
1165
-
1166
- /** Desembrulha `invalid_union` recursivamente - o ramo escolhido normalmente e
1167
- * outra uniao (cada produto da navigation e, por sua vez, uma uniao de formas),
1168
- * entao para so quando chega num erro concreto. Sem recursao, o
1169
- * `navigation.products.0` para em "Invalid input" de novo, um nivel abaixo. */
1170
- function desembrulharUnioes(issues: IssueCru[], prefixo: (string | number)[] = []): IssuePlano[] {
1171
- const saida: IssuePlano[] = [];
1172
- for (const issue of issues) {
1173
- const caminho = [...prefixo, ...(issue.path ?? [])];
1174
- const ramos = issue.code === 'invalid_union' ? issue.errors ?? issue.unionErrors : null;
1175
- if (Array.isArray(ramos) && ramos.length > 0) {
1176
- saida.push(...desembrulharUnioes(melhorRamo(ramos), caminho));
1177
- } else {
1178
- saida.push({ ...issue, caminho });
1179
- }
1180
- }
1181
- return saida;
1182
- }
1183
-
1184
- /** Parseia o texto UMA vez e devolve "caminho -> linha/coluna". Devolve null
1185
- * quando o no nao existe no texto (campo obrigatorio ausente e o caso comum):
1186
- * `line` ausente e melhor que `line` inventada. */
1187
- function criarLocalizador(rawText: string): (caminho: (string | number)[]) => { line: number; column: number } | null {
1188
- let arvore: ReturnType<typeof parseTree> | undefined;
1189
- try {
1190
- arvore = parseTree(rawText);
1191
- } catch {
1192
- arvore = undefined;
1193
- }
1194
- return (caminho) => {
1195
- if (!arvore || caminho.length === 0) return null;
1196
- const no = findNodeAtLocation(arvore, caminho);
1197
- if (!no) return null;
1198
- const antes = rawText.slice(0, no.offset);
1199
- return { line: antes.split('\n').length, column: no.offset - antes.lastIndexOf('\n') };
1200
- };
1201
- }
1202
-
1203
- /** `styles.primaryColor` -> `config.styles`. Primeiro segmento so, com fallback
1204
- * pra `config`: o conjunto tem que ficar pequeno e estavel, porque vira
1205
- * superficie publica no instante em que alguem mapear chave -> URL. */
1206
- function docKeyDoCaminho(caminho: (string | number)[]): string {
1207
- const primeiro = caminho[0];
1208
- return typeof primeiro === 'string' && primeiro ? `config.${primeiro}` : 'config';
1209
- }
1210
-
1211
- const ARTIGO_POR_TIPO: Record<string, string> = {
1212
- string: 'a piece of text',
1213
- number: 'a number',
1214
- boolean: 'true or false',
1215
- array: 'a list',
1216
- object: 'an object',
1217
- null: 'null',
1218
- };
1219
-
1220
- const COMO_ESCREVER: Record<string, string> = {
1221
- string: 'Write the value in double quotes, like "Core Platform".',
1222
- number: 'Write the value as a bare number, like 3 - no quotes.',
1223
- boolean: 'Write true or false - no quotes.',
1224
- array: 'Write the value as a list in square brackets: [ ... ].',
1225
- object: 'Write the value as an object in curly braces: { ... }.',
1226
- };
1227
-
1228
- /** O tipo que o cliente REALMENTE escreveu, lido do JSON ja parseado em vez de
1229
- * extraido da mensagem do Zod por regex - o Zod v4 nao carrega `received` como
1230
- * propriedade, so dentro do texto, e depender do texto quebra na primeira vez
1231
- * que ele mudar de forma. */
1232
- function tipoReal(valor: unknown): string {
1233
- if (valor === undefined) return 'missing';
1234
- if (valor === null) return 'null';
1235
- if (Array.isArray(valor)) return 'array';
1236
- return typeof valor;
1237
- }
1238
-
1239
- function valorEm(raiz: unknown, caminho: (string | number)[]): unknown {
1240
- let atual: unknown = raiz;
1241
- for (const passo of caminho) {
1242
- if (atual === null || typeof atual !== 'object') return undefined;
1243
- atual = (atual as Record<string | number, unknown>)[passo];
1244
- }
1245
- return atual;
1246
- }
1247
-
1248
- function comoCampo(caminho: (string | number)[]): string {
1249
- return caminho.length > 0 ? `"${caminho.join('.')}"` : 'writedocs.json';
1250
- }
1251
-
1252
- /** A frase de chave desconhecida, num lugar so.
1253
- *
1254
- * Decisao 6: chave desconhecida em sub-objeto strict e ERRO e na raiz e AVISO -
1255
- * a assimetria de SEVERIDADE e intencional e fica. A de VOCABULARIO nao: os
1256
- * dois caminhos chamam esta funcao, entao os dois dizem exatamente a mesma
1257
- * coisa, e so quem chama decide o peso. */
1258
- function fraseChaveDesconhecida(chaves: string[], dentroDe: (string | number)[]) {
1259
- const onde = dentroDe.length > 0 ? ` inside "${dentroDe.join('.')}"` : '';
1260
- const lista = chaves.map((k) => `"${k}"`).join(', ');
1261
- return {
1262
- humanMessage:
1263
- chaves.length === 1
1264
- ? `${lista} is not a writedocs.json option${onde}.`
1265
- : `${lista} are not writedocs.json options${onde}.`,
1266
- suggestion: 'Remove it, or check the spelling - options are case-sensitive.',
1267
- };
1268
- }
1269
-
1270
- /** A frase humana + a sugestao, escolhidas pelo CODIGO do Zod. `raiz` e o JSON
1271
- * ja parseado, usado so pra saber o que o cliente escreveu de fato. */
1272
- function humanizar(issue: IssuePlano, raiz: unknown): { humanMessage?: string; suggestion?: string } {
1273
- const campo = comoCampo(issue.caminho);
1274
- switch (issue.code) {
1275
- case 'invalid_type': {
1276
- const esperado = issue.expected ?? 'a different type';
1277
- const recebido = tipoReal(valorEm(raiz, issue.caminho));
1278
- if (recebido === 'missing') {
1279
- return {
1280
- humanMessage: `${campo} is required, but writedocs.json does not set it.`,
1281
- suggestion: `Add ${campo} with ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`} as its value.`,
1282
- };
1283
- }
1284
- return {
1285
- humanMessage: `${campo} must be ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`}, but writedocs.json has ${ARTIGO_POR_TIPO[recebido] ?? `a ${recebido}`}.`,
1286
- suggestion: COMO_ESCREVER[esperado],
1287
- };
1288
- }
1289
- case 'unrecognized_keys': {
1290
- // O Zod agrega as chaves sobrando numa mensagem so, com o `path` no PAI.
1291
- const chaves = issue.keys ?? [];
1292
- if (chaves.length === 0) return {};
1293
- return fraseChaveDesconhecida(chaves, issue.caminho);
1294
- }
1295
- case 'invalid_value': {
1296
- const opcoes = (issue.values ?? []).map((v) => JSON.stringify(v)).join(', ');
1297
- return {
1298
- humanMessage: `${campo} must be one of: ${opcoes}.`,
1299
- suggestion: 'Replace the value with one of the options above.',
1300
- };
1301
- }
1302
- case 'too_small': {
1303
- const unidade = issue.origin === 'array' ? 'item' : 'character';
1304
- const minimo = issue.minimum ?? 1;
1305
- return {
1306
- humanMessage: `${campo} needs at least ${minimo} ${unidade}${minimo === 1 ? '' : 's'}.`,
1307
- suggestion: issue.origin === 'array' ? 'Add an entry, or remove the field entirely.' : undefined,
1308
- };
1309
- }
1310
- case 'too_big': {
1311
- const unidade = issue.origin === 'array' ? 'item' : 'character';
1312
- const maximo = issue.maximum ?? 0;
1313
- return { humanMessage: `${campo} allows at most ${maximo} ${unidade}${maximo === 1 ? '' : 's'}.` };
1314
- }
1315
- case 'invalid_format':
1316
- return {
1317
- humanMessage: `${campo} is not a valid ${issue.format ?? 'value'}.`,
1318
- suggestion: 'Check the format against the documentation for this field.',
1319
- };
1320
- case 'invalid_union':
1321
- // So chega aqui se o desembrulho nao achou ramo nenhum (uniao sem
1322
- // `errors`). Raro, mas melhor dizer o que aconteceu do que "Invalid input".
1323
- return {
1324
- humanMessage: `${campo} does not match any of the shapes writedocs.json accepts here.`,
1325
- suggestion: 'Check the documentation for the shapes this field accepts.',
1326
- };
1327
- case 'custom':
1328
- // As mensagens de `.refine()` deste schema ja sao frases escritas pra
1329
- // gente ("Each scripts.head/scripts.body entry needs exactly one of..."),
1330
- // entao reescrever seria piorar. Repassa como frase humana pra que quem
1331
- // exibe so `humanMessage` nao fique sem nada.
1332
- return { humanMessage: issue.message };
1333
- default:
1334
- return {};
1335
- }
1336
- }
1337
-
1338
- /** Avisos de chave desconhecida na RAIZ.
1339
- *
1340
- * A raiz nao e `.strict()` (decidido no E11-desenho-geral: tornar strict
1341
- * quebraria configs existentes), entao o Zod descarta a chave em silencio e
1342
- * nao ha issue nenhum. Quem quiser avisar chama isto. Mora aqui, e nao na
1343
- * plataforma, pelo motivo de sempre: era o unico texto escrito pela plataforma,
1344
- * e enquanto ele viver la os dois lados podem divergir.
1345
- *
1346
- * Severidade e de quem chama - isto so descreve o problema. `validateDocsConfig`
1347
- * deliberadamente NAO chama: um config valido continua saindo com `issues: []`,
1348
- * porque a plataforma repassa `issues` como `errors` e um aviso ali viraria
1349
- * erro. */
1350
- export function unknownRootKeyIssues(rawText: string): ValidationIssue[] {
1351
- let raiz: unknown;
1352
- try {
1353
- raiz = JSON.parse(rawText);
1354
- } catch {
1355
- return [];
1356
- }
1357
- if (raiz === null || typeof raiz !== 'object' || Array.isArray(raiz)) return [];
1358
- const conhecidas = new Set([...Object.keys(docsConfigSchema.shape), ...ROOT_ALLOWED_EXTRA_KEYS]);
1359
- const localizar = criarLocalizador(rawText);
1360
- return Object.keys(raiz as Record<string, unknown>)
1361
- .filter((chave) => !conhecidas.has(chave))
1362
- .map((chave) => {
1363
- const frase = fraseChaveDesconhecida([chave], []);
1364
- return {
1365
- path: chave,
1366
- // O mesmo formato singular da mensagem `unrecognized_keys` do Zod, pra
1367
- // que aviso e erro sejam indistinguiveis por quem so le `message`.
1368
- message: `Unrecognized key: "${chave}"`,
1369
- code: 'unrecognized_keys',
1370
- ...(localizar([chave]) ?? {}),
1371
- ...frase,
1372
- // `config`, e NAO `config.<chave>`: a chave aqui e o que o cliente
1373
- // digitou errado, e derivar a docKey dela faria cada erro de digitacao
1374
- // inventar uma chave nova. O conjunto de docKeys tem que ser fechado -
1375
- // `config` mais um por campo do schema, nada alem disso -, senao quem
1376
- // mapeia chave -> URL do outro lado nao tem lista pra mapear.
1377
- docKey: 'config',
1378
- };
1379
- });
1380
- }
1381
-
1382
- export function validateDocsConfig(rawText: string): ValidationResult {
1383
- let raw: unknown;
1384
- try {
1385
- raw = JSON.parse(rawText);
1386
- } catch (err) {
1387
- const parseError = err as SyntaxError;
1388
- const posicao = positionFromJsonError(parseError.message, rawText);
1389
- return {
1390
- ok: false,
1391
- data: null,
1392
- kind: 'invalid_json',
1393
- parseError,
1394
- issues: [
1395
- {
1396
- path: '(root)',
1397
- message: parseError.message,
1398
- code: 'invalid_json',
1399
- ...(posicao ?? {}),
1400
- humanMessage: 'writedocs.json is not valid JSON, so none of it could be checked.',
1401
- suggestion: posicao
1402
- ? `Look at line ${posicao.line} - a missing comma, an extra trailing comma, or an unclosed bracket is the usual cause.`
1403
- : 'A missing comma, an extra trailing comma, or an unclosed bracket is the usual cause.',
1404
- docKey: 'config',
1405
- },
1406
- ],
1407
- };
1408
- }
1409
-
1410
- const result = docsConfigSchema.safeParse(raw);
1411
- if (!result.success) {
1412
- const localizar = criarLocalizador(rawText);
1413
- return {
1414
- ok: false,
1415
- data: null,
1416
- kind: 'schema',
1417
- issues: desembrulharUnioes(result.error.issues as IssueCru[]).map((i) => {
1418
- // Para chave desconhecida o `path` do Zod aponta pro PAI (`footer`), e a
1419
- // chave ofensora vem em `keys`. Para a LINHA vale a pena descer ate ela
1420
- // (`footer.banana`), que e onde o cliente precisa olhar; o `path` fica
1421
- // como o Zod deu, pra nao mudar o que a plataforma ja grava.
1422
- const alvoDaLinha =
1423
- i.code === 'unrecognized_keys' && i.keys?.length ? [...i.caminho, i.keys[0]] : i.caminho;
1424
- return {
1425
- path: i.caminho.join('.') || '(root)',
1426
- message: i.message,
1427
- code: i.code,
1428
- ...(localizar(alvoDaLinha) ?? localizar(i.caminho) ?? {}),
1429
- ...humanizar(i, raw),
1430
- docKey: docKeyDoCaminho(i.caminho),
1431
- };
1432
- }),
1433
- };
1434
- }
1435
-
1436
- return { ok: true, data: result.data, issues: [] };
1437
- }
1
+ // O schema do writedocs.json, e nada alem disso.
2
+ //
3
+ // Este arquivo era as primeiras 875 linhas do config.ts (que continua
4
+ // reexportando tudo daqui, entao nada que importa de './config.js' mudou). A
5
+ // separacao existe por um motivo concreto: o config.ts importa node:fs,
6
+ // node:path e gray-matter no topo pras funcoes que leem o disco, e o
7
+ // package.json passou a exportar './config-schema' pra que a plataforma
8
+ // (writedocs-application) possa importar o schema DE VERDADE - o mesmo que o
9
+ // build usa - em vez de manter uma copia que diverge em algumas semanas. Um
10
+ // Worker da Cloudflare nao consegue importar um modulo que traz node:fs junto.
11
+ //
12
+ // Por isso a unica dependencia aqui e o zod. Nada neste arquivo pode passar a
13
+ // tocar o sistema de arquivos: se precisar de fs/path, o lugar e o config.ts.
14
+ //
15
+ // ESTE arquivo e a fonte de verdade, mas NAO e o que o pacote publica: o script
16
+ // `prepare` (package.json) transpila ele pra ./config-schema.js, e e o `.js` que
17
+ // o `exports` e o bin/writedocs.js apontam. Sem isso o `writedocs validate`
18
+ // quebra pra quem instala o pacote - o Node recusa type stripping sob
19
+ // node_modules. Editou aqui, rode `npm run build:schema` (ou qualquer
20
+ // `npm install`) antes de testar o caminho da CLI ou da plataforma.
21
+ //
22
+ // (`zod` e nao `astro/zod`: as duas especificacoes resolvem pra mesma instalacao
23
+ // - node_modules/zod 4.4.3, que e o que astro@7.2.2 tambem pede via `^4.3.6` -
24
+ // entao a troca nao muda comportamento nenhum, so tira o astro do caminho de
25
+ // quem importa isto de fora do gerador.)
26
+ import { z } from 'zod';
27
+ // WD-063: `parseTree` + `findNodeAtLocation` traduzem o caminho de um erro
28
+ // (`navigation.products.0.product`) para o offset dele no TEXTO, que e o que
29
+ // vira linha. E o parser de JSON do VS Code: zero dependencias, nada de `node:`,
30
+ // entao continua bundlavel pro Worker da plataforma. Escrever o scanner a mao
31
+ // foi tentado e deu errado (perdia todo caminho aninhado, que e justamente o que
32
+ // importa aqui) - nao repita.
33
+ import { findNodeAtLocation, parseTree } from 'jsonc-parser';
34
+
35
+ // ---------------------------------------------------------------------
36
+ // Pages & groups - the leaf content any container ultimately bottoms out
37
+ // at. A group can nest other groups arbitrarily deep, and can optionally
38
+ // link its own label to a page (`page`) independent of expanding or
39
+ // collapsing its children - see NavTree.astro for how that renders.
40
+ // ---------------------------------------------------------------------
41
+
42
+ const navPageSchema = z.string();
43
+
44
+ // A group ordinarily lists its own children by hand (`pages`), but can
45
+ // instead auto-generate them from an OpenAPI spec via `openapi: { src,
46
+ // path }` - resolved by loadDocsConfig() (via expandOpenApiInNavigation()
47
+ // below, reading the manifest generate-api-pages.js writes for this exact
48
+ // group) into a real `{ group, pages }` node - one sub-group per tag -
49
+ // before anything else in this file ever sees it, so
50
+ // resolveSections()/flattenNav()/buildNavTree()/etc. only ever need to
51
+ // understand the ordinary pages-array shape. `.strict()` on both variants
52
+ // is load-bearing (see withChildren()'s own comment on this pattern
53
+ // below): without it, a group written with both `pages` and `openapi` at
54
+ // once would silently have one dropped instead of rejected.
55
+ const navGroupPagesSchema: z.ZodType<{ group: string; page?: string; pages: NavItem[] }> = z.lazy(() =>
56
+ z
57
+ .object({
58
+ group: z.string(),
59
+ page: z.string().optional(),
60
+ pages: z.array(navItemSchema),
61
+ })
62
+ .strict()
63
+ );
64
+
65
+ const navGroupOpenApiSchema = z.object({
66
+ group: z.string(),
67
+ openapi: z
68
+ .object({
69
+ // Path to an OpenAPI 3.x spec, relative to the content directory
70
+ // (alongside writedocs.json) - same convention the old top-level
71
+ // `openapi` field used. See generate-api-pages.js (the pre-Astro
72
+ // build/dev step that actually parses this and writes the
73
+ // manifest/operation JSON this group's pages are expanded from).
74
+ src: z.string(),
75
+ // Base URL path every page generated from this spec is namespaced
76
+ // under, e.g. "/api" -> pages served at /api/<tag>/<operation>/.
77
+ // Also doubles as this spec's own unique key under
78
+ // writedocsTempDir()'s openapi/<path>/ directory (manifest.json + operations/*.json),
79
+ // so two openapi groups in the same writedocs.json must use different
80
+ // `path` values - generate-api-pages.js throws a clear error if
81
+ // they collide.
82
+ path: z.string(),
83
+ })
84
+ .strict(),
85
+ }).strict();
86
+
87
+ export type GroupNavItem =
88
+ | { group: string; page?: string; pages: NavItem[] }
89
+ | { group: string; openapi: { src: string; path: string } };
90
+
91
+ const navGroupSchema: z.ZodType<GroupNavItem> = z.union([navGroupPagesSchema, navGroupOpenApiSchema]);
92
+
93
+ // A bare external link sitting directly in a `pages` array, alongside
94
+ // page slugs and groups - e.g. a "Support" link to an external help
95
+ // desk shown right in the sidebar, rather than only reachable via
96
+ // navigation.global.dropdowns. Discriminated from navGroupSchema by
97
+ // its required `label`/`href` keys (vs `group`/`pages`/`openapi`), so a
98
+ // plain (non-strict at the union level) schema is enough for Zod to pick
99
+ // the right branch; `.strict()` here still catches a typo'd extra key.
100
+ const navLinkSchema = z.object({ label: z.string(), href: z.string() }).strict();
101
+
102
+ const navItemSchema: z.ZodType<NavItem> = z.lazy(() =>
103
+ z.union([navPageSchema, navGroupSchema, navLinkSchema])
104
+ );
105
+
106
+ export type NavItem = string | GroupNavItem | { label: string; href: string };
107
+
108
+ // ---------------------------------------------------------------------
109
+ // Containers: tabs, versions, languages, dropdowns, products.
110
+ //
111
+ // This schema is modeled directly on Mintlify's writedocs.json
112
+ // (https://mintlify.com/writedocs.json / mintlify.com/docs/organize/navigation):
113
+ // all five container kinds are structurally identical except for their
114
+ // own identifying field. Each owns *exactly one* of {pages, tabs,
115
+ // versions, languages, dropdowns, products} as its content, or is a bare
116
+ // external `href` link with no content of its own. That symmetry is what
117
+ // lets any of them nest inside any other - a tab can contain versions, a
118
+ // version can contain languages, a language can contain tabs, and so on,
119
+ // bottoming out at a `pages` array. `withChildren()` is the single place
120
+ // that shape is defined, instead of hand-writing the union five times.
121
+ // ---------------------------------------------------------------------
122
+
123
+ export type NavChildren =
124
+ | { pages: NavItem[] }
125
+ | { tabs: TabItem[] }
126
+ | { versions: VersionItem[] }
127
+ | { languages: LanguageItem[] }
128
+ | { dropdowns: DropdownItem[] }
129
+ | { products: ProductItem[] }
130
+ | { href: string };
131
+
132
+ export type TabItem = { tab: string; icon?: string } & NavChildren;
133
+ export type VersionItem = { version: string; label?: string; tag?: string; default?: boolean } & NavChildren;
134
+ export type LanguageItem = { language: string; label?: string } & NavChildren;
135
+ export type DropdownItem = { dropdown: string; icon?: string } & NavChildren;
136
+ export type ProductItem = { product: string; icon?: string; description?: string } & NavChildren;
137
+
138
+ // `.strict()` on every variant is load-bearing, not decoration: Zod
139
+ // objects silently strip unrecognized keys by default, so without it a
140
+ // node written with two children fields at once (e.g. both `pages` and
141
+ // `versions`) would just have the second one dropped instead of
142
+ // rejected - defeating the entire "exactly one child kind per level"
143
+ // rule this schema exists to enforce (mirroring Mintlify's own "a tab
144
+ // cannot contain both anchors and groups at the same level").
145
+ function withChildren<Base extends z.ZodRawShape>(base: Base) {
146
+ return z.union([
147
+ z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
148
+ z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
149
+ z.object({ ...base, versions: z.array(versionSchema).min(1) }).strict(),
150
+ z.object({ ...base, languages: z.array(languageSchema).min(1) }).strict(),
151
+ z.object({ ...base, dropdowns: z.array(dropdownSchema).min(1) }).strict(),
152
+ z.object({ ...base, products: z.array(productSchema).min(1) }).strict(),
153
+ z.object({ ...base, href: z.string() }).strict(),
154
+ ]);
155
+ }
156
+
157
+ // Each of these five is defined via z.lazy() because withChildren()
158
+ // references all five by name (mutual recursion) - safe because none of
159
+ // them are actually *called* until a real writedocs.json is parsed, by which
160
+ // point every const below has been assigned. The same pattern already
161
+ // used for navItemSchema/navGroupSchema above.
162
+ const tabSchema: z.ZodType<TabItem> = z.lazy(() =>
163
+ withChildren({ tab: z.string(), icon: z.string().optional() })
164
+ );
165
+
166
+ const versionSchema: z.ZodType<VersionItem> = z.lazy(() =>
167
+ withChildren({
168
+ version: z.string(),
169
+ label: z.string().optional(),
170
+ tag: z.string().optional(),
171
+ default: z.boolean().optional(),
172
+ })
173
+ );
174
+
175
+ const languageSchema: z.ZodType<LanguageItem> = z.lazy(() =>
176
+ withChildren({ language: z.string(), label: z.string().optional() })
177
+ );
178
+
179
+ const dropdownSchema: z.ZodType<DropdownItem> = z.lazy(() =>
180
+ withChildren({ dropdown: z.string(), icon: z.string().optional() })
181
+ );
182
+
183
+ const productSchema: z.ZodType<ProductItem> = z.lazy(() =>
184
+ withChildren({ product: z.string(), icon: z.string().optional(), description: z.string().optional() })
185
+ );
186
+
187
+ // ---------------------------------------------------------------------
188
+ // Root navigation: a flat array (shorthand for one implicit section - the
189
+ // common case) or an object choosing exactly one primary organizational
190
+ // pattern, per Mintlify's convention ("choose one primary organizational
191
+ // pattern at the root level of your navigation").
192
+ //
193
+ // `global.dropdowns` is the one exception to "exactly one pattern": it's
194
+ // not a primary pattern, it's a persistent set of topbar dropdown
195
+ // triggers that render on every page regardless of which tab/version/
196
+ // language/product is active - equivalent to Mintlify's
197
+ // `navigation.global.anchors`. This replaces the earlier `{tabs,
198
+ // dropdowns}` shape (both present at once), which doesn't generalize
199
+ // once versions/languages/products are also root-level choices.
200
+ // ---------------------------------------------------------------------
201
+
202
+ export type NavigationConfig =
203
+ | NavItem[]
204
+ | { global?: { dropdowns: DropdownItem[] }; tabs: TabItem[] }
205
+ | { global?: { dropdowns: DropdownItem[] }; versions: VersionItem[] }
206
+ | { global?: { dropdowns: DropdownItem[] }; languages: LanguageItem[] }
207
+ | { global?: { dropdowns: DropdownItem[] }; dropdowns: DropdownItem[] }
208
+ | { global?: { dropdowns: DropdownItem[] }; products: ProductItem[] };
209
+
210
+ const globalSchema = z.object({ dropdowns: z.array(dropdownSchema).min(1) });
211
+
212
+ const navigationSchema = z.union([
213
+ z.array(navItemSchema),
214
+ z.object({ global: globalSchema.optional(), tabs: z.array(tabSchema).min(1) }).strict(),
215
+ z.object({ global: globalSchema.optional(), versions: z.array(versionSchema).min(1) }).strict(),
216
+ z.object({ global: globalSchema.optional(), languages: z.array(languageSchema).min(1) }).strict(),
217
+ z.object({ global: globalSchema.optional(), dropdowns: z.array(dropdownSchema).min(1) }).strict(),
218
+ z.object({ global: globalSchema.optional(), products: z.array(productSchema).min(1) }).strict(),
219
+ ]) as z.ZodType<NavigationConfig>;
220
+
221
+ const DEFAULT_PRIMARY = '#6366f1';
222
+
223
+ // A logo is either one path used for both color modes, or a
224
+ // { light, dark, label } object - each image shown only while that mode
225
+ // is active (see BaseLayout.astro's .wd-logo-light/.wd-logo-dark CSS).
226
+ // The topbar shows *only* the image by default - no site name text next
227
+ // to it, since a real logo asset usually already is a wordmark. `label`
228
+ // is the opt-in way to add text back next to the image (e.g. a product
229
+ // name alongside a symbol-only mark); it only exists on the object form,
230
+ // since a single shared image path is just as easy to bake the label
231
+ // into directly.
232
+ //
233
+ // `light`/`dark` are themselves optional (unlike the string form, which
234
+ // is inherently "both"): a site with no logo *images* at all can still
235
+ // set `styles.logo: { label: "..." }` to override the topbar's fallback
236
+ // text without providing any image - see BaseLayout.astro's
237
+ // hasLogoImage/logoLabel/brandLabel derivation, where an object with
238
+ // only `label` set already resolves correctly (brandLabel = logoLabel,
239
+ // same as if an image were also present) without needing any change
240
+ // there; only this schema needed loosening; light/dark were previously
241
+ // both required whenever the object form was used at all.
242
+ //
243
+ // Standalone const (rather than inlined into stylesSchema.logo below) so
244
+ // footer.logo further down can share the exact same union instead of an
245
+ // independently-written copy - see footerSchema's own comment on why a
246
+ // footer logo needs this at all.
247
+ const logoSchema = z.union([
248
+ z.string(),
249
+ z.object({ light: z.string().optional(), dark: z.string().optional(), label: z.string().optional() }).strict(),
250
+ ]);
251
+
252
+ // Same {light, dark}-with-fallback shape applies to `colors.dark`: any
253
+ // of primary/text left unset there falls back to the light-mode value
254
+ // already given above, so a site only has to specify what actually
255
+ // differs in dark mode. There used to be a `background`/`dark.background`
256
+ // pair here too, doing effectively the same job as `background.colors`
257
+ // further down (see that field's own comment) - two writedocs.json fields
258
+ // both named "background" that visually looked interchangeable but
259
+ // weren't quite (one drove --wd-background site-wide, the other only
260
+ // the <body> canvas), which was confusing to author against and easy to
261
+ // half-configure by setting only one. Removed in favor of `background`
262
+ // alone being the single place to set any background color - see that
263
+ // field's comment for what it now covers.
264
+ // One side of `styles.navbar` (light or dark) - either a bare background
265
+ // color (the original shape) or `{ background, accent? }` once a site
266
+ // wants its own accent color (the active-tab fill / hover underline)
267
+ // inside the navbar specifically, independent of the sitewide
268
+ // `styles.colors.primary`. Deliberately does NOT carry a text/icon color
269
+ // field at all - see `navbar`'s own comment below (stylesSchema) for why
270
+ // that's resolved automatically instead of being configurable. Kept as a
271
+ // standalone const rather than inlined so `light`/`dark` share the exact
272
+ // same union rather than two independently-written (and possibly
273
+ // drifting) copies of it.
274
+ const navbarColorValueSchema = z.union([
275
+ z.string(),
276
+ z
277
+ .object({
278
+ background: z.string(),
279
+ accent: z.string().optional(),
280
+ })
281
+ .strict(),
282
+ ]);
283
+
284
+ // One font declaration - almost exactly Mintlify's own `fonts` shape (see
285
+ // docs/dev/docs/site-config.mdx's own "Fonts" section for the research this
286
+ // was based on): a bare Google Fonts family name (`family` alone - Google
287
+ // Fonts loads it automatically, see googleFontsHref() below), or a local/
288
+ // externally-hosted font file (`source` + `format` together; `source` is
289
+ // either a project-relative path like the other five asset-fallback fields
290
+ // this schema already has - see collectConfiguredAssetPaths() - or a full
291
+ // https:// URL to an externally-hosted font). `weight` does two things at
292
+ // once, both driven by BaseLayout.astro: it narrows which file actually
293
+ // loads (folded into the Google Fonts URL's `:wght@` axis, or the
294
+ // generated `@font-face` rule's own `font-weight` descriptor for a
295
+ // `source` font - see googleFontsHref()/fontFaceRule() below), *and* it's
296
+ // applied as a real CSS `font-weight` on h1-h6 (for `heading`) or `body`
297
+ // (for `body`) - see BaseLayout's own `wdFontWeightHeading`/
298
+ // `wdFontWeightBody`. Both halves matter: loading only the 400-weight file
299
+ // while leaving h1-h6 at the browser's UA-default `font-weight: bold`
300
+ // faux-bolds that file back to looking bold anyway, with no visible
301
+ // change from configuring a lighter weight at all - the bug this second
302
+ // half fixes (caught from real user-reported behavior, not anticipated
303
+ // up front). Left unset anywhere in `styles.fonts` renders no font-weight
304
+ // rule at all, keeping today's plain browser-default bold headings/normal
305
+ // body text unchanged for a site that hasn't touched this field.
306
+ const fontVariantSchema = z
307
+ .object({
308
+ family: z.string(),
309
+ weight: z.number().optional(),
310
+ source: z.string().optional(),
311
+ format: z.enum(['woff', 'woff2']).optional(),
312
+ })
313
+ .strict();
314
+
315
+ export type FontVariant = z.infer<typeof fontVariantSchema>;
316
+
317
+ // The top-level shape adds `heading`/`body`: each independently optional,
318
+ // each falling back to the top-level family/weight/source/format when
319
+ // unset (see resolveFonts() below) - so a site can set one font for
320
+ // everything (`fonts.family` alone), or split headings and body text
321
+ // without needing to repeat itself for whichever side isn't changing.
322
+ const fontsSchema = z
323
+ .object({
324
+ family: z.string(),
325
+ weight: z.number().optional(),
326
+ source: z.string().optional(),
327
+ format: z.enum(['woff', 'woff2']).optional(),
328
+ heading: fontVariantSchema.optional(),
329
+ body: fontVariantSchema.optional(),
330
+ })
331
+ .strict();
332
+
333
+ export type FontsConfig = z.infer<typeof fontsSchema>;
334
+
335
+ const stylesSchema = z
336
+ .object({
337
+ colors: z
338
+ .object({
339
+ primary: z.string().default(DEFAULT_PRIMARY),
340
+ text: z.string().optional(),
341
+ dark: z
342
+ .object({
343
+ primary: z.string().optional(),
344
+ text: z.string().optional(),
345
+ })
346
+ .optional(),
347
+ })
348
+ .default({ primary: DEFAULT_PRIMARY }),
349
+ logo: logoSchema.optional(),
350
+ favicon: z.string().optional(),
351
+ // No default value here (unlike `colors.primary`'s DEFAULT_PRIMARY) -
352
+ // resolveFonts() below is where "Inter" actually gets applied as the
353
+ // site-wide default whenever this field is left unset entirely, so
354
+ // that fallback lives in one place alongside the rest of the
355
+ // resolution logic rather than being duplicated as a schema default
356
+ // *and* a resolver fallback.
357
+ fonts: fontsSchema.optional(),
358
+ // The Shiki theme *name* (not a full theme object/JSON - see
359
+ // https://shiki.style/themes for the built-in list) each fenced
360
+ // (```) MDX code block, and the API playground's request/response
361
+ // snippets, use in light/dark mode - read out of writedocs.json by both
362
+ // astro.config.mjs (Shiki's dual-theme markdown config) and
363
+ // ApiReferencePanel.astro (its own separate <Code/> usages, which
364
+ // don't inherit markdown.shikiConfig) via resolveCodeblockTheme()
365
+ // below, the single source of truth for the 'github-light'/
366
+ // 'github-dark' fallback. Left per-key optional (not defaulted in
367
+ // the schema itself) so a site can override just one side (e.g.
368
+ // only `dark`) and still get the ordinary default for the other.
369
+ codeblocks: z
370
+ .object({
371
+ light: z.string().optional(),
372
+ dark: z.string().optional(),
373
+ // Maps a fenced (```) block's own language tag to whichever real
374
+ // Shiki grammar actually highlights it, before that tag ever
375
+ // reaches Shiki - see resolveCodeblockLangAlias() below for the
376
+ // one built-in entry (mdx -> jsx) this ships with by default and
377
+ // why. A site can add its own entries for any other language tag
378
+ // that either doesn't tokenize well under its own grammar, or
379
+ // that a site wants to write under a shorter/friendlier fence tag
380
+ // than Shiki's own bundled id (e.g. `groovy: 'java'` for a close-
381
+ // enough approximation Shiki doesn't ship a dedicated grammar for
382
+ // at all) - merged with, not replacing, the built-in default, so
383
+ // adding one of a site's own doesn't require re-declaring mdx.
384
+ langAlias: z.record(z.string(), z.string()).optional(),
385
+ })
386
+ .strict()
387
+ .optional(),
388
+ // The topbar's own background, independent of `background` below (the
389
+ // page's) - a site may want its navbar to stand out (a dark or
390
+ // brand-colored bar over a light page) rather than blend into the
391
+ // page, which is the default when this is left unset. Same {light,
392
+ // dark} shape (and same per-key-optional, not defaulted here) as
393
+ // `codeblocks` right above, rather than nested under `colors` - it's a
394
+ // topbar-specific override, not a third general-purpose page color, so
395
+ // it reads more like "one more themed surface" alongside codeblocks
396
+ // than a sibling of primary/text. BaseLayout.astro's own
397
+ // lightNavbarBg/darkNavbarBg resolution is what falls each side back
398
+ // to the page background when unset.
399
+ //
400
+ // Each side (`light`/`dark`) is either a bare string - just the
401
+ // background color, exactly the original shape, kept for backwards
402
+ // compatibility - or `{ background, accent? }`, for when a site also
403
+ // wants the navbar's own active-tab-fill/hover-underline color to
404
+ // differ from the sitewide `styles.colors.primary` (`accent`'s only
405
+ // job - see BaseLayout.astro's lightNavbarAccent). Deliberately no
406
+ // text/icon color field here at all: BaseLayout.astro always resolves
407
+ // the navbar's plain text/icon color itself, picking black or white by
408
+ // contrast against whatever `background` resolves to
409
+ // (contrastTextColor() below) the moment this field is configured at
410
+ // all (string or object) - never a value read from writedocs.json. A
411
+ // manually-set text color is a legibility footgun a site author can
412
+ // get wrong (or drift out of sync after later changing `background`
413
+ // without remembering to update it too) in a way "compute it
414
+ // correctly, every time" simply can't - there's no legitimate reason
415
+ // to want *illegible* navbar text, unlike `accent`, which is a real
416
+ // aesthetic choice. Real bug this fixes: a site setting `navbar.light`
417
+ // to the same value as `colors.primary` (a plausible thing to reach
418
+ // for - "brand-colored navbar") made every topbar label/icon/link
419
+ // render in the default near-black text color against that same
420
+ // saturated background, all but unreadable - and an earlier version of
421
+ // this feature that let a writedocs.json-supplied color double as the text
422
+ // color reintroduced the identical bug one level down, just requiring
423
+ // one more (still guessable-wrong) field to trigger it.
424
+ navbar: z
425
+ .object({
426
+ light: navbarColorValueSchema.optional(),
427
+ dark: navbarColorValueSchema.optional(),
428
+ })
429
+ .strict()
430
+ .optional(),
431
+ // The single place to configure any background color: `colors` is a
432
+ // solid color (falls back to '#ffffff'/'#0b1120' when unset - see
433
+ // BaseLayout.astro's lightBackground/darkBackground resolution), and
434
+ // `images` layers an image on top of it (or is the whole background
435
+ // by itself, if `colors` is left at its default). This one pair now
436
+ // drives everything background-related site-wide - --wd-background
437
+ // (dropdowns/modals/kbd chips/footer/topbar-fallback/etc., see every
438
+ // `var(--wd-background)` call site) *and* the <body> canvas behind the
439
+ // main/toc columns - rather than the two being independently
440
+ // configurable fields that happened to look alike but didn't affect
441
+ // the same things (see this schema's own comment on `colors.dark`
442
+ // above for why that split was removed). Both are per-mode optional,
443
+ // same "only specify what differs" pattern as codeblocks/navbar above.
444
+ // The topbar and footer still get their own explicit opaque
445
+ // background (topbar.css/footer.css) specifically so an `images`
446
+ // background doesn't show through them; see those files' own
447
+ // comments. The sidebar column has no background of its own (base.css)
448
+ // and lets it show through, same as the main/toc columns.
449
+ background: z
450
+ .object({
451
+ colors: z
452
+ .object({
453
+ light: z.string().optional(),
454
+ dark: z.string().optional(),
455
+ })
456
+ .strict()
457
+ .optional(),
458
+ images: z
459
+ .object({
460
+ light: z.string().optional(),
461
+ dark: z.string().optional(),
462
+ })
463
+ .strict()
464
+ .optional(),
465
+ })
466
+ .strict()
467
+ .optional(),
468
+ })
469
+ .default({ colors: { primary: DEFAULT_PRIMARY } });
470
+
471
+ // `href` is always required - a link with nowhere to go isn't a link.
472
+ // `label`/`icon` are each individually optional, but not both at once
473
+ // (enforced by the .refine() below, same shape as scriptEntrySchema's own
474
+ // exactly-one-of check above) - a link needs *something* visible to render,
475
+ // whichever of the two, and can have both (icon rendered to the left of the
476
+ // label - see TopBar.astro/SiteFooter.astro, the two renderers of this
477
+ // shape). An icon-only link (no label) is the common case for something
478
+ // like a bare GitHub mark in the topbar; label-only (no icon) is every
479
+ // plain text link this already supported before `icon` existed - both
480
+ // still need to keep working unchanged, hence neither field being outright
481
+ // required on its own.
482
+ const topbarLinkSchema = z
483
+ .object({
484
+ label: z.string().optional(),
485
+ href: z.string(),
486
+ // Same string shape as every other writedocs.json `icon` field (a plain
487
+ // Lucide name, or the explicit "collection:icon-name" form for
488
+ // anything else - see resolveIcon()/AppIcon.astro) - not documented
489
+ // again here since that convention is already established elsewhere
490
+ // in this file (tabs/switchers/socials all take the identical shape).
491
+ icon: z.string().optional(),
492
+ })
493
+ .strict()
494
+ .refine((link) => Boolean(link.label) || Boolean(link.icon), {
495
+ message: 'Each topbar.links/footer column link needs a "label", an "icon", or both - a link with neither has nothing to render.',
496
+ });
497
+
498
+ // One column of the footer (BaseLayout.astro's <footer class="wd-footer">) -
499
+ // an optional heading (e.g. "Resources", "Community") plus a list of links,
500
+ // reusing the exact same {label, icon, href} shape as topbar.links above (a
501
+ // footer link is the same thing rendered in a different place, no reason
502
+ // for a second, identical-but-differently-named schema). `title` is
503
+ // optional so a single-column footer with no heading (just a bare list of
504
+ // links) is valid too.
505
+ const footerColumnSchema = z
506
+ .object({
507
+ title: z.string().optional(),
508
+ links: z.array(topbarLinkSchema).default([]),
509
+ })
510
+ .strict();
511
+
512
+ // Off by default (empty `columns`, same convention as `topbar` above - a
513
+ // footer with nothing configured just doesn't render at all, see
514
+ // BaseLayout.astro's `hasFooterContent` check) rather than `.optional()`
515
+ // like contextMenu: unlike that field, an empty/default footer has no
516
+ // build-output or routing implications to gate, it's purely visual, so
517
+ // there's no reason to distinguish "unset" from "set to nothing" the way
518
+ // contextMenu needs to.
519
+ // Same {light, dark, label} union as `styles.logo` (via the shared
520
+ // logoSchema above) - unset by default, in which case the footer just
521
+ // shows the sitewide `styles.logo` (BaseLayout.astro's existing
522
+ // logoLight/logoDark/logoLabel, already threaded into SiteFooter as-is).
523
+ // Set here, it replaces that entirely rather than filling in only the
524
+ // missing side of it - a footer.logo with only `dark` set shows *just*
525
+ // the dark-mode image, not styles.logo's light image plus this dark one -
526
+ // the same all-or-nothing-relative-to-styles.logo resolution BaseLayout.astro
527
+ // already applies.
528
+ const footerSchema = z
529
+ .object({
530
+ columns: z.array(footerColumnSchema).default([]),
531
+ logo: logoSchema.optional(),
532
+ })
533
+ .strict()
534
+ .default({ columns: [] });
535
+
536
+ export type FooterColumn = z.infer<typeof footerColumnSchema>;
537
+ export type FooterConfig = z.infer<typeof footerSchema>;
538
+
539
+ // Shared by both writedocs.json's top-level `seo` (site-wide defaults) and a
540
+ // page's own frontmatter `seo` (per-page overrides) - see
541
+ // content.config.ts's docsSchema, which imports this exact schema rather
542
+ // than redeclaring the same shape a second time. mergeSeo() below is what
543
+ // actually combines the two, field by field, at render time.
544
+ export const seoFieldsSchema = z
545
+ .object({
546
+ // Open Graph / Twitter card image - a relative path (resolved against
547
+ // `domain` below into an absolute URL, since most social crawlers
548
+ // require one) or an already-absolute https:// URL.
549
+ ogImage: z.string().optional(),
550
+ // og:type - "website" for most pages, "article" for blog-post-shaped
551
+ // content, etc. Defaults to "website" if never set anywhere.
552
+ ogType: z.string().optional(),
553
+ twitterCard: z.enum(['summary', 'summary_large_image']).optional(),
554
+ keywords: z.array(z.string()).optional(),
555
+ // Renders <meta name="robots" content="noindex, nofollow" /> and
556
+ // (see astro.config.mjs's sitemap `filter`) excludes the page from
557
+ // sitemap.xml entirely - both driven off this one flag, since listing
558
+ // a page in the sitemap while also telling crawlers not to index it
559
+ // would be self-contradictory.
560
+ noindex: z.boolean().optional(),
561
+ })
562
+ .strict();
563
+
564
+ export type SeoFields = z.infer<typeof seoFieldsSchema>;
565
+
566
+ /** Field-by-field merge of a page's own `seo` frontmatter over writedocs.json's
567
+ * site-wide `seo` defaults - a page only overrides the specific fields it
568
+ * sets, falling back to the site default for everything else, rather than
569
+ * a page's (possibly partial) `seo` object replacing the site's wholesale. */
570
+ export function mergeSeo(site: SeoFields, page?: SeoFields): SeoFields {
571
+ if (!page) return site;
572
+ return {
573
+ ogImage: page.ogImage ?? site.ogImage,
574
+ ogType: page.ogType ?? site.ogType,
575
+ twitterCard: page.twitterCard ?? site.twitterCard,
576
+ keywords: page.keywords ?? site.keywords,
577
+ noindex: page.noindex ?? site.noindex,
578
+ };
579
+ }
580
+
581
+ const MINTLIFY_MODE_ALIASES: Record<string, string> = { center: 'frame', assistant: 'default' };
582
+
583
+ /** A page's own frontmatter - the `pages` and `generatedDocs` content
584
+ * collections' schema (content.config.ts), and what `writedocs validate`
585
+ * checks every page against. */
586
+ export const pageFrontmatterSchema = z.object({
587
+ title: z.string(),
588
+ description: z.string().optional(),
589
+ // Overrides the URL this page is served at, independent of where the
590
+ // file actually lives - writedocs.json's `pages` arrays always keep
591
+ // referencing the file's own path regardless. This is Astro's own
592
+ // glob()-loader convention (a `slug` frontmatter field becomes
593
+ // `entry.id` verbatim - see generateIdDefault in
594
+ // astro/dist/content/loaders/glob.js), not writedocs-specific
595
+ // behavior; declaring it here just brings it into the schema (and
596
+ // its docs) rather than leaving it an undocumented Astro feature.
597
+ // See fileIdForEntry() in lib/config.ts for how routes/links still
598
+ // resolve a page by its file id once this diverges from `entry.id`.
599
+ // Leading/trailing slashes are fine either way ("/", "/guides/x",
600
+ // "guides/x" and "guides/x/" all mean the same thing) - see
601
+ // normalizeEntryId() in lib/config.ts, which is what actually
602
+ // strips them before this value is ever used as a route or href.
603
+ slug: z.string().optional(),
604
+ // Marks this page as an OpenAPI operation reference: "METHOD /path"
605
+ // matching an operation in the owning group's OpenAPI spec, e.g.
606
+ // "GET /pets/{petId}" - [...slug].astro renders <ApiPlayground /> for
607
+ // any page with this field set, below the page's own MDX body (if
608
+ // any). Sites don't write this by hand for most pages; it's either
609
+ // set on a generated stub in the `generatedDocs` collection below (see
610
+ // generate-api-pages.js) or hand-authored to "eject" one specific
611
+ // operation into a real file (in `docs`) with custom prose - either
612
+ // way, the value is always exactly the same "METHOD /path" key
613
+ // generate-api-pages.js uses to look up the operation's full resolved
614
+ // schema/examples at render time.
615
+ openapi: z.string().optional(),
616
+ // Controls how much of the site's own chrome (topbar, sidebar, table of
617
+ // contents) wraps this page - see BaseLayout.astro/[...slug].astro for
618
+ // what each value actually removes:
619
+ // default - the normal three-column reading layout (all chrome).
620
+ // wide - drops the table of contents; the article itself also
621
+ // renders wider, for content that wants the extra room
622
+ // (wide tables, side-by-side images).
623
+ // frame - drops the sidebar and table of contents, but keeps the
624
+ // topbar and the article's own normal presentation
625
+ // ("frame" as in: still inside the site's outer frame).
626
+ // custom - drops the sidebar and table of contents, the
627
+ // auto-rendered <h1>, and prev/next nav, and skips the
628
+ // article's own prose width/padding too - a blank canvas
629
+ // for a hand-built landing/home page made entirely of
630
+ // components - but keeps the topbar, so the page still
631
+ // has site branding/nav/search/theme-toggle available.
632
+ // blank - everything 'custom' drops, plus the topbar too: no site
633
+ // chrome at all, just <slot />. For a page that wants to
634
+ // look nothing like the rest of the site (an auth screen,
635
+ // a print-style page).
636
+ //
637
+ // Two Mintlify values are accepted so a migrated page doesn't fail the
638
+ // build (see MINTLIFY_MODE_ALIASES above): `center` is the same layout
639
+ // as our `frame`, and `assistant` (a full-page Mintlify AI chat, which
640
+ // writedocs doesn't have) falls back to `default`. Mintlify's own
641
+ // `frame` means something else (a canvas that keeps the sidebar) - it's
642
+ // left as our `frame`; see docs/dev/docs/mintlify-compat.mdx.
643
+ mode: z
644
+ .preprocess(
645
+ (value) => (typeof value === 'string' && value in MINTLIFY_MODE_ALIASES ? MINTLIFY_MODE_ALIASES[value] : value),
646
+ z.enum(['default', 'wide', 'frame', 'custom', 'blank']),
647
+ )
648
+ .default('default'),
649
+ // Per-page meta tag overrides - same shape as writedocs.json's top-level
650
+ // `seo` (see seoFieldsSchema in lib/config.ts, the single source of
651
+ // truth for this shape). A page only needs to set the specific fields
652
+ // it wants to override; mergeSeo() falls back to the site-wide default
653
+ // for anything left unset. See BaseLayout.astro for where this and the
654
+ // site-wide seo actually get merged and rendered.
655
+ seo: seoFieldsSchema.optional(),
656
+ // Mintlify's spelling of `seo.noindex` - a top-level `noindex: true`.
657
+ // Folded into `seo` by the transform below, so everything downstream
658
+ // (the robots meta tag, sitemap.xml, llms.txt) keeps reading
659
+ // `seo.noindex` only.
660
+ noindex: z.boolean().optional(),
661
+ // Mintlify's short navigation label - used for the sidebar and the
662
+ // topbar dropdown menus in place of `title` ([...slug].astro's
663
+ // navTitleForSlug). The page's own <h1> and prev/next links keep `title`.
664
+ sidebarTitle: z.string().optional(),
665
+ // Mintlify's spellings of `seo.keywords`, `seo.ogImage`, `seo.ogType`
666
+ // and `seo.twitterCard`, folded into `seo` below like `noindex`.
667
+ keywords: z.array(z.string()).optional(),
668
+ 'og:image': z.string().optional(),
669
+ 'og:type': z.string().optional(),
670
+ 'twitter:card': z.enum(['summary', 'summary_large_image']).optional(),
671
+ // Mintlify's sidebar/page-chrome fields - see NavTree.astro and
672
+ // [...slug].astro for where each is used:
673
+ // icon - shown before the page's label in the sidebar.
674
+ // tag - a short label after it (e.g. "NEW").
675
+ // deprecated - a "Deprecated" label in the sidebar and next
676
+ // to the page's <h1>.
677
+ // hidden - left out of the sidebar, dropdowns and
678
+ // prev/next, but still built and reachable by
679
+ // URL. Also noindexed, as on Mintlify.
680
+ // url - an external link: the page's sidebar entry
681
+ // links straight to it, and the page's own URL
682
+ // redirects there.
683
+ // hideFooterPagination - no prev/next links on this page.
684
+ // hideApiMarker - no HTTP method badge on this page's sidebar
685
+ // entry.
686
+ icon: z.string().optional(),
687
+ tag: z.string().optional(),
688
+ deprecated: z.boolean().optional(),
689
+ hidden: z.boolean().optional(),
690
+ url: z.string().optional(),
691
+ hideFooterPagination: z.boolean().optional(),
692
+ hideApiMarker: z.boolean().optional(),
693
+ }).transform(
694
+ ({ noindex, keywords, 'og:image': ogImage, 'og:type': ogType, 'twitter:card': twitterCard, ...data }) => {
695
+ // Top-level Mintlify keys fill in `seo` only where the page's own `seo`
696
+ // doesn't already set that field - an explicit `seo` value always wins.
697
+ // A `hidden` page is noindexed unless it says otherwise.
698
+ const fromMintlify = { noindex: noindex ?? (data.hidden ? true : undefined), keywords, ogImage, ogType, twitterCard };
699
+ const seo = { ...data.seo };
700
+ let changed = false;
701
+ for (const [key, value] of Object.entries(fromMintlify)) {
702
+ if (value === undefined || seo[key as keyof typeof seo] !== undefined) continue;
703
+ (seo as Record<string, unknown>)[key] = value;
704
+ changed = true;
705
+ }
706
+ return changed ? { ...data, seo } : data;
707
+ },
708
+ );
709
+
710
+ // Controls for the OpenAPI API playground's "Try it" modal - separate
711
+ // from a group's own `openapi: { src, path }` field (which spec to use,
712
+ // and where) since this is about how a *request* it sends behaves, not
713
+ // about the spec itself.
714
+ const apiSchema = z
715
+ .object({
716
+ // Whether the Try-it modal's Send button routes its request through
717
+ // writedocs' own CORS proxy (https://proxy.writechoice.io/) rather
718
+ // than calling the API directly from the browser. Almost no
719
+ // real-world API sends back Access-Control-Allow-Origin headers
720
+ // permitting an arbitrary docs site's origin, so a direct browser
721
+ // fetch() from the Try-it modal fails for most real APIs without
722
+ // this - see PROXY_BASE_URL in ApiReferencePanel.astro for the
723
+ // actual request-forwarding logic. Defaults to enabled; a site can
724
+ // set this to `false` to always call the API directly instead (the
725
+ // API is already CORS-permissive, same-origin in some deployments,
726
+ // or the site owner doesn't want requests routed through a third
727
+ // party at all).
728
+ proxy: z.boolean().default(true),
729
+ })
730
+ .strict()
731
+ .default({ proxy: true });
732
+
733
+ // The "Copy page" dropdown shown next to a page's title - copy the raw
734
+ // Markdown to the clipboard, open the raw Markdown in a new tab, or
735
+ // deep-link into an AI assistant with a prompt pointing at it. Opt-in:
736
+ // undefined (the field simply absent from writedocs.json) means the feature
737
+ // is off entirely - no menu rendered, no .md routes generated at build
738
+ // time either (see [...slug].md.ts) - rather than defaulting to on,
739
+ // since it changes both the UI and the build output. Once a site does
740
+ // write a `contextMenu` key (even `{}`), copying/viewing-as-Markdown are
741
+ // always included (they need nothing but the page's own content); only
742
+ // `openIn` (the AI-assistant deep-links, which need an absolute URL to
743
+ // point the assistant at) is independently configurable.
744
+ const contextMenuSchema = z
745
+ .object({
746
+ openIn: z.array(z.enum(['chatgpt', 'claude', 'perplexity'])).default(['chatgpt', 'claude', 'perplexity']),
747
+ })
748
+ .strict();
749
+
750
+ export type ContextMenuConfig = z.infer<typeof contextMenuSchema>;
751
+
752
+ // One entry in writedocs.json's `redirects` array - wired almost directly into
753
+ // Astro's own `redirects` config option (astro.config.mjs), which is what
754
+ // actually generates the redirect pages. `permanent` is deliberately not
755
+ // part of this schema: with no server/adapter (this project always builds
756
+ // `output: 'static'` with none installed), Astro's own docs say a static
757
+ // redirect "does not support status codes" at all - it's always a client-
758
+ // side `<meta http-equiv="refresh">` page, full stop. Accepting a
759
+ // `permanent` field here that silently did nothing would be worse than
760
+ // not offering it.
761
+ const redirectSchema = z
762
+ .object({
763
+ source: z.string(),
764
+ destination: z.string(),
765
+ })
766
+ .strict();
767
+
768
+ export type RedirectConfig = z.infer<typeof redirectSchema>;
769
+
770
+ // A site-wide banner rendered above the topbar on every page (except
771
+ // `mode: blank`, which drops all site chrome including this - see
772
+ // BaseLayout.astro). Optional/undefined by default, same convention as
773
+ // `contextMenu` above - a site with no banner configured gets no banner
774
+ // markup at all, not an empty one.
775
+ const bannerSchema = z
776
+ .object({
777
+ content: z.string(),
778
+ // Persisted via localStorage once dismissed (see BaseLayout.astro's
779
+ // initBanner()) - a static site has no server-side session to track
780
+ // this against, so "dismissed" only ever means "dismissed in this
781
+ // browser". A banner without `dismissible` reappears on every visit,
782
+ // appropriate for something that should stay visible until the site
783
+ // author removes it from writedocs.json themselves (e.g. "this version is
784
+ // deprecated"), not something a reader can permanently clear.
785
+ dismissible: z.boolean().default(false),
786
+ type: z.enum(['info', 'warning', 'critical']).default('info'),
787
+ })
788
+ .strict();
789
+
790
+ export type BannerConfig = z.infer<typeof bannerSchema>;
791
+
792
+ // Content for the custom 404 page (src/pages/404.astro) - Astro's own
793
+ // file-based convention (a static build emits this as 404.html at the
794
+ // site root; most static hosts pick it up automatically for unmatched
795
+ // routes). Both fields are optional with hardcoded fallbacks in 404.astro
796
+ // itself, so a site doesn't have to set either to get a reasonably
797
+ // branded 404 page (still rendered inside the normal BaseLayout/topbar
798
+ // chrome, just with placeholder content).
799
+ const notFoundSchema = z
800
+ .object({
801
+ title: z.string().optional(),
802
+ description: z.string().optional(),
803
+ })
804
+ .strict()
805
+ .default({});
806
+
807
+ export type NotFoundConfig = z.infer<typeof notFoundSchema>;
808
+
809
+ // One <script> tag to inject - exactly one of `src` (an external/local
810
+ // file, rendered as `<script src="...">`) or `content` (inline JS,
811
+ // rendered via `<script is:inline set:html="...">`) has to be set, not
812
+ // both and not neither - enforced by the `.refine()` below rather than
813
+ // leaving both optional and silently rendering an empty tag if a site
814
+ // author gets this wrong.
815
+ const scriptEntrySchema = z
816
+ .object({
817
+ src: z.string().optional(),
818
+ content: z.string().optional(),
819
+ })
820
+ .strict()
821
+ .refine((v) => (v.src ? !v.content : !!v.content), {
822
+ message: 'Each scripts.head/scripts.body entry needs exactly one of "src" or "content", not both or neither.',
823
+ });
824
+
825
+ export type ScriptEntry = z.infer<typeof scriptEntrySchema>;
826
+
827
+ // Raw third-party script injection - analytics snippets, chat widgets,
828
+ // anything that needs a real <script> tag writedocs.json's own structured
829
+ // fields (styles, integrations-style config, ...) don't have a dedicated
830
+ // slot for yet. `head` renders right before </head>; `body` renders right
831
+ // before </body> (after everything else has already loaded/hydrated -
832
+ // see BaseLayout.astro), matching where a third-party script's own
833
+ // install instructions usually say to put it.
834
+ const scriptsSchema = z
835
+ .object({
836
+ head: z.array(scriptEntrySchema).default([]),
837
+ body: z.array(scriptEntrySchema).default([]),
838
+ })
839
+ .strict()
840
+ .default({ head: [], body: [] });
841
+
842
+ export type ScriptsConfig = z.infer<typeof scriptsSchema>;
843
+
844
+ // writedocs.json's `integrations` - a curated, high-value subset of analytics
845
+ // providers, each getting its own minimal config object (just the id(s)
846
+ // it actually needs) rather than requiring a site author to hand-write
847
+ // the provider's own install snippet through the generic `scripts` field
848
+ // above. This is deliberately its own thing, not built as sugar over
849
+ // `scripts` the way the roadmap once implied it might be - most of these
850
+ // providers' real snippets need `defer`/`async`/`data-*` attributes
851
+ // ScriptEntry's plain `{ src?, content? }` shape has no way to express,
852
+ // so BaseLayout.astro renders each provider with its own bespoke markup
853
+ // instead of generating a ScriptEntry array to feed through the generic
854
+ // renderer. Every snippet shape below was sourced from that provider's
855
+ // own current official docs (fetched directly, not recalled from
856
+ // training data) while building this feature - see
857
+ // docs/dev/docs/integrations.mdx for links and dates.
858
+ //
859
+ // Every provider field is optional and independent - a site can turn on
860
+ // any subset (including all of them at once, for a migration period) or
861
+ // none. No "the" analytics field; a site picks whichever provider(s) it
862
+ // actually uses.
863
+
864
+ const ga4IntegrationSchema = z
865
+ .object({
866
+ // A GA4 "Measurement ID", always shaped like "G-XXXXXXXXXX" - not
867
+ // validated against that shape here, since Google could change the
868
+ // prefix/length without this schema needing to track it.
869
+ measurementId: z.string(),
870
+ })
871
+ .strict();
872
+
873
+ const googleTagManagerIntegrationSchema = z
874
+ .object({
875
+ // A GTM container id, shaped like "GTM-XXXXXXX".
876
+ containerId: z.string(),
877
+ })
878
+ .strict();
879
+
880
+ const plausibleIntegrationSchema = z
881
+ .object({
882
+ domain: z.string(),
883
+ // Plausible's own hosted script URL by default - override for a
884
+ // self-hosted instance, a reverse-proxy path (Plausible's own
885
+ // documented way to dodge ad-blockers), or one of Plausible's
886
+ // documented script *extensions* (e.g.
887
+ // "https://plausible.io/js/script.hash.outbound-links.js").
888
+ src: z.string().default('https://plausible.io/js/script.js'),
889
+ })
890
+ .strict();
891
+
892
+ const fathomIntegrationSchema = z
893
+ .object({
894
+ // Fathom's own short "Site ID", not a full URL or measurement id.
895
+ siteId: z.string(),
896
+ })
897
+ .strict();
898
+
899
+ const posthogIntegrationSchema = z
900
+ .object({
901
+ // PostHog calls this a "project API key" (starts with "phc_") in its
902
+ // own docs - `apiKey` here rather than that exact name, matching this
903
+ // schema's own plainer naming convention for the equivalent id on
904
+ // every other provider above.
905
+ apiKey: z.string(),
906
+ // PostHog is region-sharded (US/EU) with no single universal default
907
+ // - "https://us.i.posthog.com" is PostHog's own default for a new
908
+ // project, but a site on their EU cloud (or a self-hosted instance)
909
+ // must override this to the host shown in their own project
910
+ // settings, or events silently go nowhere.
911
+ apiHost: z.string().default('https://us.i.posthog.com'),
912
+ })
913
+ .strict();
914
+
915
+ const umamiIntegrationSchema = z
916
+ .object({
917
+ websiteId: z.string(),
918
+ // Unlike Plausible/PostHog, Umami has no single hosted default that
919
+ // works for most users - it's commonly self-hosted, and even Umami
920
+ // Cloud users get a per-region script URL. Defaults to Umami Cloud's
921
+ // own documented script URL, which only happens to be correct for a
922
+ // Cloud user on that specific region; anyone self-hosting (the more
923
+ // common case for this particular provider) must override it to
924
+ // their own instance's own /script.js path.
925
+ src: z.string().default('https://cloud.umami.is/script.js'),
926
+ })
927
+ .strict();
928
+
929
+ // AI chat over the site's own docs content, via a third-party provider
930
+ // (DocsBot, currently the only one wired up) rather than a self-hosted
931
+ // RAG pipeline - see the roadmap's own "AI chat over docs content" line
932
+ // in docs/dev/docs/roadmap.mdx, which this fulfills via integration
933
+ // rather than by building the retrieval/generation stack in-house.
934
+ // Deliberately named `askAi` here, not `docsbot` - this is the
935
+ // writedocs.json-facing, provider-neutral name for the feature (matching
936
+ // the "Ask AI" label this kind of button/widget is conventionally
937
+ // given in docs sites generally), independent of which vendor happens
938
+ // to sit behind it today. `id` matches DocsBot's own embed config
939
+ // field name exactly (`DocsBotAI.init({ id: 'teamId/botId' })`) - it's
940
+ // a single opaque "teamId/botId" string DocsBot treats as one value,
941
+ // not two separate ids, so this schema doesn't split it either.
942
+ const askAiIntegrationSchema = z
943
+ .object({
944
+ id: z.string(),
945
+ })
946
+ .strict();
947
+
948
+ const integrationsSchema = z
949
+ .object({
950
+ ga4: ga4IntegrationSchema.optional(),
951
+ googleTagManager: googleTagManagerIntegrationSchema.optional(),
952
+ plausible: plausibleIntegrationSchema.optional(),
953
+ fathom: fathomIntegrationSchema.optional(),
954
+ posthog: posthogIntegrationSchema.optional(),
955
+ umami: umamiIntegrationSchema.optional(),
956
+ askAi: askAiIntegrationSchema.optional(),
957
+ })
958
+ .strict()
959
+ .default({});
960
+
961
+ export type IntegrationsConfig = z.infer<typeof integrationsSchema>;
962
+
963
+ export const docsConfigSchema = z.object({
964
+ name: z.string(),
965
+ description: z.string().optional(),
966
+ styles: stylesSchema,
967
+ navigation: navigationSchema,
968
+ // Platform name -> profile/page URL (e.g. `{ "github": "https://
969
+ // github.com/...", "twitter": "https://twitter.com/..." }`), free-form
970
+ // rather than an enum of known platforms - the key doubles as the icon
971
+ // reference BaseLayout.astro's footer resolves via resolveIcon() below
972
+ // (bare `"github"` -> `lucide:github`; use an explicit
973
+ // `"simple-icons:whatever"` key instead when the bare lucide name isn't
974
+ // right for a given platform). Rendered as a row of icon links in the
975
+ // footer - see hasFooterContent/`.wd-footer-socials` in BaseLayout.astro.
976
+ socials: z.record(z.string(), z.string()).default({}),
977
+ topbar: z
978
+ .object({ links: z.array(topbarLinkSchema).default([]) })
979
+ .default({ links: [] }),
980
+ // Columns of links below the page content - see footerSchema/
981
+ // footerColumnSchema above. Renders alongside `socials` above (as a row
982
+ // of icon links) in the same <footer> - see BaseLayout.astro.
983
+ footer: footerSchema,
984
+ api: apiSchema,
985
+ // The site's own deployed domain, e.g. "docs.example.com" or
986
+ // "https://docs.example.com" (the scheme is optional - resolveSiteUrl()
987
+ // below normalizes either form to a full https:// origin). Powers three
988
+ // things that all need an absolute origin to work: sitemap.xml
989
+ // generation (astro.config.mjs only registers @astrojs/sitemap when this
990
+ // is set - a sitemap of relative URLs isn't meaningful), the canonical
991
+ // <link> and og:url/twitter meta tags (BaseLayout.astro), and resolving
992
+ // a relative `seo.ogImage` into an absolute URL for social crawlers.
993
+ // Left optional and simply skipped (with a log message, not an error) at
994
+ // every one of those call sites when unset - most fixtures/local builds
995
+ // have no real deployed domain yet.
996
+ domain: z.string().optional(),
997
+ // Site-wide meta tag defaults - see seoFieldsSchema above. A page's own
998
+ // frontmatter `seo` (content.config.ts's docsSchema) overrides these
999
+ // field by field via mergeSeo(), rather than needing to repeat every
1000
+ // field on every page.
1001
+ seo: seoFieldsSchema.default({}),
1002
+ // The "Copy page" dropdown - see contextMenuSchema above. Absent by
1003
+ // default (no menu, no .md routes).
1004
+ contextMenu: contextMenuSchema.optional(),
1005
+ // See redirectSchema above. Wired directly into Astro's own `redirects`
1006
+ // config option in astro.config.mjs.
1007
+ redirects: z.array(redirectSchema).default([]),
1008
+ // Site-wide `[[key]]` substitution values, applied to page prose text
1009
+ // at build time (see the remarkSubstituteVariables plugin wired into
1010
+ // astro.config.mjs) - e.g. `{ "productName": "Acme" }` lets every page
1011
+ // write `[[productName]]` once instead of hardcoding it everywhere.
1012
+ // Double square brackets, not the more familiar `{{key}}` - MDX
1013
+ // reserves single curly braces for embedded JS expressions, so
1014
+ // `{{productName}}` in an .mdx file parses as a JS object-literal
1015
+ // expression (`{productName}`, shorthand syntax) rather than literal
1016
+ // text, and throws `ReferenceError: productName is not defined` at
1017
+ // render time - see remarkSubstituteVariables' own comment in
1018
+ // mdx-substitute-variables.js for the full explanation. Deliberately a
1019
+ // flat string map, not typed values - this is text substitution into
1020
+ // prose, not a templating language.
1021
+ variables: z.record(z.string(), z.string()).default({}),
1022
+ // See bannerSchema above. Absent by default (no banner rendered).
1023
+ banner: bannerSchema.optional(),
1024
+ // See notFoundSchema above.
1025
+ notFound: notFoundSchema,
1026
+ // See scriptsSchema above.
1027
+ scripts: scriptsSchema,
1028
+ // See integrationsSchema above.
1029
+ integrations: integrationsSchema,
1030
+ });
1031
+
1032
+ export type DocsConfig = z.infer<typeof docsConfigSchema>;
1033
+
1034
+ // ---------------------------------------------------------------------
1035
+ // Ponto de entrada unico de validacao
1036
+ //
1037
+ // Existe pra que o gerador (`writedocs validate` e o `loadDocsConfig` que o
1038
+ // build usa) e a plataforma validem pelo MESMO codigo, com as MESMAS palavras -
1039
+ // o criterio de aceite do WD-066 e do WD-062. Se cada lado formatasse os erros
1040
+ // por conta propria, o cliente veria a plataforma aceitar um writedocs.json que
1041
+ // o build depois recusa, que e exatamente a classe de bug que a E11 existe pra
1042
+ // impedir.
1043
+ //
1044
+ // Recebe TEXTO CRU, e faz o JSON.parse por conta propria, por tres motivos:
1045
+ // 1. um JSON sintaticamente quebrado passa a ser um resultado de validacao
1046
+ // como qualquer outro, em vez de uma excecao solta do runtime;
1047
+ // 2. linha/coluna so existem no texto - e o WD-063 precisa reportar linha;
1048
+ // 3. do lado da plataforma, importProjectConfig ja tem os bytes crus em maos
1049
+ // antes de parsear, entao encaixa sem ginastica nenhuma.
1050
+ //
1051
+ // IMPORTANTE, e deliberado: as mensagens aqui sao as do Zod, VERBATIM. Melhorar
1052
+ // a linguagem delas e o WD-063, que precisa deste comportamento como ponto de
1053
+ // partida pra medir a melhora. Nada aqui reescreve, traduz ou embeleza mensagem.
1054
+ // ---------------------------------------------------------------------
1055
+
1056
+ /** Um problema encontrado na configuracao. `path` e o caminho do campo em
1057
+ * notacao de ponto (`navigation.tabs.0.label`), ou `(root)` quando o problema e
1058
+ * do documento inteiro - o mesmo texto que a mensagem de erro do build ja
1059
+ * imprime hoje. `line`/`column` so vem quando da pra saber (hoje: erro de JSON;
1060
+ * no WD-063, tambem para erros de schema). */
1061
+ export type ValidationIssue = {
1062
+ path: string;
1063
+ message: string;
1064
+ /** Codigo do Zod (`invalid_type`, `unrecognized_keys`, ...) ou `invalid_json`. */
1065
+ code?: string;
1066
+ line?: number;
1067
+ column?: number;
1068
+ /** WD-063: a mesma coisa que `message`, dita para gente. ADICIONAL, nunca
1069
+ * substituto - `message` continua sendo o texto do Zod verbatim, que e o que
1070
+ * o `writedocs build` imprime e o que o WD-062 grava em `validation_issues`.
1071
+ * Quem exibe escolhe qual dos dois mostrar. */
1072
+ humanMessage?: string;
1073
+ /** O que fazer a respeito, quando da pra dizer algo concreto. */
1074
+ suggestion?: string;
1075
+ /** Chave ESTAVEL de documentacao (`config.styles`), nunca uma URL: uma URL
1076
+ * cravada num pacote npm fica quebrada em toda instalacao ate a proxima
1077
+ * release, e o mesmo erro e exibido pela CLI e pelo dashboard, que podem
1078
+ * querer destinos diferentes. O mapa chave -> URL mora do lado de quem
1079
+ * renderiza. */
1080
+ docKey?: string;
1081
+ };
1082
+
1083
+ /** Sucesso carrega os dados ja validados (com os defaults do schema aplicados);
1084
+ * falha carrega os problemas e diz de que tipo ela foi. `parseError` so aparece
1085
+ * quando o JSON nao pode nem ser parseado, e existe para o chamador que quiser
1086
+ * relancar exatamente o erro original (e o que o loadDocsConfig faz, pra nao
1087
+ * mudar uma virgula do que o `writedocs build` ja imprime hoje). */
1088
+ export type ValidationResult =
1089
+ | { ok: true; data: DocsConfig; issues: ValidationIssue[] }
1090
+ | {
1091
+ ok: false;
1092
+ data: null;
1093
+ kind: 'invalid_json' | 'schema';
1094
+ issues: ValidationIssue[];
1095
+ parseError?: SyntaxError;
1096
+ };
1097
+
1098
+ /** "at position 22 (line 1 column 23)" -> { line: 1, column: 23 }.
1099
+ * O formato da mensagem de erro do JSON.parse e do V8, nao do padrao, entao
1100
+ * isto e melhor-esforco: se a mensagem nao trouxer posicao, devolve null e o
1101
+ * problema simplesmente fica sem linha. */
1102
+ function positionFromJsonError(message: string, rawText: string): { line: number; column: number } | null {
1103
+ const comLinha = message.match(/line (\d+) column (\d+)/);
1104
+ if (comLinha) return { line: Number(comLinha[1]), column: Number(comLinha[2]) };
1105
+ const comPosicao = message.match(/position (\d+)/);
1106
+ if (comPosicao) {
1107
+ const pos = Math.min(Number(comPosicao[1]), rawText.length);
1108
+ const antes = rawText.slice(0, pos);
1109
+ const line = antes.split('\n').length;
1110
+ return { line, column: pos - antes.lastIndexOf('\n') };
1111
+ }
1112
+ return null;
1113
+ }
1114
+
1115
+ /** O corpo da mensagem de erro, uma linha por problema - exatamente o formato
1116
+ * que o `writedocs build` imprime desde sempre (` - campo: mensagem`). Fica
1117
+ * aqui, e nao em cada chamador, pra que CLI e plataforma nao possam divergir.
1118
+ *
1119
+ * NAO mudou no WD-063, de proposito: continua imprimindo `message` (o texto do
1120
+ * Zod). O que melhorou foi o CONTEUDO dos issues - a uniao desembrulhada faz o
1121
+ * `path` apontar pro campo real em vez de parar em `navigation`. Quem quiser a
1122
+ * versao com frase humana e linha usa formatValidationIssuesDetailed. */
1123
+ export function formatValidationIssues(issues: ValidationIssue[]): string {
1124
+ return issues.map((i) => ` - ${i.path}: ${i.message}`).join('\n');
1125
+ }
1126
+
1127
+ /** A versao longa, para quem tem uma tela inteira (a CLI hoje, o dashboard do
1128
+ * WD-065 se quiser): caminho, linha, frase humana, sugestao, e a mensagem do
1129
+ * Zod por ultimo, entre parenteses, pra quem precisa do texto exato.
1130
+ *
1131
+ * Mora aqui pelo mesmo motivo que a irma curta: se cada consumidor montasse a
1132
+ * sua, a CLI e a plataforma divergiriam na primeira mudanca - que e exatamente
1133
+ * o que o AC do WD-066 proibe. `fileName` so entra na referencia de linha. */
1134
+ export function formatValidationIssuesDetailed(
1135
+ issues: ValidationIssue[],
1136
+ { fileName = 'writedocs.json' }: { fileName?: string } = {}
1137
+ ): string {
1138
+ return issues
1139
+ .map((i) => {
1140
+ const temCampo = i.path !== '(root)';
1141
+ const local = i.line ? `${fileName}:${i.line}` : i.path;
1142
+ const linhas = [` ${local}${i.line && temCampo ? ` (${i.path})` : ''}`];
1143
+ linhas.push(` ${i.humanMessage ?? i.message}`);
1144
+ if (i.suggestion) linhas.push(` ${i.suggestion}`);
1145
+ if (i.humanMessage && i.message !== i.humanMessage) linhas.push(` (${i.message})`);
1146
+ return linhas.join('\n');
1147
+ })
1148
+ .join('\n\n');
1149
+ }
1150
+
1151
+ // ---------------------------------------------------------------------
1152
+ // WD-063 — do issue do Zod para "onde esta o erro, e o que fazer"
1153
+ //
1154
+ // Tres transformacoes, nesta ordem:
1155
+ // 1. desembrulhar `invalid_union` (senao todo erro dentro da navigation vira
1156
+ // `navigation: "Invalid input"`, que e inutil num arquivo de 10 KB);
1157
+ // 2. achar a linha no texto, pelo caminho ja desembrulhado;
1158
+ // 3. escrever a frase humana, a sugestao e a docKey - POR CODIGO do Zod, nao
1159
+ // por campo: um mapa por campo teria 17 entradas so na raiz e centenas no
1160
+ // total, e envelheceria a cada campo novo do schema.
1161
+ // ---------------------------------------------------------------------
1162
+
1163
+ /** Chaves que a raiz aceita sem ser campo do schema, e que por isso NAO devem
1164
+ * gerar aviso de chave desconhecida. Deliberadamente minuscula: a raiz nao e
1165
+ * `.strict()` justamente pra nao quebrar configs existentes, e `$schema` foi um
1166
+ * dos argumentos dessa decisao - avisar sobre ele contradiria o que o protegeu.
1167
+ * Se esta lista passar de dois ou tres nomes, o problema e outro e a raiz
1168
+ * precisa de outra conversa. */
1169
+ export const ROOT_ALLOWED_EXTRA_KEYS: readonly string[] = Object.freeze(['$schema']);
1170
+
1171
+ type IssueCru = {
1172
+ code?: string;
1173
+ path?: (string | number)[];
1174
+ message: string;
1175
+ expected?: string;
1176
+ values?: unknown[];
1177
+ keys?: string[];
1178
+ minimum?: number;
1179
+ maximum?: number;
1180
+ origin?: string;
1181
+ format?: string;
1182
+ errors?: unknown[];
1183
+ unionErrors?: unknown[];
1184
+ };
1185
+
1186
+ type IssuePlano = IssueCru & { caminho: (string | number)[] };
1187
+
1188
+ function issuesDoRamo(ramo: unknown): IssueCru[] {
1189
+ if (Array.isArray(ramo)) return ramo as IssueCru[];
1190
+ const comIssues = ramo as { issues?: IssueCru[] };
1191
+ return comIssues?.issues ?? [];
1192
+ }
1193
+
1194
+ /** O maior valor de `f` sobre a lista, ou 0 se ela for vazia. Um `for` e nao
1195
+ * `Math.max(0, ...lista.map(f))`: o spread vira argumentos de chamada, e uma
1196
+ * lista grande o bastante (config com dezenas de milhares de erros) estoura o
1197
+ * limite de argumentos com RangeError em vez de devolver um numero. */
1198
+ function maiorProfundidade(lista: IssuePlano[]): number {
1199
+ let maior = 0;
1200
+ for (const issue of lista) {
1201
+ const p = issue.caminho.length + (issue.code === 'unrecognized_keys' ? 1 : 0);
1202
+ if (p > maior) maior = p;
1203
+ }
1204
+ return maior;
1205
+ }
1206
+
1207
+ /** Plano ACHATADO de um ramo, memoizado por identidade do ramo.
1208
+ *
1209
+ * A memoizacao nao e otimizacao, e o que torna o achatamento viavel. Pontuar um
1210
+ * ramo exige desembrulhar as unioes DENTRO dele, e cada uma dessas tem os seus
1211
+ * proprios ramos - sem cache, a pontuacao visita o mesmo sub-ramo uma vez por
1212
+ * caminho que leva ate ele, o que e exponencial na profundidade do aninhamento.
1213
+ * Medido com grupos aninhados e um erro no fundo: 3,7 ms com 4 niveis, 281 ms
1214
+ * com 8, 69 SEGUNDOS com 12. Com o cache, os mesmos casos ficam em 0,2 ms,
1215
+ * porque cada no da arvore de erro do Zod e achatado uma vez so.
1216
+ *
1217
+ * Se alguem remover este WeakMap por parecer supérfluo, o `writedocs validate`
1218
+ * passa a travar em configs profundas. Nao remova. */
1219
+ const planoPorRamo = new WeakMap<object, IssuePlano[]>();
1220
+
1221
+ function planoDoRamo(ramo: unknown): IssuePlano[] {
1222
+ const lista = issuesDoRamo(ramo);
1223
+ if (typeof ramo !== 'object' || ramo === null) return desembrulharUnioes(lista);
1224
+ const memoizado = planoPorRamo.get(ramo);
1225
+ if (memoizado) return memoizado;
1226
+ // Prefixo vazio de proposito: a pontuacao compara ramos entre si, entao a
1227
+ // profundidade tem que ser relativa ao proprio ramo, nao ao documento.
1228
+ const plano = desembrulharUnioes(lista);
1229
+ planoPorRamo.set(ramo, plano);
1230
+ return plano;
1231
+ }
1232
+
1233
+ /** Escolhe qual ramo de uma uniao o cliente PROVAVELMENTE quis.
1234
+ *
1235
+ * Criterio: caminho mais fundo primeiro; empate resolvido por menos issues. A
1236
+ * intuicao e a do Gabriel na spec - o ramo certo e o que nao reclama da chave
1237
+ * que o cliente usou -, e a profundidade importa porque um ramo que chegou
1238
+ * fundo antes de falhar (`products.0.product`) reconheceu a forma, enquanto um
1239
+ * que falha na raiz (`expected array, received object`) so recusou o shape.
1240
+ *
1241
+ * DUAS CORRECOES sobre a primeira versao (WD-063), as duas medidas contra o
1242
+ * `00-kitchen-sink` real - ver o relato de revisao 43:
1243
+ *
1244
+ * 1. PONTUA O RAMO ACHATADO, nao a lista crua. Um ramo que e ele proprio uma
1245
+ * uniao aparece como UM issue (`invalid_union`) no caminho vazio - ou seja,
1246
+ * com a MENOR contagem e a MENOR profundidade possiveis, sem que isso diga
1247
+ * nada sobre o quanto ele reconheceu. Era o caso mais comum que existe: um
1248
+ * item de `pages` e `string | grupo | link`, e o grupo e outra uniao, entao
1249
+ * todo erro estrutural dentro de um grupo empatava com o ramo `string` e
1250
+ * perdia pela ordem de declaracao. O cliente que escrevia
1251
+ * `{ "group": "G", "pages": 42 }` recebia "expected string, received
1252
+ * object" - conselho errado, nao so inutil. Achatando primeiro, o mesmo caso
1253
+ * vira `pages.0.pages: expected array, received number`.
1254
+ *
1255
+ * 2. PROFUNDIDADE EFETIVA: `unrecognized_keys` reporta no caminho do PAI, com a
1256
+ * chave ofensora em `keys`, entao a profundidade dele sai subestimada em 1.
1257
+ * Sem a correcao, um ramo que identificou a chave errada perde para um que
1258
+ * so exigiu um campo ausente. (`validateDocsConfig` ja fazia a mesma conta
1259
+ * para achar a LINHA; aqui ela entra tambem na pontuacao.)
1260
+ *
1261
+ * Medido sobre as 60 folhas da `navigation` do `00-kitchen-sink`, trocando o
1262
+ * tipo de cada uma: a versao anterior apontava o campo exato em 42/60 e PARAVA
1263
+ * RASO em 18/60 (sempre os mesmos 18: os que ficam sob um `pages`); esta aponta
1264
+ * 60/60 para valor escalar errado e nunca para raso. Para um objeto vazio ou
1265
+ * parcial no lugar de uma pagina, as duas ficam tecnicamente empatadas (41
1266
+ * contra 42): a anterior diz "expected string", esta lista os campos que
1267
+ * faltam para ser um grupo. E o custo aceito desta versao, e substitui o antigo
1268
+ * (`tabs` e `products` escritos juntos, que agora sai como
1269
+ * `Unrecognized key: "products"`).
1270
+ *
1271
+ * EMPATE TOTAL (mesma profundidade E mesmo numero de issues): vence o ramo
1272
+ * declarado primeiro no schema. `Array.prototype.sort` e estavel desde a
1273
+ * ES2019, entao ordenar e pegar o [0] ja entrega isso - se alguem trocar por
1274
+ * uma ordenacao instavel, o desempate vira sorteio. Duas razoes para "o
1275
+ * primeiro" em vez de "todos os empatados":
1276
+ * - um engano do cliente tem que virar UMA mensagem. Emitir os 5 ramos
1277
+ * empatados de um `"navigation": {}` produz cinco exigencias que se
1278
+ * contradizem ("tabs e obrigatorio", "versions e obrigatorio", ...);
1279
+ * - determinismo: a mesma config sempre da a mesma mensagem. Um palpite
1280
+ * instavel seria pior que um palpite ruim.
1281
+ * E nada se perde no palpite errado: `message` continua sendo o texto do Zod. */
1282
+ function melhorRamo(ramos: unknown[]): IssueCru[] {
1283
+ return ramos
1284
+ .map((ramo) => {
1285
+ const plano = planoDoRamo(ramo);
1286
+ return {
1287
+ lista: issuesDoRamo(ramo),
1288
+ n: plano.length,
1289
+ profundidade: maiorProfundidade(plano),
1290
+ };
1291
+ })
1292
+ .sort((a, b) => b.profundidade - a.profundidade || a.n - b.n)[0].lista;
1293
+ }
1294
+
1295
+ /** Desembrulha `invalid_union` recursivamente - o ramo escolhido normalmente e
1296
+ * outra uniao (cada produto da navigation e, por sua vez, uma uniao de formas),
1297
+ * entao para so quando chega num erro concreto. Sem recursao, o
1298
+ * `navigation.products.0` para em "Invalid input" de novo, um nivel abaixo. */
1299
+ function desembrulharUnioes(issues: IssueCru[], prefixo: (string | number)[] = []): IssuePlano[] {
1300
+ const saida: IssuePlano[] = [];
1301
+ for (const issue of issues) {
1302
+ const caminho = [...prefixo, ...(issue.path ?? [])];
1303
+ const ramos = issue.code === 'invalid_union' ? issue.errors ?? issue.unionErrors : null;
1304
+ if (Array.isArray(ramos) && ramos.length > 0) {
1305
+ saida.push(...desembrulharUnioes(melhorRamo(ramos), caminho));
1306
+ } else {
1307
+ saida.push({ ...issue, caminho });
1308
+ }
1309
+ }
1310
+ return saida;
1311
+ }
1312
+
1313
+ /** Exported for `writedocs validate`'s content pass (lib/content-check.js),
1314
+ * which reports writedocs.json problems of its own - navigation entries
1315
+ * with no page, unknown icons - with the same line numbers as the schema
1316
+ * errors. Same function as criarLocalizador below. */
1317
+ export function createJsonLocator(rawText: string): (path: (string | number)[]) => { line: number; column: number } | null {
1318
+ return criarLocalizador(rawText);
1319
+ }
1320
+
1321
+ /** Parseia o texto UMA vez e devolve "caminho -> linha/coluna". Devolve null
1322
+ * quando o no nao existe no texto (campo obrigatorio ausente e o caso comum):
1323
+ * `line` ausente e melhor que `line` inventada. */
1324
+ function criarLocalizador(rawText: string): (caminho: (string | number)[]) => { line: number; column: number } | null {
1325
+ let arvore: ReturnType<typeof parseTree> | undefined;
1326
+ try {
1327
+ arvore = parseTree(rawText);
1328
+ } catch {
1329
+ arvore = undefined;
1330
+ }
1331
+ return (caminho) => {
1332
+ if (!arvore || caminho.length === 0) return null;
1333
+ const no = findNodeAtLocation(arvore, caminho);
1334
+ if (!no) return null;
1335
+ const antes = rawText.slice(0, no.offset);
1336
+ return { line: antes.split('\n').length, column: no.offset - antes.lastIndexOf('\n') };
1337
+ };
1338
+ }
1339
+
1340
+ /** `styles.primaryColor` -> `config.styles`. Primeiro segmento so, com fallback
1341
+ * pra `config`: o conjunto tem que ficar pequeno e estavel, porque vira
1342
+ * superficie publica no instante em que alguem mapear chave -> URL. */
1343
+ function docKeyDoCaminho(caminho: (string | number)[]): string {
1344
+ const primeiro = caminho[0];
1345
+ return typeof primeiro === 'string' && primeiro ? `config.${primeiro}` : 'config';
1346
+ }
1347
+
1348
+ const ARTIGO_POR_TIPO: Record<string, string> = {
1349
+ string: 'a piece of text',
1350
+ number: 'a number',
1351
+ boolean: 'true or false',
1352
+ array: 'a list',
1353
+ object: 'an object',
1354
+ null: 'null',
1355
+ };
1356
+
1357
+ const COMO_ESCREVER: Record<string, string> = {
1358
+ string: 'Write the value in double quotes, like "Core Platform".',
1359
+ number: 'Write the value as a bare number, like 3 - no quotes.',
1360
+ boolean: 'Write true or false - no quotes.',
1361
+ array: 'Write the value as a list in square brackets: [ ... ].',
1362
+ object: 'Write the value as an object in curly braces: { ... }.',
1363
+ };
1364
+
1365
+ /** O tipo que o cliente REALMENTE escreveu, lido do JSON ja parseado em vez de
1366
+ * extraido da mensagem do Zod por regex - o Zod v4 nao carrega `received` como
1367
+ * propriedade, so dentro do texto, e depender do texto quebra na primeira vez
1368
+ * que ele mudar de forma. */
1369
+ function tipoReal(valor: unknown): string {
1370
+ if (valor === undefined) return 'missing';
1371
+ if (valor === null) return 'null';
1372
+ if (Array.isArray(valor)) return 'array';
1373
+ return typeof valor;
1374
+ }
1375
+
1376
+ function valorEm(raiz: unknown, caminho: (string | number)[]): unknown {
1377
+ let atual: unknown = raiz;
1378
+ for (const passo of caminho) {
1379
+ if (atual === null || typeof atual !== 'object') return undefined;
1380
+ atual = (atual as Record<string | number, unknown>)[passo];
1381
+ }
1382
+ return atual;
1383
+ }
1384
+
1385
+ function comoCampo(caminho: (string | number)[]): string {
1386
+ return caminho.length > 0 ? `"${caminho.join('.')}"` : 'writedocs.json';
1387
+ }
1388
+
1389
+ /** A frase de chave desconhecida, num lugar so.
1390
+ *
1391
+ * Decisao 6: chave desconhecida em sub-objeto strict e ERRO e na raiz e AVISO -
1392
+ * a assimetria de SEVERIDADE e intencional e fica. A de VOCABULARIO nao: os
1393
+ * dois caminhos chamam esta funcao, entao os dois dizem exatamente a mesma
1394
+ * coisa, e so quem chama decide o peso. */
1395
+ function fraseChaveDesconhecida(chaves: string[], dentroDe: (string | number)[]) {
1396
+ const onde = dentroDe.length > 0 ? ` inside "${dentroDe.join('.')}"` : '';
1397
+ const lista = chaves.map((k) => `"${k}"`).join(', ');
1398
+ return {
1399
+ humanMessage:
1400
+ chaves.length === 1
1401
+ ? `${lista} is not a writedocs.json option${onde}.`
1402
+ : `${lista} are not writedocs.json options${onde}.`,
1403
+ suggestion: 'Remove it, or check the spelling - options are case-sensitive.',
1404
+ };
1405
+ }
1406
+
1407
+ /** A frase humana + a sugestao, escolhidas pelo CODIGO do Zod. `raiz` e o JSON
1408
+ * ja parseado, usado so pra saber o que o cliente escreveu de fato. */
1409
+ function humanizar(issue: IssuePlano, raiz: unknown): { humanMessage?: string; suggestion?: string } {
1410
+ const campo = comoCampo(issue.caminho);
1411
+ switch (issue.code) {
1412
+ case 'invalid_type': {
1413
+ const esperado = issue.expected ?? 'a different type';
1414
+ const recebido = tipoReal(valorEm(raiz, issue.caminho));
1415
+ if (recebido === 'missing') {
1416
+ return {
1417
+ humanMessage: `${campo} is required, but writedocs.json does not set it.`,
1418
+ suggestion: `Add ${campo} with ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`} as its value.`,
1419
+ };
1420
+ }
1421
+ return {
1422
+ humanMessage: `${campo} must be ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`}, but writedocs.json has ${ARTIGO_POR_TIPO[recebido] ?? `a ${recebido}`}.`,
1423
+ suggestion: COMO_ESCREVER[esperado],
1424
+ };
1425
+ }
1426
+ case 'unrecognized_keys': {
1427
+ // O Zod agrega as chaves sobrando numa mensagem so, com o `path` no PAI.
1428
+ const chaves = issue.keys ?? [];
1429
+ if (chaves.length === 0) return {};
1430
+ return fraseChaveDesconhecida(chaves, issue.caminho);
1431
+ }
1432
+ case 'invalid_value': {
1433
+ const opcoes = (issue.values ?? []).map((v) => JSON.stringify(v)).join(', ');
1434
+ return {
1435
+ humanMessage: `${campo} must be one of: ${opcoes}.`,
1436
+ suggestion: 'Replace the value with one of the options above.',
1437
+ };
1438
+ }
1439
+ case 'too_small': {
1440
+ const unidade = issue.origin === 'array' ? 'item' : 'character';
1441
+ const minimo = issue.minimum ?? 1;
1442
+ return {
1443
+ humanMessage: `${campo} needs at least ${minimo} ${unidade}${minimo === 1 ? '' : 's'}.`,
1444
+ suggestion: issue.origin === 'array' ? 'Add an entry, or remove the field entirely.' : undefined,
1445
+ };
1446
+ }
1447
+ case 'too_big': {
1448
+ const unidade = issue.origin === 'array' ? 'item' : 'character';
1449
+ const maximo = issue.maximum ?? 0;
1450
+ return { humanMessage: `${campo} allows at most ${maximo} ${unidade}${maximo === 1 ? '' : 's'}.` };
1451
+ }
1452
+ case 'invalid_format':
1453
+ return {
1454
+ humanMessage: `${campo} is not a valid ${issue.format ?? 'value'}.`,
1455
+ suggestion: 'Check the format against the documentation for this field.',
1456
+ };
1457
+ case 'invalid_union':
1458
+ // So chega aqui se o desembrulho nao achou ramo nenhum (uniao sem
1459
+ // `errors`). Raro, mas melhor dizer o que aconteceu do que "Invalid input".
1460
+ return {
1461
+ humanMessage: `${campo} does not match any of the shapes writedocs.json accepts here.`,
1462
+ suggestion: 'Check the documentation for the shapes this field accepts.',
1463
+ };
1464
+ case 'custom':
1465
+ // As mensagens de `.refine()` deste schema ja sao frases escritas pra
1466
+ // gente ("Each scripts.head/scripts.body entry needs exactly one of..."),
1467
+ // entao reescrever seria piorar. Repassa como frase humana pra que quem
1468
+ // exibe so `humanMessage` nao fique sem nada.
1469
+ return { humanMessage: issue.message };
1470
+ default:
1471
+ return {};
1472
+ }
1473
+ }
1474
+
1475
+ /** Avisos de chave desconhecida na RAIZ.
1476
+ *
1477
+ * A raiz nao e `.strict()` (decidido no E11-desenho-geral: tornar strict
1478
+ * quebraria configs existentes), entao o Zod descarta a chave em silencio e
1479
+ * nao ha issue nenhum. Quem quiser avisar chama isto. Mora aqui, e nao na
1480
+ * plataforma, pelo motivo de sempre: era o unico texto escrito pela plataforma,
1481
+ * e enquanto ele viver la os dois lados podem divergir.
1482
+ *
1483
+ * Severidade e de quem chama - isto so descreve o problema. `validateDocsConfig`
1484
+ * deliberadamente NAO chama: um config valido continua saindo com `issues: []`,
1485
+ * porque a plataforma repassa `issues` como `errors` e um aviso ali viraria
1486
+ * erro. */
1487
+ export function unknownRootKeyIssues(rawText: string): ValidationIssue[] {
1488
+ let raiz: unknown;
1489
+ try {
1490
+ raiz = JSON.parse(rawText);
1491
+ } catch {
1492
+ return [];
1493
+ }
1494
+ if (raiz === null || typeof raiz !== 'object' || Array.isArray(raiz)) return [];
1495
+ const conhecidas = new Set([...Object.keys(docsConfigSchema.shape), ...ROOT_ALLOWED_EXTRA_KEYS]);
1496
+ const localizar = criarLocalizador(rawText);
1497
+ return Object.keys(raiz as Record<string, unknown>)
1498
+ .filter((chave) => !conhecidas.has(chave))
1499
+ .map((chave) => {
1500
+ const frase = fraseChaveDesconhecida([chave], []);
1501
+ return {
1502
+ path: chave,
1503
+ // O mesmo formato singular da mensagem `unrecognized_keys` do Zod, pra
1504
+ // que aviso e erro sejam indistinguiveis por quem so le `message`.
1505
+ message: `Unrecognized key: "${chave}"`,
1506
+ code: 'unrecognized_keys',
1507
+ ...(localizar([chave]) ?? {}),
1508
+ ...frase,
1509
+ // `config`, e NAO `config.<chave>`: a chave aqui e o que o cliente
1510
+ // digitou errado, e derivar a docKey dela faria cada erro de digitacao
1511
+ // inventar uma chave nova. O conjunto de docKeys tem que ser fechado -
1512
+ // `config` mais um por campo do schema, nada alem disso -, senao quem
1513
+ // mapeia chave -> URL do outro lado nao tem lista pra mapear.
1514
+ docKey: 'config',
1515
+ };
1516
+ });
1517
+ }
1518
+
1519
+ export function validateDocsConfig(rawText: string): ValidationResult {
1520
+ let raw: unknown;
1521
+ try {
1522
+ raw = JSON.parse(rawText);
1523
+ } catch (err) {
1524
+ const parseError = err as SyntaxError;
1525
+ const posicao = positionFromJsonError(parseError.message, rawText);
1526
+ return {
1527
+ ok: false,
1528
+ data: null,
1529
+ kind: 'invalid_json',
1530
+ parseError,
1531
+ issues: [
1532
+ {
1533
+ path: '(root)',
1534
+ message: parseError.message,
1535
+ code: 'invalid_json',
1536
+ ...(posicao ?? {}),
1537
+ humanMessage: 'writedocs.json is not valid JSON, so none of it could be checked.',
1538
+ suggestion: posicao
1539
+ ? `Look at line ${posicao.line} - a missing comma, an extra trailing comma, or an unclosed bracket is the usual cause.`
1540
+ : 'A missing comma, an extra trailing comma, or an unclosed bracket is the usual cause.',
1541
+ docKey: 'config',
1542
+ },
1543
+ ],
1544
+ };
1545
+ }
1546
+
1547
+ const result = docsConfigSchema.safeParse(raw);
1548
+ if (!result.success) {
1549
+ const localizar = criarLocalizador(rawText);
1550
+ return {
1551
+ ok: false,
1552
+ data: null,
1553
+ kind: 'schema',
1554
+ issues: desembrulharUnioes(result.error.issues as IssueCru[]).map((i) => {
1555
+ // Para chave desconhecida o `path` do Zod aponta pro PAI (`footer`), e a
1556
+ // chave ofensora vem em `keys`. Para a LINHA vale a pena descer ate ela
1557
+ // (`footer.banana`), que e onde o cliente precisa olhar; o `path` fica
1558
+ // como o Zod deu, pra nao mudar o que a plataforma ja grava.
1559
+ const alvoDaLinha =
1560
+ i.code === 'unrecognized_keys' && i.keys?.length ? [...i.caminho, i.keys[0]] : i.caminho;
1561
+ return {
1562
+ path: i.caminho.join('.') || '(root)',
1563
+ message: i.message,
1564
+ code: i.code,
1565
+ ...(localizar(alvoDaLinha) ?? localizar(i.caminho) ?? {}),
1566
+ ...humanizar(i, raw),
1567
+ docKey: docKeyDoCaminho(i.caminho),
1568
+ };
1569
+ }),
1570
+ };
1571
+ }
1572
+
1573
+ return { ok: true, data: result.data, issues: [] };
1574
+ }