@writedocs/generator 0.2.1 → 0.4.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
@@ -22,7 +22,11 @@ import { requireBuildKey } from '../src/cli/build-auth.js';
22
22
  // gerado do `.ts` pelo script `prepare` (package.json), entao ele existe tanto
23
23
  // no checkout quanto dentro do tarball, e o consumidor nao roda build nenhum.
24
24
  // Ver a mesma regra ja escrita em src/cli/write-redirects-file.js.
25
- import { validateDocsConfig, formatValidationIssues } from '../src/lib/config-schema.js';
25
+ import {
26
+ validateDocsConfig,
27
+ formatValidationIssuesDetailed,
28
+ unknownRootKeyIssues,
29
+ } from '../src/lib/config-schema.js';
26
30
 
27
31
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
32
  const packageRoot = path.resolve(__dirname, '..');
@@ -92,17 +96,33 @@ program
92
96
  process.exit(1);
93
97
  }
94
98
 
95
- const result = validateDocsConfig(fs.readFileSync(configPath, 'utf-8'));
96
- if (result.ok) {
97
- console.log(`[writedocs] ${configPath} is valid.`);
98
- return;
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('');
114
+ }
115
+
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('');
99
120
  }
100
121
 
101
- // Mesmo formato que o build imprime num config invalido - mesmo texto,
102
- // mesma ordem, montado pelo mesmo formatador.
103
- console.error('[writedocs] writedocs.json failed validation:');
104
- console.error(formatValidationIssues(result.issues));
105
- 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.`);
106
126
  });
107
127
 
108
128
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.2.1",
3
+ "version": "0.4.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": {
@@ -20,7 +20,8 @@
20
20
  "build": "node bin/writedocs.js build",
21
21
  "init": "node bin/writedocs.js init",
22
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"
23
+ "prepare": "npm run build:schema",
24
+ "test": "node --test \"scripts/*.test.js\""
24
25
  },
25
26
  "keywords": [
26
27
  "docs",
@@ -65,6 +66,7 @@
65
66
  "commander": "^15.0.0",
66
67
  "dotenv": "^17.4.2",
67
68
  "gray-matter": "^4.0.3",
69
+ "jsonc-parser": "3.3.1",
68
70
  "katex": "^0.16.47",
69
71
  "mermaid": "^11.16.0",
70
72
  "pagefind": "^1.5.2",
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { findNodeAtLocation, parseTree } from "jsonc-parser";
2
3
  const navPageSchema = z.string();
3
4
  const navGroupPagesSchema = z.lazy(
4
5
  () => z.object({
@@ -446,6 +447,210 @@ function positionFromJsonError(message, rawText) {
446
447
  function formatValidationIssues(issues) {
447
448
  return issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n");
448
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 maiorProfundidade(lista) {
468
+ let maior = 0;
469
+ for (const issue of lista) {
470
+ const p = issue.caminho.length + (issue.code === "unrecognized_keys" ? 1 : 0);
471
+ if (p > maior) maior = p;
472
+ }
473
+ return maior;
474
+ }
475
+ const planoPorRamo = /* @__PURE__ */ new WeakMap();
476
+ function planoDoRamo(ramo) {
477
+ const lista = issuesDoRamo(ramo);
478
+ if (typeof ramo !== "object" || ramo === null) return desembrulharUnioes(lista);
479
+ const memoizado = planoPorRamo.get(ramo);
480
+ if (memoizado) return memoizado;
481
+ const plano = desembrulharUnioes(lista);
482
+ planoPorRamo.set(ramo, plano);
483
+ return plano;
484
+ }
485
+ function melhorRamo(ramos) {
486
+ return ramos.map((ramo) => {
487
+ const plano = planoDoRamo(ramo);
488
+ return {
489
+ lista: issuesDoRamo(ramo),
490
+ n: plano.length,
491
+ profundidade: maiorProfundidade(plano)
492
+ };
493
+ }).sort((a, b) => b.profundidade - a.profundidade || a.n - b.n)[0].lista;
494
+ }
495
+ function desembrulharUnioes(issues, prefixo = []) {
496
+ const saida = [];
497
+ for (const issue of issues) {
498
+ const caminho = [...prefixo, ...issue.path ?? []];
499
+ const ramos = issue.code === "invalid_union" ? issue.errors ?? issue.unionErrors : null;
500
+ if (Array.isArray(ramos) && ramos.length > 0) {
501
+ saida.push(...desembrulharUnioes(melhorRamo(ramos), caminho));
502
+ } else {
503
+ saida.push({ ...issue, caminho });
504
+ }
505
+ }
506
+ return saida;
507
+ }
508
+ function criarLocalizador(rawText) {
509
+ let arvore;
510
+ try {
511
+ arvore = parseTree(rawText);
512
+ } catch {
513
+ arvore = void 0;
514
+ }
515
+ return (caminho) => {
516
+ if (!arvore || caminho.length === 0) return null;
517
+ const no = findNodeAtLocation(arvore, caminho);
518
+ if (!no) return null;
519
+ const antes = rawText.slice(0, no.offset);
520
+ return { line: antes.split("\n").length, column: no.offset - antes.lastIndexOf("\n") };
521
+ };
522
+ }
523
+ function docKeyDoCaminho(caminho) {
524
+ const primeiro = caminho[0];
525
+ return typeof primeiro === "string" && primeiro ? `config.${primeiro}` : "config";
526
+ }
527
+ const ARTIGO_POR_TIPO = {
528
+ string: "a piece of text",
529
+ number: "a number",
530
+ boolean: "true or false",
531
+ array: "a list",
532
+ object: "an object",
533
+ null: "null"
534
+ };
535
+ const COMO_ESCREVER = {
536
+ string: 'Write the value in double quotes, like "Core Platform".',
537
+ number: "Write the value as a bare number, like 3 - no quotes.",
538
+ boolean: "Write true or false - no quotes.",
539
+ array: "Write the value as a list in square brackets: [ ... ].",
540
+ object: "Write the value as an object in curly braces: { ... }."
541
+ };
542
+ function tipoReal(valor) {
543
+ if (valor === void 0) return "missing";
544
+ if (valor === null) return "null";
545
+ if (Array.isArray(valor)) return "array";
546
+ return typeof valor;
547
+ }
548
+ function valorEm(raiz, caminho) {
549
+ let atual = raiz;
550
+ for (const passo of caminho) {
551
+ if (atual === null || typeof atual !== "object") return void 0;
552
+ atual = atual[passo];
553
+ }
554
+ return atual;
555
+ }
556
+ function comoCampo(caminho) {
557
+ return caminho.length > 0 ? `"${caminho.join(".")}"` : "writedocs.json";
558
+ }
559
+ function fraseChaveDesconhecida(chaves, dentroDe) {
560
+ const onde = dentroDe.length > 0 ? ` inside "${dentroDe.join(".")}"` : "";
561
+ const lista = chaves.map((k) => `"${k}"`).join(", ");
562
+ return {
563
+ humanMessage: chaves.length === 1 ? `${lista} is not a writedocs.json option${onde}.` : `${lista} are not writedocs.json options${onde}.`,
564
+ suggestion: "Remove it, or check the spelling - options are case-sensitive."
565
+ };
566
+ }
567
+ function humanizar(issue, raiz) {
568
+ const campo = comoCampo(issue.caminho);
569
+ switch (issue.code) {
570
+ case "invalid_type": {
571
+ const esperado = issue.expected ?? "a different type";
572
+ const recebido = tipoReal(valorEm(raiz, issue.caminho));
573
+ if (recebido === "missing") {
574
+ return {
575
+ humanMessage: `${campo} is required, but writedocs.json does not set it.`,
576
+ suggestion: `Add ${campo} with ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`} as its value.`
577
+ };
578
+ }
579
+ return {
580
+ humanMessage: `${campo} must be ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`}, but writedocs.json has ${ARTIGO_POR_TIPO[recebido] ?? `a ${recebido}`}.`,
581
+ suggestion: COMO_ESCREVER[esperado]
582
+ };
583
+ }
584
+ case "unrecognized_keys": {
585
+ const chaves = issue.keys ?? [];
586
+ if (chaves.length === 0) return {};
587
+ return fraseChaveDesconhecida(chaves, issue.caminho);
588
+ }
589
+ case "invalid_value": {
590
+ const opcoes = (issue.values ?? []).map((v) => JSON.stringify(v)).join(", ");
591
+ return {
592
+ humanMessage: `${campo} must be one of: ${opcoes}.`,
593
+ suggestion: "Replace the value with one of the options above."
594
+ };
595
+ }
596
+ case "too_small": {
597
+ const unidade = issue.origin === "array" ? "item" : "character";
598
+ const minimo = issue.minimum ?? 1;
599
+ return {
600
+ humanMessage: `${campo} needs at least ${minimo} ${unidade}${minimo === 1 ? "" : "s"}.`,
601
+ suggestion: issue.origin === "array" ? "Add an entry, or remove the field entirely." : void 0
602
+ };
603
+ }
604
+ case "too_big": {
605
+ const unidade = issue.origin === "array" ? "item" : "character";
606
+ const maximo = issue.maximum ?? 0;
607
+ return { humanMessage: `${campo} allows at most ${maximo} ${unidade}${maximo === 1 ? "" : "s"}.` };
608
+ }
609
+ case "invalid_format":
610
+ return {
611
+ humanMessage: `${campo} is not a valid ${issue.format ?? "value"}.`,
612
+ suggestion: "Check the format against the documentation for this field."
613
+ };
614
+ case "invalid_union":
615
+ return {
616
+ humanMessage: `${campo} does not match any of the shapes writedocs.json accepts here.`,
617
+ suggestion: "Check the documentation for the shapes this field accepts."
618
+ };
619
+ case "custom":
620
+ return { humanMessage: issue.message };
621
+ default:
622
+ return {};
623
+ }
624
+ }
625
+ function unknownRootKeyIssues(rawText) {
626
+ let raiz;
627
+ try {
628
+ raiz = JSON.parse(rawText);
629
+ } catch {
630
+ return [];
631
+ }
632
+ if (raiz === null || typeof raiz !== "object" || Array.isArray(raiz)) return [];
633
+ const conhecidas = /* @__PURE__ */ new Set([...Object.keys(docsConfigSchema.shape), ...ROOT_ALLOWED_EXTRA_KEYS]);
634
+ const localizar = criarLocalizador(rawText);
635
+ return Object.keys(raiz).filter((chave) => !conhecidas.has(chave)).map((chave) => {
636
+ const frase = fraseChaveDesconhecida([chave], []);
637
+ return {
638
+ path: chave,
639
+ // O mesmo formato singular da mensagem `unrecognized_keys` do Zod, pra
640
+ // que aviso e erro sejam indistinguiveis por quem so le `message`.
641
+ message: `Unrecognized key: "${chave}"`,
642
+ code: "unrecognized_keys",
643
+ ...localizar([chave]) ?? {},
644
+ ...frase,
645
+ // `config`, e NAO `config.<chave>`: a chave aqui e o que o cliente
646
+ // digitou errado, e derivar a docKey dela faria cada erro de digitacao
647
+ // inventar uma chave nova. O conjunto de docKeys tem que ser fechado -
648
+ // `config` mais um por campo do schema, nada alem disso -, senao quem
649
+ // mapeia chave -> URL do outro lado nao tem lista pra mapear.
650
+ docKey: "config"
651
+ };
652
+ });
653
+ }
449
654
  function validateDocsConfig(rawText) {
450
655
  let raw;
451
656
  try {
@@ -458,28 +663,48 @@ function validateDocsConfig(rawText) {
458
663
  data: null,
459
664
  kind: "invalid_json",
460
665
  parseError,
461
- issues: [{ path: "(root)", message: parseError.message, code: "invalid_json", ...posicao ?? {} }]
666
+ issues: [
667
+ {
668
+ path: "(root)",
669
+ message: parseError.message,
670
+ code: "invalid_json",
671
+ ...posicao ?? {},
672
+ humanMessage: "writedocs.json is not valid JSON, so none of it could be checked.",
673
+ 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.",
674
+ docKey: "config"
675
+ }
676
+ ]
462
677
  };
463
678
  }
464
679
  const result = docsConfigSchema.safeParse(raw);
465
680
  if (!result.success) {
681
+ const localizar = criarLocalizador(rawText);
466
682
  return {
467
683
  ok: false,
468
684
  data: null,
469
685
  kind: "schema",
470
- issues: result.error.issues.map((i) => ({
471
- path: i.path.join(".") || "(root)",
472
- message: i.message,
473
- code: i.code
474
- }))
686
+ issues: desembrulharUnioes(result.error.issues).map((i) => {
687
+ const alvoDaLinha = i.code === "unrecognized_keys" && i.keys?.length ? [...i.caminho, i.keys[0]] : i.caminho;
688
+ return {
689
+ path: i.caminho.join(".") || "(root)",
690
+ message: i.message,
691
+ code: i.code,
692
+ ...localizar(alvoDaLinha) ?? localizar(i.caminho) ?? {},
693
+ ...humanizar(i, raw),
694
+ docKey: docKeyDoCaminho(i.caminho)
695
+ };
696
+ })
475
697
  };
476
698
  }
477
699
  return { ok: true, data: result.data, issues: [] };
478
700
  }
479
701
  export {
702
+ ROOT_ALLOWED_EXTRA_KEYS,
480
703
  docsConfigSchema,
481
704
  formatValidationIssues,
705
+ formatValidationIssuesDetailed,
482
706
  mergeSeo,
483
707
  seoFieldsSchema,
708
+ unknownRootKeyIssues,
484
709
  validateDocsConfig
485
710
  };
@@ -24,6 +24,13 @@
24
24
  // entao a troca nao muda comportamento nenhum, so tira o astro do caminho de
25
25
  // quem importa isto de fora do gerador.)
26
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';
27
34
 
28
35
  // ---------------------------------------------------------------------
29
36
  // Pages & groups - the leaf content any container ultimately bottoms out
@@ -929,6 +936,19 @@ export type ValidationIssue = {
929
936
  code?: string;
930
937
  line?: number;
931
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;
932
952
  };
933
953
 
934
954
  /** Sucesso carrega os dados ja validados (com os defaults do schema aplicados);
@@ -965,11 +985,400 @@ function positionFromJsonError(message: string, rawText: string): { line: number
965
985
 
966
986
  /** O corpo da mensagem de erro, uma linha por problema - exatamente o formato
967
987
  * que o `writedocs build` imprime desde sempre (` - campo: mensagem`). Fica
968
- * 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. */
969
994
  export function formatValidationIssues(issues: ValidationIssue[]): string {
970
995
  return issues.map((i) => ` - ${i.path}: ${i.message}`).join('\n');
971
996
  }
972
997
 
998
+ /** A versao longa, para quem tem uma tela inteira (a CLI hoje, o dashboard do
999
+ * WD-065 se quiser): caminho, linha, frase humana, sugestao, e a mensagem do
1000
+ * Zod por ultimo, entre parenteses, pra quem precisa do texto exato.
1001
+ *
1002
+ * Mora aqui pelo mesmo motivo que a irma curta: se cada consumidor montasse a
1003
+ * sua, a CLI e a plataforma divergiriam na primeira mudanca - que e exatamente
1004
+ * o que o AC do WD-066 proibe. `fileName` so entra na referencia de linha. */
1005
+ export function formatValidationIssuesDetailed(
1006
+ issues: ValidationIssue[],
1007
+ { fileName = 'writedocs.json' }: { fileName?: string } = {}
1008
+ ): string {
1009
+ return issues
1010
+ .map((i) => {
1011
+ const temCampo = i.path !== '(root)';
1012
+ const local = i.line ? `${fileName}:${i.line}` : i.path;
1013
+ const linhas = [` ${local}${i.line && temCampo ? ` (${i.path})` : ''}`];
1014
+ linhas.push(` ${i.humanMessage ?? i.message}`);
1015
+ if (i.suggestion) linhas.push(` ${i.suggestion}`);
1016
+ if (i.humanMessage && i.message !== i.humanMessage) linhas.push(` (${i.message})`);
1017
+ return linhas.join('\n');
1018
+ })
1019
+ .join('\n\n');
1020
+ }
1021
+
1022
+ // ---------------------------------------------------------------------
1023
+ // WD-063 — do issue do Zod para "onde esta o erro, e o que fazer"
1024
+ //
1025
+ // Tres transformacoes, nesta ordem:
1026
+ // 1. desembrulhar `invalid_union` (senao todo erro dentro da navigation vira
1027
+ // `navigation: "Invalid input"`, que e inutil num arquivo de 10 KB);
1028
+ // 2. achar a linha no texto, pelo caminho ja desembrulhado;
1029
+ // 3. escrever a frase humana, a sugestao e a docKey - POR CODIGO do Zod, nao
1030
+ // por campo: um mapa por campo teria 17 entradas so na raiz e centenas no
1031
+ // total, e envelheceria a cada campo novo do schema.
1032
+ // ---------------------------------------------------------------------
1033
+
1034
+ /** Chaves que a raiz aceita sem ser campo do schema, e que por isso NAO devem
1035
+ * gerar aviso de chave desconhecida. Deliberadamente minuscula: a raiz nao e
1036
+ * `.strict()` justamente pra nao quebrar configs existentes, e `$schema` foi um
1037
+ * dos argumentos dessa decisao - avisar sobre ele contradiria o que o protegeu.
1038
+ * Se esta lista passar de dois ou tres nomes, o problema e outro e a raiz
1039
+ * precisa de outra conversa. */
1040
+ export const ROOT_ALLOWED_EXTRA_KEYS: readonly string[] = Object.freeze(['$schema']);
1041
+
1042
+ type IssueCru = {
1043
+ code?: string;
1044
+ path?: (string | number)[];
1045
+ message: string;
1046
+ expected?: string;
1047
+ values?: unknown[];
1048
+ keys?: string[];
1049
+ minimum?: number;
1050
+ maximum?: number;
1051
+ origin?: string;
1052
+ format?: string;
1053
+ errors?: unknown[];
1054
+ unionErrors?: unknown[];
1055
+ };
1056
+
1057
+ type IssuePlano = IssueCru & { caminho: (string | number)[] };
1058
+
1059
+ function issuesDoRamo(ramo: unknown): IssueCru[] {
1060
+ if (Array.isArray(ramo)) return ramo as IssueCru[];
1061
+ const comIssues = ramo as { issues?: IssueCru[] };
1062
+ return comIssues?.issues ?? [];
1063
+ }
1064
+
1065
+ /** O maior valor de `f` sobre a lista, ou 0 se ela for vazia. Um `for` e nao
1066
+ * `Math.max(0, ...lista.map(f))`: o spread vira argumentos de chamada, e uma
1067
+ * lista grande o bastante (config com dezenas de milhares de erros) estoura o
1068
+ * limite de argumentos com RangeError em vez de devolver um numero. */
1069
+ function maiorProfundidade(lista: IssuePlano[]): number {
1070
+ let maior = 0;
1071
+ for (const issue of lista) {
1072
+ const p = issue.caminho.length + (issue.code === 'unrecognized_keys' ? 1 : 0);
1073
+ if (p > maior) maior = p;
1074
+ }
1075
+ return maior;
1076
+ }
1077
+
1078
+ /** Plano ACHATADO de um ramo, memoizado por identidade do ramo.
1079
+ *
1080
+ * A memoizacao nao e otimizacao, e o que torna o achatamento viavel. Pontuar um
1081
+ * ramo exige desembrulhar as unioes DENTRO dele, e cada uma dessas tem os seus
1082
+ * proprios ramos - sem cache, a pontuacao visita o mesmo sub-ramo uma vez por
1083
+ * caminho que leva ate ele, o que e exponencial na profundidade do aninhamento.
1084
+ * Medido com grupos aninhados e um erro no fundo: 3,7 ms com 4 niveis, 281 ms
1085
+ * com 8, 69 SEGUNDOS com 12. Com o cache, os mesmos casos ficam em 0,2 ms,
1086
+ * porque cada no da arvore de erro do Zod e achatado uma vez so.
1087
+ *
1088
+ * Se alguem remover este WeakMap por parecer supérfluo, o `writedocs validate`
1089
+ * passa a travar em configs profundas. Nao remova. */
1090
+ const planoPorRamo = new WeakMap<object, IssuePlano[]>();
1091
+
1092
+ function planoDoRamo(ramo: unknown): IssuePlano[] {
1093
+ const lista = issuesDoRamo(ramo);
1094
+ if (typeof ramo !== 'object' || ramo === null) return desembrulharUnioes(lista);
1095
+ const memoizado = planoPorRamo.get(ramo);
1096
+ if (memoizado) return memoizado;
1097
+ // Prefixo vazio de proposito: a pontuacao compara ramos entre si, entao a
1098
+ // profundidade tem que ser relativa ao proprio ramo, nao ao documento.
1099
+ const plano = desembrulharUnioes(lista);
1100
+ planoPorRamo.set(ramo, plano);
1101
+ return plano;
1102
+ }
1103
+
1104
+ /** Escolhe qual ramo de uma uniao o cliente PROVAVELMENTE quis.
1105
+ *
1106
+ * Criterio: caminho mais fundo primeiro; empate resolvido por menos issues. A
1107
+ * intuicao e a do Gabriel na spec - o ramo certo e o que nao reclama da chave
1108
+ * que o cliente usou -, e a profundidade importa porque um ramo que chegou
1109
+ * fundo antes de falhar (`products.0.product`) reconheceu a forma, enquanto um
1110
+ * que falha na raiz (`expected array, received object`) so recusou o shape.
1111
+ *
1112
+ * DUAS CORRECOES sobre a primeira versao (WD-063), as duas medidas contra o
1113
+ * `00-kitchen-sink` real - ver o relato de revisao 43:
1114
+ *
1115
+ * 1. PONTUA O RAMO ACHATADO, nao a lista crua. Um ramo que e ele proprio uma
1116
+ * uniao aparece como UM issue (`invalid_union`) no caminho vazio - ou seja,
1117
+ * com a MENOR contagem e a MENOR profundidade possiveis, sem que isso diga
1118
+ * nada sobre o quanto ele reconheceu. Era o caso mais comum que existe: um
1119
+ * item de `pages` e `string | grupo | link`, e o grupo e outra uniao, entao
1120
+ * todo erro estrutural dentro de um grupo empatava com o ramo `string` e
1121
+ * perdia pela ordem de declaracao. O cliente que escrevia
1122
+ * `{ "group": "G", "pages": 42 }` recebia "expected string, received
1123
+ * object" - conselho errado, nao so inutil. Achatando primeiro, o mesmo caso
1124
+ * vira `pages.0.pages: expected array, received number`.
1125
+ *
1126
+ * 2. PROFUNDIDADE EFETIVA: `unrecognized_keys` reporta no caminho do PAI, com a
1127
+ * chave ofensora em `keys`, entao a profundidade dele sai subestimada em 1.
1128
+ * Sem a correcao, um ramo que identificou a chave errada perde para um que
1129
+ * so exigiu um campo ausente. (`validateDocsConfig` ja fazia a mesma conta
1130
+ * para achar a LINHA; aqui ela entra tambem na pontuacao.)
1131
+ *
1132
+ * Medido sobre as 60 folhas da `navigation` do `00-kitchen-sink`, trocando o
1133
+ * tipo de cada uma: a versao anterior apontava o campo exato em 42/60 e PARAVA
1134
+ * RASO em 18/60 (sempre os mesmos 18: os que ficam sob um `pages`); esta aponta
1135
+ * 60/60 para valor escalar errado e nunca para raso. Para um objeto vazio ou
1136
+ * parcial no lugar de uma pagina, as duas ficam tecnicamente empatadas (41
1137
+ * contra 42): a anterior diz "expected string", esta lista os campos que
1138
+ * faltam para ser um grupo. E o custo aceito desta versao, e substitui o antigo
1139
+ * (`tabs` e `products` escritos juntos, que agora sai como
1140
+ * `Unrecognized key: "products"`).
1141
+ *
1142
+ * EMPATE TOTAL (mesma profundidade E mesmo numero de issues): vence o ramo
1143
+ * declarado primeiro no schema. `Array.prototype.sort` e estavel desde a
1144
+ * ES2019, entao ordenar e pegar o [0] ja entrega isso - se alguem trocar por
1145
+ * uma ordenacao instavel, o desempate vira sorteio. Duas razoes para "o
1146
+ * primeiro" em vez de "todos os empatados":
1147
+ * - um engano do cliente tem que virar UMA mensagem. Emitir os 5 ramos
1148
+ * empatados de um `"navigation": {}` produz cinco exigencias que se
1149
+ * contradizem ("tabs e obrigatorio", "versions e obrigatorio", ...);
1150
+ * - determinismo: a mesma config sempre da a mesma mensagem. Um palpite
1151
+ * instavel seria pior que um palpite ruim.
1152
+ * E nada se perde no palpite errado: `message` continua sendo o texto do Zod. */
1153
+ function melhorRamo(ramos: unknown[]): IssueCru[] {
1154
+ return ramos
1155
+ .map((ramo) => {
1156
+ const plano = planoDoRamo(ramo);
1157
+ return {
1158
+ lista: issuesDoRamo(ramo),
1159
+ n: plano.length,
1160
+ profundidade: maiorProfundidade(plano),
1161
+ };
1162
+ })
1163
+ .sort((a, b) => b.profundidade - a.profundidade || a.n - b.n)[0].lista;
1164
+ }
1165
+
1166
+ /** Desembrulha `invalid_union` recursivamente - o ramo escolhido normalmente e
1167
+ * outra uniao (cada produto da navigation e, por sua vez, uma uniao de formas),
1168
+ * entao para so quando chega num erro concreto. Sem recursao, o
1169
+ * `navigation.products.0` para em "Invalid input" de novo, um nivel abaixo. */
1170
+ function desembrulharUnioes(issues: IssueCru[], prefixo: (string | number)[] = []): IssuePlano[] {
1171
+ const saida: IssuePlano[] = [];
1172
+ for (const issue of issues) {
1173
+ const caminho = [...prefixo, ...(issue.path ?? [])];
1174
+ const ramos = issue.code === 'invalid_union' ? issue.errors ?? issue.unionErrors : null;
1175
+ if (Array.isArray(ramos) && ramos.length > 0) {
1176
+ saida.push(...desembrulharUnioes(melhorRamo(ramos), caminho));
1177
+ } else {
1178
+ saida.push({ ...issue, caminho });
1179
+ }
1180
+ }
1181
+ return saida;
1182
+ }
1183
+
1184
+ /** Parseia o texto UMA vez e devolve "caminho -> linha/coluna". Devolve null
1185
+ * quando o no nao existe no texto (campo obrigatorio ausente e o caso comum):
1186
+ * `line` ausente e melhor que `line` inventada. */
1187
+ function criarLocalizador(rawText: string): (caminho: (string | number)[]) => { line: number; column: number } | null {
1188
+ let arvore: ReturnType<typeof parseTree> | undefined;
1189
+ try {
1190
+ arvore = parseTree(rawText);
1191
+ } catch {
1192
+ arvore = undefined;
1193
+ }
1194
+ return (caminho) => {
1195
+ if (!arvore || caminho.length === 0) return null;
1196
+ const no = findNodeAtLocation(arvore, caminho);
1197
+ if (!no) return null;
1198
+ const antes = rawText.slice(0, no.offset);
1199
+ return { line: antes.split('\n').length, column: no.offset - antes.lastIndexOf('\n') };
1200
+ };
1201
+ }
1202
+
1203
+ /** `styles.primaryColor` -> `config.styles`. Primeiro segmento so, com fallback
1204
+ * pra `config`: o conjunto tem que ficar pequeno e estavel, porque vira
1205
+ * superficie publica no instante em que alguem mapear chave -> URL. */
1206
+ function docKeyDoCaminho(caminho: (string | number)[]): string {
1207
+ const primeiro = caminho[0];
1208
+ return typeof primeiro === 'string' && primeiro ? `config.${primeiro}` : 'config';
1209
+ }
1210
+
1211
+ const ARTIGO_POR_TIPO: Record<string, string> = {
1212
+ string: 'a piece of text',
1213
+ number: 'a number',
1214
+ boolean: 'true or false',
1215
+ array: 'a list',
1216
+ object: 'an object',
1217
+ null: 'null',
1218
+ };
1219
+
1220
+ const COMO_ESCREVER: Record<string, string> = {
1221
+ string: 'Write the value in double quotes, like "Core Platform".',
1222
+ number: 'Write the value as a bare number, like 3 - no quotes.',
1223
+ boolean: 'Write true or false - no quotes.',
1224
+ array: 'Write the value as a list in square brackets: [ ... ].',
1225
+ object: 'Write the value as an object in curly braces: { ... }.',
1226
+ };
1227
+
1228
+ /** O tipo que o cliente REALMENTE escreveu, lido do JSON ja parseado em vez de
1229
+ * extraido da mensagem do Zod por regex - o Zod v4 nao carrega `received` como
1230
+ * propriedade, so dentro do texto, e depender do texto quebra na primeira vez
1231
+ * que ele mudar de forma. */
1232
+ function tipoReal(valor: unknown): string {
1233
+ if (valor === undefined) return 'missing';
1234
+ if (valor === null) return 'null';
1235
+ if (Array.isArray(valor)) return 'array';
1236
+ return typeof valor;
1237
+ }
1238
+
1239
+ function valorEm(raiz: unknown, caminho: (string | number)[]): unknown {
1240
+ let atual: unknown = raiz;
1241
+ for (const passo of caminho) {
1242
+ if (atual === null || typeof atual !== 'object') return undefined;
1243
+ atual = (atual as Record<string | number, unknown>)[passo];
1244
+ }
1245
+ return atual;
1246
+ }
1247
+
1248
+ function comoCampo(caminho: (string | number)[]): string {
1249
+ return caminho.length > 0 ? `"${caminho.join('.')}"` : 'writedocs.json';
1250
+ }
1251
+
1252
+ /** A frase de chave desconhecida, num lugar so.
1253
+ *
1254
+ * Decisao 6: chave desconhecida em sub-objeto strict e ERRO e na raiz e AVISO -
1255
+ * a assimetria de SEVERIDADE e intencional e fica. A de VOCABULARIO nao: os
1256
+ * dois caminhos chamam esta funcao, entao os dois dizem exatamente a mesma
1257
+ * coisa, e so quem chama decide o peso. */
1258
+ function fraseChaveDesconhecida(chaves: string[], dentroDe: (string | number)[]) {
1259
+ const onde = dentroDe.length > 0 ? ` inside "${dentroDe.join('.')}"` : '';
1260
+ const lista = chaves.map((k) => `"${k}"`).join(', ');
1261
+ return {
1262
+ humanMessage:
1263
+ chaves.length === 1
1264
+ ? `${lista} is not a writedocs.json option${onde}.`
1265
+ : `${lista} are not writedocs.json options${onde}.`,
1266
+ suggestion: 'Remove it, or check the spelling - options are case-sensitive.',
1267
+ };
1268
+ }
1269
+
1270
+ /** A frase humana + a sugestao, escolhidas pelo CODIGO do Zod. `raiz` e o JSON
1271
+ * ja parseado, usado so pra saber o que o cliente escreveu de fato. */
1272
+ function humanizar(issue: IssuePlano, raiz: unknown): { humanMessage?: string; suggestion?: string } {
1273
+ const campo = comoCampo(issue.caminho);
1274
+ switch (issue.code) {
1275
+ case 'invalid_type': {
1276
+ const esperado = issue.expected ?? 'a different type';
1277
+ const recebido = tipoReal(valorEm(raiz, issue.caminho));
1278
+ if (recebido === 'missing') {
1279
+ return {
1280
+ humanMessage: `${campo} is required, but writedocs.json does not set it.`,
1281
+ suggestion: `Add ${campo} with ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`} as its value.`,
1282
+ };
1283
+ }
1284
+ return {
1285
+ humanMessage: `${campo} must be ${ARTIGO_POR_TIPO[esperado] ?? `a ${esperado}`}, but writedocs.json has ${ARTIGO_POR_TIPO[recebido] ?? `a ${recebido}`}.`,
1286
+ suggestion: COMO_ESCREVER[esperado],
1287
+ };
1288
+ }
1289
+ case 'unrecognized_keys': {
1290
+ // O Zod agrega as chaves sobrando numa mensagem so, com o `path` no PAI.
1291
+ const chaves = issue.keys ?? [];
1292
+ if (chaves.length === 0) return {};
1293
+ return fraseChaveDesconhecida(chaves, issue.caminho);
1294
+ }
1295
+ case 'invalid_value': {
1296
+ const opcoes = (issue.values ?? []).map((v) => JSON.stringify(v)).join(', ');
1297
+ return {
1298
+ humanMessage: `${campo} must be one of: ${opcoes}.`,
1299
+ suggestion: 'Replace the value with one of the options above.',
1300
+ };
1301
+ }
1302
+ case 'too_small': {
1303
+ const unidade = issue.origin === 'array' ? 'item' : 'character';
1304
+ const minimo = issue.minimum ?? 1;
1305
+ return {
1306
+ humanMessage: `${campo} needs at least ${minimo} ${unidade}${minimo === 1 ? '' : 's'}.`,
1307
+ suggestion: issue.origin === 'array' ? 'Add an entry, or remove the field entirely.' : undefined,
1308
+ };
1309
+ }
1310
+ case 'too_big': {
1311
+ const unidade = issue.origin === 'array' ? 'item' : 'character';
1312
+ const maximo = issue.maximum ?? 0;
1313
+ return { humanMessage: `${campo} allows at most ${maximo} ${unidade}${maximo === 1 ? '' : 's'}.` };
1314
+ }
1315
+ case 'invalid_format':
1316
+ return {
1317
+ humanMessage: `${campo} is not a valid ${issue.format ?? 'value'}.`,
1318
+ suggestion: 'Check the format against the documentation for this field.',
1319
+ };
1320
+ case 'invalid_union':
1321
+ // So chega aqui se o desembrulho nao achou ramo nenhum (uniao sem
1322
+ // `errors`). Raro, mas melhor dizer o que aconteceu do que "Invalid input".
1323
+ return {
1324
+ humanMessage: `${campo} does not match any of the shapes writedocs.json accepts here.`,
1325
+ suggestion: 'Check the documentation for the shapes this field accepts.',
1326
+ };
1327
+ case 'custom':
1328
+ // As mensagens de `.refine()` deste schema ja sao frases escritas pra
1329
+ // gente ("Each scripts.head/scripts.body entry needs exactly one of..."),
1330
+ // entao reescrever seria piorar. Repassa como frase humana pra que quem
1331
+ // exibe so `humanMessage` nao fique sem nada.
1332
+ return { humanMessage: issue.message };
1333
+ default:
1334
+ return {};
1335
+ }
1336
+ }
1337
+
1338
+ /** Avisos de chave desconhecida na RAIZ.
1339
+ *
1340
+ * A raiz nao e `.strict()` (decidido no E11-desenho-geral: tornar strict
1341
+ * quebraria configs existentes), entao o Zod descarta a chave em silencio e
1342
+ * nao ha issue nenhum. Quem quiser avisar chama isto. Mora aqui, e nao na
1343
+ * plataforma, pelo motivo de sempre: era o unico texto escrito pela plataforma,
1344
+ * e enquanto ele viver la os dois lados podem divergir.
1345
+ *
1346
+ * Severidade e de quem chama - isto so descreve o problema. `validateDocsConfig`
1347
+ * deliberadamente NAO chama: um config valido continua saindo com `issues: []`,
1348
+ * porque a plataforma repassa `issues` como `errors` e um aviso ali viraria
1349
+ * erro. */
1350
+ export function unknownRootKeyIssues(rawText: string): ValidationIssue[] {
1351
+ let raiz: unknown;
1352
+ try {
1353
+ raiz = JSON.parse(rawText);
1354
+ } catch {
1355
+ return [];
1356
+ }
1357
+ if (raiz === null || typeof raiz !== 'object' || Array.isArray(raiz)) return [];
1358
+ const conhecidas = new Set([...Object.keys(docsConfigSchema.shape), ...ROOT_ALLOWED_EXTRA_KEYS]);
1359
+ const localizar = criarLocalizador(rawText);
1360
+ return Object.keys(raiz as Record<string, unknown>)
1361
+ .filter((chave) => !conhecidas.has(chave))
1362
+ .map((chave) => {
1363
+ const frase = fraseChaveDesconhecida([chave], []);
1364
+ return {
1365
+ path: chave,
1366
+ // O mesmo formato singular da mensagem `unrecognized_keys` do Zod, pra
1367
+ // que aviso e erro sejam indistinguiveis por quem so le `message`.
1368
+ message: `Unrecognized key: "${chave}"`,
1369
+ code: 'unrecognized_keys',
1370
+ ...(localizar([chave]) ?? {}),
1371
+ ...frase,
1372
+ // `config`, e NAO `config.<chave>`: a chave aqui e o que o cliente
1373
+ // digitou errado, e derivar a docKey dela faria cada erro de digitacao
1374
+ // inventar uma chave nova. O conjunto de docKeys tem que ser fechado -
1375
+ // `config` mais um por campo do schema, nada alem disso -, senao quem
1376
+ // mapeia chave -> URL do outro lado nao tem lista pra mapear.
1377
+ docKey: 'config',
1378
+ };
1379
+ });
1380
+ }
1381
+
973
1382
  export function validateDocsConfig(rawText: string): ValidationResult {
974
1383
  let raw: unknown;
975
1384
  try {
@@ -982,21 +1391,45 @@ export function validateDocsConfig(rawText: string): ValidationResult {
982
1391
  data: null,
983
1392
  kind: 'invalid_json',
984
1393
  parseError,
985
- issues: [{ path: '(root)', message: parseError.message, code: 'invalid_json', ...(posicao ?? {}) }],
1394
+ issues: [
1395
+ {
1396
+ path: '(root)',
1397
+ message: parseError.message,
1398
+ code: 'invalid_json',
1399
+ ...(posicao ?? {}),
1400
+ humanMessage: 'writedocs.json is not valid JSON, so none of it could be checked.',
1401
+ suggestion: posicao
1402
+ ? `Look at line ${posicao.line} - a missing comma, an extra trailing comma, or an unclosed bracket is the usual cause.`
1403
+ : 'A missing comma, an extra trailing comma, or an unclosed bracket is the usual cause.',
1404
+ docKey: 'config',
1405
+ },
1406
+ ],
986
1407
  };
987
1408
  }
988
1409
 
989
1410
  const result = docsConfigSchema.safeParse(raw);
990
1411
  if (!result.success) {
1412
+ const localizar = criarLocalizador(rawText);
991
1413
  return {
992
1414
  ok: false,
993
1415
  data: null,
994
1416
  kind: 'schema',
995
- issues: result.error.issues.map((i) => ({
996
- path: i.path.join('.') || '(root)',
997
- message: i.message,
998
- code: i.code,
999
- })),
1417
+ issues: desembrulharUnioes(result.error.issues as IssueCru[]).map((i) => {
1418
+ // Para chave desconhecida o `path` do Zod aponta pro PAI (`footer`), e a
1419
+ // chave ofensora vem em `keys`. Para a LINHA vale a pena descer ate ela
1420
+ // (`footer.banana`), que e onde o cliente precisa olhar; o `path` fica
1421
+ // como o Zod deu, pra nao mudar o que a plataforma ja grava.
1422
+ const alvoDaLinha =
1423
+ i.code === 'unrecognized_keys' && i.keys?.length ? [...i.caminho, i.keys[0]] : i.caminho;
1424
+ return {
1425
+ path: i.caminho.join('.') || '(root)',
1426
+ message: i.message,
1427
+ code: i.code,
1428
+ ...(localizar(alvoDaLinha) ?? localizar(i.caminho) ?? {}),
1429
+ ...humanizar(i, raw),
1430
+ docKey: docKeyDoCaminho(i.caminho),
1431
+ };
1432
+ }),
1000
1433
  };
1001
1434
  }
1002
1435