@writedocs/generator 0.2.0 → 0.3.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/bin/writedocs.js CHANGED
@@ -8,6 +8,25 @@ 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 {
26
+ validateDocsConfig,
27
+ formatValidationIssuesDetailed,
28
+ unknownRootKeyIssues,
29
+ } from '../src/lib/config-schema.js';
11
30
 
12
31
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
32
  const packageRoot = path.resolve(__dirname, '..');
@@ -77,39 +96,33 @@ program
77
96
  process.exit(1);
78
97
  }
79
98
 
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);
99
+ const rawText = fs.readFileSync(configPath, 'utf-8');
100
+ const result = validateDocsConfig(rawText);
101
+ // Chave desconhecida na raiz e AVISO, nunca erro: a raiz nao e `.strict()`
102
+ // de proposito (tornar strict quebraria configs existentes), entao o Zod
103
+ // descarta a chave em silencio. Sem JSON parseavel nao ha chave pra avaliar.
104
+ const warnings = result.kind === 'invalid_json' ? [] : unknownRootKeyIssues(rawText);
105
+ const nomeArquivo = path.basename(configPath);
106
+
107
+ if (!result.ok) {
108
+ // A versao longa (linha, frase humana, sugestao) em vez da curta que o
109
+ // `writedocs build` imprime: aqui o usuario pediu explicitamente um
110
+ // diagnostico, e tem a tela inteira pra ele.
111
+ console.error(`[writedocs] ${configPath} failed validation:\n`);
112
+ console.error(formatValidationIssuesDetailed(result.issues, { fileName: nomeArquivo }));
113
+ console.error('');
100
114
  }
101
115
 
102
- const result = validateDocsConfig(fs.readFileSync(configPath, 'utf-8'));
103
- if (result.ok) {
104
- console.log(`[writedocs] ${configPath} is valid.`);
105
- return;
116
+ if (warnings.length > 0) {
117
+ console.error(`[writedocs] ${warnings.length} warning${warnings.length === 1 ? '' : 's'}:\n`);
118
+ console.error(formatValidationIssuesDetailed(warnings, { fileName: nomeArquivo }));
119
+ console.error('');
106
120
  }
107
121
 
108
- // Mesmo formato que o build imprime num config invalido - mesmo texto,
109
- // mesma ordem, montado pelo mesmo formatador.
110
- console.error('[writedocs] writedocs.json failed validation:');
111
- console.error(formatValidationIssues(result.issues));
112
- process.exit(1);
122
+ if (!result.ok) process.exit(1);
123
+ // Aviso nao invalida: um config so com avisos continua valido, e sai 0 -
124
+ // o mesmo criterio que a plataforma usa pra marcar o projeto como `valid`.
125
+ console.log(`[writedocs] ${configPath} is valid.`);
113
126
  });
114
127
 
115
128
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",
@@ -63,6 +65,7 @@
63
65
  "commander": "^15.0.0",
64
66
  "dotenv": "^17.4.2",
65
67
  "gray-matter": "^4.0.3",
68
+ "jsonc-parser": "3.3.1",
66
69
  "katex": "^0.16.47",
67
70
  "mermaid": "^11.16.0",
68
71
  "pagefind": "^1.5.2",
@@ -75,6 +78,9 @@
75
78
  "zod": "^4.4.3"
76
79
  },
77
80
  "engines": {
78
- "node": ">=22.18.0"
81
+ "node": ">=22.12.0"
82
+ },
83
+ "devDependencies": {
84
+ "esbuild": "0.28.2"
79
85
  }
80
86
  }
