@writedocs/generator 0.2.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/bin/writedocs.js CHANGED
@@ -8,6 +8,21 @@ import { runDev } from '../src/cli/dev.js';
8
8
  import { runBuild } from '../src/cli/build.js';
9
9
  import { runInit } from '../src/cli/init.js';
10
10
  import { requireBuildKey } from '../src/cli/build-auth.js';
11
+ // O MESMO modulo que o build usa (via loadDocsConfig, que reexporta daqui) e
12
+ // que a plataforma importa por `@writedocs/generator/config-schema` - e o que
13
+ // faz os tres reportarem os mesmos problemas com as mesmas palavras, em vez de
14
+ // cada um manter a sua copia do schema.
15
+ //
16
+ // `.js`, e nao o `.ts` que e a fonte de verdade: este arquivo e carregado por
17
+ // node puro, sem o transform do Astro/Vite, e o Node RECUSA type stripping para
18
+ // qualquer arquivo sob node_modules - por desenho, em qualquer versao
19
+ // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). Ou seja: importar o `.ts` aqui
20
+ // funciona a partir do checkout e falha 100% das vezes para quem instala o
21
+ // pacote, que foi exatamente o que quebrou o `validate` no 0.2.0. O `.js` e
22
+ // gerado do `.ts` pelo script `prepare` (package.json), entao ele existe tanto
23
+ // no checkout quanto dentro do tarball, e o consumidor nao roda build nenhum.
24
+ // Ver a mesma regra ja escrita em src/cli/write-redirects-file.js.
25
+ import { validateDocsConfig, formatValidationIssues } from '../src/lib/config-schema.js';
11
26
 
12
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
28
  const packageRoot = path.resolve(__dirname, '..');
@@ -77,28 +92,6 @@ program
77
92
  process.exit(1);
78
93
  }
79
94
 
80
- // O MESMO modulo que o build usa (via loadDocsConfig) e que a plataforma
81
- // importa por `@writedocs/generator/config-schema` - e o que faz os tres
82
- // reportarem os mesmos problemas com as mesmas palavras, em vez de cada um
83
- // manter a sua copia do schema.
84
- //
85
- // Import dinamico porque isto e um `.ts` sendo carregado por node puro (sem
86
- // o transform do Astro/Vite): funciona com o type stripping nativo do Node,
87
- // estavel a partir do 22.18/23.6. O astro deste pacote ja exige >=22.12, mas
88
- // entre 22.12 e 22.18 o import falharia com uma mensagem cifrada sobre
89
- // "Unknown file extension .ts" - a mensagem abaixo diz o que fazer.
90
- let validateDocsConfig;
91
- let formatValidationIssues;
92
- try {
93
- ({ validateDocsConfig, formatValidationIssues } = await import('../src/lib/config-schema.ts'));
94
- } catch (err) {
95
- console.error(
96
- `[writedocs] validate needs a Node that can load TypeScript directly (Node 22.18+ or 23.6+); this one is ${process.version}.`
97
- );
98
- console.error(`[writedocs] ${err.message}`);
99
- process.exit(1);
100
- }
101
-
102
95
  const result = validateDocsConfig(fs.readFileSync(configPath, 'utf-8'));
103
96
  if (result.ok) {
104
97
  console.log(`[writedocs] ${configPath} is valid.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Static site generator for docs — a writedocs.json + MDX folder in, a static site out.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "exports": {
10
10
  "./components": "./src/components/index.ts",
11
- "./config-schema": "./src/lib/config-schema.ts"
11
+ "./config-schema": "./src/lib/config-schema.js"
12
12
  },
13
13
  "files": [
14
14
  "bin",
@@ -18,7 +18,9 @@
18
18
  "scripts": {
19
19
  "dev": "node bin/writedocs.js dev",
20
20
  "build": "node bin/writedocs.js build",
21
- "init": "node bin/writedocs.js init"
21
+ "init": "node bin/writedocs.js init",
22
+ "build:schema": "esbuild src/lib/config-schema.ts --format=esm --target=node22 --outfile=src/lib/config-schema.js",
23
+ "prepare": "npm run build:schema"
22
24
  },
23
25
  "keywords": [
24
26
  "docs",
@@ -75,6 +77,9 @@
75
77
  "zod": "^4.4.3"
76
78
  },
77
79
  "engines": {
78
- "node": ">=22.18.0"
80
+ "node": ">=22.12.0"
81
+ },
82
+ "devDependencies": {
83
+ "esbuild": "0.28.2"
79
84
  }
80
85
  }
@@ -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
+ };
@@ -12,6 +12,13 @@
12
12
  // Por isso a unica dependencia aqui e o zod. Nada neste arquivo pode passar a
13
13
  // tocar o sistema de arquivos: se precisar de fs/path, o lugar e o config.ts.
14
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
+ //
15
22
  // (`zod` e nao `astro/zod`: as duas especificacoes resolvem pra mesma instalacao
16
23
  // - node_modules/zod 4.4.3, que e o que astro@7.2.2 tambem pede via `^4.3.6` -
17
24
  // entao a troca nao muda comportamento nenhum, so tira o astro do caminho de
package/src/lib/config.ts CHANGED
@@ -23,6 +23,22 @@ import {
23
23
  // mantem a superficie deste arquivo exatamente como era - quem importa
24
24
  // `docsConfigSchema`, `DocsConfig`, `seoFieldsSchema`, `mergeSeo` etc. de
25
25
  // './config.js' continua importando do mesmo lugar, sem mudar uma linha.
26
+ //
27
+ // `.ts` e nao o `./config-schema.js` gerado, de proposito - e a unica coisa no
28
+ // repositorio que ainda aponta pra fonte, e o bin/ e o `exports` apontam pro
29
+ // `.js`. O motivo e que este reexport carrega 24 `export type`/`interface`
30
+ // (DocsConfig, NavItem, Selector, GlobalDropdownView, ContextMenuConfig...) que
31
+ // uma duzia de .astro consome como `import type { X } from '../lib/config'`, e
32
+ // o esbuild apaga todos eles: o `.js` gerado exporta exatamente 5 simbolos de
33
+ // runtime. Este repositorio nao tem tsconfig.json nem o typescript instalado,
34
+ // entao (a) nada configura o mapeamento `.js` -> `.ts` que faria os tipos
35
+ // voltarem e (b) nao existe typecheck que reclamasse - a troca so apagaria os
36
+ // tipos em silencio. Aqui tudo passa pelo transform do Astro/Vite, que le `.ts`
37
+ // nativamente, entao o `.js` nao resolve problema nenhum deste lado.
38
+ //
39
+ // O custo aceito e que o tarball leva as duas copias e um build do Astro pode
40
+ // carregar as duas (o `.ts` por aqui, o `.js` por quem importa o subpath) - duas
41
+ // instancias do schema, inofensivas porque nada compara identidade de schema.
26
42
  export * from './config-schema.ts';
27
43
 
28
44
  /** Normalizes writedocs.json's `domain` into a full origin with no trailing