@writedocs/generator 0.2.1 → 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 +30 -10
- package/package.json +2 -1
- package/src/lib/config-schema.js +213 -6
- package/src/lib/config-schema.ts +371 -7
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 {
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
console.
|
|
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.
|
|
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": {
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"commander": "^15.0.0",
|
|
66
66
|
"dotenv": "^17.4.2",
|
|
67
67
|
"gray-matter": "^4.0.3",
|
|
68
|
+
"jsonc-parser": "3.3.1",
|
|
68
69
|
"katex": "^0.16.47",
|
|
69
70
|
"mermaid": "^11.16.0",
|
|
70
71
|
"pagefind": "^1.5.2",
|
package/src/lib/config-schema.js
CHANGED
|
@@ -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,192 @@ 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 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
|
+
}
|
|
449
636
|
function validateDocsConfig(rawText) {
|
|
450
637
|
let raw;
|
|
451
638
|
try {
|
|
@@ -458,28 +645,48 @@ function validateDocsConfig(rawText) {
|
|
|
458
645
|
data: null,
|
|
459
646
|
kind: "invalid_json",
|
|
460
647
|
parseError,
|
|
461
|
-
issues: [
|
|
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
|
+
]
|
|
462
659
|
};
|
|
463
660
|
}
|
|
464
661
|
const result = docsConfigSchema.safeParse(raw);
|
|
465
662
|
if (!result.success) {
|
|
663
|
+
const localizar = criarLocalizador(rawText);
|
|
466
664
|
return {
|
|
467
665
|
ok: false,
|
|
468
666
|
data: null,
|
|
469
667
|
kind: "schema",
|
|
470
|
-
issues: result.error.issues.map((i) =>
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
+
})
|
|
475
679
|
};
|
|
476
680
|
}
|
|
477
681
|
return { ok: true, data: result.data, issues: [] };
|
|
478
682
|
}
|
|
479
683
|
export {
|
|
684
|
+
ROOT_ALLOWED_EXTRA_KEYS,
|
|
480
685
|
docsConfigSchema,
|
|
481
686
|
formatValidationIssues,
|
|
687
|
+
formatValidationIssuesDetailed,
|
|
482
688
|
mergeSeo,
|
|
483
689
|
seoFieldsSchema,
|
|
690
|
+
unknownRootKeyIssues,
|
|
484
691
|
validateDocsConfig
|
|
485
692
|
};
|
package/src/lib/config-schema.ts
CHANGED
|
@@ -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,331 @@ 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
|
+
/** 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
|
+
|
|
973
1313
|
export function validateDocsConfig(rawText: string): ValidationResult {
|
|
974
1314
|
let raw: unknown;
|
|
975
1315
|
try {
|
|
@@ -982,21 +1322,45 @@ export function validateDocsConfig(rawText: string): ValidationResult {
|
|
|
982
1322
|
data: null,
|
|
983
1323
|
kind: 'invalid_json',
|
|
984
1324
|
parseError,
|
|
985
|
-
issues: [
|
|
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
|
+
],
|
|
986
1338
|
};
|
|
987
1339
|
}
|
|
988
1340
|
|
|
989
1341
|
const result = docsConfigSchema.safeParse(raw);
|
|
990
1342
|
if (!result.success) {
|
|
1343
|
+
const localizar = criarLocalizador(rawText);
|
|
991
1344
|
return {
|
|
992
1345
|
ok: false,
|
|
993
1346
|
data: null,
|
|
994
1347
|
kind: 'schema',
|
|
995
|
-
issues: result.error.issues.map((i) =>
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
+
}),
|
|
1000
1364
|
};
|
|
1001
1365
|
}
|
|
1002
1366
|
|