@@ -0,0 +1,692 @@
1
+ import { z } from "zod";
2
+ import { findNodeAtLocation, parseTree } from "jsonc-parser";
3
+ const navPageSchema = z.string();
4
+ const navGroupPagesSchema = z.lazy(
5
+ () => z.object({
6
+ group: z.string(),
7
+ page: z.string().optional(),
8
+ pages: z.array(navItemSchema)
9
+ }).strict()
10
+ );
11
+ const navGroupOpenApiSchema = z.object({
12
+ group: z.string(),
13
+ openapi: z.object({
14
+ // Path to an OpenAPI 3.x spec, relative to the content directory
15
+ // (alongside writedocs.json) - same convention the old top-level
16
+ // `openapi` field used. See generate-api-pages.js (the pre-Astro
17
+ // build/dev step that actually parses this and writes the
18
+ // manifest/operation JSON this group's pages are expanded from).
19
+ src: z.string(),
20
+ // Base URL path every page generated from this spec is namespaced
21
+ // under, e.g. "/api" -> pages served at /api/<tag>/<operation>/.
22
+ // Also doubles as this spec's own unique key under
23
+ // writedocsTempDir()'s openapi/<path>/ directory (manifest.json + operations/*.json),
24
+ // so two openapi groups in the same writedocs.json must use different
25
+ // `path` values - generate-api-pages.js throws a clear error if
26
+ // they collide.
27
+ path: z.string()
28
+ }).strict()
29
+ }).strict();
30
+ const navGroupSchema = z.union([navGroupPagesSchema, navGroupOpenApiSchema]);
31
+ const navLinkSchema = z.object({ label: z.string(), href: z.string() }).strict();
32
+ const navItemSchema = z.lazy(
33
+ () => z.union([navPageSchema, navGroupSchema, navLinkSchema])
34
+ );
35
+ function withChildren(base) {
36
+ return z.union([
37
+ z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
38
+ z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
39
+ z.object({ ...base, versions: z.array(versionSchema).min(1) }).strict(),
40
+ z.object({ ...base, languages: z.array(languageSchema).min(1) }).strict(),
41
+ z.object({ ...base, dropdowns: z.array(dropdownSchema).min(1) }).strict(),
42
+ z.object({ ...base, products: z.array(productSchema).min(1) }).strict(),
43
+ z.object({ ...base, href: z.string() }).strict()
44
+ ]);
45
+ }
46
+ const tabSchema = z.lazy(
47
+ () => withChildren({ tab: z.string(), icon: z.string().optional() })
48
+ );
49
+ const versionSchema = z.lazy(
50
+ () => withChildren({
51
+ version: z.string(),
52
+ label: z.string().optional(),
53
+ tag: z.string().optional(),
54
+ default: z.boolean().optional()
55
+ })
56
+ );
57
+ const languageSchema = z.lazy(
58
+ () => withChildren({ language: z.string(), label: z.string().optional() })
59
+ );
60
+ const dropdownSchema = z.lazy(
61
+ () => withChildren({ dropdown: z.string(), icon: z.string().optional() })
62
+ );
63
+ const productSchema = z.lazy(
64
+ () => withChildren({ product: z.string(), icon: z.string().optional(), description: z.string().optional() })
65
+ );
66
+ const globalSchema = z.object({ dropdowns: z.array(dropdownSchema).min(1) });
67
+ const navigationSchema = z.union([
68
+ z.array(navItemSchema),
69
+ z.object({ global: globalSchema.optional(), tabs: z.array(tabSchema).min(1) }).strict(),
70
+ z.object({ global: globalSchema.optional(), versions: z.array(versionSchema).min(1) }).strict(),
71
+ z.object({ global: globalSchema.optional(), languages: z.array(languageSchema).min(1) }).strict(),
72
+ z.object({ global: globalSchema.optional(), dropdowns: z.array(dropdownSchema).min(1) }).strict(),
73
+ z.object({ global: globalSchema.optional(), products: z.array(productSchema).min(1) }).strict()
74
+ ]);
75
+ const DEFAULT_PRIMARY = "#6366f1";
76
+ const logoSchema = z.union([
77
+ z.string(),
78
+ z.object({ light: z.string().optional(), dark: z.string().optional(), label: z.string().optional() }).strict()
79
+ ]);
80
+ const navbarColorValueSchema = z.union([
81
+ z.string(),
82
+ z.object({
83
+ background: z.string(),
84
+ accent: z.string().optional()
85
+ }).strict()
86
+ ]);
87
+ const fontVariantSchema = z.object({
88
+ family: z.string(),
89
+ weight: z.number().optional(),
90
+ source: z.string().optional(),
91
+ format: z.enum(["woff", "woff2"]).optional()
92
+ }).strict();
93
+ const fontsSchema = z.object({
94
+ family: z.string(),
95
+ weight: z.number().optional(),
96
+ source: z.string().optional(),
97
+ format: z.enum(["woff", "woff2"]).optional(),
98
+ heading: fontVariantSchema.optional(),
99
+ body: fontVariantSchema.optional()
100
+ }).strict();
101
+ const stylesSchema = z.object({
102
+ colors: z.object({
103
+ primary: z.string().default(DEFAULT_PRIMARY),
104
+ text: z.string().optional(),
105
+ dark: z.object({
106
+ primary: z.string().optional(),
107
+ text: z.string().optional()
108
+ }).optional()
109
+ }).default({ primary: DEFAULT_PRIMARY }),
110
+ logo: logoSchema.optional(),
111
+ favicon: z.string().optional(),
112
+ // No default value here (unlike `colors.primary`'s DEFAULT_PRIMARY) -
113
+ // resolveFonts() below is where "Inter" actually gets applied as the
114
+ // site-wide default whenever this field is left unset entirely, so
115
+ // that fallback lives in one place alongside the rest of the
116
+ // resolution logic rather than being duplicated as a schema default
117
+ // *and* a resolver fallback.
118
+ fonts: fontsSchema.optional(),
119
+ // The Shiki theme *name* (not a full theme object/JSON - see
120
+ // https://shiki.style/themes for the built-in list) each fenced
121
+ // (```) MDX code block, and the API playground's request/response
122
+ // snippets, use in light/dark mode - read out of writedocs.json by both
123
+ // astro.config.mjs (Shiki's dual-theme markdown config) and
124
+ // ApiReferencePanel.astro (its own separate <Code/> usages, which
125
+ // don't inherit markdown.shikiConfig) via resolveCodeblockTheme()
126
+ // below, the single source of truth for the 'github-light'/
127
+ // 'github-dark' fallback. Left per-key optional (not defaulted in
128
+ // the schema itself) so a site can override just one side (e.g.
129
+ // only `dark`) and still get the ordinary default for the other.
130
+ codeblocks: z.object({
131
+ light: z.string().optional(),
132
+ dark: z.string().optional(),
133
+ // Maps a fenced (```) block's own language tag to whichever real
134
+ // Shiki grammar actually highlights it, before that tag ever
135
+ // reaches Shiki - see resolveCodeblockLangAlias() below for the
136
+ // one built-in entry (mdx -> jsx) this ships with by default and
137
+ // why. A site can add its own entries for any other language tag
138
+ // that either doesn't tokenize well under its own grammar, or
139
+ // that a site wants to write under a shorter/friendlier fence tag
140
+ // than Shiki's own bundled id (e.g. `groovy: 'java'` for a close-
141
+ // enough approximation Shiki doesn't ship a dedicated grammar for
142
+ // at all) - merged with, not replacing, the built-in default, so
143
+ // adding one of a site's own doesn't require re-declaring mdx.
144
+ langAlias: z.record(z.string(), z.string()).optional()
145
+ }).strict().optional(),
146
+ // The topbar's own background, independent of `background` below (the
147
+ // page's) - a site may want its navbar to stand out (a dark or
148
+ // brand-colored bar over a light page) rather than blend into the
149
+ // page, which is the default when this is left unset. Same {light,
150
+ // dark} shape (and same per-key-optional, not defaulted here) as
151
+ // `codeblocks` right above, rather than nested under `colors` - it's a
152
+ // topbar-specific override, not a third general-purpose page color, so
153
+ // it reads more like "one more themed surface" alongside codeblocks
154
+ // than a sibling of primary/text. BaseLayout.astro's own
155
+ // lightNavbarBg/darkNavbarBg resolution is what falls each side back
156
+ // to the page background when unset.
157
+ //
158
+ // Each side (`light`/`dark`) is either a bare string - just the
159
+ // background color, exactly the original shape, kept for backwards
160
+ // compatibility - or `{ background, accent? }`, for when a site also
161
+ // wants the navbar's own active-tab-fill/hover-underline color to
162
+ // differ from the sitewide `styles.colors.primary` (`accent`'s only
163
+ // job - see BaseLayout.astro's lightNavbarAccent). Deliberately no
164
+ // text/icon color field here at all: BaseLayout.astro always resolves
165
+ // the navbar's plain text/icon color itself, picking black or white by
166
+ // contrast against whatever `background` resolves to
167
+ // (contrastTextColor() below) the moment this field is configured at
168
+ // all (string or object) - never a value read from writedocs.json. A
169
+ // manually-set text color is a legibility footgun a site author can
170
+ // get wrong (or drift out of sync after later changing `background`
171
+ // without remembering to update it too) in a way "compute it
172
+ // correctly, every time" simply can't - there's no legitimate reason
173
+ // to want *illegible* navbar text, unlike `accent`, which is a real
174
+ // aesthetic choice. Real bug this fixes: a site setting `navbar.light`
175
+ // to the same value as `colors.primary` (a plausible thing to reach
176
+ // for - "brand-colored navbar") made every topbar label/icon/link
177
+ // render in the default near-black text color against that same
178
+ // saturated background, all but unreadable - and an earlier version of
179
+ // this feature that let a writedocs.json-supplied color double as the text
180
+ // color reintroduced the identical bug one level down, just requiring
181
+ // one more (still guessable-wrong) field to trigger it.
182
+ navbar: z.object({
183
+ light: navbarColorValueSchema.optional(),
184
+ dark: navbarColorValueSchema.optional()
185
+ }).strict().optional(),
186
+ // The single place to configure any background color: `colors` is a
187
+ // solid color (falls back to '#ffffff'/'#0b1120' when unset - see
188
+ // BaseLayout.astro's lightBackground/darkBackground resolution), and
189
+ // `images` layers an image on top of it (or is the whole background
190
+ // by itself, if `colors` is left at its default). This one pair now
191
+ // drives everything background-related site-wide - --wd-background
192
+ // (dropdowns/modals/kbd chips/footer/topbar-fallback/etc., see every
193
+ // `var(--wd-background)` call site) *and* the <body> canvas behind the
194
+ // main/toc columns - rather than the two being independently
195
+ // configurable fields that happened to look alike but didn't affect
196
+ // the same things (see this schema's own comment on `colors.dark`
197
+ // above for why that split was removed). Both are per-mode optional,
198
+ // same "only specify what differs" pattern as codeblocks/navbar above.
199
+ // The topbar and footer still get their own explicit opaque
200
+ // background (topbar.css/footer.css) specifically so an `images`
201
+ // background doesn't show through them; see those files' own
202
+ // comments. The sidebar column has no background of its own (base.css)
203
+ // and lets it show through, same as the main/toc columns.
204
+ background: z.object({
205
+ colors: z.object({
206
+ light: z.string().optional(),
207
+ dark: z.string().optional()
208
+ }).strict().optional(),
209
+ images: z.object({
210
+ light: z.string().optional(),
211
+ dark: z.string().optional()
212
+ }).strict().optional()
213
+ }).strict().optional()
214
+ }).default({ colors: { primary: DEFAULT_PRIMARY } });
215
+ const topbarLinkSchema = z.object({
216
+ label: z.string().optional(),
217
+ href: z.string(),
218
+ // Same string shape as every other writedocs.json `icon` field (a plain
219
+ // Lucide name, or the explicit "collection:icon-name" form for
220
+ // anything else - see resolveIcon()/AppIcon.astro) - not documented
221
+ // again here since that convention is already established elsewhere
222
+ // in this file (tabs/switchers/socials all take the identical shape).
223
+ icon: z.string().optional()
224
+ }).strict().refine((link) => Boolean(link.label) || Boolean(link.icon), {
225
+ message: 'Each topbar.links/footer column link needs a "label", an "icon", or both - a link with neither has nothing to render.'
226
+ });
227
+ const footerColumnSchema = z.object({
228
+ title: z.string().optional(),
229
+ links: z.array(topbarLinkSchema).default([])
230
+ }).strict();
231
+ const footerSchema = z.object({
232
+ columns: z.array(footerColumnSchema).default([]),
233
+ logo: logoSchema.optional()
234
+ }).strict().default({ columns: [] });
235
+ const seoFieldsSchema = z.object({
236
+ // Open Graph / Twitter card image - a relative path (resolved against
237
+ // `domain` below into an absolute URL, since most social crawlers
238
+ // require one) or an already-absolute https:// URL.
239
+ ogImage: z.string().optional(),
240
+ // og:type - "website" for most pages, "article" for blog-post-shaped
241
+ // content, etc. Defaults to "website" if never set anywhere.
242
+ ogType: z.string().optional(),
243
+ twitterCard: z.enum(["summary", "summary_large_image"]).optional(),
244
+ keywords: z.array(z.string()).optional(),
245
+ // Renders <meta name="robots" content="noindex, nofollow" /> and
246
+ // (see astro.config.mjs's sitemap `filter`) excludes the page from
247
+ // sitemap.xml entirely - both driven off this one flag, since listing
248
+ // a page in the sitemap while also telling crawlers not to index it
249
+ // would be self-contradictory.
250
+ noindex: z.boolean().optional()
251
+ }).strict();
252
+ function mergeSeo(site, page) {
253
+ if (!page) return site;
254
+ return {
255
+ ogImage: page.ogImage ?? site.ogImage,
256
+ ogType: page.ogType ?? site.ogType,
257
+ twitterCard: page.twitterCard ?? site.twitterCard,
258
+ keywords: page.keywords ?? site.keywords,
259
+ noindex: page.noindex ?? site.noindex
260
+ };
261
+ }
262
+ const apiSchema = z.object({
263
+ // Whether the Try-it modal's Send button routes its request through
264
+ // writedocs' own CORS proxy (https://proxy.writechoice.io/) rather
265
+ // than calling the API directly from the browser. Almost no
266
+ // real-world API sends back Access-Control-Allow-Origin headers
267
+ // permitting an arbitrary docs site's origin, so a direct browser
268
+ // fetch() from the Try-it modal fails for most real APIs without
269
+ // this - see PROXY_BASE_URL in ApiReferencePanel.astro for the
270
+ // actual request-forwarding logic. Defaults to enabled; a site can
271
+ // set this to `false` to always call the API directly instead (the
272
+ // API is already CORS-permissive, same-origin in some deployments,
273
+ // or the site owner doesn't want requests routed through a third
274
+ // party at all).
275
+ proxy: z.boolean().default(true)
276
+ }).strict().default({ proxy: true });
277
+ const contextMenuSchema = z.object({
278
+ openIn: z.array(z.enum(["chatgpt", "claude", "perplexity"])).default(["chatgpt", "claude", "perplexity"])
279
+ }).strict();
280
+ const redirectSchema = z.object({
281
+ source: z.string(),
282
+ destination: z.string()
283
+ }).strict();
284
+ const bannerSchema = z.object({
285
+ content: z.string(),
286
+ // Persisted via localStorage once dismissed (see BaseLayout.astro's
287
+ // initBanner()) - a static site has no server-side session to track
288
+ // this against, so "dismissed" only ever means "dismissed in this
289
+ // browser". A banner without `dismissible` reappears on every visit,
290
+ // appropriate for something that should stay visible until the site
291
+ // author removes it from writedocs.json themselves (e.g. "this version is
292
+ // deprecated"), not something a reader can permanently clear.
293
+ dismissible: z.boolean().default(false),
294
+ type: z.enum(["info", "warning", "critical"]).default("info")
295
+ }).strict();
296
+ const notFoundSchema = z.object({
297
+ title: z.string().optional(),
298
+ description: z.string().optional()
299
+ }).strict().default({});
300
+ const scriptEntrySchema = z.object({
301
+ src: z.string().optional(),
302
+ content: z.string().optional()
303
+ }).strict().refine((v) => v.src ? !v.content : !!v.content, {
304
+ message: 'Each scripts.head/scripts.body entry needs exactly one of "src" or "content", not both or neither.'
305
+ });
306
+ const scriptsSchema = z.object({
307
+ head: z.array(scriptEntrySchema).default([]),
308
+ body: z.array(scriptEntrySchema).default([])
309
+ }).strict().default({ head: [], body: [] });
310
+ const ga4IntegrationSchema = z.object({
311
+ // A GA4 "Measurement ID", always shaped like "G-XXXXXXXXXX" - not
312
+ // validated against that shape here, since Google could change the
313
+ // prefix/length without this schema needing to track it.
314
+ measurementId: z.string()
315
+ }).strict();
316
+ const googleTagManagerIntegrationSchema = z.object({
317
+ // A GTM container id, shaped like "GTM-XXXXXXX".
318
+ containerId: z.string()
319
+ }).strict();
320
+ const plausibleIntegrationSchema = z.object({
321
+ domain: z.string(),
322
+ // Plausible's own hosted script URL by default - override for a
323
+ // self-hosted instance, a reverse-proxy path (Plausible's own
324
+ // documented way to dodge ad-blockers), or one of Plausible's
325
+ // documented script *extensions* (e.g.
326
+ // "https://plausible.io/js/script.hash.outbound-links.js").
327
+ src: z.string().default("https://plausible.io/js/script.js")
328
+ }).strict();
329
+ const fathomIntegrationSchema = z.object({
330
+ // Fathom's own short "Site ID", not a full URL or measurement id.
331
+ siteId: z.string()
332
+ }).strict();
333
+ const posthogIntegrationSchema = z.object({
334
+ // PostHog calls this a "project API key" (starts with "phc_") in its
335
+ // own docs - `apiKey` here rather than that exact name, matching this
336
+ // schema's own plainer naming convention for the equivalent id on
337
+ // every other provider above.
338
+ apiKey: z.string(),
339
+ // PostHog is region-sharded (US/EU) with no single universal default
340
+ // - "https://us.i.posthog.com" is PostHog's own default for a new
341
+ // project, but a site on their EU cloud (or a self-hosted instance)
342
+ // must override this to the host shown in their own project
343
+ // settings, or events silently go nowhere.
344
+ apiHost: z.string().default("https://us.i.posthog.com")
345
+ }).strict();
346
+ const umamiIntegrationSchema = z.object({
347
+ websiteId: z.string(),
348
+ // Unlike Plausible/PostHog, Umami has no single hosted default that
349
+ // works for most users - it's commonly self-hosted, and even Umami
350
+ // Cloud users get a per-region script URL. Defaults to Umami Cloud's
351
+ // own documented script URL, which only happens to be correct for a
352
+ // Cloud user on that specific region; anyone self-hosting (the more
353
+ // common case for this particular provider) must override it to
354
+ // their own instance's own /script.js path.
355
+ src: z.string().default("https://cloud.umami.is/script.js")
356
+ }).strict();
357
+ const askAiIntegrationSchema = z.object({
358
+ id: z.string()
359
+ }).strict();
360
+ const integrationsSchema = z.object({
361
+ ga4: ga4IntegrationSchema.optional(),
362
+ googleTagManager: googleTagManagerIntegrationSchema.optional(),
363
+ plausible: plausibleIntegrationSchema.optional(),
364
+ fathom: fathomIntegrationSchema.optional(),
365
+ posthog: posthogIntegrationSchema.optional(),
366
+ umami: umamiIntegrationSchema.optional(),
367
+ askAi: askAiIntegrationSchema.optional()
368
+ }).strict().default({});
369
+ const docsConfigSchema = z.object({
370
+ name: z.string(),
371
+ description: z.string().optional(),
372
+ styles: stylesSchema,
373
+ navigation: navigationSchema,
374
+ // Platform name -> profile/page URL (e.g. `{ "github": "https://
375
+ // github.com/...", "twitter": "https://twitter.com/..." }`), free-form
376
+ // rather than an enum of known platforms - the key doubles as the icon
377
+ // reference BaseLayout.astro's footer resolves via resolveIcon() below
378
+ // (bare `"github"` -> `lucide:github`; use an explicit
379
+ // `"simple-icons:whatever"` key instead when the bare lucide name isn't
380
+ // right for a given platform). Rendered as a row of icon links in the
381
+ // footer - see hasFooterContent/`.wd-footer-socials` in BaseLayout.astro.
382
+ socials: z.record(z.string(), z.string()).default({}),
383
+ topbar: z.object({ links: z.array(topbarLinkSchema).default([]) }).default({ links: [] }),
384
+ // Columns of links below the page content - see footerSchema/
385
+ // footerColumnSchema above. Renders alongside `socials` above (as a row
386
+ // of icon links) in the same <footer> - see BaseLayout.astro.
387
+ footer: footerSchema,
388
+ api: apiSchema,
389
+ // The site's own deployed domain, e.g. "docs.example.com" or
390
+ // "https://docs.example.com" (the scheme is optional - resolveSiteUrl()
391
+ // below normalizes either form to a full https:// origin). Powers three
392
+ // things that all need an absolute origin to work: sitemap.xml
393
+ // generation (astro.config.mjs only registers @astrojs/sitemap when this
394
+ // is set - a sitemap of relative URLs isn't meaningful), the canonical
395
+ // <link> and og:url/twitter meta tags (BaseLayout.astro), and resolving
396
+ // a relative `seo.ogImage` into an absolute URL for social crawlers.
397
+ // Left optional and simply skipped (with a log message, not an error) at
398
+ // every one of those call sites when unset - most fixtures/local builds
399
+ // have no real deployed domain yet.
400
+ domain: z.string().optional(),
401
+ // Site-wide meta tag defaults - see seoFieldsSchema above. A page's own
402
+ // frontmatter `seo` (content.config.ts's docsSchema) overrides these
403
+ // field by field via mergeSeo(), rather than needing to repeat every
404
+ // field on every page.
405
+ seo: seoFieldsSchema.default({}),
406
+ // The "Copy page" dropdown - see contextMenuSchema above. Absent by
407
+ // default (no menu, no .md routes).
408
+ contextMenu: contextMenuSchema.optional(),
409
+ // See redirectSchema above. Wired directly into Astro's own `redirects`
410
+ // config option in astro.config.mjs.
411
+ redirects: z.array(redirectSchema).default([]),
412
+ // Site-wide `[[key]]` substitution values, applied to page prose text
413
+ // at build time (see the remarkSubstituteVariables plugin wired into
414
+ // astro.config.mjs) - e.g. `{ "productName": "Acme" }` lets every page
415
+ // write `[[productName]]` once instead of hardcoding it everywhere.
416
+ // Double square brackets, not the more familiar `{{key}}` - MDX
417
+ // reserves single curly braces for embedded JS expressions, so
418
+ // `{{productName}}` in an .mdx file parses as a JS object-literal
419
+ // expression (`{productName}`, shorthand syntax) rather than literal
420
+ // text, and throws `ReferenceError: productName is not defined` at
421
+ // render time - see remarkSubstituteVariables' own comment in
422
+ // mdx-substitute-variables.js for the full explanation. Deliberately a
423
+ // flat string map, not typed values - this is text substitution into
424
+ // prose, not a templating language.
425
+ variables: z.record(z.string(), z.string()).default({}),
426
+ // See bannerSchema above. Absent by default (no banner rendered).
427
+ banner: bannerSchema.optional(),
428
+ // See notFoundSchema above.
429
+ notFound: notFoundSchema,
430
+ // See scriptsSchema above.
431
+ scripts: scriptsSchema,
432
+ // See integrationsSchema above.
433
+ integrations: integrationsSchema
434
+ });
435
+ function positionFromJsonError(message, rawText) {
436
+ const comLinha = message.match(/line (\d+) column (\d+)/);
437
+ if (comLinha) return { line: Number(comLinha[1]), column: Number(comLinha[2]) };
438
+ const comPosicao = message.match(/position (\d+)/);
439
+ if (comPosicao) {
440
+ const pos = Math.min(Number(comPosicao[1]), rawText.length);
441
+ const antes = rawText.slice(0, pos);
442
+ const line = antes.split("\n").length;
443
+ return { line, column: pos - antes.lastIndexOf("\n") };
444
+ }
445
+ return null;
446
+ }
447
+ function formatValidationIssues(issues) {
448
+ return issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n");
449
+ }
450
+ function formatValidationIssuesDetailed(issues, { fileName = "writedocs.json" } = {}) {
451
+ return issues.map((i) => {
452
+ const temCampo = i.path !== "(root)";
453
+ const local = i.line ? `${fileName}:${i.line}` : i.path;
454
+ const linhas = [` ${local}${i.line && temCampo ? ` (${i.path})` : ""}`];
455
+ linhas.push(` ${i.humanMessage ?? i.message}`);
456
+ if (i.suggestion) linhas.push(` ${i.suggestion}`);
457
+ if (i.humanMessage && i.message !== i.humanMessage) linhas.push(` (${i.message})`);
458
+ return linhas.join("\n");
459
+ }).join("\n\n");
460
+ }
461
+ const ROOT_ALLOWED_EXTRA_KEYS = Object.freeze(["$schema"]);
462
+ function issuesDoRamo(ramo) {
463
+ if (Array.isArray(ramo)) return ramo;
464
+ const comIssues = ramo;
465
+ return comIssues?.issues ?? [];
466
+ }
467
+ function melhorRamo(ramos) {
468
+ return ramos.map((ramo) => {
469
+ const lista = issuesDoRamo(ramo);
470
+ return {
471
+ lista,
472
+ n: lista.length,
473
+ profundidade: Math.max(0, ...lista.map((i) => (i.path ?? []).length))
474
+ };
475
+ }).sort((a, b) => a.n - b.n || b.profundidade - a.profundidade)[0].lista;
476
+ }
477
+ function desembrulharUnioes(issues, prefixo = []) {
478
+ const saida = [];
479
+ for (const issue of issues) {
480
+ const caminho = [...prefixo, ...issue.path ?? []];
481
+ const ramos = issue.code === "invalid_union" ? issue.errors ?? issue.unionErrors : null;
482
+ if (Array.isArray(ramos) && ramos.length > 0) {
483
+ saida.push(...desembrulharUnioes(melhorRamo(ramos), caminho));
484
+ } else {
485
+ saida.push({ ...issue, caminho });
486
+ }
487
+ }
488
+ return saida;
489
+ }
490
+ function criarLocalizador(rawText) {
491
+ let arvore;
492
+ try {
493
+ arvore = parseTree(rawText);
494
+ } catch {
495
+ arvore = void 0;
496
+ }
497
+ return (caminho) => {
498
+ if (!arvore || caminho.length === 0) return null;
499
+ const no = findNodeAtLocation(arvore, caminho);
500
+ if (!no) return null;
501
+ const antes = rawText.slice(0, no.offset);
502
+ return { line: antes.split("\n").length, column: no.offset - antes.lastIndexOf("\n") };
503
+ };
504
+ }
505
+ function docKeyDoCaminho(caminho) {
506
+ const primeiro = caminho[0];
507
+ return typeof primeiro === "string" && primeiro ? `config.${primeiro}` : "config";
508
+ }
509
+ const ARTIGO_POR_TIPO = {
510
+ string: "a piece of text",
511
+ number: "a number",
512
+ boolean: "true or false",
513
+ array: "a list",
514
+ object: "an object",
515
+ null: "null"
516
+ };
517
+ const COMO_ESCREVER = {
518
+ string: 'Write the value in double quotes, like "Core Platform".',
519
+ number: "Write the value as a bare number, like 3 - no quotes.",
520
+ boolean: "Write true or false - no quotes.",
521
+ array: "Write the value as a list in square brackets: [ ... ].",
522
+ object: "Write the value as an object in curly braces: { ... }."
523
+ };
524
+ function tipoReal(valor) {
525
+ if (valor === void 0) return "missing";
526
+ if (valor === null) return "null";
527
+ if (Array.isArray(valor)) return "array";
528
+ return typeof valor;
529
+ }
530
+ function valorEm(raiz, caminho) {
531
+ let atual = raiz;
532
+ for (const passo of caminho) {
533
+ if (atual === null || typeof atual !== "object") return void 0;
534
+ atual = atual[passo];
535
+ }
536
+ return atual;
537
+ }
538
+ function comoCampo(caminho) {
539
+ return caminho.length > 0 ? `"${caminho.join(".")}"` : "writedocs.json";
540
+ }
541
+ function fraseChaveDesconhecida(chaves, dentroDe) {
542
+ const onde = dentroDe.length > 0 ? ` inside "${dentroDe.join(".")}"` : "";
543
+ const lista = chaves.map((k) => `"${k}"`).join(", ");
544
+ return {
545
+ humanMessage: chaves.length === 1 ? `${lista} is not a writedocs.json option${onde}.` : `${lista} are not writedocs.json options${onde}.`,
546
+ suggestion: "Remove it, or check the spelling - options are case-sensitive."
547
+ };
548
+ }
549
+ function humanizar(issue, raiz) {
550
+ const campo = comoCampo(issue.caminho);
551
+ switch (issue.code) {
552
+ case "invalid_type": {
553
+ const esperado = issue.expected ?? "a different type";
554
+ const recebido = tipoReal(valorEm(raiz, issue.caminho));
555
+ if (recebido === "missing") {
556
+ return {
557
+ humanMessage: `${campo} is required, but writedocs.json does not set it.`,
558
+ suggestion: `Add ${campo} with ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`} as its value.`
559
+ };
560
+ }
561
+ return {
562
+ humanMessage: `${campo} must be ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`}, but writedocs.json has ${ARTIGO_POR_TIPO[recebido] ?? `a ${recebido}`}.`,
563
+ suggestion: COMO_ESCREVER[esperado]
564
+ };
565
+ }
566
+ case "unrecognized_keys": {
567
+ const chaves = issue.keys ?? [];
568
+ if (chaves.length === 0) return {};
569
+ return fraseChaveDesconhecida(chaves, issue.caminho);
570
+ }
571
+ case "invalid_value": {
572
+ const opcoes = (issue.values ?? []).map((v) => JSON.stringify(v)).join(", ");
573
+ return {
574
+ humanMessage: `${campo} must be one of: ${opcoes}.`,
575
+ suggestion: "Replace the value with one of the options above."
576
+ };
577
+ }
578
+ case "too_small": {
579
+ const unidade = issue.origin === "array" ? "item" : "character";
580
+ const minimo = issue.minimum ?? 1;
581
+ return {
582
+ humanMessage: `${campo} needs at least ${minimo} ${unidade}${minimo === 1 ? "" : "s"}.`,
583
+ suggestion: issue.origin === "array" ? "Add an entry, or remove the field entirely." : void 0
584
+ };
585
+ }
586
+ case "too_big": {
587
+ const unidade = issue.origin === "array" ? "item" : "character";
588
+ const maximo = issue.maximum ?? 0;
589
+ return { humanMessage: `${campo} allows at most ${maximo} ${unidade}${maximo === 1 ? "" : "s"}.` };
590
+ }
591
+ case "invalid_format":
592
+ return {
593
+ humanMessage: `${campo} is not a valid ${issue.format ?? "value"}.`,
594
+ suggestion: "Check the format against the documentation for this field."
595
+ };
596
+ case "invalid_union":
597
+ return {
598
+ humanMessage: `${campo} does not match any of the shapes writedocs.json accepts here.`,
599
+ suggestion: "Check the documentation for the shapes this field accepts."
600
+ };
601
+ case "custom":
602
+ return { humanMessage: issue.message };
603
+ default:
604
+ return {};
605
+ }
606
+ }
607
+ function unknownRootKeyIssues(rawText) {
608
+ let raiz;
609
+ try {
610
+ raiz = JSON.parse(rawText);
611
+ } catch {
612
+ return [];
613
+ }
614
+ if (raiz === null || typeof raiz !== "object" || Array.isArray(raiz)) return [];
615
+ const conhecidas = /* @__PURE__ */ new Set([...Object.keys(docsConfigSchema.shape), ...ROOT_ALLOWED_EXTRA_KEYS]);
616
+ const localizar = criarLocalizador(rawText);
617
+ return Object.keys(raiz).filter((chave) => !conhecidas.has(chave)).map((chave) => {
618
+ const frase = fraseChaveDesconhecida([chave], []);
619
+ return {
620
+ path: chave,
621
+ // O mesmo formato singular da mensagem `unrecognized_keys` do Zod, pra
622
+ // que aviso e erro sejam indistinguiveis por quem so le `message`.
623
+ message: `Unrecognized key: "${chave}"`,
624
+ code: "unrecognized_keys",
625
+ ...localizar([chave]) ?? {},
626
+ ...frase,
627
+ // `config`, e NAO `config.<chave>`: a chave aqui e o que o cliente
628
+ // digitou errado, e derivar a docKey dela faria cada erro de digitacao
629
+ // inventar uma chave nova. O conjunto de docKeys tem que ser fechado -
630
+ // `config` mais um por campo do schema, nada alem disso -, senao quem
631
+ // mapeia chave -> URL do outro lado nao tem lista pra mapear.
632
+ docKey: "config"
633
+ };
634
+ });
635
+ }
636
+ function validateDocsConfig(rawText) {
637
+ let raw;
638
+ try {
639
+ raw = JSON.parse(rawText);
640
+ } catch (err) {
641
+ const parseError = err;
642
+ const posicao = positionFromJsonError(parseError.message, rawText);
643
+ return {
644
+ ok: false,
645
+ data: null,
646
+ kind: "invalid_json",
647
+ parseError,
648
+ issues: [
649
+ {
650
+ path: "(root)",
651
+ message: parseError.message,
652
+ code: "invalid_json",
653
+ ...posicao ?? {},
654
+ humanMessage: "writedocs.json is not valid JSON, so none of it could be checked.",
655
+ suggestion: posicao ? `Look at line ${posicao.line} - a missing comma, an extra trailing comma, or an unclosed bracket is the usual cause.` : "A missing comma, an extra trailing comma, or an unclosed bracket is the usual cause.",
656
+ docKey: "config"
657
+ }
658
+ ]
659
+ };
660
+ }
661
+ const result = docsConfigSchema.safeParse(raw);
662
+ if (!result.success) {
663
+ const localizar = criarLocalizador(rawText);
664
+ return {
665
+ ok: false,
666
+ data: null,
667
+ kind: "schema",
668
+ issues: desembrulharUnioes(result.error.issues).map((i) => {
669
+ const alvoDaLinha = i.code === "unrecognized_keys" && i.keys?.length ? [...i.caminho, i.keys[0]] : i.caminho;
670
+ return {
671
+ path: i.caminho.join(".") || "(root)",
672
+ message: i.message,
673
+ code: i.code,
674
+ ...localizar(alvoDaLinha) ?? localizar(i.caminho) ?? {},
675
+ ...humanizar(i, raw),
676
+ docKey: docKeyDoCaminho(i.caminho)
677
+ };
678
+ })
679
+ };
680
+ }
681
+ return { ok: true, data: result.data, issues: [] };
682
+ }
683
+ export {
684
+ ROOT_ALLOWED_EXTRA_KEYS,
685
+ docsConfigSchema,
686
+ formatValidationIssues,
687
+ formatValidationIssuesDetailed,
688
+ mergeSeo,
689
+ seoFieldsSchema,
690
+ unknownRootKeyIssues,
691
+ validateDocsConfig
692
+ };
@@ -12,11 +12,25 @@
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
18
25
  // quem importa isto de fora do gerador.)
