@writedocs/generator 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -17
- package/bin/writedocs.js +119 -73
- package/package.json +85 -79
- package/src/lib/config-schema.js +485 -0
- package/src/lib/config-schema.ts +1004 -0
- package/src/lib/config.ts +1306 -2131
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const navPageSchema = z.string();
|
|
3
|
+
const navGroupPagesSchema = z.lazy(
|
|
4
|
+
() => z.object({
|
|
5
|
+
group: z.string(),
|
|
6
|
+
page: z.string().optional(),
|
|
7
|
+
pages: z.array(navItemSchema)
|
|
8
|
+
}).strict()
|
|
9
|
+
);
|
|
10
|
+
const navGroupOpenApiSchema = z.object({
|
|
11
|
+
group: z.string(),
|
|
12
|
+
openapi: z.object({
|
|
13
|
+
// Path to an OpenAPI 3.x spec, relative to the content directory
|
|
14
|
+
// (alongside writedocs.json) - same convention the old top-level
|
|
15
|
+
// `openapi` field used. See generate-api-pages.js (the pre-Astro
|
|
16
|
+
// build/dev step that actually parses this and writes the
|
|
17
|
+
// manifest/operation JSON this group's pages are expanded from).
|
|
18
|
+
src: z.string(),
|
|
19
|
+
// Base URL path every page generated from this spec is namespaced
|
|
20
|
+
// under, e.g. "/api" -> pages served at /api/<tag>/<operation>/.
|
|
21
|
+
// Also doubles as this spec's own unique key under
|
|
22
|
+
// writedocsTempDir()'s openapi/<path>/ directory (manifest.json + operations/*.json),
|
|
23
|
+
// so two openapi groups in the same writedocs.json must use different
|
|
24
|
+
// `path` values - generate-api-pages.js throws a clear error if
|
|
25
|
+
// they collide.
|
|
26
|
+
path: z.string()
|
|
27
|
+
}).strict()
|
|
28
|
+
}).strict();
|
|
29
|
+
const navGroupSchema = z.union([navGroupPagesSchema, navGroupOpenApiSchema]);
|
|
30
|
+
const navLinkSchema = z.object({ label: z.string(), href: z.string() }).strict();
|
|
31
|
+
const navItemSchema = z.lazy(
|
|
32
|
+
() => z.union([navPageSchema, navGroupSchema, navLinkSchema])
|
|
33
|
+
);
|
|
34
|
+
function withChildren(base) {
|
|
35
|
+
return z.union([
|
|
36
|
+
z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
|
|
37
|
+
z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
|
|
38
|
+
z.object({ ...base, versions: z.array(versionSchema).min(1) }).strict(),
|
|
39
|
+
z.object({ ...base, languages: z.array(languageSchema).min(1) }).strict(),
|
|
40
|
+
z.object({ ...base, dropdowns: z.array(dropdownSchema).min(1) }).strict(),
|
|
41
|
+
z.object({ ...base, products: z.array(productSchema).min(1) }).strict(),
|
|
42
|
+
z.object({ ...base, href: z.string() }).strict()
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
const tabSchema = z.lazy(
|
|
46
|
+
() => withChildren({ tab: z.string(), icon: z.string().optional() })
|
|
47
|
+
);
|
|
48
|
+
const versionSchema = z.lazy(
|
|
49
|
+
() => withChildren({
|
|
50
|
+
version: z.string(),
|
|
51
|
+
label: z.string().optional(),
|
|
52
|
+
tag: z.string().optional(),
|
|
53
|
+
default: z.boolean().optional()
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
const languageSchema = z.lazy(
|
|
57
|
+
() => withChildren({ language: z.string(), label: z.string().optional() })
|
|
58
|
+
);
|
|
59
|
+
const dropdownSchema = z.lazy(
|
|
60
|
+
() => withChildren({ dropdown: z.string(), icon: z.string().optional() })
|
|
61
|
+
);
|
|
62
|
+
const productSchema = z.lazy(
|
|
63
|
+
() => withChildren({ product: z.string(), icon: z.string().optional(), description: z.string().optional() })
|
|
64
|
+
);
|
|
65
|
+
const globalSchema = z.object({ dropdowns: z.array(dropdownSchema).min(1) });
|
|
66
|
+
const navigationSchema = z.union([
|
|
67
|
+
z.array(navItemSchema),
|
|
68
|
+
z.object({ global: globalSchema.optional(), tabs: z.array(tabSchema).min(1) }).strict(),
|
|
69
|
+
z.object({ global: globalSchema.optional(), versions: z.array(versionSchema).min(1) }).strict(),
|
|
70
|
+
z.object({ global: globalSchema.optional(), languages: z.array(languageSchema).min(1) }).strict(),
|
|
71
|
+
z.object({ global: globalSchema.optional(), dropdowns: z.array(dropdownSchema).min(1) }).strict(),
|
|
72
|
+
z.object({ global: globalSchema.optional(), products: z.array(productSchema).min(1) }).strict()
|
|
73
|
+
]);
|
|
74
|
+
const DEFAULT_PRIMARY = "#6366f1";
|
|
75
|
+
const logoSchema = z.union([
|
|
76
|
+
z.string(),
|
|
77
|
+
z.object({ light: z.string().optional(), dark: z.string().optional(), label: z.string().optional() }).strict()
|
|
78
|
+
]);
|
|
79
|
+
const navbarColorValueSchema = z.union([
|
|
80
|
+
z.string(),
|
|
81
|
+
z.object({
|
|
82
|
+
background: z.string(),
|
|
83
|
+
accent: z.string().optional()
|
|
84
|
+
}).strict()
|
|
85
|
+
]);
|
|
86
|
+
const fontVariantSchema = z.object({
|
|
87
|
+
family: z.string(),
|
|
88
|
+
weight: z.number().optional(),
|
|
89
|
+
source: z.string().optional(),
|
|
90
|
+
format: z.enum(["woff", "woff2"]).optional()
|
|
91
|
+
}).strict();
|
|
92
|
+
const fontsSchema = z.object({
|
|
93
|
+
family: z.string(),
|
|
94
|
+
weight: z.number().optional(),
|
|
95
|
+
source: z.string().optional(),
|
|
96
|
+
format: z.enum(["woff", "woff2"]).optional(),
|
|
97
|
+
heading: fontVariantSchema.optional(),
|
|
98
|
+
body: fontVariantSchema.optional()
|
|
99
|
+
}).strict();
|
|
100
|
+
const stylesSchema = z.object({
|
|
101
|
+
colors: z.object({
|
|
102
|
+
primary: z.string().default(DEFAULT_PRIMARY),
|
|
103
|
+
text: z.string().optional(),
|
|
104
|
+
dark: z.object({
|
|
105
|
+
primary: z.string().optional(),
|
|
106
|
+
text: z.string().optional()
|
|
107
|
+
}).optional()
|
|
108
|
+
}).default({ primary: DEFAULT_PRIMARY }),
|
|
109
|
+
logo: logoSchema.optional(),
|
|
110
|
+
favicon: z.string().optional(),
|
|
111
|
+
// No default value here (unlike `colors.primary`'s DEFAULT_PRIMARY) -
|
|
112
|
+
// resolveFonts() below is where "Inter" actually gets applied as the
|
|
113
|
+
// site-wide default whenever this field is left unset entirely, so
|
|
114
|
+
// that fallback lives in one place alongside the rest of the
|
|
115
|
+
// resolution logic rather than being duplicated as a schema default
|
|
116
|
+
// *and* a resolver fallback.
|
|
117
|
+
fonts: fontsSchema.optional(),
|
|
118
|
+
// The Shiki theme *name* (not a full theme object/JSON - see
|
|
119
|
+
// https://shiki.style/themes for the built-in list) each fenced
|
|
120
|
+
// (```) MDX code block, and the API playground's request/response
|
|
121
|
+
// snippets, use in light/dark mode - read out of writedocs.json by both
|
|
122
|
+
// astro.config.mjs (Shiki's dual-theme markdown config) and
|
|
123
|
+
// ApiReferencePanel.astro (its own separate <Code/> usages, which
|
|
124
|
+
// don't inherit markdown.shikiConfig) via resolveCodeblockTheme()
|
|
125
|
+
// below, the single source of truth for the 'github-light'/
|
|
126
|
+
// 'github-dark' fallback. Left per-key optional (not defaulted in
|
|
127
|
+
// the schema itself) so a site can override just one side (e.g.
|
|
128
|
+
// only `dark`) and still get the ordinary default for the other.
|
|
129
|
+
codeblocks: z.object({
|
|
130
|
+
light: z.string().optional(),
|
|
131
|
+
dark: z.string().optional(),
|
|
132
|
+
// Maps a fenced (```) block's own language tag to whichever real
|
|
133
|
+
// Shiki grammar actually highlights it, before that tag ever
|
|
134
|
+
// reaches Shiki - see resolveCodeblockLangAlias() below for the
|
|
135
|
+
// one built-in entry (mdx -> jsx) this ships with by default and
|
|
136
|
+
// why. A site can add its own entries for any other language tag
|
|
137
|
+
// that either doesn't tokenize well under its own grammar, or
|
|
138
|
+
// that a site wants to write under a shorter/friendlier fence tag
|
|
139
|
+
// than Shiki's own bundled id (e.g. `groovy: 'java'` for a close-
|
|
140
|
+
// enough approximation Shiki doesn't ship a dedicated grammar for
|
|
141
|
+
// at all) - merged with, not replacing, the built-in default, so
|
|
142
|
+
// adding one of a site's own doesn't require re-declaring mdx.
|
|
143
|
+
langAlias: z.record(z.string(), z.string()).optional()
|
|
144
|
+
}).strict().optional(),
|
|
145
|
+
// The topbar's own background, independent of `background` below (the
|
|
146
|
+
// page's) - a site may want its navbar to stand out (a dark or
|
|
147
|
+
// brand-colored bar over a light page) rather than blend into the
|
|
148
|
+
// page, which is the default when this is left unset. Same {light,
|
|
149
|
+
// dark} shape (and same per-key-optional, not defaulted here) as
|
|
150
|
+
// `codeblocks` right above, rather than nested under `colors` - it's a
|
|
151
|
+
// topbar-specific override, not a third general-purpose page color, so
|
|
152
|
+
// it reads more like "one more themed surface" alongside codeblocks
|
|
153
|
+
// than a sibling of primary/text. BaseLayout.astro's own
|
|
154
|
+
// lightNavbarBg/darkNavbarBg resolution is what falls each side back
|
|
155
|
+
// to the page background when unset.
|
|
156
|
+
//
|
|
157
|
+
// Each side (`light`/`dark`) is either a bare string - just the
|
|
158
|
+
// background color, exactly the original shape, kept for backwards
|
|
159
|
+
// compatibility - or `{ background, accent? }`, for when a site also
|
|
160
|
+
// wants the navbar's own active-tab-fill/hover-underline color to
|
|
161
|
+
// differ from the sitewide `styles.colors.primary` (`accent`'s only
|
|
162
|
+
// job - see BaseLayout.astro's lightNavbarAccent). Deliberately no
|
|
163
|
+
// text/icon color field here at all: BaseLayout.astro always resolves
|
|
164
|
+
// the navbar's plain text/icon color itself, picking black or white by
|
|
165
|
+
// contrast against whatever `background` resolves to
|
|
166
|
+
// (contrastTextColor() below) the moment this field is configured at
|
|
167
|
+
// all (string or object) - never a value read from writedocs.json. A
|
|
168
|
+
// manually-set text color is a legibility footgun a site author can
|
|
169
|
+
// get wrong (or drift out of sync after later changing `background`
|
|
170
|
+
// without remembering to update it too) in a way "compute it
|
|
171
|
+
// correctly, every time" simply can't - there's no legitimate reason
|
|
172
|
+
// to want *illegible* navbar text, unlike `accent`, which is a real
|
|
173
|
+
// aesthetic choice. Real bug this fixes: a site setting `navbar.light`
|
|
174
|
+
// to the same value as `colors.primary` (a plausible thing to reach
|
|
175
|
+
// for - "brand-colored navbar") made every topbar label/icon/link
|
|
176
|
+
// render in the default near-black text color against that same
|
|
177
|
+
// saturated background, all but unreadable - and an earlier version of
|
|
178
|
+
// this feature that let a writedocs.json-supplied color double as the text
|
|
179
|
+
// color reintroduced the identical bug one level down, just requiring
|
|
180
|
+
// one more (still guessable-wrong) field to trigger it.
|
|
181
|
+
navbar: z.object({
|
|
182
|
+
light: navbarColorValueSchema.optional(),
|
|
183
|
+
dark: navbarColorValueSchema.optional()
|
|
184
|
+
}).strict().optional(),
|
|
185
|
+
// The single place to configure any background color: `colors` is a
|
|
186
|
+
// solid color (falls back to '#ffffff'/'#0b1120' when unset - see
|
|
187
|
+
// BaseLayout.astro's lightBackground/darkBackground resolution), and
|
|
188
|
+
// `images` layers an image on top of it (or is the whole background
|
|
189
|
+
// by itself, if `colors` is left at its default). This one pair now
|
|
190
|
+
// drives everything background-related site-wide - --wd-background
|
|
191
|
+
// (dropdowns/modals/kbd chips/footer/topbar-fallback/etc., see every
|
|
192
|
+
// `var(--wd-background)` call site) *and* the <body> canvas behind the
|
|
193
|
+
// main/toc columns - rather than the two being independently
|
|
194
|
+
// configurable fields that happened to look alike but didn't affect
|
|
195
|
+
// the same things (see this schema's own comment on `colors.dark`
|
|
196
|
+
// above for why that split was removed). Both are per-mode optional,
|
|
197
|
+
// same "only specify what differs" pattern as codeblocks/navbar above.
|
|
198
|
+
// The topbar and footer still get their own explicit opaque
|
|
199
|
+
// background (topbar.css/footer.css) specifically so an `images`
|
|
200
|
+
// background doesn't show through them; see those files' own
|
|
201
|
+
// comments. The sidebar column has no background of its own (base.css)
|
|
202
|
+
// and lets it show through, same as the main/toc columns.
|
|
203
|
+
background: z.object({
|
|
204
|
+
colors: z.object({
|
|
205
|
+
light: z.string().optional(),
|
|
206
|
+
dark: z.string().optional()
|
|
207
|
+
}).strict().optional(),
|
|
208
|
+
images: z.object({
|
|
209
|
+
light: z.string().optional(),
|
|
210
|
+
dark: z.string().optional()
|
|
211
|
+
}).strict().optional()
|
|
212
|
+
}).strict().optional()
|
|
213
|
+
}).default({ colors: { primary: DEFAULT_PRIMARY } });
|
|
214
|
+
const topbarLinkSchema = z.object({
|
|
215
|
+
label: z.string().optional(),
|
|
216
|
+
href: z.string(),
|
|
217
|
+
// Same string shape as every other writedocs.json `icon` field (a plain
|
|
218
|
+
// Lucide name, or the explicit "collection:icon-name" form for
|
|
219
|
+
// anything else - see resolveIcon()/AppIcon.astro) - not documented
|
|
220
|
+
// again here since that convention is already established elsewhere
|
|
221
|
+
// in this file (tabs/switchers/socials all take the identical shape).
|
|
222
|
+
icon: z.string().optional()
|
|
223
|
+
}).strict().refine((link) => Boolean(link.label) || Boolean(link.icon), {
|
|
224
|
+
message: 'Each topbar.links/footer column link needs a "label", an "icon", or both - a link with neither has nothing to render.'
|
|
225
|
+
});
|
|
226
|
+
const footerColumnSchema = z.object({
|
|
227
|
+
title: z.string().optional(),
|
|
228
|
+
links: z.array(topbarLinkSchema).default([])
|
|
229
|
+
}).strict();
|
|
230
|
+
const footerSchema = z.object({
|
|
231
|
+
columns: z.array(footerColumnSchema).default([]),
|
|
232
|
+
logo: logoSchema.optional()
|
|
233
|
+
}).strict().default({ columns: [] });
|
|
234
|
+
const seoFieldsSchema = z.object({
|
|
235
|
+
// Open Graph / Twitter card image - a relative path (resolved against
|
|
236
|
+
// `domain` below into an absolute URL, since most social crawlers
|
|
237
|
+
// require one) or an already-absolute https:// URL.
|
|
238
|
+
ogImage: z.string().optional(),
|
|
239
|
+
// og:type - "website" for most pages, "article" for blog-post-shaped
|
|
240
|
+
// content, etc. Defaults to "website" if never set anywhere.
|
|
241
|
+
ogType: z.string().optional(),
|
|
242
|
+
twitterCard: z.enum(["summary", "summary_large_image"]).optional(),
|
|
243
|
+
keywords: z.array(z.string()).optional(),
|
|
244
|
+
// Renders <meta name="robots" content="noindex, nofollow" /> and
|
|
245
|
+
// (see astro.config.mjs's sitemap `filter`) excludes the page from
|
|
246
|
+
// sitemap.xml entirely - both driven off this one flag, since listing
|
|
247
|
+
// a page in the sitemap while also telling crawlers not to index it
|
|
248
|
+
// would be self-contradictory.
|
|
249
|
+
noindex: z.boolean().optional()
|
|
250
|
+
}).strict();
|
|
251
|
+
function mergeSeo(site, page) {
|
|
252
|
+
if (!page) return site;
|
|
253
|
+
return {
|
|
254
|
+
ogImage: page.ogImage ?? site.ogImage,
|
|
255
|
+
ogType: page.ogType ?? site.ogType,
|
|
256
|
+
twitterCard: page.twitterCard ?? site.twitterCard,
|
|
257
|
+
keywords: page.keywords ?? site.keywords,
|
|
258
|
+
noindex: page.noindex ?? site.noindex
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const apiSchema = z.object({
|
|
262
|
+
// Whether the Try-it modal's Send button routes its request through
|
|
263
|
+
// writedocs' own CORS proxy (https://proxy.writechoice.io/) rather
|
|
264
|
+
// than calling the API directly from the browser. Almost no
|
|
265
|
+
// real-world API sends back Access-Control-Allow-Origin headers
|
|
266
|
+
// permitting an arbitrary docs site's origin, so a direct browser
|
|
267
|
+
// fetch() from the Try-it modal fails for most real APIs without
|
|
268
|
+
// this - see PROXY_BASE_URL in ApiReferencePanel.astro for the
|
|
269
|
+
// actual request-forwarding logic. Defaults to enabled; a site can
|
|
270
|
+
// set this to `false` to always call the API directly instead (the
|
|
271
|
+
// API is already CORS-permissive, same-origin in some deployments,
|
|
272
|
+
// or the site owner doesn't want requests routed through a third
|
|
273
|
+
// party at all).
|
|
274
|
+
proxy: z.boolean().default(true)
|
|
275
|
+
}).strict().default({ proxy: true });
|
|
276
|
+
const contextMenuSchema = z.object({
|
|
277
|
+
openIn: z.array(z.enum(["chatgpt", "claude", "perplexity"])).default(["chatgpt", "claude", "perplexity"])
|
|
278
|
+
}).strict();
|
|
279
|
+
const redirectSchema = z.object({
|
|
280
|
+
source: z.string(),
|
|
281
|
+
destination: z.string()
|
|
282
|
+
}).strict();
|
|
283
|
+
const bannerSchema = z.object({
|
|
284
|
+
content: z.string(),
|
|
285
|
+
// Persisted via localStorage once dismissed (see BaseLayout.astro's
|
|
286
|
+
// initBanner()) - a static site has no server-side session to track
|
|
287
|
+
// this against, so "dismissed" only ever means "dismissed in this
|
|
288
|
+
// browser". A banner without `dismissible` reappears on every visit,
|
|
289
|
+
// appropriate for something that should stay visible until the site
|
|
290
|
+
// author removes it from writedocs.json themselves (e.g. "this version is
|
|
291
|
+
// deprecated"), not something a reader can permanently clear.
|
|
292
|
+
dismissible: z.boolean().default(false),
|
|
293
|
+
type: z.enum(["info", "warning", "critical"]).default("info")
|
|
294
|
+
}).strict();
|
|
295
|
+
const notFoundSchema = z.object({
|
|
296
|
+
title: z.string().optional(),
|
|
297
|
+
description: z.string().optional()
|
|
298
|
+
}).strict().default({});
|
|
299
|
+
const scriptEntrySchema = z.object({
|
|
300
|
+
src: z.string().optional(),
|
|
301
|
+
content: z.string().optional()
|
|
302
|
+
}).strict().refine((v) => v.src ? !v.content : !!v.content, {
|
|
303
|
+
message: 'Each scripts.head/scripts.body entry needs exactly one of "src" or "content", not both or neither.'
|
|
304
|
+
});
|
|
305
|
+
const scriptsSchema = z.object({
|
|
306
|
+
head: z.array(scriptEntrySchema).default([]),
|
|
307
|
+
body: z.array(scriptEntrySchema).default([])
|
|
308
|
+
}).strict().default({ head: [], body: [] });
|
|
309
|
+
const ga4IntegrationSchema = z.object({
|
|
310
|
+
// A GA4 "Measurement ID", always shaped like "G-XXXXXXXXXX" - not
|
|
311
|
+
// validated against that shape here, since Google could change the
|
|
312
|
+
// prefix/length without this schema needing to track it.
|
|
313
|
+
measurementId: z.string()
|
|
314
|
+
}).strict();
|
|
315
|
+
const googleTagManagerIntegrationSchema = z.object({
|
|
316
|
+
// A GTM container id, shaped like "GTM-XXXXXXX".
|
|
317
|
+
containerId: z.string()
|
|
318
|
+
}).strict();
|
|
319
|
+
const plausibleIntegrationSchema = z.object({
|
|
320
|
+
domain: z.string(),
|
|
321
|
+
// Plausible's own hosted script URL by default - override for a
|
|
322
|
+
// self-hosted instance, a reverse-proxy path (Plausible's own
|
|
323
|
+
// documented way to dodge ad-blockers), or one of Plausible's
|
|
324
|
+
// documented script *extensions* (e.g.
|
|
325
|
+
// "https://plausible.io/js/script.hash.outbound-links.js").
|
|
326
|
+
src: z.string().default("https://plausible.io/js/script.js")
|
|
327
|
+
}).strict();
|
|
328
|
+
const fathomIntegrationSchema = z.object({
|
|
329
|
+
// Fathom's own short "Site ID", not a full URL or measurement id.
|
|
330
|
+
siteId: z.string()
|
|
331
|
+
}).strict();
|
|
332
|
+
const posthogIntegrationSchema = z.object({
|
|
333
|
+
// PostHog calls this a "project API key" (starts with "phc_") in its
|
|
334
|
+
// own docs - `apiKey` here rather than that exact name, matching this
|
|
335
|
+
// schema's own plainer naming convention for the equivalent id on
|
|
336
|
+
// every other provider above.
|
|
337
|
+
apiKey: z.string(),
|
|
338
|
+
// PostHog is region-sharded (US/EU) with no single universal default
|
|
339
|
+
// - "https://us.i.posthog.com" is PostHog's own default for a new
|
|
340
|
+
// project, but a site on their EU cloud (or a self-hosted instance)
|
|
341
|
+
// must override this to the host shown in their own project
|
|
342
|
+
// settings, or events silently go nowhere.
|
|
343
|
+
apiHost: z.string().default("https://us.i.posthog.com")
|
|
344
|
+
}).strict();
|
|
345
|
+
const umamiIntegrationSchema = z.object({
|
|
346
|
+
websiteId: z.string(),
|
|
347
|
+
// Unlike Plausible/PostHog, Umami has no single hosted default that
|
|
348
|
+
// works for most users - it's commonly self-hosted, and even Umami
|
|
349
|
+
// Cloud users get a per-region script URL. Defaults to Umami Cloud's
|
|
350
|
+
// own documented script URL, which only happens to be correct for a
|
|
351
|
+
// Cloud user on that specific region; anyone self-hosting (the more
|
|
352
|
+
// common case for this particular provider) must override it to
|
|
353
|
+
// their own instance's own /script.js path.
|
|
354
|
+
src: z.string().default("https://cloud.umami.is/script.js")
|
|
355
|
+
}).strict();
|
|
356
|
+
const askAiIntegrationSchema = z.object({
|
|
357
|
+
id: z.string()
|
|
358
|
+
}).strict();
|
|
359
|
+
const integrationsSchema = z.object({
|
|
360
|
+
ga4: ga4IntegrationSchema.optional(),
|
|
361
|
+
googleTagManager: googleTagManagerIntegrationSchema.optional(),
|
|
362
|
+
plausible: plausibleIntegrationSchema.optional(),
|
|
363
|
+
fathom: fathomIntegrationSchema.optional(),
|
|
364
|
+
posthog: posthogIntegrationSchema.optional(),
|
|
365
|
+
umami: umamiIntegrationSchema.optional(),
|
|
366
|
+
askAi: askAiIntegrationSchema.optional()
|
|
367
|
+
}).strict().default({});
|
|
368
|
+
const docsConfigSchema = z.object({
|
|
369
|
+
name: z.string(),
|
|
370
|
+
description: z.string().optional(),
|
|
371
|
+
styles: stylesSchema,
|
|
372
|
+
navigation: navigationSchema,
|
|
373
|
+
// Platform name -> profile/page URL (e.g. `{ "github": "https://
|
|
374
|
+
// github.com/...", "twitter": "https://twitter.com/..." }`), free-form
|
|
375
|
+
// rather than an enum of known platforms - the key doubles as the icon
|
|
376
|
+
// reference BaseLayout.astro's footer resolves via resolveIcon() below
|
|
377
|
+
// (bare `"github"` -> `lucide:github`; use an explicit
|
|
378
|
+
// `"simple-icons:whatever"` key instead when the bare lucide name isn't
|
|
379
|
+
// right for a given platform). Rendered as a row of icon links in the
|
|
380
|
+
// footer - see hasFooterContent/`.wd-footer-socials` in BaseLayout.astro.
|
|
381
|
+
socials: z.record(z.string(), z.string()).default({}),
|
|
382
|
+
topbar: z.object({ links: z.array(topbarLinkSchema).default([]) }).default({ links: [] }),
|
|
383
|
+
// Columns of links below the page content - see footerSchema/
|
|
384
|
+
// footerColumnSchema above. Renders alongside `socials` above (as a row
|
|
385
|
+
// of icon links) in the same <footer> - see BaseLayout.astro.
|
|
386
|
+
footer: footerSchema,
|
|
387
|
+
api: apiSchema,
|
|
388
|
+
// The site's own deployed domain, e.g. "docs.example.com" or
|
|
389
|
+
// "https://docs.example.com" (the scheme is optional - resolveSiteUrl()
|
|
390
|
+
// below normalizes either form to a full https:// origin). Powers three
|
|
391
|
+
// things that all need an absolute origin to work: sitemap.xml
|
|
392
|
+
// generation (astro.config.mjs only registers @astrojs/sitemap when this
|
|
393
|
+
// is set - a sitemap of relative URLs isn't meaningful), the canonical
|
|
394
|
+
// <link> and og:url/twitter meta tags (BaseLayout.astro), and resolving
|
|
395
|
+
// a relative `seo.ogImage` into an absolute URL for social crawlers.
|
|
396
|
+
// Left optional and simply skipped (with a log message, not an error) at
|
|
397
|
+
// every one of those call sites when unset - most fixtures/local builds
|
|
398
|
+
// have no real deployed domain yet.
|
|
399
|
+
domain: z.string().optional(),
|
|
400
|
+
// Site-wide meta tag defaults - see seoFieldsSchema above. A page's own
|
|
401
|
+
// frontmatter `seo` (content.config.ts's docsSchema) overrides these
|
|
402
|
+
// field by field via mergeSeo(), rather than needing to repeat every
|
|
403
|
+
// field on every page.
|
|
404
|
+
seo: seoFieldsSchema.default({}),
|
|
405
|
+
// The "Copy page" dropdown - see contextMenuSchema above. Absent by
|
|
406
|
+
// default (no menu, no .md routes).
|
|
407
|
+
contextMenu: contextMenuSchema.optional(),
|
|
408
|
+
// See redirectSchema above. Wired directly into Astro's own `redirects`
|
|
409
|
+
// config option in astro.config.mjs.
|
|
410
|
+
redirects: z.array(redirectSchema).default([]),
|
|
411
|
+
// Site-wide `[[key]]` substitution values, applied to page prose text
|
|
412
|
+
// at build time (see the remarkSubstituteVariables plugin wired into
|
|
413
|
+
// astro.config.mjs) - e.g. `{ "productName": "Acme" }` lets every page
|
|
414
|
+
// write `[[productName]]` once instead of hardcoding it everywhere.
|
|
415
|
+
// Double square brackets, not the more familiar `{{key}}` - MDX
|
|
416
|
+
// reserves single curly braces for embedded JS expressions, so
|
|
417
|
+
// `{{productName}}` in an .mdx file parses as a JS object-literal
|
|
418
|
+
// expression (`{productName}`, shorthand syntax) rather than literal
|
|
419
|
+
// text, and throws `ReferenceError: productName is not defined` at
|
|
420
|
+
// render time - see remarkSubstituteVariables' own comment in
|
|
421
|
+
// mdx-substitute-variables.js for the full explanation. Deliberately a
|
|
422
|
+
// flat string map, not typed values - this is text substitution into
|
|
423
|
+
// prose, not a templating language.
|
|
424
|
+
variables: z.record(z.string(), z.string()).default({}),
|
|
425
|
+
// See bannerSchema above. Absent by default (no banner rendered).
|
|
426
|
+
banner: bannerSchema.optional(),
|
|
427
|
+
// See notFoundSchema above.
|
|
428
|
+
notFound: notFoundSchema,
|
|
429
|
+
// See scriptsSchema above.
|
|
430
|
+
scripts: scriptsSchema,
|
|
431
|
+
// See integrationsSchema above.
|
|
432
|
+
integrations: integrationsSchema
|
|
433
|
+
});
|
|
434
|
+
function positionFromJsonError(message, rawText) {
|
|
435
|
+
const comLinha = message.match(/line (\d+) column (\d+)/);
|
|
436
|
+
if (comLinha) return { line: Number(comLinha[1]), column: Number(comLinha[2]) };
|
|
437
|
+
const comPosicao = message.match(/position (\d+)/);
|
|
438
|
+
if (comPosicao) {
|
|
439
|
+
const pos = Math.min(Number(comPosicao[1]), rawText.length);
|
|
440
|
+
const antes = rawText.slice(0, pos);
|
|
441
|
+
const line = antes.split("\n").length;
|
|
442
|
+
return { line, column: pos - antes.lastIndexOf("\n") };
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
function formatValidationIssues(issues) {
|
|
447
|
+
return issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n");
|
|
448
|
+
}
|
|
449
|
+
function validateDocsConfig(rawText) {
|
|
450
|
+
let raw;
|
|
451
|
+
try {
|
|
452
|
+
raw = JSON.parse(rawText);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
const parseError = err;
|
|
455
|
+
const posicao = positionFromJsonError(parseError.message, rawText);
|
|
456
|
+
return {
|
|
457
|
+
ok: false,
|
|
458
|
+
data: null,
|
|
459
|
+
kind: "invalid_json",
|
|
460
|
+
parseError,
|
|
461
|
+
issues: [{ path: "(root)", message: parseError.message, code: "invalid_json", ...posicao ?? {} }]
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const result = docsConfigSchema.safeParse(raw);
|
|
465
|
+
if (!result.success) {
|
|
466
|
+
return {
|
|
467
|
+
ok: false,
|
|
468
|
+
data: null,
|
|
469
|
+
kind: "schema",
|
|
470
|
+
issues: result.error.issues.map((i) => ({
|
|
471
|
+
path: i.path.join(".") || "(root)",
|
|
472
|
+
message: i.message,
|
|
473
|
+
code: i.code
|
|
474
|
+
}))
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
return { ok: true, data: result.data, issues: [] };
|
|
478
|
+
}
|
|
479
|
+
export {
|
|
480
|
+
docsConfigSchema,
|
|
481
|
+
formatValidationIssues,
|
|
482
|
+
mergeSeo,
|
|
483
|
+
seoFieldsSchema,
|
|
484
|
+
validateDocsConfig
|
|
485
|
+
};
|