@writedocs/generator 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -17
- package/bin/writedocs.js +126 -73
- package/package.json +80 -79
- package/src/lib/config-schema.ts +997 -0
- package/src/lib/config.ts +1290 -2131
|
@@ -0,0 +1,997 @@
|
|
|
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
|
+
// (`zod` e nao `astro/zod`: as duas especificacoes resolvem pra mesma instalacao
|
|
16
|
+
// - node_modules/zod 4.4.3, que e o que astro@7.2.2 tambem pede via `^4.3.6` -
|
|
17
|
+
// entao a troca nao muda comportamento nenhum, so tira o astro do caminho de
|
|
18
|
+
// quem importa isto de fora do gerador.)
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------
|
|
22
|
+
// Pages & groups - the leaf content any container ultimately bottoms out
|
|
23
|
+
// at. A group can nest other groups arbitrarily deep, and can optionally
|
|
24
|
+
// link its own label to a page (`page`) independent of expanding or
|
|
25
|
+
// collapsing its children - see NavTree.astro for how that renders.
|
|
26
|
+
// ---------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const navPageSchema = z.string();
|
|
29
|
+
|
|
30
|
+
// A group ordinarily lists its own children by hand (`pages`), but can
|
|
31
|
+
// instead auto-generate them from an OpenAPI spec via `openapi: { src,
|
|
32
|
+
// path }` - resolved by loadDocsConfig() (via expandOpenApiInNavigation()
|
|
33
|
+
// below, reading the manifest generate-api-pages.js writes for this exact
|
|
34
|
+
// group) into a real `{ group, pages }` node - one sub-group per tag -
|
|
35
|
+
// before anything else in this file ever sees it, so
|
|
36
|
+
// resolveSections()/flattenNav()/buildNavTree()/etc. only ever need to
|
|
37
|
+
// understand the ordinary pages-array shape. `.strict()` on both variants
|
|
38
|
+
// is load-bearing (see withChildren()'s own comment on this pattern
|
|
39
|
+
// below): without it, a group written with both `pages` and `openapi` at
|
|
40
|
+
// once would silently have one dropped instead of rejected.
|
|
41
|
+
const navGroupPagesSchema: z.ZodType<{ group: string; page?: string; pages: NavItem[] }> = z.lazy(() =>
|
|
42
|
+
z
|
|
43
|
+
.object({
|
|
44
|
+
group: z.string(),
|
|
45
|
+
page: z.string().optional(),
|
|
46
|
+
pages: z.array(navItemSchema),
|
|
47
|
+
})
|
|
48
|
+
.strict()
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const navGroupOpenApiSchema = z.object({
|
|
52
|
+
group: z.string(),
|
|
53
|
+
openapi: z
|
|
54
|
+
.object({
|
|
55
|
+
// Path to an OpenAPI 3.x spec, relative to the content directory
|
|
56
|
+
// (alongside writedocs.json) - same convention the old top-level
|
|
57
|
+
// `openapi` field used. See generate-api-pages.js (the pre-Astro
|
|
58
|
+
// build/dev step that actually parses this and writes the
|
|
59
|
+
// manifest/operation JSON this group's pages are expanded from).
|
|
60
|
+
src: z.string(),
|
|
61
|
+
// Base URL path every page generated from this spec is namespaced
|
|
62
|
+
// under, e.g. "/api" -> pages served at /api/<tag>/<operation>/.
|
|
63
|
+
// Also doubles as this spec's own unique key under
|
|
64
|
+
// writedocsTempDir()'s openapi/<path>/ directory (manifest.json + operations/*.json),
|
|
65
|
+
// so two openapi groups in the same writedocs.json must use different
|
|
66
|
+
// `path` values - generate-api-pages.js throws a clear error if
|
|
67
|
+
// they collide.
|
|
68
|
+
path: z.string(),
|
|
69
|
+
})
|
|
70
|
+
.strict(),
|
|
71
|
+
}).strict();
|
|
72
|
+
|
|
73
|
+
export type GroupNavItem =
|
|
74
|
+
| { group: string; page?: string; pages: NavItem[] }
|
|
75
|
+
| { group: string; openapi: { src: string; path: string } };
|
|
76
|
+
|
|
77
|
+
const navGroupSchema: z.ZodType<GroupNavItem> = z.union([navGroupPagesSchema, navGroupOpenApiSchema]);
|
|
78
|
+
|
|
79
|
+
// A bare external link sitting directly in a `pages` array, alongside
|
|
80
|
+
// page slugs and groups - e.g. a "Support" link to an external help
|
|
81
|
+
// desk shown right in the sidebar, rather than only reachable via
|
|
82
|
+
// navigation.global.dropdowns. Discriminated from navGroupSchema by
|
|
83
|
+
// its required `label`/`href` keys (vs `group`/`pages`/`openapi`), so a
|
|
84
|
+
// plain (non-strict at the union level) schema is enough for Zod to pick
|
|
85
|
+
// the right branch; `.strict()` here still catches a typo'd extra key.
|
|
86
|
+
const navLinkSchema = z.object({ label: z.string(), href: z.string() }).strict();
|
|
87
|
+
|
|
88
|
+
const navItemSchema: z.ZodType<NavItem> = z.lazy(() =>
|
|
89
|
+
z.union([navPageSchema, navGroupSchema, navLinkSchema])
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
export type NavItem = string | GroupNavItem | { label: string; href: string };
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------
|
|
95
|
+
// Containers: tabs, versions, languages, dropdowns, products.
|
|
96
|
+
//
|
|
97
|
+
// This schema is modeled directly on Mintlify's writedocs.json
|
|
98
|
+
// (https://mintlify.com/writedocs.json / mintlify.com/docs/organize/navigation):
|
|
99
|
+
// all five container kinds are structurally identical except for their
|
|
100
|
+
// own identifying field. Each owns *exactly one* of {pages, tabs,
|
|
101
|
+
// versions, languages, dropdowns, products} as its content, or is a bare
|
|
102
|
+
// external `href` link with no content of its own. That symmetry is what
|
|
103
|
+
// lets any of them nest inside any other - a tab can contain versions, a
|
|
104
|
+
// version can contain languages, a language can contain tabs, and so on,
|
|
105
|
+
// bottoming out at a `pages` array. `withChildren()` is the single place
|
|
106
|
+
// that shape is defined, instead of hand-writing the union five times.
|
|
107
|
+
// ---------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export type NavChildren =
|
|
110
|
+
| { pages: NavItem[] }
|
|
111
|
+
| { tabs: TabItem[] }
|
|
112
|
+
| { versions: VersionItem[] }
|
|
113
|
+
| { languages: LanguageItem[] }
|
|
114
|
+
| { dropdowns: DropdownItem[] }
|
|
115
|
+
| { products: ProductItem[] }
|
|
116
|
+
| { href: string };
|
|
117
|
+
|
|
118
|
+
export type TabItem = { tab: string; icon?: string } & NavChildren;
|
|
119
|
+
export type VersionItem = { version: string; label?: string; tag?: string; default?: boolean } & NavChildren;
|
|
120
|
+
export type LanguageItem = { language: string; label?: string } & NavChildren;
|
|
121
|
+
export type DropdownItem = { dropdown: string; icon?: string } & NavChildren;
|
|
122
|
+
export type ProductItem = { product: string; icon?: string; description?: string } & NavChildren;
|
|
123
|
+
|
|
124
|
+
// `.strict()` on every variant is load-bearing, not decoration: Zod
|
|
125
|
+
// objects silently strip unrecognized keys by default, so without it a
|
|
126
|
+
// node written with two children fields at once (e.g. both `pages` and
|
|
127
|
+
// `versions`) would just have the second one dropped instead of
|
|
128
|
+
// rejected - defeating the entire "exactly one child kind per level"
|
|
129
|
+
// rule this schema exists to enforce (mirroring Mintlify's own "a tab
|
|
130
|
+
// cannot contain both anchors and groups at the same level").
|
|
131
|
+
function withChildren<Base extends z.ZodRawShape>(base: Base) {
|
|
132
|
+
return z.union([
|
|
133
|
+
z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
|
|
134
|
+
z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
|
|
135
|
+
z.object({ ...base, versions: z.array(versionSchema).min(1) }).strict(),
|
|
136
|
+
z.object({ ...base, languages: z.array(languageSchema).min(1) }).strict(),
|
|
137
|
+
z.object({ ...base, dropdowns: z.array(dropdownSchema).min(1) }).strict(),
|
|
138
|
+
z.object({ ...base, products: z.array(productSchema).min(1) }).strict(),
|
|
139
|
+
z.object({ ...base, href: z.string() }).strict(),
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Each of these five is defined via z.lazy() because withChildren()
|
|
144
|
+
// references all five by name (mutual recursion) - safe because none of
|
|
145
|
+
// them are actually *called* until a real writedocs.json is parsed, by which
|
|
146
|
+
// point every const below has been assigned. The same pattern already
|
|
147
|
+
// used for navItemSchema/navGroupSchema above.
|
|
148
|
+
const tabSchema: z.ZodType<TabItem> = z.lazy(() =>
|
|
149
|
+
withChildren({ tab: z.string(), icon: z.string().optional() })
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const versionSchema: z.ZodType<VersionItem> = z.lazy(() =>
|
|
153
|
+
withChildren({
|
|
154
|
+
version: z.string(),
|
|
155
|
+
label: z.string().optional(),
|
|
156
|
+
tag: z.string().optional(),
|
|
157
|
+
default: z.boolean().optional(),
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const languageSchema: z.ZodType<LanguageItem> = z.lazy(() =>
|
|
162
|
+
withChildren({ language: z.string(), label: z.string().optional() })
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const dropdownSchema: z.ZodType<DropdownItem> = z.lazy(() =>
|
|
166
|
+
withChildren({ dropdown: z.string(), icon: z.string().optional() })
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const productSchema: z.ZodType<ProductItem> = z.lazy(() =>
|
|
170
|
+
withChildren({ product: z.string(), icon: z.string().optional(), description: z.string().optional() })
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------
|
|
174
|
+
// Root navigation: a flat array (shorthand for one implicit section - the
|
|
175
|
+
// common case) or an object choosing exactly one primary organizational
|
|
176
|
+
// pattern, per Mintlify's convention ("choose one primary organizational
|
|
177
|
+
// pattern at the root level of your navigation").
|
|
178
|
+
//
|
|
179
|
+
// `global.dropdowns` is the one exception to "exactly one pattern": it's
|
|
180
|
+
// not a primary pattern, it's a persistent set of topbar dropdown
|
|
181
|
+
// triggers that render on every page regardless of which tab/version/
|
|
182
|
+
// language/product is active - equivalent to Mintlify's
|
|
183
|
+
// `navigation.global.anchors`. This replaces the earlier `{tabs,
|
|
184
|
+
// dropdowns}` shape (both present at once), which doesn't generalize
|
|
185
|
+
// once versions/languages/products are also root-level choices.
|
|
186
|
+
// ---------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
export type NavigationConfig =
|
|
189
|
+
| NavItem[]
|
|
190
|
+
| { global?: { dropdowns: DropdownItem[] }; tabs: TabItem[] }
|
|
191
|
+
| { global?: { dropdowns: DropdownItem[] }; versions: VersionItem[] }
|
|
192
|
+
| { global?: { dropdowns: DropdownItem[] }; languages: LanguageItem[] }
|
|
193
|
+
| { global?: { dropdowns: DropdownItem[] }; dropdowns: DropdownItem[] }
|
|
194
|
+
| { global?: { dropdowns: DropdownItem[] }; products: ProductItem[] };
|
|
195
|
+
|
|
196
|
+
const globalSchema = z.object({ dropdowns: z.array(dropdownSchema).min(1) });
|
|
197
|
+
|
|
198
|
+
const navigationSchema = z.union([
|
|
199
|
+
z.array(navItemSchema),
|
|
200
|
+
z.object({ global: globalSchema.optional(), tabs: z.array(tabSchema).min(1) }).strict(),
|
|
201
|
+
z.object({ global: globalSchema.optional(), versions: z.array(versionSchema).min(1) }).strict(),
|
|
202
|
+
z.object({ global: globalSchema.optional(), languages: z.array(languageSchema).min(1) }).strict(),
|
|
203
|
+
z.object({ global: globalSchema.optional(), dropdowns: z.array(dropdownSchema).min(1) }).strict(),
|
|
204
|
+
z.object({ global: globalSchema.optional(), products: z.array(productSchema).min(1) }).strict(),
|
|
205
|
+
]) as z.ZodType<NavigationConfig>;
|
|
206
|
+
|
|
207
|
+
const DEFAULT_PRIMARY = '#6366f1';
|
|
208
|
+
|
|
209
|
+
// A logo is either one path used for both color modes, or a
|
|
210
|
+
// { light, dark, label } object - each image shown only while that mode
|
|
211
|
+
// is active (see BaseLayout.astro's .wd-logo-light/.wd-logo-dark CSS).
|
|
212
|
+
// The topbar shows *only* the image by default - no site name text next
|
|
213
|
+
// to it, since a real logo asset usually already is a wordmark. `label`
|
|
214
|
+
// is the opt-in way to add text back next to the image (e.g. a product
|
|
215
|
+
// name alongside a symbol-only mark); it only exists on the object form,
|
|
216
|
+
// since a single shared image path is just as easy to bake the label
|
|
217
|
+
// into directly.
|
|
218
|
+
//
|
|
219
|
+
// `light`/`dark` are themselves optional (unlike the string form, which
|
|
220
|
+
// is inherently "both"): a site with no logo *images* at all can still
|
|
221
|
+
// set `styles.logo: { label: "..." }` to override the topbar's fallback
|
|
222
|
+
// text without providing any image - see BaseLayout.astro's
|
|
223
|
+
// hasLogoImage/logoLabel/brandLabel derivation, where an object with
|
|
224
|
+
// only `label` set already resolves correctly (brandLabel = logoLabel,
|
|
225
|
+
// same as if an image were also present) without needing any change
|
|
226
|
+
// there; only this schema needed loosening; light/dark were previously
|
|
227
|
+
// both required whenever the object form was used at all.
|
|
228
|
+
//
|
|
229
|
+
// Standalone const (rather than inlined into stylesSchema.logo below) so
|
|
230
|
+
// footer.logo further down can share the exact same union instead of an
|
|
231
|
+
// independently-written copy - see footerSchema's own comment on why a
|
|
232
|
+
// footer logo needs this at all.
|
|
233
|
+
const logoSchema = z.union([
|
|
234
|
+
z.string(),
|
|
235
|
+
z.object({ light: z.string().optional(), dark: z.string().optional(), label: z.string().optional() }).strict(),
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
// Same {light, dark}-with-fallback shape applies to `colors.dark`: any
|
|
239
|
+
// of primary/text left unset there falls back to the light-mode value
|
|
240
|
+
// already given above, so a site only has to specify what actually
|
|
241
|
+
// differs in dark mode. There used to be a `background`/`dark.background`
|
|
242
|
+
// pair here too, doing effectively the same job as `background.colors`
|
|
243
|
+
// further down (see that field's own comment) - two writedocs.json fields
|
|
244
|
+
// both named "background" that visually looked interchangeable but
|
|
245
|
+
// weren't quite (one drove --wd-background site-wide, the other only
|
|
246
|
+
// the <body> canvas), which was confusing to author against and easy to
|
|
247
|
+
// half-configure by setting only one. Removed in favor of `background`
|
|
248
|
+
// alone being the single place to set any background color - see that
|
|
249
|
+
// field's comment for what it now covers.
|
|
250
|
+
// One side of `styles.navbar` (light or dark) - either a bare background
|
|
251
|
+
// color (the original shape) or `{ background, accent? }` once a site
|
|
252
|
+
// wants its own accent color (the active-tab fill / hover underline)
|
|
253
|
+
// inside the navbar specifically, independent of the sitewide
|
|
254
|
+
// `styles.colors.primary`. Deliberately does NOT carry a text/icon color
|
|
255
|
+
// field at all - see `navbar`'s own comment below (stylesSchema) for why
|
|
256
|
+
// that's resolved automatically instead of being configurable. Kept as a
|
|
257
|
+
// standalone const rather than inlined so `light`/`dark` share the exact
|
|
258
|
+
// same union rather than two independently-written (and possibly
|
|
259
|
+
// drifting) copies of it.
|
|
260
|
+
const navbarColorValueSchema = z.union([
|
|
261
|
+
z.string(),
|
|
262
|
+
z
|
|
263
|
+
.object({
|
|
264
|
+
background: z.string(),
|
|
265
|
+
accent: z.string().optional(),
|
|
266
|
+
})
|
|
267
|
+
.strict(),
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
// One font declaration - almost exactly Mintlify's own `fonts` shape (see
|
|
271
|
+
// docs/dev/docs/site-config.mdx's own "Fonts" section for the research this
|
|
272
|
+
// was based on): a bare Google Fonts family name (`family` alone - Google
|
|
273
|
+
// Fonts loads it automatically, see googleFontsHref() below), or a local/
|
|
274
|
+
// externally-hosted font file (`source` + `format` together; `source` is
|
|
275
|
+
// either a project-relative path like the other five asset-fallback fields
|
|
276
|
+
// this schema already has - see collectConfiguredAssetPaths() - or a full
|
|
277
|
+
// https:// URL to an externally-hosted font). `weight` does two things at
|
|
278
|
+
// once, both driven by BaseLayout.astro: it narrows which file actually
|
|
279
|
+
// loads (folded into the Google Fonts URL's `:wght@` axis, or the
|
|
280
|
+
// generated `@font-face` rule's own `font-weight` descriptor for a
|
|
281
|
+
// `source` font - see googleFontsHref()/fontFaceRule() below), *and* it's
|
|
282
|
+
// applied as a real CSS `font-weight` on h1-h6 (for `heading`) or `body`
|
|
283
|
+
// (for `body`) - see BaseLayout's own `wdFontWeightHeading`/
|
|
284
|
+
// `wdFontWeightBody`. Both halves matter: loading only the 400-weight file
|
|
285
|
+
// while leaving h1-h6 at the browser's UA-default `font-weight: bold`
|
|
286
|
+
// faux-bolds that file back to looking bold anyway, with no visible
|
|
287
|
+
// change from configuring a lighter weight at all - the bug this second
|
|
288
|
+
// half fixes (caught from real user-reported behavior, not anticipated
|
|
289
|
+
// up front). Left unset anywhere in `styles.fonts` renders no font-weight
|
|
290
|
+
// rule at all, keeping today's plain browser-default bold headings/normal
|
|
291
|
+
// body text unchanged for a site that hasn't touched this field.
|
|
292
|
+
const fontVariantSchema = z
|
|
293
|
+
.object({
|
|
294
|
+
family: z.string(),
|
|
295
|
+
weight: z.number().optional(),
|
|
296
|
+
source: z.string().optional(),
|
|
297
|
+
format: z.enum(['woff', 'woff2']).optional(),
|
|
298
|
+
})
|
|
299
|
+
.strict();
|
|
300
|
+
|
|
301
|
+
export type FontVariant = z.infer<typeof fontVariantSchema>;
|
|
302
|
+
|
|
303
|
+
// The top-level shape adds `heading`/`body`: each independently optional,
|
|
304
|
+
// each falling back to the top-level family/weight/source/format when
|
|
305
|
+
// unset (see resolveFonts() below) - so a site can set one font for
|
|
306
|
+
// everything (`fonts.family` alone), or split headings and body text
|
|
307
|
+
// without needing to repeat itself for whichever side isn't changing.
|
|
308
|
+
const fontsSchema = z
|
|
309
|
+
.object({
|
|
310
|
+
family: z.string(),
|
|
311
|
+
weight: z.number().optional(),
|
|
312
|
+
source: z.string().optional(),
|
|
313
|
+
format: z.enum(['woff', 'woff2']).optional(),
|
|
314
|
+
heading: fontVariantSchema.optional(),
|
|
315
|
+
body: fontVariantSchema.optional(),
|
|
316
|
+
})
|
|
317
|
+
.strict();
|
|
318
|
+
|
|
319
|
+
export type FontsConfig = z.infer<typeof fontsSchema>;
|
|
320
|
+
|
|
321
|
+
const stylesSchema = z
|
|
322
|
+
.object({
|
|
323
|
+
colors: z
|
|
324
|
+
.object({
|
|
325
|
+
primary: z.string().default(DEFAULT_PRIMARY),
|
|
326
|
+
text: z.string().optional(),
|
|
327
|
+
dark: z
|
|
328
|
+
.object({
|
|
329
|
+
primary: z.string().optional(),
|
|
330
|
+
text: z.string().optional(),
|
|
331
|
+
})
|
|
332
|
+
.optional(),
|
|
333
|
+
})
|
|
334
|
+
.default({ primary: DEFAULT_PRIMARY }),
|
|
335
|
+
logo: logoSchema.optional(),
|
|
336
|
+
favicon: z.string().optional(),
|
|
337
|
+
// No default value here (unlike `colors.primary`'s DEFAULT_PRIMARY) -
|
|
338
|
+
// resolveFonts() below is where "Inter" actually gets applied as the
|
|
339
|
+
// site-wide default whenever this field is left unset entirely, so
|
|
340
|
+
// that fallback lives in one place alongside the rest of the
|
|
341
|
+
// resolution logic rather than being duplicated as a schema default
|
|
342
|
+
// *and* a resolver fallback.
|
|
343
|
+
fonts: fontsSchema.optional(),
|
|
344
|
+
// The Shiki theme *name* (not a full theme object/JSON - see
|
|
345
|
+
// https://shiki.style/themes for the built-in list) each fenced
|
|
346
|
+
// (```) MDX code block, and the API playground's request/response
|
|
347
|
+
// snippets, use in light/dark mode - read out of writedocs.json by both
|
|
348
|
+
// astro.config.mjs (Shiki's dual-theme markdown config) and
|
|
349
|
+
// ApiReferencePanel.astro (its own separate <Code/> usages, which
|
|
350
|
+
// don't inherit markdown.shikiConfig) via resolveCodeblockTheme()
|
|
351
|
+
// below, the single source of truth for the 'github-light'/
|
|
352
|
+
// 'github-dark' fallback. Left per-key optional (not defaulted in
|
|
353
|
+
// the schema itself) so a site can override just one side (e.g.
|
|
354
|
+
// only `dark`) and still get the ordinary default for the other.
|
|
355
|
+
codeblocks: z
|
|
356
|
+
.object({
|
|
357
|
+
light: z.string().optional(),
|
|
358
|
+
dark: z.string().optional(),
|
|
359
|
+
// Maps a fenced (```) block's own language tag to whichever real
|
|
360
|
+
// Shiki grammar actually highlights it, before that tag ever
|
|
361
|
+
// reaches Shiki - see resolveCodeblockLangAlias() below for the
|
|
362
|
+
// one built-in entry (mdx -> jsx) this ships with by default and
|
|
363
|
+
// why. A site can add its own entries for any other language tag
|
|
364
|
+
// that either doesn't tokenize well under its own grammar, or
|
|
365
|
+
// that a site wants to write under a shorter/friendlier fence tag
|
|
366
|
+
// than Shiki's own bundled id (e.g. `groovy: 'java'` for a close-
|
|
367
|
+
// enough approximation Shiki doesn't ship a dedicated grammar for
|
|
368
|
+
// at all) - merged with, not replacing, the built-in default, so
|
|
369
|
+
// adding one of a site's own doesn't require re-declaring mdx.
|
|
370
|
+
langAlias: z.record(z.string(), z.string()).optional(),
|
|
371
|
+
})
|
|
372
|
+
.strict()
|
|
373
|
+
.optional(),
|
|
374
|
+
// The topbar's own background, independent of `background` below (the
|
|
375
|
+
// page's) - a site may want its navbar to stand out (a dark or
|
|
376
|
+
// brand-colored bar over a light page) rather than blend into the
|
|
377
|
+
// page, which is the default when this is left unset. Same {light,
|
|
378
|
+
// dark} shape (and same per-key-optional, not defaulted here) as
|
|
379
|
+
// `codeblocks` right above, rather than nested under `colors` - it's a
|
|
380
|
+
// topbar-specific override, not a third general-purpose page color, so
|
|
381
|
+
// it reads more like "one more themed surface" alongside codeblocks
|
|
382
|
+
// than a sibling of primary/text. BaseLayout.astro's own
|
|
383
|
+
// lightNavbarBg/darkNavbarBg resolution is what falls each side back
|
|
384
|
+
// to the page background when unset.
|
|
385
|
+
//
|
|
386
|
+
// Each side (`light`/`dark`) is either a bare string - just the
|
|
387
|
+
// background color, exactly the original shape, kept for backwards
|
|
388
|
+
// compatibility - or `{ background, accent? }`, for when a site also
|
|
389
|
+
// wants the navbar's own active-tab-fill/hover-underline color to
|
|
390
|
+
// differ from the sitewide `styles.colors.primary` (`accent`'s only
|
|
391
|
+
// job - see BaseLayout.astro's lightNavbarAccent). Deliberately no
|
|
392
|
+
// text/icon color field here at all: BaseLayout.astro always resolves
|
|
393
|
+
// the navbar's plain text/icon color itself, picking black or white by
|
|
394
|
+
// contrast against whatever `background` resolves to
|
|
395
|
+
// (contrastTextColor() below) the moment this field is configured at
|
|
396
|
+
// all (string or object) - never a value read from writedocs.json. A
|
|
397
|
+
// manually-set text color is a legibility footgun a site author can
|
|
398
|
+
// get wrong (or drift out of sync after later changing `background`
|
|
399
|
+
// without remembering to update it too) in a way "compute it
|
|
400
|
+
// correctly, every time" simply can't - there's no legitimate reason
|
|
401
|
+
// to want *illegible* navbar text, unlike `accent`, which is a real
|
|
402
|
+
// aesthetic choice. Real bug this fixes: a site setting `navbar.light`
|
|
403
|
+
// to the same value as `colors.primary` (a plausible thing to reach
|
|
404
|
+
// for - "brand-colored navbar") made every topbar label/icon/link
|
|
405
|
+
// render in the default near-black text color against that same
|
|
406
|
+
// saturated background, all but unreadable - and an earlier version of
|
|
407
|
+
// this feature that let a writedocs.json-supplied color double as the text
|
|
408
|
+
// color reintroduced the identical bug one level down, just requiring
|
|
409
|
+
// one more (still guessable-wrong) field to trigger it.
|
|
410
|
+
navbar: z
|
|
411
|
+
.object({
|
|
412
|
+
light: navbarColorValueSchema.optional(),
|
|
413
|
+
dark: navbarColorValueSchema.optional(),
|
|
414
|
+
})
|
|
415
|
+
.strict()
|
|
416
|
+
.optional(),
|
|
417
|
+
// The single place to configure any background color: `colors` is a
|
|
418
|
+
// solid color (falls back to '#ffffff'/'#0b1120' when unset - see
|
|
419
|
+
// BaseLayout.astro's lightBackground/darkBackground resolution), and
|
|
420
|
+
// `images` layers an image on top of it (or is the whole background
|
|
421
|
+
// by itself, if `colors` is left at its default). This one pair now
|
|
422
|
+
// drives everything background-related site-wide - --wd-background
|
|
423
|
+
// (dropdowns/modals/kbd chips/footer/topbar-fallback/etc., see every
|
|
424
|
+
// `var(--wd-background)` call site) *and* the <body> canvas behind the
|
|
425
|
+
// main/toc columns - rather than the two being independently
|
|
426
|
+
// configurable fields that happened to look alike but didn't affect
|
|
427
|
+
// the same things (see this schema's own comment on `colors.dark`
|
|
428
|
+
// above for why that split was removed). Both are per-mode optional,
|
|
429
|
+
// same "only specify what differs" pattern as codeblocks/navbar above.
|
|
430
|
+
// The topbar and footer still get their own explicit opaque
|
|
431
|
+
// background (topbar.css/footer.css) specifically so an `images`
|
|
432
|
+
// background doesn't show through them; see those files' own
|
|
433
|
+
// comments. The sidebar column has no background of its own (base.css)
|
|
434
|
+
// and lets it show through, same as the main/toc columns.
|
|
435
|
+
background: z
|
|
436
|
+
.object({
|
|
437
|
+
colors: z
|
|
438
|
+
.object({
|
|
439
|
+
light: z.string().optional(),
|
|
440
|
+
dark: z.string().optional(),
|
|
441
|
+
})
|
|
442
|
+
.strict()
|
|
443
|
+
.optional(),
|
|
444
|
+
images: z
|
|
445
|
+
.object({
|
|
446
|
+
light: z.string().optional(),
|
|
447
|
+
dark: z.string().optional(),
|
|
448
|
+
})
|
|
449
|
+
.strict()
|
|
450
|
+
.optional(),
|
|
451
|
+
})
|
|
452
|
+
.strict()
|
|
453
|
+
.optional(),
|
|
454
|
+
})
|
|
455
|
+
.default({ colors: { primary: DEFAULT_PRIMARY } });
|
|
456
|
+
|
|
457
|
+
// `href` is always required - a link with nowhere to go isn't a link.
|
|
458
|
+
// `label`/`icon` are each individually optional, but not both at once
|
|
459
|
+
// (enforced by the .refine() below, same shape as scriptEntrySchema's own
|
|
460
|
+
// exactly-one-of check above) - a link needs *something* visible to render,
|
|
461
|
+
// whichever of the two, and can have both (icon rendered to the left of the
|
|
462
|
+
// label - see TopBar.astro/SiteFooter.astro, the two renderers of this
|
|
463
|
+
// shape). An icon-only link (no label) is the common case for something
|
|
464
|
+
// like a bare GitHub mark in the topbar; label-only (no icon) is every
|
|
465
|
+
// plain text link this already supported before `icon` existed - both
|
|
466
|
+
// still need to keep working unchanged, hence neither field being outright
|
|
467
|
+
// required on its own.
|
|
468
|
+
const topbarLinkSchema = z
|
|
469
|
+
.object({
|
|
470
|
+
label: z.string().optional(),
|
|
471
|
+
href: z.string(),
|
|
472
|
+
// Same string shape as every other writedocs.json `icon` field (a plain
|
|
473
|
+
// Lucide name, or the explicit "collection:icon-name" form for
|
|
474
|
+
// anything else - see resolveIcon()/AppIcon.astro) - not documented
|
|
475
|
+
// again here since that convention is already established elsewhere
|
|
476
|
+
// in this file (tabs/switchers/socials all take the identical shape).
|
|
477
|
+
icon: z.string().optional(),
|
|
478
|
+
})
|
|
479
|
+
.strict()
|
|
480
|
+
.refine((link) => Boolean(link.label) || Boolean(link.icon), {
|
|
481
|
+
message: 'Each topbar.links/footer column link needs a "label", an "icon", or both - a link with neither has nothing to render.',
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// One column of the footer (BaseLayout.astro's <footer class="wd-footer">) -
|
|
485
|
+
// an optional heading (e.g. "Resources", "Community") plus a list of links,
|
|
486
|
+
// reusing the exact same {label, icon, href} shape as topbar.links above (a
|
|
487
|
+
// footer link is the same thing rendered in a different place, no reason
|
|
488
|
+
// for a second, identical-but-differently-named schema). `title` is
|
|
489
|
+
// optional so a single-column footer with no heading (just a bare list of
|
|
490
|
+
// links) is valid too.
|
|
491
|
+
const footerColumnSchema = z
|
|
492
|
+
.object({
|
|
493
|
+
title: z.string().optional(),
|
|
494
|
+
links: z.array(topbarLinkSchema).default([]),
|
|
495
|
+
})
|
|
496
|
+
.strict();
|
|
497
|
+
|
|
498
|
+
// Off by default (empty `columns`, same convention as `topbar` above - a
|
|
499
|
+
// footer with nothing configured just doesn't render at all, see
|
|
500
|
+
// BaseLayout.astro's `hasFooterContent` check) rather than `.optional()`
|
|
501
|
+
// like contextMenu: unlike that field, an empty/default footer has no
|
|
502
|
+
// build-output or routing implications to gate, it's purely visual, so
|
|
503
|
+
// there's no reason to distinguish "unset" from "set to nothing" the way
|
|
504
|
+
// contextMenu needs to.
|
|
505
|
+
// Same {light, dark, label} union as `styles.logo` (via the shared
|
|
506
|
+
// logoSchema above) - unset by default, in which case the footer just
|
|
507
|
+
// shows the sitewide `styles.logo` (BaseLayout.astro's existing
|
|
508
|
+
// logoLight/logoDark/logoLabel, already threaded into SiteFooter as-is).
|
|
509
|
+
// Set here, it replaces that entirely rather than filling in only the
|
|
510
|
+
// missing side of it - a footer.logo with only `dark` set shows *just*
|
|
511
|
+
// the dark-mode image, not styles.logo's light image plus this dark one -
|
|
512
|
+
// the same all-or-nothing-relative-to-styles.logo resolution BaseLayout.astro
|
|
513
|
+
// already applies.
|
|
514
|
+
const footerSchema = z
|
|
515
|
+
.object({
|
|
516
|
+
columns: z.array(footerColumnSchema).default([]),
|
|
517
|
+
logo: logoSchema.optional(),
|
|
518
|
+
})
|
|
519
|
+
.strict()
|
|
520
|
+
.default({ columns: [] });
|
|
521
|
+
|
|
522
|
+
export type FooterColumn = z.infer<typeof footerColumnSchema>;
|
|
523
|
+
export type FooterConfig = z.infer<typeof footerSchema>;
|
|
524
|
+
|
|
525
|
+
// Shared by both writedocs.json's top-level `seo` (site-wide defaults) and a
|
|
526
|
+
// page's own frontmatter `seo` (per-page overrides) - see
|
|
527
|
+
// content.config.ts's docsSchema, which imports this exact schema rather
|
|
528
|
+
// than redeclaring the same shape a second time. mergeSeo() below is what
|
|
529
|
+
// actually combines the two, field by field, at render time.
|
|
530
|
+
export const seoFieldsSchema = z
|
|
531
|
+
.object({
|
|
532
|
+
// Open Graph / Twitter card image - a relative path (resolved against
|
|
533
|
+
// `domain` below into an absolute URL, since most social crawlers
|
|
534
|
+
// require one) or an already-absolute https:// URL.
|
|
535
|
+
ogImage: z.string().optional(),
|
|
536
|
+
// og:type - "website" for most pages, "article" for blog-post-shaped
|
|
537
|
+
// content, etc. Defaults to "website" if never set anywhere.
|
|
538
|
+
ogType: z.string().optional(),
|
|
539
|
+
twitterCard: z.enum(['summary', 'summary_large_image']).optional(),
|
|
540
|
+
keywords: z.array(z.string()).optional(),
|
|
541
|
+
// Renders <meta name="robots" content="noindex, nofollow" /> and
|
|
542
|
+
// (see astro.config.mjs's sitemap `filter`) excludes the page from
|
|
543
|
+
// sitemap.xml entirely - both driven off this one flag, since listing
|
|
544
|
+
// a page in the sitemap while also telling crawlers not to index it
|
|
545
|
+
// would be self-contradictory.
|
|
546
|
+
noindex: z.boolean().optional(),
|
|
547
|
+
})
|
|
548
|
+
.strict();
|
|
549
|
+
|
|
550
|
+
export type SeoFields = z.infer<typeof seoFieldsSchema>;
|
|
551
|
+
|
|
552
|
+
/** Field-by-field merge of a page's own `seo` frontmatter over writedocs.json's
|
|
553
|
+
* site-wide `seo` defaults - a page only overrides the specific fields it
|
|
554
|
+
* sets, falling back to the site default for everything else, rather than
|
|
555
|
+
* a page's (possibly partial) `seo` object replacing the site's wholesale. */
|
|
556
|
+
export function mergeSeo(site: SeoFields, page?: SeoFields): SeoFields {
|
|
557
|
+
if (!page) return site;
|
|
558
|
+
return {
|
|
559
|
+
ogImage: page.ogImage ?? site.ogImage,
|
|
560
|
+
ogType: page.ogType ?? site.ogType,
|
|
561
|
+
twitterCard: page.twitterCard ?? site.twitterCard,
|
|
562
|
+
keywords: page.keywords ?? site.keywords,
|
|
563
|
+
noindex: page.noindex ?? site.noindex,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Controls for the OpenAPI API playground's "Try it" modal - separate
|
|
568
|
+
// from a group's own `openapi: { src, path }` field (which spec to use,
|
|
569
|
+
// and where) since this is about how a *request* it sends behaves, not
|
|
570
|
+
// about the spec itself.
|
|
571
|
+
const apiSchema = z
|
|
572
|
+
.object({
|
|
573
|
+
// Whether the Try-it modal's Send button routes its request through
|
|
574
|
+
// writedocs' own CORS proxy (https://proxy.writechoice.io/) rather
|
|
575
|
+
// than calling the API directly from the browser. Almost no
|
|
576
|
+
// real-world API sends back Access-Control-Allow-Origin headers
|
|
577
|
+
// permitting an arbitrary docs site's origin, so a direct browser
|
|
578
|
+
// fetch() from the Try-it modal fails for most real APIs without
|
|
579
|
+
// this - see PROXY_BASE_URL in ApiReferencePanel.astro for the
|
|
580
|
+
// actual request-forwarding logic. Defaults to enabled; a site can
|
|
581
|
+
// set this to `false` to always call the API directly instead (the
|
|
582
|
+
// API is already CORS-permissive, same-origin in some deployments,
|
|
583
|
+
// or the site owner doesn't want requests routed through a third
|
|
584
|
+
// party at all).
|
|
585
|
+
proxy: z.boolean().default(true),
|
|
586
|
+
})
|
|
587
|
+
.strict()
|
|
588
|
+
.default({ proxy: true });
|
|
589
|
+
|
|
590
|
+
// The "Copy page" dropdown shown next to a page's title - copy the raw
|
|
591
|
+
// Markdown to the clipboard, open the raw Markdown in a new tab, or
|
|
592
|
+
// deep-link into an AI assistant with a prompt pointing at it. Opt-in:
|
|
593
|
+
// undefined (the field simply absent from writedocs.json) means the feature
|
|
594
|
+
// is off entirely - no menu rendered, no .md routes generated at build
|
|
595
|
+
// time either (see [...slug].md.ts) - rather than defaulting to on,
|
|
596
|
+
// since it changes both the UI and the build output. Once a site does
|
|
597
|
+
// write a `contextMenu` key (even `{}`), copying/viewing-as-Markdown are
|
|
598
|
+
// always included (they need nothing but the page's own content); only
|
|
599
|
+
// `openIn` (the AI-assistant deep-links, which need an absolute URL to
|
|
600
|
+
// point the assistant at) is independently configurable.
|
|
601
|
+
const contextMenuSchema = z
|
|
602
|
+
.object({
|
|
603
|
+
openIn: z.array(z.enum(['chatgpt', 'claude', 'perplexity'])).default(['chatgpt', 'claude', 'perplexity']),
|
|
604
|
+
})
|
|
605
|
+
.strict();
|
|
606
|
+
|
|
607
|
+
export type ContextMenuConfig = z.infer<typeof contextMenuSchema>;
|
|
608
|
+
|
|
609
|
+
// One entry in writedocs.json's `redirects` array - wired almost directly into
|
|
610
|
+
// Astro's own `redirects` config option (astro.config.mjs), which is what
|
|
611
|
+
// actually generates the redirect pages. `permanent` is deliberately not
|
|
612
|
+
// part of this schema: with no server/adapter (this project always builds
|
|
613
|
+
// `output: 'static'` with none installed), Astro's own docs say a static
|
|
614
|
+
// redirect "does not support status codes" at all - it's always a client-
|
|
615
|
+
// side `<meta http-equiv="refresh">` page, full stop. Accepting a
|
|
616
|
+
// `permanent` field here that silently did nothing would be worse than
|
|
617
|
+
// not offering it.
|
|
618
|
+
const redirectSchema = z
|
|
619
|
+
.object({
|
|
620
|
+
source: z.string(),
|
|
621
|
+
destination: z.string(),
|
|
622
|
+
})
|
|
623
|
+
.strict();
|
|
624
|
+
|
|
625
|
+
export type RedirectConfig = z.infer<typeof redirectSchema>;
|
|
626
|
+
|
|
627
|
+
// A site-wide banner rendered above the topbar on every page (except
|
|
628
|
+
// `mode: blank`, which drops all site chrome including this - see
|
|
629
|
+
// BaseLayout.astro). Optional/undefined by default, same convention as
|
|
630
|
+
// `contextMenu` above - a site with no banner configured gets no banner
|
|
631
|
+
// markup at all, not an empty one.
|
|
632
|
+
const bannerSchema = z
|
|
633
|
+
.object({
|
|
634
|
+
content: z.string(),
|
|
635
|
+
// Persisted via localStorage once dismissed (see BaseLayout.astro's
|
|
636
|
+
// initBanner()) - a static site has no server-side session to track
|
|
637
|
+
// this against, so "dismissed" only ever means "dismissed in this
|
|
638
|
+
// browser". A banner without `dismissible` reappears on every visit,
|
|
639
|
+
// appropriate for something that should stay visible until the site
|
|
640
|
+
// author removes it from writedocs.json themselves (e.g. "this version is
|
|
641
|
+
// deprecated"), not something a reader can permanently clear.
|
|
642
|
+
dismissible: z.boolean().default(false),
|
|
643
|
+
type: z.enum(['info', 'warning', 'critical']).default('info'),
|
|
644
|
+
})
|
|
645
|
+
.strict();
|
|
646
|
+
|
|
647
|
+
export type BannerConfig = z.infer<typeof bannerSchema>;
|
|
648
|
+
|
|
649
|
+
// Content for the custom 404 page (src/pages/404.astro) - Astro's own
|
|
650
|
+
// file-based convention (a static build emits this as 404.html at the
|
|
651
|
+
// site root; most static hosts pick it up automatically for unmatched
|
|
652
|
+
// routes). Both fields are optional with hardcoded fallbacks in 404.astro
|
|
653
|
+
// itself, so a site doesn't have to set either to get a reasonably
|
|
654
|
+
// branded 404 page (still rendered inside the normal BaseLayout/topbar
|
|
655
|
+
// chrome, just with placeholder content).
|
|
656
|
+
const notFoundSchema = z
|
|
657
|
+
.object({
|
|
658
|
+
title: z.string().optional(),
|
|
659
|
+
description: z.string().optional(),
|
|
660
|
+
})
|
|
661
|
+
.strict()
|
|
662
|
+
.default({});
|
|
663
|
+
|
|
664
|
+
export type NotFoundConfig = z.infer<typeof notFoundSchema>;
|
|
665
|
+
|
|
666
|
+
// One <script> tag to inject - exactly one of `src` (an external/local
|
|
667
|
+
// file, rendered as `<script src="...">`) or `content` (inline JS,
|
|
668
|
+
// rendered via `<script is:inline set:html="...">`) has to be set, not
|
|
669
|
+
// both and not neither - enforced by the `.refine()` below rather than
|
|
670
|
+
// leaving both optional and silently rendering an empty tag if a site
|
|
671
|
+
// author gets this wrong.
|
|
672
|
+
const scriptEntrySchema = z
|
|
673
|
+
.object({
|
|
674
|
+
src: z.string().optional(),
|
|
675
|
+
content: z.string().optional(),
|
|
676
|
+
})
|
|
677
|
+
.strict()
|
|
678
|
+
.refine((v) => (v.src ? !v.content : !!v.content), {
|
|
679
|
+
message: 'Each scripts.head/scripts.body entry needs exactly one of "src" or "content", not both or neither.',
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
export type ScriptEntry = z.infer<typeof scriptEntrySchema>;
|
|
683
|
+
|
|
684
|
+
// Raw third-party script injection - analytics snippets, chat widgets,
|
|
685
|
+
// anything that needs a real <script> tag writedocs.json's own structured
|
|
686
|
+
// fields (styles, integrations-style config, ...) don't have a dedicated
|
|
687
|
+
// slot for yet. `head` renders right before </head>; `body` renders right
|
|
688
|
+
// before </body> (after everything else has already loaded/hydrated -
|
|
689
|
+
// see BaseLayout.astro), matching where a third-party script's own
|
|
690
|
+
// install instructions usually say to put it.
|
|
691
|
+
const scriptsSchema = z
|
|
692
|
+
.object({
|
|
693
|
+
head: z.array(scriptEntrySchema).default([]),
|
|
694
|
+
body: z.array(scriptEntrySchema).default([]),
|
|
695
|
+
})
|
|
696
|
+
.strict()
|
|
697
|
+
.default({ head: [], body: [] });
|
|
698
|
+
|
|
699
|
+
export type ScriptsConfig = z.infer<typeof scriptsSchema>;
|
|
700
|
+
|
|
701
|
+
// writedocs.json's `integrations` - a curated, high-value subset of analytics
|
|
702
|
+
// providers, each getting its own minimal config object (just the id(s)
|
|
703
|
+
// it actually needs) rather than requiring a site author to hand-write
|
|
704
|
+
// the provider's own install snippet through the generic `scripts` field
|
|
705
|
+
// above. This is deliberately its own thing, not built as sugar over
|
|
706
|
+
// `scripts` the way the roadmap once implied it might be - most of these
|
|
707
|
+
// providers' real snippets need `defer`/`async`/`data-*` attributes
|
|
708
|
+
// ScriptEntry's plain `{ src?, content? }` shape has no way to express,
|
|
709
|
+
// so BaseLayout.astro renders each provider with its own bespoke markup
|
|
710
|
+
// instead of generating a ScriptEntry array to feed through the generic
|
|
711
|
+
// renderer. Every snippet shape below was sourced from that provider's
|
|
712
|
+
// own current official docs (fetched directly, not recalled from
|
|
713
|
+
// training data) while building this feature - see
|
|
714
|
+
// docs/dev/docs/integrations.mdx for links and dates.
|
|
715
|
+
//
|
|
716
|
+
// Every provider field is optional and independent - a site can turn on
|
|
717
|
+
// any subset (including all of them at once, for a migration period) or
|
|
718
|
+
// none. No "the" analytics field; a site picks whichever provider(s) it
|
|
719
|
+
// actually uses.
|
|
720
|
+
|
|
721
|
+
const ga4IntegrationSchema = z
|
|
722
|
+
.object({
|
|
723
|
+
// A GA4 "Measurement ID", always shaped like "G-XXXXXXXXXX" - not
|
|
724
|
+
// validated against that shape here, since Google could change the
|
|
725
|
+
// prefix/length without this schema needing to track it.
|
|
726
|
+
measurementId: z.string(),
|
|
727
|
+
})
|
|
728
|
+
.strict();
|
|
729
|
+
|
|
730
|
+
const googleTagManagerIntegrationSchema = z
|
|
731
|
+
.object({
|
|
732
|
+
// A GTM container id, shaped like "GTM-XXXXXXX".
|
|
733
|
+
containerId: z.string(),
|
|
734
|
+
})
|
|
735
|
+
.strict();
|
|
736
|
+
|
|
737
|
+
const plausibleIntegrationSchema = z
|
|
738
|
+
.object({
|
|
739
|
+
domain: z.string(),
|
|
740
|
+
// Plausible's own hosted script URL by default - override for a
|
|
741
|
+
// self-hosted instance, a reverse-proxy path (Plausible's own
|
|
742
|
+
// documented way to dodge ad-blockers), or one of Plausible's
|
|
743
|
+
// documented script *extensions* (e.g.
|
|
744
|
+
// "https://plausible.io/js/script.hash.outbound-links.js").
|
|
745
|
+
src: z.string().default('https://plausible.io/js/script.js'),
|
|
746
|
+
})
|
|
747
|
+
.strict();
|
|
748
|
+
|
|
749
|
+
const fathomIntegrationSchema = z
|
|
750
|
+
.object({
|
|
751
|
+
// Fathom's own short "Site ID", not a full URL or measurement id.
|
|
752
|
+
siteId: z.string(),
|
|
753
|
+
})
|
|
754
|
+
.strict();
|
|
755
|
+
|
|
756
|
+
const posthogIntegrationSchema = z
|
|
757
|
+
.object({
|
|
758
|
+
// PostHog calls this a "project API key" (starts with "phc_") in its
|
|
759
|
+
// own docs - `apiKey` here rather than that exact name, matching this
|
|
760
|
+
// schema's own plainer naming convention for the equivalent id on
|
|
761
|
+
// every other provider above.
|
|
762
|
+
apiKey: z.string(),
|
|
763
|
+
// PostHog is region-sharded (US/EU) with no single universal default
|
|
764
|
+
// - "https://us.i.posthog.com" is PostHog's own default for a new
|
|
765
|
+
// project, but a site on their EU cloud (or a self-hosted instance)
|
|
766
|
+
// must override this to the host shown in their own project
|
|
767
|
+
// settings, or events silently go nowhere.
|
|
768
|
+
apiHost: z.string().default('https://us.i.posthog.com'),
|
|
769
|
+
})
|
|
770
|
+
.strict();
|
|
771
|
+
|
|
772
|
+
const umamiIntegrationSchema = z
|
|
773
|
+
.object({
|
|
774
|
+
websiteId: z.string(),
|
|
775
|
+
// Unlike Plausible/PostHog, Umami has no single hosted default that
|
|
776
|
+
// works for most users - it's commonly self-hosted, and even Umami
|
|
777
|
+
// Cloud users get a per-region script URL. Defaults to Umami Cloud's
|
|
778
|
+
// own documented script URL, which only happens to be correct for a
|
|
779
|
+
// Cloud user on that specific region; anyone self-hosting (the more
|
|
780
|
+
// common case for this particular provider) must override it to
|
|
781
|
+
// their own instance's own /script.js path.
|
|
782
|
+
src: z.string().default('https://cloud.umami.is/script.js'),
|
|
783
|
+
})
|
|
784
|
+
.strict();
|
|
785
|
+
|
|
786
|
+
// AI chat over the site's own docs content, via a third-party provider
|
|
787
|
+
// (DocsBot, currently the only one wired up) rather than a self-hosted
|
|
788
|
+
// RAG pipeline - see the roadmap's own "AI chat over docs content" line
|
|
789
|
+
// in docs/dev/docs/roadmap.mdx, which this fulfills via integration
|
|
790
|
+
// rather than by building the retrieval/generation stack in-house.
|
|
791
|
+
// Deliberately named `askAi` here, not `docsbot` - this is the
|
|
792
|
+
// writedocs.json-facing, provider-neutral name for the feature (matching
|
|
793
|
+
// the "Ask AI" label this kind of button/widget is conventionally
|
|
794
|
+
// given in docs sites generally), independent of which vendor happens
|
|
795
|
+
// to sit behind it today. `id` matches DocsBot's own embed config
|
|
796
|
+
// field name exactly (`DocsBotAI.init({ id: 'teamId/botId' })`) - it's
|
|
797
|
+
// a single opaque "teamId/botId" string DocsBot treats as one value,
|
|
798
|
+
// not two separate ids, so this schema doesn't split it either.
|
|
799
|
+
const askAiIntegrationSchema = z
|
|
800
|
+
.object({
|
|
801
|
+
id: z.string(),
|
|
802
|
+
})
|
|
803
|
+
.strict();
|
|
804
|
+
|
|
805
|
+
const integrationsSchema = z
|
|
806
|
+
.object({
|
|
807
|
+
ga4: ga4IntegrationSchema.optional(),
|
|
808
|
+
googleTagManager: googleTagManagerIntegrationSchema.optional(),
|
|
809
|
+
plausible: plausibleIntegrationSchema.optional(),
|
|
810
|
+
fathom: fathomIntegrationSchema.optional(),
|
|
811
|
+
posthog: posthogIntegrationSchema.optional(),
|
|
812
|
+
umami: umamiIntegrationSchema.optional(),
|
|
813
|
+
askAi: askAiIntegrationSchema.optional(),
|
|
814
|
+
})
|
|
815
|
+
.strict()
|
|
816
|
+
.default({});
|
|
817
|
+
|
|
818
|
+
export type IntegrationsConfig = z.infer<typeof integrationsSchema>;
|
|
819
|
+
|
|
820
|
+
export const docsConfigSchema = z.object({
|
|
821
|
+
name: z.string(),
|
|
822
|
+
description: z.string().optional(),
|
|
823
|
+
styles: stylesSchema,
|
|
824
|
+
navigation: navigationSchema,
|
|
825
|
+
// Platform name -> profile/page URL (e.g. `{ "github": "https://
|
|
826
|
+
// github.com/...", "twitter": "https://twitter.com/..." }`), free-form
|
|
827
|
+
// rather than an enum of known platforms - the key doubles as the icon
|
|
828
|
+
// reference BaseLayout.astro's footer resolves via resolveIcon() below
|
|
829
|
+
// (bare `"github"` -> `lucide:github`; use an explicit
|
|
830
|
+
// `"simple-icons:whatever"` key instead when the bare lucide name isn't
|
|
831
|
+
// right for a given platform). Rendered as a row of icon links in the
|
|
832
|
+
// footer - see hasFooterContent/`.wd-footer-socials` in BaseLayout.astro.
|
|
833
|
+
socials: z.record(z.string(), z.string()).default({}),
|
|
834
|
+
topbar: z
|
|
835
|
+
.object({ links: z.array(topbarLinkSchema).default([]) })
|
|
836
|
+
.default({ links: [] }),
|
|
837
|
+
// Columns of links below the page content - see footerSchema/
|
|
838
|
+
// footerColumnSchema above. Renders alongside `socials` above (as a row
|
|
839
|
+
// of icon links) in the same <footer> - see BaseLayout.astro.
|
|
840
|
+
footer: footerSchema,
|
|
841
|
+
api: apiSchema,
|
|
842
|
+
// The site's own deployed domain, e.g. "docs.example.com" or
|
|
843
|
+
// "https://docs.example.com" (the scheme is optional - resolveSiteUrl()
|
|
844
|
+
// below normalizes either form to a full https:// origin). Powers three
|
|
845
|
+
// things that all need an absolute origin to work: sitemap.xml
|
|
846
|
+
// generation (astro.config.mjs only registers @astrojs/sitemap when this
|
|
847
|
+
// is set - a sitemap of relative URLs isn't meaningful), the canonical
|
|
848
|
+
// <link> and og:url/twitter meta tags (BaseLayout.astro), and resolving
|
|
849
|
+
// a relative `seo.ogImage` into an absolute URL for social crawlers.
|
|
850
|
+
// Left optional and simply skipped (with a log message, not an error) at
|
|
851
|
+
// every one of those call sites when unset - most fixtures/local builds
|
|
852
|
+
// have no real deployed domain yet.
|
|
853
|
+
domain: z.string().optional(),
|
|
854
|
+
// Site-wide meta tag defaults - see seoFieldsSchema above. A page's own
|
|
855
|
+
// frontmatter `seo` (content.config.ts's docsSchema) overrides these
|
|
856
|
+
// field by field via mergeSeo(), rather than needing to repeat every
|
|
857
|
+
// field on every page.
|
|
858
|
+
seo: seoFieldsSchema.default({}),
|
|
859
|
+
// The "Copy page" dropdown - see contextMenuSchema above. Absent by
|
|
860
|
+
// default (no menu, no .md routes).
|
|
861
|
+
contextMenu: contextMenuSchema.optional(),
|
|
862
|
+
// See redirectSchema above. Wired directly into Astro's own `redirects`
|
|
863
|
+
// config option in astro.config.mjs.
|
|
864
|
+
redirects: z.array(redirectSchema).default([]),
|
|
865
|
+
// Site-wide `[[key]]` substitution values, applied to page prose text
|
|
866
|
+
// at build time (see the remarkSubstituteVariables plugin wired into
|
|
867
|
+
// astro.config.mjs) - e.g. `{ "productName": "Acme" }` lets every page
|
|
868
|
+
// write `[[productName]]` once instead of hardcoding it everywhere.
|
|
869
|
+
// Double square brackets, not the more familiar `{{key}}` - MDX
|
|
870
|
+
// reserves single curly braces for embedded JS expressions, so
|
|
871
|
+
// `{{productName}}` in an .mdx file parses as a JS object-literal
|
|
872
|
+
// expression (`{productName}`, shorthand syntax) rather than literal
|
|
873
|
+
// text, and throws `ReferenceError: productName is not defined` at
|
|
874
|
+
// render time - see remarkSubstituteVariables' own comment in
|
|
875
|
+
// mdx-substitute-variables.js for the full explanation. Deliberately a
|
|
876
|
+
// flat string map, not typed values - this is text substitution into
|
|
877
|
+
// prose, not a templating language.
|
|
878
|
+
variables: z.record(z.string(), z.string()).default({}),
|
|
879
|
+
// See bannerSchema above. Absent by default (no banner rendered).
|
|
880
|
+
banner: bannerSchema.optional(),
|
|
881
|
+
// See notFoundSchema above.
|
|
882
|
+
notFound: notFoundSchema,
|
|
883
|
+
// See scriptsSchema above.
|
|
884
|
+
scripts: scriptsSchema,
|
|
885
|
+
// See integrationsSchema above.
|
|
886
|
+
integrations: integrationsSchema,
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
export type DocsConfig = z.infer<typeof docsConfigSchema>;
|
|
890
|
+
|
|
891
|
+
// ---------------------------------------------------------------------
|
|
892
|
+
// Ponto de entrada unico de validacao
|
|
893
|
+
//
|
|
894
|
+
// Existe pra que o gerador (`writedocs validate` e o `loadDocsConfig` que o
|
|
895
|
+
// build usa) e a plataforma validem pelo MESMO codigo, com as MESMAS palavras -
|
|
896
|
+
// o criterio de aceite do WD-066 e do WD-062. Se cada lado formatasse os erros
|
|
897
|
+
// por conta propria, o cliente veria a plataforma aceitar um writedocs.json que
|
|
898
|
+
// o build depois recusa, que e exatamente a classe de bug que a E11 existe pra
|
|
899
|
+
// impedir.
|
|
900
|
+
//
|
|
901
|
+
// Recebe TEXTO CRU, e faz o JSON.parse por conta propria, por tres motivos:
|
|
902
|
+
// 1. um JSON sintaticamente quebrado passa a ser um resultado de validacao
|
|
903
|
+
// como qualquer outro, em vez de uma excecao solta do runtime;
|
|
904
|
+
// 2. linha/coluna so existem no texto - e o WD-063 precisa reportar linha;
|
|
905
|
+
// 3. do lado da plataforma, importProjectConfig ja tem os bytes crus em maos
|
|
906
|
+
// antes de parsear, entao encaixa sem ginastica nenhuma.
|
|
907
|
+
//
|
|
908
|
+
// IMPORTANTE, e deliberado: as mensagens aqui sao as do Zod, VERBATIM. Melhorar
|
|
909
|
+
// a linguagem delas e o WD-063, que precisa deste comportamento como ponto de
|
|
910
|
+
// partida pra medir a melhora. Nada aqui reescreve, traduz ou embeleza mensagem.
|
|
911
|
+
// ---------------------------------------------------------------------
|
|
912
|
+
|
|
913
|
+
/** Um problema encontrado na configuracao. `path` e o caminho do campo em
|
|
914
|
+
* notacao de ponto (`navigation.tabs.0.label`), ou `(root)` quando o problema e
|
|
915
|
+
* do documento inteiro - o mesmo texto que a mensagem de erro do build ja
|
|
916
|
+
* imprime hoje. `line`/`column` so vem quando da pra saber (hoje: erro de JSON;
|
|
917
|
+
* no WD-063, tambem para erros de schema). */
|
|
918
|
+
export type ValidationIssue = {
|
|
919
|
+
path: string;
|
|
920
|
+
message: string;
|
|
921
|
+
/** Codigo do Zod (`invalid_type`, `unrecognized_keys`, ...) ou `invalid_json`. */
|
|
922
|
+
code?: string;
|
|
923
|
+
line?: number;
|
|
924
|
+
column?: number;
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
/** Sucesso carrega os dados ja validados (com os defaults do schema aplicados);
|
|
928
|
+
* falha carrega os problemas e diz de que tipo ela foi. `parseError` so aparece
|
|
929
|
+
* quando o JSON nao pode nem ser parseado, e existe para o chamador que quiser
|
|
930
|
+
* relancar exatamente o erro original (e o que o loadDocsConfig faz, pra nao
|
|
931
|
+
* mudar uma virgula do que o `writedocs build` ja imprime hoje). */
|
|
932
|
+
export type ValidationResult =
|
|
933
|
+
| { ok: true; data: DocsConfig; issues: ValidationIssue[] }
|
|
934
|
+
| {
|
|
935
|
+
ok: false;
|
|
936
|
+
data: null;
|
|
937
|
+
kind: 'invalid_json' | 'schema';
|
|
938
|
+
issues: ValidationIssue[];
|
|
939
|
+
parseError?: SyntaxError;
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
/** "at position 22 (line 1 column 23)" -> { line: 1, column: 23 }.
|
|
943
|
+
* O formato da mensagem de erro do JSON.parse e do V8, nao do padrao, entao
|
|
944
|
+
* isto e melhor-esforco: se a mensagem nao trouxer posicao, devolve null e o
|
|
945
|
+
* problema simplesmente fica sem linha. */
|
|
946
|
+
function positionFromJsonError(message: string, rawText: string): { line: number; column: number } | null {
|
|
947
|
+
const comLinha = message.match(/line (\d+) column (\d+)/);
|
|
948
|
+
if (comLinha) return { line: Number(comLinha[1]), column: Number(comLinha[2]) };
|
|
949
|
+
const comPosicao = message.match(/position (\d+)/);
|
|
950
|
+
if (comPosicao) {
|
|
951
|
+
const pos = Math.min(Number(comPosicao[1]), rawText.length);
|
|
952
|
+
const antes = rawText.slice(0, pos);
|
|
953
|
+
const line = antes.split('\n').length;
|
|
954
|
+
return { line, column: pos - antes.lastIndexOf('\n') };
|
|
955
|
+
}
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** O corpo da mensagem de erro, uma linha por problema - exatamente o formato
|
|
960
|
+
* que o `writedocs build` imprime desde sempre (` - campo: mensagem`). Fica
|
|
961
|
+
* aqui, e nao em cada chamador, pra que CLI e plataforma nao possam divergir. */
|
|
962
|
+
export function formatValidationIssues(issues: ValidationIssue[]): string {
|
|
963
|
+
return issues.map((i) => ` - ${i.path}: ${i.message}`).join('\n');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export function validateDocsConfig(rawText: string): ValidationResult {
|
|
967
|
+
let raw: unknown;
|
|
968
|
+
try {
|
|
969
|
+
raw = JSON.parse(rawText);
|
|
970
|
+
} catch (err) {
|
|
971
|
+
const parseError = err as SyntaxError;
|
|
972
|
+
const posicao = positionFromJsonError(parseError.message, rawText);
|
|
973
|
+
return {
|
|
974
|
+
ok: false,
|
|
975
|
+
data: null,
|
|
976
|
+
kind: 'invalid_json',
|
|
977
|
+
parseError,
|
|
978
|
+
issues: [{ path: '(root)', message: parseError.message, code: 'invalid_json', ...(posicao ?? {}) }],
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const result = docsConfigSchema.safeParse(raw);
|
|
983
|
+
if (!result.success) {
|
|
984
|
+
return {
|
|
985
|
+
ok: false,
|
|
986
|
+
data: null,
|
|
987
|
+
kind: 'schema',
|
|
988
|
+
issues: result.error.issues.map((i) => ({
|
|
989
|
+
path: i.path.join('.') || '(root)',
|
|
990
|
+
message: i.message,
|
|
991
|
+
code: i.code,
|
|
992
|
+
})),
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return { ok: true, data: result.data, issues: [] };
|
|
997
|
+
}
|