19
26
  import { z } from 'zod';
27
+ // WD-063: `parseTree` + `findNodeAtLocation` traduzem o caminho de um erro
28
+ // (`navigation.products.0.product`) para o offset dele no TEXTO, que e o que
29
+ // vira linha. E o parser de JSON do VS Code: zero dependencias, nada de `node:`,
30
+ // entao continua bundlavel pro Worker da plataforma. Escrever o scanner a mao
31
+ // foi tentado e deu errado (perdia todo caminho aninhado, que e justamente o que
32
+ // importa aqui) - nao repita.
33
+ import { findNodeAtLocation, parseTree } from 'jsonc-parser';
20
34
 
21
35
  // ---------------------------------------------------------------------
22
36
  // Pages & groups - the leaf content any container ultimately bottoms out
@@ -922,6 +936,19 @@ export type ValidationIssue = {
922
936
  code?: string;
923
937
  line?: number;
924
938
  column?: number;
939
+ /** WD-063: a mesma coisa que `message`, dita para gente. ADICIONAL, nunca
940
+ * substituto - `message` continua sendo o texto do Zod verbatim, que e o que
941
+ * o `writedocs build` imprime e o que o WD-062 grava em `validation_issues`.
942
+ * Quem exibe escolhe qual dos dois mostrar. */
943
+ humanMessage?: string;
944
+ /** O que fazer a respeito, quando da pra dizer algo concreto. */
945
+ suggestion?: string;
946
+ /** Chave ESTAVEL de documentacao (`config.styles`), nunca uma URL: uma URL
947
+ * cravada num pacote npm fica quebrada em toda instalacao ate a proxima
948
+ * release, e o mesmo erro e exibido pela CLI e pelo dashboard, que podem
949
+ * querer destinos diferentes. O mapa chave -> URL mora do lado de quem
950
+ * renderiza. */
951
+ docKey?: string;
925
952
  };
