@chrissgon/light-site-auditor 0.1.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/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/a11y.js +44 -0
- package/dist/cli.js +130 -0
- package/dist/consent.js +94 -0
- package/dist/explanations.js +42 -0
- package/dist/explanations.pt.json +619 -0
- package/dist/lighthouse.js +47 -0
- package/dist/profile.js +19 -0
- package/dist/report.js +241 -0
- package/dist/weight.js +32 -0
- package/docs/field/consentimento.md +45 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christopher Gonçalves
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# light-site-auditor
|
|
2
|
+
|
|
3
|
+
[Português](#português) · [English](#english)
|
|
4
|
+
|
|
5
|
+
## Português
|
|
6
|
+
|
|
7
|
+
Uma ferramenta de linha de comando, `auditor <url>`, que mede o peso da página inicial de um site, o tempo que ela leva para aparecer num celular com conexão 3G e as barreiras de acessibilidade, e escreve um relatório em português simples com o que corrigir.
|
|
8
|
+
|
|
9
|
+
Cada número do relatório vem dos arquivos JSON salvos ao lado dele (`lighthouse.json` e `axe.json`), e cada explicação vem de um catálogo escrito e conferido de antemão (`src/explanations.pt.json`), nunca de texto gerado na hora. A leitura do catálogo por uma pessoa ainda está pendente.
|
|
10
|
+
|
|
11
|
+
### Regra do consentimento
|
|
12
|
+
|
|
13
|
+
O auditor só verifica um site real se o dono aceitou. Sem um registro de consentimento aceito para o endereço exato, ele recusa e sai com o código 2. Páginas deste computador (`localhost`, `127.0.0.1`, `[::1]`) não precisam de registro. Não existe opção para pular essa verificação.
|
|
14
|
+
|
|
15
|
+
1. Peça o aceite ao dono com o texto de [`docs/field/consentimento.md`](docs/field/consentimento.md). Ele diz o que a verificação faz (abre a página inicial pública duas vezes, como um visitante, e mede com o Lighthouse e o axe no seu computador), o que não faz (não entra com login, não usa formulários, não abre outras páginas) e que o relatório fica só com o dono, a menos que ele concorde com outro uso.
|
|
16
|
+
2. Registre o aceite num arquivo que comece assim:
|
|
17
|
+
|
|
18
|
+
```markdown
|
|
19
|
+
---
|
|
20
|
+
endereco: https://www.exemplo.com.br/
|
|
21
|
+
dono: Nome de quem aceitou
|
|
22
|
+
status: aceito
|
|
23
|
+
data: 2026-09-30
|
|
24
|
+
como: por e-mail, respondendo ao pedido de consentimento
|
|
25
|
+
reacao: pendente
|
|
26
|
+
---
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
3. Rode com `--consent <arquivo>`. Um registro com `status: pendente`, outro endereço, um campo faltando ou uma data futura é recusado.
|
|
30
|
+
|
|
31
|
+
### Instalar e usar
|
|
32
|
+
|
|
33
|
+
Precisa do Node 22.19 ou mais novo (o Lighthouse 13.5.0 exige isso).
|
|
34
|
+
|
|
35
|
+
Uma vez, baixe o navegador que o auditor usa (o Chromium do Playwright):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npx @chrissgon/light-site-auditor --instalar-navegador
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Depois, audite:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npx @chrissgon/light-site-auditor https://www.exemplo.com.br/ --consent consentimento.md --out relatorio-exemplo
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Ou instale o comando `auditor` de vez: `npm install -g @chrissgon/light-site-auditor`, e então `auditor --instalar-navegador` e `auditor <url> --consent <arquivo>`.
|
|
48
|
+
|
|
49
|
+
O que é baixado (medido em 2026-09-30, num Mac com processador Apple, com cache vazio):
|
|
50
|
+
|
|
51
|
+
| O quê | Tamanho | Quando |
|
|
52
|
+
|-------|---------|--------|
|
|
53
|
+
| O pacote `@chrissgon/light-site-auditor` | cerca de 23 kB | no primeiro `npx` |
|
|
54
|
+
| As dependências (Lighthouse, Playwright, axe e o que elas usam) | cerca de 148 MB baixados, 185 MB instalados | no primeiro `npx` |
|
|
55
|
+
| O Chromium do Playwright (Chrome for Testing 153.0.8010.12) | cerca de 191 MB baixados no Mac com processador Apple, 196 MB no Linux e 205 MB no Windows; 369 MB instalado no Mac | uma vez, com `--instalar-navegador` |
|
|
56
|
+
| O FFmpeg do Playwright | cerca de 1 MB | junto com o Chromium |
|
|
57
|
+
|
|
58
|
+
O navegador fica na pasta de navegadores do Playwright (no Mac, `~/Library/Caches/ms-playwright`) e serve para as próximas auditorias. No Linux, o Chromium pode pedir bibliotecas do sistema: `sudo npx playwright@1.63.0 install-deps chromium`. Se o navegador faltar, o auditor avisa e sai com o código 3.
|
|
59
|
+
|
|
60
|
+
### O que o relatório traz
|
|
61
|
+
|
|
62
|
+
A pasta do relatório recebe `relatorio.md`, `relatorio.html`, `lighthouse.json` e `axe.json`. Sem `--out`, ela é `relatorios/<endereço>-<data e hora>/`.
|
|
63
|
+
|
|
64
|
+
| Parte | O que mostra | De onde vem no JSON |
|
|
65
|
+
|-------|--------------|---------------------|
|
|
66
|
+
| Peso da página | total baixado e peso por tipo de arquivo (HTML, CSS, JavaScript, imagens, fontes, outros) | `lighthouse.json`: `audits["network-requests"].details.items[].transferSize` |
|
|
67
|
+
| Tempo no 3G | primeira coisa na tela e parte principal na tela, em segundos | `audits["first-contentful-paint"]` e `audits["largest-contentful-paint"]`, `numericValue` |
|
|
68
|
+
| O que deixa a página lenta | diagnósticos e insights do Lighthouse abaixo de 0,9, cada um com o que é e o que fazer | `audits[<id>]` |
|
|
69
|
+
| Barreiras de acessibilidade | regras WCAG 2.1 níveis A e AA do axe-core 4.13.0, com os trechos da página | `axe.json`: `violations` |
|
|
70
|
+
| Palavras usadas | glossário dos termos técnicos do relatório | catálogo |
|
|
71
|
+
|
|
72
|
+
O perfil de 3G é o `mobileRegular3G` do próprio Lighthouse (300 ms de ida e volta, 700 kbit/s, processador 4 vezes mais lento), com a simulação padrão do Lighthouse. Fontes e datas de acesso: [`docs/spikes/3g.md`](docs/spikes/3g.md).
|
|
73
|
+
|
|
74
|
+
Trecho do relatório da página de exemplo:
|
|
75
|
+
|
|
76
|
+
```markdown
|
|
77
|
+
**Total baixado: 200,2 KB em 4 arquivos.**
|
|
78
|
+
|
|
79
|
+
- **Parte principal na tela: 4,2 segundos.** Quanto tempo leva até aparecer o maior texto ou imagem da tela.
|
|
80
|
+
|
|
81
|
+
### Imagem sem descrição
|
|
82
|
+
|
|
83
|
+
**O que é:** A imagem não tem texto alternativo, o alt. Quem usa leitor de tela não sabe o que ela mostra.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Desenvolver
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
npm ci
|
|
90
|
+
npx playwright install --no-shell chromium
|
|
91
|
+
npm test
|
|
92
|
+
npm run build
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Os testes sobem um servidor local com a página de exemplo de `tests/fixtures/site/` e não usam a internet. Para testar à mão: `npm run fixture` num terminal e `npm run auditor -- http://127.0.0.1:4173/ --out relatorios/exemplo` em outro. `npm run check-report -- <pasta>` confere se todo número de um relatório já escrito vem dos JSON da pasta.
|
|
96
|
+
|
|
97
|
+
### Como os números são conferidos
|
|
98
|
+
|
|
99
|
+
`tests/report.test.ts` roda o auditor na página de exemplo, recalcula a partir dos JSON salvos cada número que o relatório pode mostrar (sem usar o código do relatório) e falha se aparecer qualquer outro número. O catálogo não tem algarismos, então nenhum número pode vir dele.
|
|
100
|
+
|
|
101
|
+
### O catálogo de explicações
|
|
102
|
+
|
|
103
|
+
`src/explanations.pt.json` tem, para cada regra do axe e cada auditoria de peso do Lighthouse, um título, o que é e o que fazer. `tests/explanations.test.ts` confere que cada frase tem no máximo 25 palavras, que todo problema tem um "o que fazer", que nenhuma palavra técnica aparece sem estar no glossário do relatório e que os ids existem nas versões instaladas. Uma regra fora do catálogo aparece com o texto original da ferramenta e a marca "sem explicação ainda".
|
|
104
|
+
|
|
105
|
+
### Licença
|
|
106
|
+
|
|
107
|
+
MIT.
|
|
108
|
+
|
|
109
|
+
## English
|
|
110
|
+
|
|
111
|
+
A command-line tool, `auditor <url>`, that measures a site's home page weight, how long it takes to appear on a phone over 3G, and its accessibility barriers (WCAG 2.1 AA), and writes a plain-Portuguese report saying what to fix, with the raw Lighthouse and axe JSON beside it.
|
|
112
|
+
|
|
113
|
+
**Consent rule.** A real site is audited only when its owner accepted: pass a consent record with `--consent <file>` (format and the Portuguese request text in [`docs/field/consentimento.md`](docs/field/consentimento.md)). Without an accepted record for the exact address the tool refuses (exit code 2); a `pendente` (pending) record is refused too. Pages on your own computer need no record. There is no flag to skip the check.
|
|
114
|
+
|
|
115
|
+
**Install and use** (Node 22.19 or newer):
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
npx @chrissgon/light-site-auditor --instalar-navegador # once: Playwright's Chromium, about 191-205 MB
|
|
119
|
+
npx @chrissgon/light-site-auditor https://www.example.com/ --consent consent.md --out report
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The first `npx` also downloads the package (about 23 kB) and its dependencies (about 148 MB, 185 MB installed). On Linux, Chromium may need system libraries: `sudo npx playwright@1.63.0 install-deps chromium`.
|
|
123
|
+
|
|
124
|
+
**The report** (`relatorio.md` and `relatorio.html`, in Portuguese): total weight and weight by file type, first and largest content on 3G in seconds, Lighthouse diagnostics that did not pass with what to do, and axe's WCAG 2.1 A and AA violations with the affected snippets. Every number comes from `lighthouse.json` or `axe.json`; every explanation comes from a catalog written in advance.
|
|
125
|
+
|
|
126
|
+
**Develop:** `npm ci`, `npx playwright install --no-shell chromium`, `npm test`, `npm run build`. MIT license.
|
package/dist/a11y.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { AxeBuilder } from "@axe-core/playwright";
|
|
4
|
+
import { screenEmulationMetrics, userAgents } from "lighthouse/core/config/constants.js";
|
|
5
|
+
import { chromium } from "playwright";
|
|
6
|
+
/** The axe tags for WCAG 2.1 levels A and AA (axe-core's tag names). */
|
|
7
|
+
export const WCAG21_AA_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"];
|
|
8
|
+
/**
|
|
9
|
+
* Opens the page in Playwright's Chromium with the same phone screen Lighthouse emulates, runs
|
|
10
|
+
* axe-core with the WCAG 2.1 A and AA rules, and returns axe's raw result.
|
|
11
|
+
*/
|
|
12
|
+
export async function runAxe(url) {
|
|
13
|
+
// The full Chromium in its new headless mode, the same browser Lighthouse drives (src/lighthouse.ts),
|
|
14
|
+
// so users install one browser (`playwright install chromium --no-shell`), not two.
|
|
15
|
+
const browser = await chromium.launch({ channel: "chromium" });
|
|
16
|
+
try {
|
|
17
|
+
const screen = screenEmulationMetrics.mobile;
|
|
18
|
+
const context = await browser.newContext({
|
|
19
|
+
viewport: { width: screen.width, height: screen.height },
|
|
20
|
+
deviceScaleFactor: screen.deviceScaleFactor,
|
|
21
|
+
isMobile: true,
|
|
22
|
+
hasTouch: true,
|
|
23
|
+
userAgent: userAgents.mobile,
|
|
24
|
+
});
|
|
25
|
+
const page = await context.newPage();
|
|
26
|
+
const response = await page.goto(url, { waitUntil: "load" });
|
|
27
|
+
if (!response || !response.ok())
|
|
28
|
+
throw new Error(`could not load ${url}: HTTP ${response?.status() ?? "no response"}`);
|
|
29
|
+
return await new AxeBuilder({ page }).withTags([...WCAG21_AA_TAGS]).analyze();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await browser.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Entry point for trying it alone: `npx tsx src/a11y.ts <url> [out.json]` saves the raw JSON.
|
|
36
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
37
|
+
const [url, out = "axe.json"] = process.argv.slice(2);
|
|
38
|
+
if (!url || url === "--help") {
|
|
39
|
+
process.stdout.write("Usage: npx tsx src/a11y.ts <url> [out.json]\n");
|
|
40
|
+
process.exit(url ? 0 : 2);
|
|
41
|
+
}
|
|
42
|
+
writeFileSync(out, `${JSON.stringify(await runAxe(url), null, 2)}\n`);
|
|
43
|
+
process.stderr.write(`saved ${out}\n`);
|
|
44
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { chromium } from "playwright";
|
|
9
|
+
import { runAxe } from "./a11y.js";
|
|
10
|
+
import { checkConsent } from "./consent.js";
|
|
11
|
+
import { runLighthouse } from "./lighthouse.js";
|
|
12
|
+
import { buildReport } from "./report.js";
|
|
13
|
+
const USAGE = `Uso: auditor <url> [--consent <arquivo>] [--out <pasta>]
|
|
14
|
+
auditor --instalar-navegador
|
|
15
|
+
|
|
16
|
+
Mede o peso da página, o tempo para aparecer num celular com 3G e as barreiras de acessibilidade,
|
|
17
|
+
e escreve relatorio.md e relatorio.html em português, com lighthouse.json e axe.json ao lado.
|
|
18
|
+
|
|
19
|
+
--consent <arquivo> registro do consentimento do dono do site (obrigatório para sites reais;
|
|
20
|
+
páginas deste computador, como localhost, não precisam). Modelo e texto
|
|
21
|
+
do pedido: docs/field/consentimento.md
|
|
22
|
+
--out <pasta> onde salvar (padrão: relatorios/<endereço>-<data e hora>)
|
|
23
|
+
--instalar-navegador baixa, uma vez, o Chromium que o auditor usa (o do Playwright)
|
|
24
|
+
--help mostra esta ajuda
|
|
25
|
+
|
|
26
|
+
Códigos de saída: 0 relatório escrito; 1 a auditoria falhou; 2 uso errado ou endereço recusado;
|
|
27
|
+
3 falta instalar o navegador.
|
|
28
|
+
`;
|
|
29
|
+
/** The command that downloads the Chromium build this package's Playwright expects (full browser only). */
|
|
30
|
+
export function browserInstallCommand() {
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const cli = join(dirname(require.resolve("playwright/package.json")), "cli.js");
|
|
33
|
+
return { command: process.execPath, args: [cli, "install", "chromium", "--no-shell"] };
|
|
34
|
+
}
|
|
35
|
+
const defaultOutput = { stdout: (t) => process.stdout.write(t), stderr: (t) => process.stderr.write(t) };
|
|
36
|
+
/** Runs the CLI and returns its exit code. */
|
|
37
|
+
export async function main(argv, io = defaultOutput) {
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = parseArgs({
|
|
41
|
+
args: argv,
|
|
42
|
+
allowPositionals: true,
|
|
43
|
+
options: {
|
|
44
|
+
out: { type: "string" },
|
|
45
|
+
consent: { type: "string" },
|
|
46
|
+
"instalar-navegador": { type: "boolean" },
|
|
47
|
+
help: { type: "boolean" },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
io.stderr(`${error.message}\n\n${USAGE}`);
|
|
53
|
+
return 2;
|
|
54
|
+
}
|
|
55
|
+
if (parsed.values.help) {
|
|
56
|
+
io.stdout(USAGE);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
if (parsed.values["instalar-navegador"]) {
|
|
60
|
+
const { command, args } = browserInstallCommand();
|
|
61
|
+
io.stderr(`Baixando o Chromium do Playwright (${args.slice(1).join(" ")}).\n` +
|
|
62
|
+
"Se aparecer um aviso em inglês sobre 'npx playwright install', pode ignorar: o auditor já traz a versão certa do Playwright.\n");
|
|
63
|
+
return spawnSync(command, args, { stdio: "inherit" }).status ?? 1;
|
|
64
|
+
}
|
|
65
|
+
const [target] = parsed.positionals;
|
|
66
|
+
if (!target || parsed.positionals.length > 1) {
|
|
67
|
+
io.stderr(USAGE);
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
let url;
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(target);
|
|
73
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
74
|
+
throw new Error("not http");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
io.stderr(`Endereço inválido: ${target}. Use um endereço completo, como http://localhost:4173/\n`);
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
let consentText;
|
|
81
|
+
if (parsed.values.consent !== undefined) {
|
|
82
|
+
try {
|
|
83
|
+
consentText = readFileSync(parsed.values.consent, "utf8");
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
io.stderr(`Recusado: não consegui ler o registro de consentimento ${parsed.values.consent}.\n`);
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const consent = checkConsent(url, consentText, new Date().toISOString().slice(0, 10));
|
|
91
|
+
if (!consent.ok) {
|
|
92
|
+
io.stderr(`Recusado: ${consent.reason}\n`);
|
|
93
|
+
return 2;
|
|
94
|
+
}
|
|
95
|
+
if (consent.record) {
|
|
96
|
+
const r = consent.record;
|
|
97
|
+
io.stderr(`Consentimento: ${r.dono}; ${r.status} em ${r.data}; ${r.como}\n`);
|
|
98
|
+
}
|
|
99
|
+
if (!existsSync(chromium.executablePath())) {
|
|
100
|
+
io.stderr("Falta o navegador que o auditor usa (o Chromium do Playwright).\n" +
|
|
101
|
+
"Instale uma vez com: auditor --instalar-navegador\n" +
|
|
102
|
+
"(com npx: npx @chrissgon/light-site-auditor --instalar-navegador)\n");
|
|
103
|
+
return 3;
|
|
104
|
+
}
|
|
105
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
106
|
+
const out = resolve(parsed.values.out ?? join("relatorios", `${url.host.replace(/[^\w.-]/g, "_")}-${stamp}`));
|
|
107
|
+
try {
|
|
108
|
+
mkdirSync(out, { recursive: true });
|
|
109
|
+
// One after the other: the Lighthouse simulation starts from a real load, and a second browser
|
|
110
|
+
// running at the same time would slow that load and change the numbers.
|
|
111
|
+
io.stderr(`Lighthouse (3G, celular): ${url.href}\n`);
|
|
112
|
+
const lhr = await runLighthouse(url.href);
|
|
113
|
+
writeFileSync(join(out, "lighthouse.json"), `${JSON.stringify(lhr, null, 2)}\n`);
|
|
114
|
+
io.stderr(`axe (WCAG 2.1 A e AA): ${url.href}\n`);
|
|
115
|
+
const axe = await runAxe(url.href);
|
|
116
|
+
writeFileSync(join(out, "axe.json"), `${JSON.stringify(axe, null, 2)}\n`);
|
|
117
|
+
const report = buildReport(lhr, axe, { lighthouse: "lighthouse.json", axe: "axe.json" });
|
|
118
|
+
writeFileSync(join(out, "relatorio.md"), report.markdown);
|
|
119
|
+
writeFileSync(join(out, "relatorio.html"), report.html);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
io.stderr(`A auditoria falhou: ${error.message}\n`);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
io.stdout(`${join(out, "relatorio.md")}\n${join(out, "relatorio.html")}\n`);
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
const invoked = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : "";
|
|
129
|
+
if (import.meta.url === invoked)
|
|
130
|
+
process.exitCode = await main(process.argv.slice(2));
|
package/dist/consent.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consent gate (T-aud-7): a real site is audited only when a consent record says its owner
|
|
3
|
+
* accepted. The record is a Markdown file whose front matter holds the fields below; the text that
|
|
4
|
+
* asks for consent is docs/field/consentimento.md. There is no bypass: without an accepted record
|
|
5
|
+
* that names the exact address, the CLI refuses.
|
|
6
|
+
*
|
|
7
|
+
* ---
|
|
8
|
+
* endereco: https://example.com/
|
|
9
|
+
* dono: Nome do dono
|
|
10
|
+
* status: aceito
|
|
11
|
+
* data: 2026-09-30
|
|
12
|
+
* como: por e-mail, respondendo ao pedido de consentimento
|
|
13
|
+
* reacao: pendente
|
|
14
|
+
* ---
|
|
15
|
+
*/
|
|
16
|
+
/** Hosts on this computer. They need no consent: they are the user's own pages. */
|
|
17
|
+
export const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
18
|
+
export const ACCEPTED = "aceito";
|
|
19
|
+
const REQUIRED = ["endereco", "dono", "status", "data", "como"];
|
|
20
|
+
/** Reads the front matter (`key: value` lines between two `---` lines at the top of the file). */
|
|
21
|
+
export function parseConsent(text) {
|
|
22
|
+
const lines = text.replace(/^/, "").split(/\r?\n/);
|
|
23
|
+
if (lines[0]?.trim() !== "---")
|
|
24
|
+
return { error: "o arquivo não começa com um bloco entre linhas ---" };
|
|
25
|
+
const end = lines.indexOf("---", 1);
|
|
26
|
+
if (end < 0)
|
|
27
|
+
return { error: "o bloco do registro não fecha com uma linha ---" };
|
|
28
|
+
const fields = {};
|
|
29
|
+
for (const line of lines.slice(1, end)) {
|
|
30
|
+
if (!line.trim() || line.trimStart().startsWith("#"))
|
|
31
|
+
continue;
|
|
32
|
+
const colon = line.indexOf(":");
|
|
33
|
+
if (colon < 1)
|
|
34
|
+
return { error: `linha sem "campo: valor": ${line}` };
|
|
35
|
+
fields[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
|
|
36
|
+
}
|
|
37
|
+
const missing = REQUIRED.filter((key) => !fields[key]);
|
|
38
|
+
if (missing.length)
|
|
39
|
+
return { error: `faltam os campos ${missing.join(", ")}` };
|
|
40
|
+
return fields;
|
|
41
|
+
}
|
|
42
|
+
/** Normalises an address so `https://Site.com` and `https://site.com/` compare equal. */
|
|
43
|
+
function normalise(address) {
|
|
44
|
+
try {
|
|
45
|
+
return new URL(address).href;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Decides whether `url` may be audited. `recordText` is the consent file's content (undefined when
|
|
53
|
+
* no file was given); `today` is YYYY-MM-DD.
|
|
54
|
+
*/
|
|
55
|
+
export function checkConsent(url, recordText, today) {
|
|
56
|
+
if (LOCAL_HOSTS.has(url.hostname))
|
|
57
|
+
return { ok: true };
|
|
58
|
+
if (recordText === undefined) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: `${url.hostname} é um site real. Auditar um site exige o consentimento do dono.\n` +
|
|
62
|
+
"Peça o aceite com o texto de docs/field/consentimento.md (no pacote e em " +
|
|
63
|
+
"https://github.com/chrissgon/light-site-auditor/blob/main/docs/field/consentimento.md), " +
|
|
64
|
+
"registre-o num arquivo e rode de novo com --consent <arquivo>.",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const record = parseConsent(recordText);
|
|
68
|
+
if ("error" in record)
|
|
69
|
+
return { ok: false, reason: `O registro de consentimento não pôde ser lido: ${record.error}.` };
|
|
70
|
+
const expected = normalise(record.endereco);
|
|
71
|
+
if (!expected)
|
|
72
|
+
return { ok: false, reason: `O endereço do registro não é válido: ${record.endereco}.` };
|
|
73
|
+
if (expected !== url.href) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
reason: `O consentimento cobre ${expected}, não ${url.href}. Audite exatamente o endereço aceito pelo dono.`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (record.status !== ACCEPTED) {
|
|
80
|
+
const pending = record.status.startsWith("pendente");
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: pending
|
|
84
|
+
? `O consentimento de ${expected} está pendente (${record.status}). Registre o aceite do dono (status: aceito, data e como) antes de auditar.`
|
|
85
|
+
: `O consentimento de ${expected} não está aceito (status: ${record.status}). Só "status: aceito" libera a auditoria.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(record.data) || Number.isNaN(Date.parse(record.data))) {
|
|
89
|
+
return { ok: false, reason: `A data do consentimento não está no formato AAAA-MM-DD: ${record.data}.` };
|
|
90
|
+
}
|
|
91
|
+
if (record.data > today)
|
|
92
|
+
return { ok: false, reason: `A data do consentimento (${record.data}) é futura.` };
|
|
93
|
+
return { ok: true, record };
|
|
94
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import catalog from "./explanations.pt.json" with { type: "json" };
|
|
2
|
+
/**
|
|
3
|
+
* The reviewed catalog of plain-Portuguese explanations (AC-4). Nothing here writes new text: every
|
|
4
|
+
* sentence of a report comes from src/explanations.pt.json, and tests/explanations.test.ts checks it.
|
|
5
|
+
*/
|
|
6
|
+
export const CATALOG = catalog;
|
|
7
|
+
export const NO_EXPLANATION = CATALOG.report.noExplanation;
|
|
8
|
+
function explain(entries, id, original) {
|
|
9
|
+
const entry = Object.hasOwn(entries, id) ? entries[id] : undefined;
|
|
10
|
+
if (entry)
|
|
11
|
+
return { explained: true, ...entry };
|
|
12
|
+
return { explained: false, title: original, original, mark: NO_EXPLANATION };
|
|
13
|
+
}
|
|
14
|
+
/** The explanation of an axe rule, or the rule's own `help` text marked "sem explicação ainda". */
|
|
15
|
+
export const explainAxe = (ruleId, help) => explain(CATALOG.axe, ruleId, help);
|
|
16
|
+
/** The explanation of a Lighthouse audit, or the audit's own title marked "sem explicação ainda". */
|
|
17
|
+
export const explainLighthouse = (auditId, title) => explain(CATALOG.lighthouse, auditId, title);
|
|
18
|
+
/** Replaces `{name}` placeholders; a placeholder without a value is an error, never left in a report. */
|
|
19
|
+
export function fill(template, values) {
|
|
20
|
+
return template.replace(/\{(\w+)\}/g, (_, name) => {
|
|
21
|
+
if (!(name in values))
|
|
22
|
+
throw new Error(`no value for {${name}} in "${template}"`);
|
|
23
|
+
return String(values[name]);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export const sentences = (text) => text.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
|
|
27
|
+
export const words = (sentence) => sentence.split(/\s+/).filter((w) => /[\p{L}\p{N}{]/u.test(w));
|
|
28
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
29
|
+
/** A whole-word, case-insensitive pattern for a term, with the Portuguese plural (-s, -es). */
|
|
30
|
+
export const termPattern = (term) => new RegExp(`(?<![\\p{L}\\p{N}])${escape(term)}(?:s|es)?(?![\\p{L}\\p{N}])`, "iu");
|
|
31
|
+
/** The glossary terms that appear in the texts. */
|
|
32
|
+
export const glossaryTermsIn = (texts) => Object.keys(CATALOG.glossary).filter((term) => texts.some((text) => termPattern(term).test(text)));
|
|
33
|
+
/** The glossary terms the texts use, plus the terms their definitions use, until nothing new appears. */
|
|
34
|
+
export function glossaryClosure(texts) {
|
|
35
|
+
const found = new Set(glossaryTermsIn(texts));
|
|
36
|
+
for (let size = -1; size !== found.size;) {
|
|
37
|
+
size = found.size;
|
|
38
|
+
for (const term of glossaryTermsIn([...found].map((t) => CATALOG.glossary[t])))
|
|
39
|
+
found.add(term);
|
|
40
|
+
}
|
|
41
|
+
return Object.keys(CATALOG.glossary).filter((term) => found.has(term));
|
|
42
|
+
}
|