926
953
 
927
954
  /** Sucesso carrega os dados ja validados (com os defaults do schema aplicados);
@@ -958,11 +985,331 @@ function positionFromJsonError(message: string, rawText: string): { line: number
958
985
 
959
986
  /** O corpo da mensagem de erro, uma linha por problema - exatamente o formato
960
987
  * 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. */
988
+ * aqui, e nao em cada chamador, pra que CLI e plataforma nao possam divergir.
989
+ *
990
+ * NAO mudou no WD-063, de proposito: continua imprimindo `message` (o texto do
991
+ * Zod). O que melhorou foi o CONTEUDO dos issues - a uniao desembrulhada faz o
992
+ * `path` apontar pro campo real em vez de parar em `navigation`. Quem quiser a
993
+ * versao com frase humana e linha usa formatValidationIssuesDetailed. */
962
994
  export function formatValidationIssues(issues: ValidationIssue[]): string {
963
995
  return issues.map((i) => ` - ${i.path}: ${i.message}`).join('\n');
964
996
  }
965
997
 
998
+ /** A versao longa, para quem tem uma tela inteira (a CLI hoje, o dashboard do
999
+ * WD-065 se quiser): caminho, linha, frase humana, sugestao, e a mensagem do
1000
+ * Zod por ultimo, entre parenteses, pra quem precisa do texto exato.
1001
+ *
1002
+ * Mora aqui pelo mesmo motivo que a irma curta: se cada consumidor montasse a
1003
+ * sua, a CLI e a plataforma divergiriam na primeira mudanca - que e exatamente
1004
+ * o que o AC do WD-066 proibe. `fileName` so entra na referencia de linha. */
1005
+ export function formatValidationIssuesDetailed(
1006
+ issues: ValidationIssue[],
1007
+ { fileName = 'writedocs.json' }: { fileName?: string } = {}
1008
+ ): string {
1009
+ return issues
1010
+ .map((i) => {
1011
+ const temCampo = i.path !== '(root)';
1012
+ const local = i.line ? `${fileName}:${i.line}` : i.path;
1013
+ const linhas = [` ${local}${i.line && temCampo ? ` (${i.path})` : ''}`];
1014
+ linhas.push(` ${i.humanMessage ?? i.message}`);
1015
+ if (i.suggestion) linhas.push(` ${i.suggestion}`);
1016
+ if (i.humanMessage && i.message !== i.humanMessage) linhas.push(` (${i.message})`);
1017
+ return linhas.join('\n');
1018
+ })
1019
+ .join('\n\n');
1020
+ }
1021
+
1022
+ // ---------------------------------------------------------------------
1023
+ // WD-063 — do issue do Zod para "onde esta o erro, e o que fazer"
1024
+ //
1025
+ // Tres transformacoes, nesta ordem:
1026
+ // 1. desembrulhar `invalid_union` (senao todo erro dentro da navigation vira
1027
+ // `navigation: "Invalid input"`, que e inutil num arquivo de 10 KB);
1028
+ // 2. achar a linha no texto, pelo caminho ja desembrulhado;
1029
+ // 3. escrever a frase humana, a sugestao e a docKey - POR CODIGO do Zod, nao
1030
+ // por campo: um mapa por campo teria 17 entradas so na raiz e centenas no
1031
+ // total, e envelheceria a cada campo novo do schema.
1032
+ // ---------------------------------------------------------------------
1033
+
1034
+ /** Chaves que a raiz aceita sem ser campo do schema, e que por isso NAO devem
1035
+ * gerar aviso de chave desconhecida. Deliberadamente minuscula: a raiz nao e
1036
+ * `.strict()` justamente pra nao quebrar configs existentes, e `$schema` foi um
1037
+ * dos argumentos dessa decisao - avisar sobre ele contradiria o que o protegeu.
1038
+ * Se esta lista passar de dois ou tres nomes, o problema e outro e a raiz
1039
+ * precisa de outra conversa. */
1040
+ export const ROOT_ALLOWED_EXTRA_KEYS: readonly string[] = Object.freeze(['$schema']);
1041
+
1042
+ type IssueCru = {
1043
+ code?: string;
1044
+ path?: (string | number)[];
1045
+ message: string;
1046
+ expected?: string;
1047
+ values?: unknown[];
1048
+ keys?: string[];
1049
+ minimum?: number;
1050
+ maximum?: number;
1051
+ origin?: string;
1052
+ format?: string;
1053
+ errors?: unknown[];
1054
+ unionErrors?: unknown[];
1055
+ };
1056
+
1057
+ type IssuePlano = IssueCru & { caminho: (string | number)[] };
1058
+
1059
+ function issuesDoRamo(ramo: unknown): IssueCru[] {
1060
+ if (Array.isArray(ramo)) return ramo as IssueCru[];
1061
+ const comIssues = ramo as { issues?: IssueCru[] };
1062
+ return comIssues?.issues ?? [];
1063
+ }
1064
+
1065
+ /** Escolhe qual ramo de uma uniao o cliente PROVAVELMENTE quis.
1066
+ *
1067
+ * Criterio: menos issues primeiro; empate resolvido pelo caminho mais fundo. A
1068
+ * intuicao e a do Gabriel na spec - o ramo certo e o que nao reclama da chave
1069
+ * que o cliente usou -, e a profundidade importa porque um ramo que chegou
1070
+ * fundo antes de falhar (`products.0.product`) reconheceu a forma, enquanto um
1071
+ * que falha na raiz (`expected array, received object`) so recusou o shape.
1072
+ *
1073
+ * EMPATE TOTAL (mesmo numero de issues E mesma profundidade): vence o ramo
1074
+ * declarado primeiro no schema. `Array.prototype.sort` e estavel desde a
1075
+ * ES2019, entao ordenar e pegar o [0] ja entrega isso - se alguem trocar por
1076
+ * uma ordenacao instavel, o desempate vira sorteio. Duas razoes para "o
1077
+ * primeiro" em vez de "todos os empatados":
1078
+ * - um engano do cliente tem que virar UMA mensagem. Emitir os 5 ramos
1079
+ * empatados de um `"navigation": {}` produz cinco exigencias que se
1080
+ * contradizem ("tabs e obrigatorio", "versions e obrigatorio", ...);
1081
+ * - determinismo: a mesma config sempre da a mesma mensagem. Um palpite
1082
+ * instavel seria pior que um palpite ruim.
1083
+ * E nada se perde no palpite errado: `message` continua sendo o texto do Zod. */
1084
+ function melhorRamo(ramos: unknown[]): IssueCru[] {
1085
+ return ramos
1086
+ .map((ramo) => {
1087
+ const lista = issuesDoRamo(ramo);
1088
+ return {
1089
+ lista,
1090
+ n: lista.length,
1091
+ profundidade: Math.max(0, ...lista.map((i) => (i.path ?? []).length)),
1092
+ };
1093
+ })
1094
+ .sort((a, b) => a.n - b.n || b.profundidade - a.profundidade)[0].lista;
1095
+ }
1096
+
1097
+ /** Desembrulha `invalid_union` recursivamente - o ramo escolhido normalmente e
1098
+ * outra uniao (cada produto da navigation e, por sua vez, uma uniao de formas),
1099
+ * entao para so quando chega num erro concreto. Sem recursao, o
1100
+ * `navigation.products.0` para em "Invalid input" de novo, um nivel abaixo. */
1101
+ function desembrulharUnioes(issues: IssueCru[], prefixo: (string | number)[] = []): IssuePlano[] {
1102
+ const saida: IssuePlano[] = [];
1103
+ for (const issue of issues) {
1104
+ const caminho = [...prefixo, ...(issue.path ?? [])];
1105
+ const ramos = issue.code === 'invalid_union' ? issue.errors ?? issue.unionErrors : null;
1106
+ if (Array.isArray(ramos) && ramos.length > 0) {
1107
+ saida.push(...desembrulharUnioes(melhorRamo(ramos), caminho));
1108
+ } else {
1109
+ saida.push({ ...issue, caminho });
1110
+ }
1111
+ }
1112
+ return saida;
1113
+ }
1114
+
1115
+ /** Parseia o texto UMA vez e devolve "caminho -> linha/coluna". Devolve null
1116
+ * quando o no nao existe no texto (campo obrigatorio ausente e o caso comum):
1117
+ * `line` ausente e melhor que `line` inventada. */
1118
+ function criarLocalizador(rawText: string): (caminho: (string | number)[]) => { line: number; column: number } | null {
1119
+ let arvore: ReturnType<typeof parseTree> | undefined;
1120
+ try {
1121
+ arvore = parseTree(rawText);
1122
+ } catch {
1123
+ arvore = undefined;
1124
+ }
1125
+ return (caminho) => {
1126
+ if (!arvore || caminho.length === 0) return null;
1127
+ const no = findNodeAtLocation(arvore, caminho);
1128
+ if (!no) return null;
1129
+ const antes = rawText.slice(0, no.offset);
1130
+ return { line: antes.split('\n').length, column: no.offset - antes.lastIndexOf('\n') };
1131
+ };
1132
+ }
1133
+
1134
+ /** `styles.primaryColor` -> `config.styles`. Primeiro segmento so, com fallback
1135
+ * pra `config`: o conjunto tem que ficar pequeno e estavel, porque vira
1136
+ * superficie publica no instante em que alguem mapear chave -> URL. */
1137
+ function docKeyDoCaminho(caminho: (string | number)[]): string {
1138
+ const primeiro = caminho[0];
1139
+ return typeof primeiro === 'string' && primeiro ? `config.${primeiro}` : 'config';
1140
+ }
1141
+
1142
+ const ARTIGO_POR_TIPO: Record<string, string> = {
1143
+ string: 'a piece of text',
1144
+ number: 'a number',
1145
+ boolean: 'true or false',
1146
+ array: 'a list',
1147
+ object: 'an object',
1148
+ null: 'null',
1149
+ };
1150
+
1151
+ const COMO_ESCREVER: Record<string, string> = {
1152
+ string: 'Write the value in double quotes, like "Core Platform".',
1153
+ number: 'Write the value as a bare number, like 3 - no quotes.',
1154
+ boolean: 'Write true or false - no quotes.',
1155
+ array: 'Write the value as a list in square brackets: [ ... ].',
1156
+ object: 'Write the value as an object in curly braces: { ... }.',
1157
+ };
1158
+
1159
+ /** O tipo que o cliente REALMENTE escreveu, lido do JSON ja parseado em vez de
1160
+ * extraido da mensagem do Zod por regex - o Zod v4 nao carrega `received` como
1161
+ * propriedade, so dentro do texto, e depender do texto quebra na primeira vez
1162
+ * que ele mudar de forma. */
1163
+ function tipoReal(valor: unknown): string {
1164
+ if (valor === undefined) return 'missing';
1165
+ if (valor === null) return 'null';
1166
+ if (Array.isArray(valor)) return 'array';
1167
+ return typeof valor;
1168
+ }
1169
+
1170
+ function valorEm(raiz: unknown, caminho: (string | number)[]): unknown {
1171
+ let atual: unknown = raiz;
1172
+ for (const passo of caminho) {
1173
+ if (atual === null || typeof atual !== 'object') return undefined;
1174
+ atual = (atual as Record<string | number, unknown>)[passo];
1175
+ }
1176
+ return atual;
1177
+ }
1178
+
1179
+ function comoCampo(caminho: (string | number)[]): string {
1180
+ return caminho.length > 0 ? `"${caminho.join('.')}"` : 'writedocs.json';
1181
+ }
1182
+
1183
+ /** A frase de chave desconhecida, num lugar so.
1184
+ *
1185
+ * Decisao 6: chave desconhecida em sub-objeto strict e ERRO e na raiz e AVISO -
1186
+ * a assimetria de SEVERIDADE e intencional e fica. A de VOCABULARIO nao: os
1187
+ * dois caminhos chamam esta funcao, entao os dois dizem exatamente a mesma
1188
+ * coisa, e so quem chama decide o peso. */
1189
+ function fraseChaveDesconhecida(chaves: string[], dentroDe: (string | number)[]) {
1190
+ const onde = dentroDe.length > 0 ? ` inside "${dentroDe.join('.')}"` : '';
1191
+ const lista = chaves.map((k) => `"${k}"`).join(', ');
1192
+ return {
1193
+ humanMessage:
1194
+ chaves.length === 1
1195
+ ? `${lista} is not a writedocs.json option${onde}.`
1196
+ : `${lista} are not writedocs.json options${onde}.`,
1197
+ suggestion: 'Remove it, or check the spelling - options are case-sensitive.',
1198
+ };
1199
+ }
1200
+
1201
+ /** A frase humana + a sugestao, escolhidas pelo CODIGO do Zod. `raiz` e o JSON
1202
+ * ja parseado, usado so pra saber o que o cliente escreveu de fato. */
1203
+ function humanizar(issue: IssuePlano, raiz: unknown): { humanMessage?: string; suggestion?: string } {
1204
+ const campo = comoCampo(issue.caminho);
1205
+ switch (issue.code) {
1206
+ case 'invalid_type': {
1207
+ const esperado = issue.expected ?? 'a different type';
1208
+ const recebido = tipoReal(valorEm(raiz, issue.caminho));
1209
+ if (recebido === 'missing') {
1210
+ return {
1211
+ humanMessage: `${campo} is required, but writedocs.json does not set it.`,
1212
+ suggestion: `Add ${campo} with ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`} as its value.`,
1213
+ };
1214
+ }
1215
+ return {
1216
+ humanMessage: `${campo} must be ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`}, but writedocs.json has ${ARTIGO_POR_TIPO[recebido] ?? `a ${recebido}`}.`,
1217
+ suggestion: COMO_ESCREVER[esperado],
1218
+ };
1219
+ }
1220
+ case 'unrecognized_keys': {
1221
+ // O Zod agrega as chaves sobrando numa mensagem so, com o `path` no PAI.
1222
+ const chaves = issue.keys ?? [];
1223
+ if (chaves.length === 0) return {};
1224
+ return fraseChaveDesconhecida(chaves, issue.caminho);
1225
+ }
1226
+ case 'invalid_value': {
1227
+ const opcoes = (issue.values ?? []).map((v) => JSON.stringify(v)).join(', ');
1228
+ return {
1229
+ humanMessage: `${campo} must be one of: ${opcoes}.`,
1230
+ suggestion: 'Replace the value with one of the options above.',
1231
+ };
1232
+ }
1233
+ case 'too_small': {
1234
+ const unidade = issue.origin === 'array' ? 'item' : 'character';
1235
+ const minimo = issue.minimum ?? 1;
1236
+ return {
1237
+ humanMessage: `${campo} needs at least ${minimo} ${unidade}${minimo === 1 ? '' : 's'}.`,
1238
+ suggestion: issue.origin === 'array' ? 'Add an entry, or remove the field entirely.' : undefined,
1239
+ };
1240
+ }
1241
+ case 'too_big': {
1242
+ const unidade = issue.origin === 'array' ? 'item' : 'character';
1243
+ const maximo = issue.maximum ?? 0;
1244
+ return { humanMessage: `${campo} allows at most ${maximo} ${unidade}${maximo === 1 ? '' : 's'}.` };
1245
+ }
1246
+ case 'invalid_format':
1247
+ return {
1248
+ humanMessage: `${campo} is not a valid ${issue.format ?? 'value'}.`,
1249
+ suggestion: 'Check the format against the documentation for this field.',
1250
+ };
1251
+ case 'invalid_union':
1252
+ // So chega aqui se o desembrulho nao achou ramo nenhum (uniao sem
1253
+ // `errors`). Raro, mas melhor dizer o que aconteceu do que "Invalid input".
1254
+ return {
1255
+ humanMessage: `${campo} does not match any of the shapes writedocs.json accepts here.`,
1256
+ suggestion: 'Check the documentation for the shapes this field accepts.',
1257
+ };
1258
+ case 'custom':
1259
+ // As mensagens de `.refine()` deste schema ja sao frases escritas pra
1260
+ // gente ("Each scripts.head/scripts.body entry needs exactly one of..."),
1261
+ // entao reescrever seria piorar. Repassa como frase humana pra que quem
1262
+ // exibe so `humanMessage` nao fique sem nada.
1263
+ return { humanMessage: issue.message };
1264
+ default:
1265
+ return {};
1266
+ }
1267
+ }
1268
+
1269
+ /** Avisos de chave desconhecida na RAIZ.
1270
+ *
1271
+ * A raiz nao e `.strict()` (decidido no E11-desenho-geral: tornar strict
1272
+ * quebraria configs existentes), entao o Zod descarta a chave em silencio e
1273
+ * nao ha issue nenhum. Quem quiser avisar chama isto. Mora aqui, e nao na
1274
+ * plataforma, pelo motivo de sempre: era o unico texto escrito pela plataforma,
1275
+ * e enquanto ele viver la os dois lados podem divergir.
1276
+ *
1277
+ * Severidade e de quem chama - isto so descreve o problema. `validateDocsConfig`
1278
+ * deliberadamente NAO chama: um config valido continua saindo com `issues: []`,
1279
+ * porque a plataforma repassa `issues` como `errors` e um aviso ali viraria
1280
+ * erro. */
1281
+ export function unknownRootKeyIssues(rawText: string): ValidationIssue[] {
1282
+ let raiz: unknown;
1283
+ try {
1284
+ raiz = JSON.parse(rawText);
1285
+ } catch {
1286
+ return [];
1287
+ }
1288
+ if (raiz === null || typeof raiz !== 'object' || Array.isArray(raiz)) return [];
1289
+ const conhecidas = new Set([...Object.keys(docsConfigSchema.shape), ...ROOT_ALLOWED_EXTRA_KEYS]);
1290
+ const localizar = criarLocalizador(rawText);
1291
+ return Object.keys(raiz as Record<string, unknown>)
1292
+ .filter((chave) => !conhecidas.has(chave))
1293
+ .map((chave) => {
1294
+ const frase = fraseChaveDesconhecida([chave], []);
1295
+ return {
1296
+ path: chave,
1297
+ // O mesmo formato singular da mensagem `unrecognized_keys` do Zod, pra
1298
+ // que aviso e erro sejam indistinguiveis por quem so le `message`.
1299
+ message: `Unrecognized key: "${chave}"`,
1300
+ code: 'unrecognized_keys',
1301
+ ...(localizar([chave]) ?? {}),
1302
+ ...frase,
1303
+ // `config`, e NAO `config.<chave>`: a chave aqui e o que o cliente
1304
+ // digitou errado, e derivar a docKey dela faria cada erro de digitacao
1305
+ // inventar uma chave nova. O conjunto de docKeys tem que ser fechado -
1306
+ // `config` mais um por campo do schema, nada alem disso -, senao quem
1307
+ // mapeia chave -> URL do outro lado nao tem lista pra mapear.
1308
+ docKey: 'config',
1309
+ };
1310
+ });
1311
+ }
1312
+
966
1313
  export function validateDocsConfig(rawText: string): ValidationResult {
967
1314
  let raw: unknown;
968
1315
  try {
@@ -975,21 +1322,45 @@ export function validateDocsConfig(rawText: string): ValidationResult {
975
1322
  data: null,
976
1323
  kind: 'invalid_json',
977
1324
  parseError,
978
- issues: [{ path: '(root)', message: parseError.message, code: 'invalid_json', ...(posicao ?? {}) }],
1325
+ issues: [
1326
+ {
1327
+ path: '(root)',
1328
+ message: parseError.message,
1329
+ code: 'invalid_json',
1330
+ ...(posicao ?? {}),
1331
+ humanMessage: 'writedocs.json is not valid JSON, so none of it could be checked.',
1332
+ suggestion: posicao
1333
+ ? `Look at line ${posicao.line} - a missing comma, an extra trailing comma, or an unclosed bracket is the usual cause.`
1334
+ : 'A missing comma, an extra trailing comma, or an unclosed bracket is the usual cause.',
1335
+ docKey: 'config',
1336
+ },
1337
+ ],
979
1338
  };
980
1339
  }
981
1340
 
982
1341
  const result = docsConfigSchema.safeParse(raw);
983
1342
  if (!result.success) {
1343
+ const localizar = criarLocalizador(rawText);
984
1344
  return {
985
1345
  ok: false,
986
1346
  data: null,
987
1347
  kind: 'schema',
988
- issues: result.error.issues.map((i) => ({
989
- path: i.path.join('.') || '(root)',
990
- message: i.message,
991
- code: i.code,
992
- })),
1348
+ issues: desembrulharUnioes(result.error.issues as IssueCru[]).map((i) => {
1349
+ // Para chave desconhecida o `path` do Zod aponta pro PAI (`footer`), e a
1350
+ // chave ofensora vem em `keys`. Para a LINHA vale a pena descer ate ela
1351
+ // (`footer.banana`), que e onde o cliente precisa olhar; o `path` fica
1352
+ // como o Zod deu, pra nao mudar o que a plataforma ja grava.
1353
+ const alvoDaLinha =
1354
+ i.code === 'unrecognized_keys' && i.keys?.length ? [...i.caminho, i.keys[0]] : i.caminho;
1355
+ return {
1356
+ path: i.caminho.join('.') || '(root)',
1357
+ message: i.message,
1358
+ code: i.code,
1359
+ ...(localizar(alvoDaLinha) ?? localizar(i.caminho) ?? {}),
1360
+ ...humanizar(i, raw),
1361
+ docKey: docKeyDoCaminho(i.caminho),
1362
+ };
1363
+ }),
993
1364
  };
994
1365
  }
995
1366
 
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