@elia97/officina 0.7.1 → 0.9.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/README.md +19 -9
- package/dist/checks/bundle.js +25 -12
- package/dist/checks/lighthouse.js +24 -10
- package/dist/checks/placeholders.js +30 -13
- package/dist/checks/smoke.js +4 -2
- package/dist/lib/bundle-ssr.d.ts +13 -0
- package/dist/lib/bundle-ssr.js +37 -0
- package/dist/lib/check-placeholders.d.ts +2 -1
- package/dist/lib/check-placeholders.js +30 -6
- package/dist/lib/config.d.ts +2 -1
- package/dist/lib/config.js +2 -2
- package/dist/lib/lighthouse.d.ts +6 -1
- package/dist/lib/lighthouse.js +15 -4
- package/dist/lib/smoke-production.d.ts +2 -1
- package/dist/lib/smoke-production.js +32 -6
- package/dist/lib/vercel-build.d.ts +11 -0
- package/dist/lib/vercel-build.js +118 -0
- package/dist/sh/lhci-local.sh +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,12 +40,18 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
|
|
|
40
40
|
|
|
41
41
|
| Comando | Cosa guarda | Da dove prende le rotte |
|
|
42
42
|
|---|---|---|
|
|
43
|
-
| `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages
|
|
43
|
+
| `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages`; sotto SSR, il manifest della build Vercel |
|
|
44
44
|
| `check smoke [url]` | la produzione viva: pagine, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
|
|
45
45
|
| `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
|
|
46
|
-
| `check lighthouse [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; `--local` fa build, server e Chrome da sé | `src/pages` |
|
|
46
|
+
| `check lighthouse [url] [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; con `url` misura un sito già servito, `--local` fa build, server statico e Chrome da sé | `src/pages` |
|
|
47
47
|
| `gen icons` | le icone del manifest, disegnate da `public/favicon.svg`, che è il suo ingresso obbligatorio | — |
|
|
48
48
|
|
|
49
|
+
Una rotta renderizzata a richiesta non emette HTML. Con l'adapter Vercel, `check bundle` la legge dal manifest che Astro scrive in `.vercel/output/_functions` e la misura con gli stessi budget delle pagine statiche: gli script della rotta, le isole e gli script dei componenti che i chunk server della pagina nominano, e il runtime del framework quando c'è un'isola. Il JavaScript che Astro incorpora nell'HTML non si conta, né qui né per le pagine statiche. Due limiti: le isole raggiungibili solo attraverso un `import()` dinamico del server — un'isola dentro un contenuto MDX, per esempio — restano fuori, perché seguire quegli import attribuirebbe a ogni pagina il contenuto di tutto il sito; e un'isola si attribuisce per chunk, quindi una pagina eredita le isole dei componenti che condividono un chunk con quelli che usa. Il manifest è un formato interno di Astro, già cambiato una volta sotto i piedi del misuratore che c'era prima: quando non lo riconosce il check lo dice, e fallisce se non gli resta nessuna rotta da misurare; un'isola che non sa collegare al client è un fallimento, non una misura più bassa. Con un altro adapter le rotte SSR restano fuori, dichiarate, e il check misura soltanto il CSS.
|
|
50
|
+
|
|
51
|
+
Per Lighthouse un sito SSR si misura con `check lighthouse <url>`, sulla produzione o su un'anteprima: al posto del server del progetto visita quell'indirizzo, ed è l'unico modo di avere numeri di produzione. `--local` serve i file statici della build, quindi su un sito renderizzato a richiesta si ferma e lo dice, invece di misurare dei 404. Un'anteprima Vercel con la Deployment Protection risponde con la pagina di accesso, e il check non la può misurare: serve la produzione o un'anteprima non protetta. La porta del server locale segue `LH_PORT` sia con `--listen` sia con `--port`.
|
|
52
|
+
|
|
53
|
+
Lo smoke legge gli header di sicurezza sulla pagina a cui arriva la radice. Se `/` è un redirect sulla stessa origine — il 302 di lingua che Astro emette con `i18n.routing.redirectToDefaultLocale`, per esempio — li cerca sulla destinazione, perché il redirect non porta la CSP delle pagine renderizzate, e il nome del controllo dice dove li ha letti: `header content-security-policy su /it`. Un redirect verso un'altra origine, o senza `location`, è un fallimento. Anche l'attesa che l'alias di produzione punti al deployment nuovo conta un 3xx come una risposta.
|
|
54
|
+
|
|
49
55
|
Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
|
|
50
56
|
|
|
51
57
|
```ts
|
|
@@ -62,7 +68,7 @@ export default defineConfig({
|
|
|
62
68
|
icons: { background: SITE.themeColor.light },
|
|
63
69
|
bundle: { budgets: [motion], cssMaxGzip: 14 * 1024 },
|
|
64
70
|
smoke: { checks: [...DEFAULT_CHECKS, checkLanguageRedirect] },
|
|
65
|
-
features: { analytics: 'required', roadmap: false },
|
|
71
|
+
features: { analytics: 'required', roadmap: false, botId: 'required' },
|
|
66
72
|
routes: { representatives: { '/blog/[slug]': '/blog/ciao-mondo' } },
|
|
67
73
|
})
|
|
68
74
|
```
|
|
@@ -75,14 +81,16 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
|
|
|
75
81
|
| `@elia97/officina/bundle` | `CSS_BUDGET_GZIP` |
|
|
76
82
|
| `@elia97/officina/icons` | `ICON_SPECS` |
|
|
77
83
|
|
|
78
|
-
`siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi
|
|
84
|
+
`siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi ne toglie un controllo la ricompone da `DEFAULT_CHECKS`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`.
|
|
79
85
|
|
|
80
|
-
`features` dice quali controlli il progetto pretende: `'required'` fa fallire il gate quando ciò che deve guardare non c'è, `false` lo spegne dicendolo. Una voce non dichiarata vale `'required'`, e `doctor` la segnala: un gate che esce 0 su un container GTM che non esiste non afferma niente. `routes.representatives` dà a ogni pattern dinamico di `src/pages` un percorso vero, e da lì smoke e Lighthouse guardano anche quelle pagine.
|
|
86
|
+
`features` dice quali controlli il progetto pretende: `'required'` fa fallire il gate quando ciò che deve guardare non c'è, `false` lo spegne dicendolo. Per BotID è `features.botId`: con `false` lo smoke salta la challenge e lo scrive nell'uscita, anche dentro una lista di `smoke.checks` che la contiene. Una voce non dichiarata vale `'required'`, e `doctor` la segnala: un gate che esce 0 su un container GTM che non esiste non afferma niente. `routes.representatives` dà a ogni pattern dinamico di `src/pages` un percorso vero, e da lì smoke e Lighthouse guardano anche quelle pagine.
|
|
81
87
|
|
|
82
|
-
`features.generators` ha un valore in più delle altre
|
|
88
|
+
`features.generators` ha un valore in più delle altre, perché non tutti i siti in produzione nascono dallo scaffold: `'required'` è il comportamento di sempre; `false` spegne i generatori del pacchetto, e `doctor` smette di chiedere i punti di aggancio e gli ancoraggi invece di pretendere file che nessun codice di quel sito userebbe; `'project'` fa lo stesso ma lascia vivi i generatori che il progetto scrive in `officina.generators.mjs`, che a quel punto `doctor` pretende. Con `false` o `'project'`, `officina gen section|page|component|collection` si ferma dicendolo, mentre gli script `gen` e `gen:*` restano attesi in `package.json`: meglio un comando che spiega di uno script che manca. `gen icons` non c'entra con i generatori di codice e non cambia.
|
|
83
89
|
|
|
84
90
|
`placeholders` dice a `check placeholders` cosa guardare: `sources` sono i file da scandire, `contactEnvKeys` le variabili che `--env` pretende. Una lista dichiarata **sostituisce** quella del pacchetto, dizionari compresi: senza dichiarazione i sorgenti sono `src/lib/site.ts`, `src/lib/company.ts` e i dizionari di `src/i18n/strings`, quando la cartella esiste. Serve ai progetti che non nascono dallo scaffold: se partita IVA, recapiti e destinatario dei form stanno nel contenuto invece che in un modulo, i sorgenti del template non sono i suoi, e crearli per far passare il gate terrebbe gli stessi dati in due sedi. Un sorgente elencato che non esiste è un ritrovamento con il suo percorso, non un errore che ferma il comando.
|
|
85
91
|
|
|
92
|
+
Nel deploy `check placeholders` gira due volte sui file di Vercel. Con `--env .vercel/.env.production.local`, dopo `vercel pull`, distingue una chiave che su Vercel non esiste, una vuota e una che porta un segnaposto del template, tutte e tre errori, da una variabile **Sensitive**: `vercel pull` non ne scarica il valore e scrive al suo posto `[SENSITIVE]`, quindi il check lo dice con un avviso, che non ferma il deploy, invece di darla per verificata. Il segnaposto però diventa un problema se la build legge la variabile, per esempio una variabile di `astro:env` con `access: 'public'`, che Astro incorpora nel codice: con `--output .vercel/output`, dopo `vercel build`, il check cerca `[SENSITIVE]` nei file della build e ferma il deploy nominandoli. Una variabile che la build legge va tenuta Encrypted su Vercel, o letta a runtime.
|
|
93
|
+
|
|
86
94
|
Il file lo carica Node, che fuori da `node_modules` toglie i tipi da sé: niente `enum`, niente alias `@/`. Due file `officina.config.*` insieme sono un errore, come un file senza default export; il resto lo valida `loadConfig` a runtime, nominando il percorso della voce sbagliata — `bundle.cssMaxGzip: atteso un numero, ricevuto una stringa` — e una voce sconosciuta, perché `sitUrl` al posto di `siteUrl` non deve passare in silenzio.
|
|
87
95
|
|
|
88
96
|
`sharp` è una dipendenza facoltativa del progetto, non del pacchetto: serve solo a `gen icons`, che senza lo dice ed esce 1. Un ingresso che manca — il favicon, `.lighthouserc.json`, `src/pages`, la build in `dist/client` — è sempre una riga che nomina il file e dice a cosa serve, mai uno stack trace. `@biomejs/biome` invece è una peer dependency con l'intervallo che il preset regge, e `doctor` confronta la versione del progetto con quello.
|
|
@@ -110,9 +118,9 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
|
|
|
110
118
|
|
|
111
119
|
| Action | Passi | Cosa resta nel workflow del progetto |
|
|
112
120
|
|---|---|---|
|
|
113
|
-
| `actions/ci` | checkout, node, install, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
|
|
114
|
-
| `actions/review` | `fallow review` sul diff contro il merge-base | il job informativo |
|
|
115
|
-
| `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono |
|
|
121
|
+
| `actions/ci` | checkout, node, install, `astro sync`, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
|
|
122
|
+
| `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
|
|
123
|
+
| `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, controllo della build, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono |
|
|
116
124
|
| `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
|
|
117
125
|
|
|
118
126
|
```yaml
|
|
@@ -132,6 +140,8 @@ jobs:
|
|
|
132
140
|
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
|
|
133
141
|
```
|
|
134
142
|
|
|
143
|
+
`astro sync` genera i tipi di `.astro/`, che fallow pretende e un checkout pulito non ha; se fallisce il passo lo segnala con un avviso e il job prosegue.
|
|
144
|
+
|
|
135
145
|
I tre segreti sono input obbligatori e l'action li mette nell'`env` dei soli tre passi che chiamano `vercel`. Nell'`env` del job li vedrebbero anche `pnpm install` e gli script di installazione di ogni dipendenza, che girano codice di terzi con in mano un token di deploy.
|
|
136
146
|
|
|
137
147
|
Quello che si scarica al volo è fissato a una versione esatta, in un posto solo: la CLI di Vercel in `actions/deploy/action.yml`, `@lhci/cli` in `src/checks/lighthouse.ts`, `serve` in `src/sh/lhci-local.sh`. Che i primi due siano esatti, uguali ovunque e non indietro di una major su npm lo guarda ogni lunedì `.github/workflows/vercel-cli.yml`, con `officina check vercel-cli`.
|
package/dist/checks/bundle.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import { gzipSync } from 'node:zlib';
|
|
7
7
|
import { budgetFor, CSS_BUDGET_GZIP, cssBudgetFailure, deferredClosure, heaviestStylesheet, htmlEntries, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
|
|
8
|
+
import { readSsr } from '../lib/bundle-ssr.js';
|
|
8
9
|
import { missingInput } from '../lib/cli.js';
|
|
9
10
|
import { loadConfig } from '../lib/config.js';
|
|
10
11
|
import { expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
|
|
@@ -25,12 +26,17 @@ function readChunks() {
|
|
|
25
26
|
}
|
|
26
27
|
return chunks;
|
|
27
28
|
}
|
|
28
|
-
function
|
|
29
|
+
function htmlPages() {
|
|
30
|
+
return filesWithExtension(DIST, '.html').map((htmlPath) => ({
|
|
31
|
+
route: routeOf(htmlPath, DIST),
|
|
32
|
+
entries: htmlEntries(readFileSync(htmlPath, 'utf8')),
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
function measurePages(pages, chunks, budgets) {
|
|
29
36
|
const total = (names) => [...names].reduce((sum, name) => sum + (chunks.get(name)?.gzip ?? 0), 0);
|
|
30
|
-
return
|
|
31
|
-
.map((
|
|
32
|
-
const
|
|
33
|
-
const { reached, unknown } = staticClosure(htmlEntries(readFileSync(htmlPath, 'utf8')), chunks);
|
|
37
|
+
return pages
|
|
38
|
+
.map(({ route, entries }) => {
|
|
39
|
+
const { reached, unknown } = staticClosure(entries, chunks);
|
|
34
40
|
return {
|
|
35
41
|
route,
|
|
36
42
|
gzip: total(reached),
|
|
@@ -74,8 +80,10 @@ function printStylesheets(stylesheets, cssMaxGzip, failed, width) {
|
|
|
74
80
|
console.log(` ${sheet.file.padEnd(width - 2)} ${gz(sheet.gzip).padStart(9)} ${gz(cssMaxGzip).padStart(9)}${mark}`);
|
|
75
81
|
}
|
|
76
82
|
}
|
|
77
|
-
function printSsrNote(expected) {
|
|
78
|
-
|
|
83
|
+
function printSsrNote(expected, ssr) {
|
|
84
|
+
for (const note of ssr.notes)
|
|
85
|
+
console.log(`\nNOTA ${note}`);
|
|
86
|
+
if (ssr.measured || expected.ssr.length === 0)
|
|
79
87
|
return;
|
|
80
88
|
console.log(`\nNOTA ${expected.ssr.length} pagina/e con \`export const prerender = false\`, fuori da questo budget:`);
|
|
81
89
|
for (const file of expected.ssr)
|
|
@@ -88,10 +96,13 @@ export async function main() {
|
|
|
88
96
|
if (missingInput(PAGES, 'le rotte attese si derivano da lì'))
|
|
89
97
|
return 1;
|
|
90
98
|
const cssMaxGzip = bundle.cssMaxGzip ?? CSS_BUDGET_GZIP;
|
|
91
|
-
const
|
|
99
|
+
const html = htmlPages();
|
|
100
|
+
const ssr = readSsr(html.length > 0);
|
|
101
|
+
const pages = measurePages([...html, ...ssr.pages], readChunks(), bundle.budgets ?? []);
|
|
92
102
|
const expected = expectedRoutes(readPageFiles(PAGES), PAGES);
|
|
93
|
-
const
|
|
94
|
-
failures.
|
|
103
|
+
const measured = pages.map((page) => page.route);
|
|
104
|
+
const failures = ssr.comparesExpectedRoutes ? missingRouteFailures(expected, measured, DIST) : [];
|
|
105
|
+
failures.push(...ssr.failures, ...unknownChunkFailures(pages), ...overBudgetFailures(pages));
|
|
95
106
|
const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
|
|
96
107
|
printPages(pages, width);
|
|
97
108
|
const stylesheets = readStylesheets();
|
|
@@ -99,12 +110,14 @@ export async function main() {
|
|
|
99
110
|
if (cssFailure)
|
|
100
111
|
failures.push(cssFailure);
|
|
101
112
|
printStylesheets(stylesheets, cssMaxGzip, cssFailure !== null, width);
|
|
102
|
-
printSsrNote(expected);
|
|
113
|
+
printSsrNote(expected, ssr);
|
|
103
114
|
if (failures.length > 0) {
|
|
104
115
|
console.error(`\n✗ Budget di bundle:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
|
|
105
116
|
return 1;
|
|
106
117
|
}
|
|
107
|
-
console.log(
|
|
118
|
+
console.log(ssr.comparesExpectedRoutes
|
|
119
|
+
? '\n✓ Budget di bundle rispettato su ogni rotta attesa.\n'
|
|
120
|
+
: '\n✓ Budget del CSS rispettato; le rotte non sono state misurate.\n');
|
|
108
121
|
return 0;
|
|
109
122
|
}
|
|
110
123
|
if (import.meta.main)
|
|
@@ -7,7 +7,7 @@ import process from 'node:process';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { missingInput } from '../lib/cli.js';
|
|
9
9
|
import { loadConfig } from '../lib/config.js';
|
|
10
|
-
import { lighthouseConfig } from '../lib/lighthouse.js';
|
|
10
|
+
import { baseUrlArg, lighthouseConfig } from '../lib/lighthouse.js';
|
|
11
11
|
import { auditRoutes, expectedRoutes, readPageFiles } from '../lib/routes.js';
|
|
12
12
|
const PAGES_DIR = 'src/pages';
|
|
13
13
|
const RC_FILE = '.lighthouserc.json';
|
|
@@ -18,21 +18,24 @@ function runLocal() {
|
|
|
18
18
|
const env = { ...process.env, OFFICINA_BIN: process.argv[1] };
|
|
19
19
|
return spawnSync('bash', [script], { stdio: 'inherit', env }).status ?? 1;
|
|
20
20
|
}
|
|
21
|
-
|
|
22
|
-
if (args.includes('--local'))
|
|
23
|
-
return runLocal();
|
|
21
|
+
async function resolveConfig(url) {
|
|
24
22
|
const { routes: configured = {} } = await loadConfig(process.cwd());
|
|
25
23
|
if (missingInput(RC_FILE, 'è la configurazione di Lighthouse CI'))
|
|
26
|
-
return
|
|
24
|
+
return null;
|
|
27
25
|
if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
|
|
28
|
-
return
|
|
26
|
+
return null;
|
|
29
27
|
const rc = JSON.parse(readFileSync(RC_FILE, 'utf8'));
|
|
30
28
|
const routes = auditRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR), configured.representatives);
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
const env = url === undefined ? process.env : { ...process.env, LH_BASE_URL: url };
|
|
30
|
+
const resolved = lighthouseConfig(rc, routes, env);
|
|
31
|
+
const where = url === undefined ? '' : ` su ${url}`;
|
|
32
|
+
console.log(`\nLighthouse CI — ${routes.length} rotta/e derivate da ${PAGES_DIR}${where}\n`);
|
|
33
|
+
for (const page of resolved.ci.collect.url ?? [])
|
|
34
|
+
console.log(` ${page}`);
|
|
35
35
|
console.log();
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
function autorun(resolved) {
|
|
36
39
|
const dir = mkdtempSync(join(tmpdir(), 'lhci-'));
|
|
37
40
|
const config = join(dir, 'lighthouserc.json');
|
|
38
41
|
writeFileSync(config, JSON.stringify(resolved, null, 2));
|
|
@@ -42,5 +45,16 @@ export async function main(args = []) {
|
|
|
42
45
|
rmSync(dir, { recursive: true, force: true });
|
|
43
46
|
return status ?? 1;
|
|
44
47
|
}
|
|
48
|
+
export async function main(args = []) {
|
|
49
|
+
if (args.includes('--local'))
|
|
50
|
+
return runLocal();
|
|
51
|
+
const { url, error } = baseUrlArg(args);
|
|
52
|
+
if (error !== undefined) {
|
|
53
|
+
console.error(`\n✗ ${error}\n`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
const resolved = await resolveConfig(url);
|
|
57
|
+
return resolved === null ? 1 : autorun(resolved);
|
|
58
|
+
}
|
|
45
59
|
if (import.meta.main)
|
|
46
60
|
process.exit(await main(process.argv.slice(2)));
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { contactEnvKeys, envFindings, placeholderSources, sourceFindings } from '../lib/check-placeholders.js';
|
|
5
|
+
import { contactEnvKeys, envFindings, outputFindings, placeholderSources, sourceFindings, } from '../lib/check-placeholders.js';
|
|
6
6
|
import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
|
|
7
7
|
import { loadConfig } from '../lib/config.js';
|
|
8
8
|
const MISSING_SOURCE = 'sorgente assente: `placeholders.sources` in officina.config.ts dice quali file guardare';
|
|
@@ -16,30 +16,47 @@ function sourceScan(paths) {
|
|
|
16
16
|
function envScan(path, keys) {
|
|
17
17
|
if (!existsSync(path))
|
|
18
18
|
return [{ path, message: "file dell'ambiente assente: lo scrive vercel pull", severity: 'error' }];
|
|
19
|
-
return envFindings(readFileSync(path, 'utf8'), keys).map((hit) => ({ path, ...hit
|
|
19
|
+
return envFindings(readFileSync(path, 'utf8'), keys).map((hit) => ({ path, ...hit }));
|
|
20
20
|
}
|
|
21
|
-
function
|
|
21
|
+
function outputScan(dir) {
|
|
22
|
+
if (!existsSync(dir))
|
|
23
|
+
return [{ path: dir, message: 'cartella della build assente: la scrive vercel build', severity: 'error' }];
|
|
24
|
+
return outputFindings(dir);
|
|
25
|
+
}
|
|
26
|
+
function scan(env, output, config) {
|
|
27
|
+
if (output !== undefined) {
|
|
28
|
+
return {
|
|
29
|
+
scope: `build in ${output}`,
|
|
30
|
+
findings: outputScan(output),
|
|
31
|
+
clean: 'Nessuna variabile Sensitive entrata nella build.',
|
|
32
|
+
advice: 'Una variabile che la build legge non può essere Sensitive su Vercel: portala a Encrypted, o leggila a runtime',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
22
35
|
if (env !== undefined) {
|
|
23
36
|
return {
|
|
24
37
|
scope: `ambiente in ${env}`,
|
|
25
38
|
findings: envScan(env, contactEnvKeys(config)),
|
|
26
|
-
|
|
39
|
+
clean: 'Nessun segnaposto del template.',
|
|
40
|
+
advice: 'Da sostituire prima del deploy: docs/guides/deploy-ops.md § Checklist per il go-live',
|
|
27
41
|
};
|
|
28
42
|
}
|
|
29
43
|
const paths = placeholderSources(config);
|
|
30
|
-
return {
|
|
44
|
+
return {
|
|
45
|
+
scope: `${paths.length} sorgenti`,
|
|
46
|
+
findings: sourceScan(paths),
|
|
47
|
+
clean: 'Nessun segnaposto del template.',
|
|
48
|
+
advice: 'Da sostituire prima del deploy: README.md § Cosa tocca il rebranding',
|
|
49
|
+
};
|
|
31
50
|
}
|
|
32
51
|
export async function main(args = process.argv.slice(2)) {
|
|
33
|
-
const { env } = parseArgs({ args, options: { env: { type: 'string' } } }).values;
|
|
34
|
-
const { scope, findings,
|
|
52
|
+
const { env, output } = parseArgs({ args, options: { env: { type: 'string' }, output: { type: 'string' } } }).values;
|
|
53
|
+
const { scope, findings, clean, advice } = scan(env, output, await loadConfig(process.cwd()));
|
|
35
54
|
console.log(`\ncheck:placeholders — ${scope}\n`);
|
|
36
55
|
printFindings(findings, cliOptions([]).format);
|
|
37
|
-
if (findings.length === 0)
|
|
38
|
-
console.log(
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
console.log(`\n Da sostituire prima del deploy: ${fix}.\n`);
|
|
42
|
-
}
|
|
56
|
+
if (findings.length === 0)
|
|
57
|
+
console.log(` ${clean}\n`);
|
|
58
|
+
else if (findings.some(({ severity }) => severity === 'error'))
|
|
59
|
+
console.log(`\n ${advice}.\n`);
|
|
43
60
|
return exitCode(findings, false);
|
|
44
61
|
}
|
|
45
62
|
if (import.meta.main)
|
package/dist/checks/smoke.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// L'unico controllo che vede cosa serve davvero il bordo: i test del progetto fissano solo vercel.json.
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { missingInput } from '../lib/cli.js';
|
|
5
|
-
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { isRequired, loadConfig } from '../lib/config.js';
|
|
6
6
|
import { expectedRoutes, readPageFiles, smokeRoutes } from '../lib/routes.js';
|
|
7
7
|
import { runChecks, SECURITY_HEADERS, waitForAlias, } from '../lib/smoke-production.js';
|
|
8
8
|
const PAGES_DIR = 'src/pages';
|
|
@@ -23,7 +23,8 @@ function printFailures(failures) {
|
|
|
23
23
|
console.error('Deployments → l’ultimo deployment di produzione sano → Promote to Production.\n');
|
|
24
24
|
}
|
|
25
25
|
export async function main(args = []) {
|
|
26
|
-
const
|
|
26
|
+
const config = await loadConfig(process.cwd());
|
|
27
|
+
const { siteUrl, smoke = {}, routes = {} } = config;
|
|
27
28
|
const [url] = args;
|
|
28
29
|
if (siteUrl === undefined) {
|
|
29
30
|
console.error('\n✗ officina.config.ts non dichiara siteUrl: il controllo dell’host canonico non ha con cosa confrontarsi.\n');
|
|
@@ -40,6 +41,7 @@ export async function main(args = []) {
|
|
|
40
41
|
baseUrl,
|
|
41
42
|
siteUrl,
|
|
42
43
|
securityHeaders: smoke.securityHeaders ?? SECURITY_HEADERS,
|
|
44
|
+
botIdRequired: isRequired(config, 'botId'),
|
|
43
45
|
};
|
|
44
46
|
if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
|
|
45
47
|
return 1;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type PageEntries = {
|
|
2
|
+
route: string;
|
|
3
|
+
entries: string[];
|
|
4
|
+
};
|
|
5
|
+
export type SsrReading = {
|
|
6
|
+
pages: PageEntries[];
|
|
7
|
+
failures: string[];
|
|
8
|
+
notes: string[];
|
|
9
|
+
comparesExpectedRoutes: boolean;
|
|
10
|
+
measured: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** Le rotte renderizzate a richiesta non emettono HTML: le dà il manifest che Astro scrive nella build Vercel. */
|
|
13
|
+
export declare function readSsr(hasHtml: boolean): SsrReading;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ssrRoutes } from './vercel-build.js';
|
|
4
|
+
const FUNCTIONS = '.vercel/output/_functions';
|
|
5
|
+
const SERVER_OUTPUT = 'dist/server';
|
|
6
|
+
function readServerFiles() {
|
|
7
|
+
const read = (path) => ({ path, source: readFileSync(join(FUNCTIONS, path), 'utf8') });
|
|
8
|
+
const chunks = existsSync(join(FUNCTIONS, 'chunks')) ? readdirSync(join(FUNCTIONS, 'chunks')) : [];
|
|
9
|
+
const entry = existsSync(join(FUNCTIONS, 'entry.mjs')) ? ['entry.mjs'] : [];
|
|
10
|
+
return [...entry, ...chunks.filter((name) => name.endsWith('.mjs')).map((name) => `chunks/${name}`)].map(read);
|
|
11
|
+
}
|
|
12
|
+
/** Le rotte renderizzate a richiesta non emettono HTML: le dà il manifest che Astro scrive nella build Vercel. */
|
|
13
|
+
export function readSsr(hasHtml) {
|
|
14
|
+
const none = { pages: [], failures: [], notes: [], comparesExpectedRoutes: true, measured: false };
|
|
15
|
+
if (!existsSync(FUNCTIONS)) {
|
|
16
|
+
if (hasHtml || !existsSync(SERVER_OUTPUT))
|
|
17
|
+
return none;
|
|
18
|
+
const note = `build SSR senza l'adapter Vercel: le rotte non si misurano, resta il budget del CSS`;
|
|
19
|
+
return { ...none, notes: [note], comparesExpectedRoutes: false };
|
|
20
|
+
}
|
|
21
|
+
const routes = ssrRoutes(readServerFiles());
|
|
22
|
+
if (routes === null) {
|
|
23
|
+
const problem = `${FUNCTIONS}: il manifest di Astro non è in una forma riconosciuta`;
|
|
24
|
+
return hasHtml
|
|
25
|
+
? { ...none, notes: [`${problem}, le rotte renderizzate a richiesta restano fuori dal budget`] }
|
|
26
|
+
: { ...none, failures: [`${problem}, quindi non c'è nessuna rotta da misurare`], comparesExpectedRoutes: false };
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
...none,
|
|
30
|
+
pages: routes.map(({ route, entries }) => ({ route, entries })),
|
|
31
|
+
failures: routes.flatMap(({ route, unmapped, unknownIslands }) => [
|
|
32
|
+
...(unmapped ? [`${route}: nessun chunk server in ${FUNCTIONS}, la misura non coprirebbe le isole`] : []),
|
|
33
|
+
...unknownIslands.map((island) => `${route}: l'isola ${island} non ha un chunk in dist/client/_astro fra quelli del manifest`),
|
|
34
|
+
]),
|
|
35
|
+
measured: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type Finding, type Hit } from './cli.ts';
|
|
2
2
|
import type { OfficinaConfig } from './config.ts';
|
|
3
|
-
type EnvHit = Omit<Finding, 'path'
|
|
3
|
+
type EnvHit = Omit<Finding, 'path'>;
|
|
4
4
|
export declare function placeholderSources(config?: OfficinaConfig): string[];
|
|
5
5
|
export declare const contactEnvKeys: (config?: OfficinaConfig) => readonly string[];
|
|
6
6
|
export declare function sourceFindings(source: string): Hit[];
|
|
7
7
|
export declare function envFindings(content: string, keys: readonly string[]): EnvHit[];
|
|
8
|
+
export declare const outputFindings: (dir: string) => Finding[];
|
|
8
9
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parseEnv } from 'node:util';
|
|
4
4
|
import { readLines } from './cli.js';
|
|
@@ -11,6 +11,8 @@ const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
|
|
|
11
11
|
const COMMENT_ONLY = /^\s*(?:\/\/|\/\*|\*)/;
|
|
12
12
|
const ZERO_POSTAL_CODE = /\bpostalCode:\s*(['"])00000\1/;
|
|
13
13
|
const EMPTY_HREF = /\bhref:\s*(['"])#\1/;
|
|
14
|
+
// `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@59.22.0: `vercel pull` lo scrive al posto del valore di una Sensitive.
|
|
15
|
+
const SENSITIVE = '[SENSITIVE]';
|
|
14
16
|
const dictionaries = () => existsSync(DICTIONARIES)
|
|
15
17
|
? readdirSync(DICTIONARIES)
|
|
16
18
|
.filter((name) => name.endsWith('.ts'))
|
|
@@ -51,13 +53,35 @@ export function envFindings(content, keys) {
|
|
|
51
53
|
const env = parseEnv(content);
|
|
52
54
|
const lines = readLines(content);
|
|
53
55
|
return keys.flatMap((key) => {
|
|
54
|
-
const value = env[key]?.trim()
|
|
55
|
-
const declaration = lines.find(({ text }) => new RegExp(`^\\s
|
|
56
|
-
|
|
56
|
+
const value = env[key]?.trim();
|
|
57
|
+
const declaration = lines.find(({ text }) => new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`).test(text));
|
|
58
|
+
if (value === undefined || declaration === undefined) {
|
|
59
|
+
const message = `${key} non c'è fra le variabili di produzione su Vercel: vale il default di astro.config.mjs`;
|
|
60
|
+
return [{ severity: 'error', message }];
|
|
61
|
+
}
|
|
62
|
+
const hit = (severity, message) => [
|
|
63
|
+
{ line: declaration.n, severity, message: `${key} ${message}` },
|
|
64
|
+
];
|
|
65
|
+
if (value === SENSITIVE)
|
|
66
|
+
return hit('warning', 'è Sensitive su Vercel: vercel pull non ne scarica il valore, e il deploy non lo può verificare');
|
|
57
67
|
if (value === '')
|
|
58
|
-
return
|
|
68
|
+
return hit('error', 'è vuota: vale il default di astro.config.mjs');
|
|
59
69
|
if (TOKEN.test(value) || DOMAIN.test(value))
|
|
60
|
-
return
|
|
70
|
+
return hit('error', 'porta un segnaposto del template');
|
|
61
71
|
return [];
|
|
62
72
|
});
|
|
63
73
|
}
|
|
74
|
+
const filesUnder = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
75
|
+
const path = join(dir, entry.name);
|
|
76
|
+
if (entry.isDirectory())
|
|
77
|
+
return filesUnder(path);
|
|
78
|
+
return entry.isFile() ? [path] : [];
|
|
79
|
+
});
|
|
80
|
+
export const outputFindings = (dir) => filesUnder(dir)
|
|
81
|
+
.filter((path) => readFileSync(path).includes(SENSITIVE))
|
|
82
|
+
.sort()
|
|
83
|
+
.map((path) => ({
|
|
84
|
+
path,
|
|
85
|
+
severity: 'error',
|
|
86
|
+
message: `contiene ${SENSITIVE}, il segnaposto che vercel pull scrive per una variabile Sensitive: la build l'ha incorporato al posto del valore`,
|
|
87
|
+
}));
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface OfficinaConfig {
|
|
|
27
27
|
analytics?: FeatureSetting;
|
|
28
28
|
roadmap?: FeatureSetting;
|
|
29
29
|
generators?: GeneratorsSetting;
|
|
30
|
+
botId?: FeatureSetting;
|
|
30
31
|
};
|
|
31
32
|
placeholders?: {
|
|
32
33
|
sources?: readonly string[];
|
|
@@ -37,7 +38,7 @@ export interface OfficinaConfig {
|
|
|
37
38
|
representatives?: Representatives;
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
|
-
export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators'];
|
|
41
|
+
export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators', 'botId'];
|
|
41
42
|
export type FeatureName = (typeof FEATURES)[number];
|
|
42
43
|
/** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
|
|
43
44
|
export declare const isRequired: (config: OfficinaConfig, name: FeatureName) => boolean;
|
package/dist/lib/config.js
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync } from 'node:fs';
|
|
|
10
10
|
import { basename, join } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { aFunction, aNumber, anArrayOf, aRecordOf, aShape, aString, aStringOrNull, oneOf } from './validate.js';
|
|
13
|
-
export const FEATURES = ['analytics', 'roadmap', 'generators'];
|
|
13
|
+
export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId'];
|
|
14
14
|
/** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
|
|
15
15
|
export const isRequired = (config, name) => (config.features?.[name] ?? 'required') === 'required';
|
|
16
16
|
export const generatorsSetting = (config) => config.features?.generators ?? 'required';
|
|
@@ -28,7 +28,7 @@ const SHAPE = aShape({
|
|
|
28
28
|
checks: anArrayOf(aFunction),
|
|
29
29
|
}),
|
|
30
30
|
analytics: aShape({ linkTracking: aString }),
|
|
31
|
-
features: aShape({ analytics: feature, roadmap: feature, generators }),
|
|
31
|
+
features: aShape({ analytics: feature, roadmap: feature, generators, botId: feature }),
|
|
32
32
|
placeholders: aShape({ sources: anArrayOf(aString), contactEnvKeys: anArrayOf(aString) }),
|
|
33
33
|
routes: aShape({ representatives: aRecordOf(aString) }),
|
|
34
34
|
});
|
package/dist/lib/lighthouse.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface LighthouseRc {
|
|
|
12
12
|
[section: string]: unknown;
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
-
export type LighthouseEnv = Partial<Record<'LH_PORT' | 'LH_RUNS' | 'LH_EXTERNAL_SERVER' | 'LH_CHROME_FLAGS' | 'LH_OUT', string>>;
|
|
15
|
+
export type LighthouseEnv = Partial<Record<'LH_PORT' | 'LH_RUNS' | 'LH_EXTERNAL_SERVER' | 'LH_BASE_URL' | 'LH_CHROME_FLAGS' | 'LH_OUT', string>>;
|
|
16
|
+
export type BaseUrlArg = {
|
|
17
|
+
url?: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function baseUrlArg(args: readonly string[]): BaseUrlArg;
|
|
16
21
|
/** Le rotte arrivano da src/pages, il resto dal `.lighthouserc.json` del progetto; l'ambiente lo piega al giro locale. */
|
|
17
22
|
export declare function lighthouseConfig(rc: LighthouseRc, routes: readonly string[], env: LighthouseEnv): LighthouseRc;
|
package/dist/lib/lighthouse.js
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
const DEFAULT_PORT = '4321';
|
|
2
|
+
const SERVER_PORT = /(--listen|--port)([ =])\d+/;
|
|
3
|
+
export function baseUrlArg(args) {
|
|
4
|
+
const url = args.find((arg) => !arg.startsWith('--'));
|
|
5
|
+
if (url === undefined)
|
|
6
|
+
return {};
|
|
7
|
+
const protocol = URL.parse(url)?.protocol;
|
|
8
|
+
if (protocol === 'http:' || protocol === 'https:')
|
|
9
|
+
return { url: url.replace(/\/+$/, '') };
|
|
10
|
+
return { error: `${url} non è un URL http(s): passa l'indirizzo di un sito già servito, per esempio https://…` };
|
|
11
|
+
}
|
|
2
12
|
/** Le rotte arrivano da src/pages, il resto dal `.lighthouserc.json` del progetto; l'ambiente lo piega al giro locale. */
|
|
3
13
|
export function lighthouseConfig(rc, routes, env) {
|
|
4
14
|
const port = env.LH_PORT ?? DEFAULT_PORT;
|
|
5
|
-
const
|
|
15
|
+
const base = env.LH_BASE_URL ?? `http://localhost:${port}`;
|
|
16
|
+
const collect = { ...rc.ci.collect, url: routes.map((route) => `${base}${route}`) };
|
|
6
17
|
if (env.LH_RUNS)
|
|
7
18
|
collect.numberOfRuns = Number(env.LH_RUNS);
|
|
8
|
-
// Il server è già in ascolto: lo avvia lhci-local.sh, per servire una directory diversa.
|
|
9
|
-
if (env.LH_EXTERNAL_SERVER) {
|
|
19
|
+
// Il server è già in ascolto: lo avvia lhci-local.sh, per servire una directory diversa, oppure è il sito dell'URL.
|
|
20
|
+
if (env.LH_EXTERNAL_SERVER || env.LH_BASE_URL) {
|
|
10
21
|
delete collect.startServerCommand;
|
|
11
22
|
delete collect.startServerReadyPattern;
|
|
12
23
|
delete collect.startServerReadyTimeout;
|
|
13
24
|
}
|
|
14
25
|
else if (collect.startServerCommand !== undefined) {
|
|
15
|
-
collect.startServerCommand = collect.startServerCommand.replace(
|
|
26
|
+
collect.startServerCommand = collect.startServerCommand.replace(SERVER_PORT, `$1$2${port}`);
|
|
16
27
|
}
|
|
17
28
|
if (env.LH_CHROME_FLAGS)
|
|
18
29
|
collect.settings = { ...collect.settings, chromeFlags: env.LH_CHROME_FLAGS };
|
|
@@ -12,6 +12,7 @@ export interface SmokeContext {
|
|
|
12
12
|
baseUrl: string;
|
|
13
13
|
siteUrl: string;
|
|
14
14
|
securityHeaders: Record<string, string | null>;
|
|
15
|
+
botIdRequired: boolean;
|
|
15
16
|
}
|
|
16
17
|
export type SmokeCheck = (context: SmokeContext, pages: readonly VerifiedRoute[]) => Promise<CheckResult[]>;
|
|
17
18
|
export interface CheckResult {
|
|
@@ -26,7 +27,7 @@ export declare const BOTID_CHALLENGE = "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d
|
|
|
26
27
|
export declare function waitForAlias({ get, baseUrl }: SmokeContext, sleep: (ms: number) => Promise<void>, attempts?: number): Promise<void>;
|
|
27
28
|
export declare function checkPages({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
|
|
28
29
|
export declare function checkSecurityHeaders({ get, baseUrl, securityHeaders }: SmokeContext): Promise<CheckResult[]>;
|
|
29
|
-
export declare function checkBotIdChallenge({ get, baseUrl }: SmokeContext): Promise<CheckResult[]>;
|
|
30
|
+
export declare function checkBotIdChallenge({ get, baseUrl, botIdRequired }: SmokeContext): Promise<CheckResult[]>;
|
|
30
31
|
/** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
|
|
31
32
|
export declare function checkCanonicalHost({ get, baseUrl, siteUrl }: SmokeContext): Promise<CheckResult[]>;
|
|
32
33
|
export declare function checkTrailingSlash({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
|
|
@@ -13,11 +13,14 @@ export const SECURITY_HEADERS = {
|
|
|
13
13
|
};
|
|
14
14
|
/** Deve corrispondere al `source` del rewrite in vercel.json. */
|
|
15
15
|
export const BOTID_CHALLENGE = '/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js';
|
|
16
|
+
const isRedirect = (status) => status >= 300 && status < 400;
|
|
17
|
+
const MAX_REDIRECTS = 5;
|
|
16
18
|
/** L'alias di produzione di Vercel impiega un attimo a puntare al deployment appena caricato. */
|
|
17
19
|
export async function waitForAlias({ get, baseUrl }, sleep, attempts = 5) {
|
|
18
20
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
19
21
|
try {
|
|
20
|
-
|
|
22
|
+
const response = await get(baseUrl);
|
|
23
|
+
if (response.ok || isRedirect(response.status))
|
|
21
24
|
return;
|
|
22
25
|
}
|
|
23
26
|
catch { }
|
|
@@ -25,6 +28,25 @@ export async function waitForAlias({ get, baseUrl }, sleep, attempts = 5) {
|
|
|
25
28
|
await sleep(attempt * 2000);
|
|
26
29
|
}
|
|
27
30
|
}
|
|
31
|
+
/** La radice può essere un redirect, come il 302 di lingua di Astro, che non porta la CSP delle pagine renderizzate. */
|
|
32
|
+
async function landingPage(get, baseUrl) {
|
|
33
|
+
const { origin } = new URL(baseUrl);
|
|
34
|
+
let url = baseUrl;
|
|
35
|
+
for (let hops = 0;; hops++) {
|
|
36
|
+
const response = await get(url);
|
|
37
|
+
if (!isRedirect(response.status))
|
|
38
|
+
return { url, response };
|
|
39
|
+
if (hops === MAX_REDIRECTS)
|
|
40
|
+
throw new Error(`più di ${MAX_REDIRECTS} redirect a partire da ${baseUrl}`);
|
|
41
|
+
const location = response.headers.get('location');
|
|
42
|
+
if (location === null)
|
|
43
|
+
throw new Error(`${url} risponde ${response.status} senza location`);
|
|
44
|
+
const next = new URL(location, url);
|
|
45
|
+
if (next.origin !== origin)
|
|
46
|
+
throw new Error(`${url} rimanda a ${next.href}, fuori da ${origin}`);
|
|
47
|
+
url = next.href;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
28
50
|
export async function checkPages({ get, baseUrl }, pages) {
|
|
29
51
|
const results = [];
|
|
30
52
|
for (const { path, type } of pages) {
|
|
@@ -46,15 +68,17 @@ export async function checkPages({ get, baseUrl }, pages) {
|
|
|
46
68
|
return results;
|
|
47
69
|
}
|
|
48
70
|
export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
|
|
49
|
-
let
|
|
71
|
+
let landing;
|
|
50
72
|
try {
|
|
51
|
-
|
|
73
|
+
landing = await landingPage(get, baseUrl);
|
|
52
74
|
}
|
|
53
75
|
catch (error) {
|
|
54
76
|
return [fail('header di sicurezza', messageOf(error))];
|
|
55
77
|
}
|
|
78
|
+
const { url, response } = landing;
|
|
79
|
+
const where = url === baseUrl ? '' : ` su ${new URL(url).pathname}`;
|
|
56
80
|
const results = Object.entries(securityHeaders).map(([header, expected]) => {
|
|
57
|
-
const check = `header ${header}`;
|
|
81
|
+
const check = `header ${header}${where}`;
|
|
58
82
|
const value = response.headers.get(header);
|
|
59
83
|
if (value === null)
|
|
60
84
|
return fail(check, 'assente');
|
|
@@ -65,11 +89,13 @@ export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
|
|
|
65
89
|
// Il noindex `has: host = *.vercel.app` di vercel.json applicato al dominio vero farebbe sparire il sito da ogni indice.
|
|
66
90
|
const check = 'nessun x-robots-tag sull’host di produzione';
|
|
67
91
|
const robots = response.headers.get('x-robots-tag');
|
|
68
|
-
results.push(robots === null ? pass(check) : fail(check, `presente su ${
|
|
92
|
+
results.push(robots === null ? pass(check) : fail(check, `presente su ${url}: "${robots}"`));
|
|
69
93
|
return results;
|
|
70
94
|
}
|
|
71
|
-
export async function checkBotIdChallenge({ get, baseUrl }) {
|
|
95
|
+
export async function checkBotIdChallenge({ get, baseUrl, botIdRequired }) {
|
|
72
96
|
const check = 'challenge di BotID servita dalla stessa origine';
|
|
97
|
+
if (!botIdRequired)
|
|
98
|
+
return [skip(check, 'spento da `features.botId: false`')];
|
|
73
99
|
try {
|
|
74
100
|
const response = await get(`${baseUrl}${BOTID_CHALLENGE}`);
|
|
75
101
|
if (response.status !== 200)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ServerFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
source: string;
|
|
4
|
+
};
|
|
5
|
+
export type SsrRoute = {
|
|
6
|
+
route: string;
|
|
7
|
+
entries: string[];
|
|
8
|
+
unmapped: boolean;
|
|
9
|
+
unknownIslands: string[];
|
|
10
|
+
};
|
|
11
|
+
export declare function ssrRoutes(files: readonly ServerFile[]): SsrRoute[] | null;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
const MANIFEST_CALL = 'deserializeManifest({';
|
|
3
|
+
const ASSET = /^_astro\/([^/]+\.js)$/;
|
|
4
|
+
const RENDERER_RUNTIME = /^@astrojs\/[\w-]+\/client\.js$/;
|
|
5
|
+
const COMPONENT_SCRIPT = '?astro&type=script';
|
|
6
|
+
const PAGE_ROUTE = /\["(src\/pages\/[^"]+)", (_page\d+)\]/g;
|
|
7
|
+
const PAGE_IMPORT = /(?:var|const|let) (_page\d+) = \(\) => import\("(\.{1,2}\/[^"]+\.mjs)"\)/g;
|
|
8
|
+
const ISLAND_PATH = /"client:component-path":\s*"([^"]+)"/g;
|
|
9
|
+
const SERVER_IMPORT = /(?<![\w$.])(?:from|import)\s*"(\.{1,2}\/[^"]+\.mjs)"/g;
|
|
10
|
+
const resolve = (from, specifier) => posix.join(posix.dirname(from), specifier);
|
|
11
|
+
function objectLiteralAt(source, start) {
|
|
12
|
+
let depth = 0;
|
|
13
|
+
let quoted = false;
|
|
14
|
+
for (let i = start; i < source.length; i++) {
|
|
15
|
+
const char = source[i];
|
|
16
|
+
if (quoted) {
|
|
17
|
+
if (char === '\\')
|
|
18
|
+
i++;
|
|
19
|
+
else if (char === '"')
|
|
20
|
+
quoted = false;
|
|
21
|
+
}
|
|
22
|
+
else if (char === '"')
|
|
23
|
+
quoted = true;
|
|
24
|
+
else if (char === '{')
|
|
25
|
+
depth++;
|
|
26
|
+
else if (char === '}' && --depth === 0)
|
|
27
|
+
return source.slice(start, i + 1);
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function parseManifest(source) {
|
|
32
|
+
const call = source.indexOf(MANIFEST_CALL);
|
|
33
|
+
const literal = call === -1 ? null : objectLiteralAt(source, call + MANIFEST_CALL.length - 1);
|
|
34
|
+
if (literal === null)
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(literal);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function pageChunks(file) {
|
|
44
|
+
const chunkOf = new Map([...file.source.matchAll(PAGE_IMPORT)].map(([, name, path]) => [name, resolve(file.path, path)]));
|
|
45
|
+
const pages = new Map();
|
|
46
|
+
for (const [, component, name] of file.source.matchAll(PAGE_ROUTE)) {
|
|
47
|
+
const chunk = chunkOf.get(name);
|
|
48
|
+
if (chunk !== undefined)
|
|
49
|
+
pages.set(component, chunk);
|
|
50
|
+
}
|
|
51
|
+
return pages;
|
|
52
|
+
}
|
|
53
|
+
// Il file del manifest nomina ogni modulo del sito: seguirlo attribuirebbe a ogni pagina tutte le isole.
|
|
54
|
+
function serverClosure(start, files, manifestFile) {
|
|
55
|
+
const reached = new Map();
|
|
56
|
+
const queue = [start];
|
|
57
|
+
while (queue.length > 0) {
|
|
58
|
+
const path = queue.pop();
|
|
59
|
+
const source = files.get(path);
|
|
60
|
+
if (path === manifestFile || reached.has(path) || source === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
reached.set(path, source);
|
|
63
|
+
for (const [, specifier] of source.matchAll(SERVER_IMPORT))
|
|
64
|
+
queue.push(resolve(path, specifier));
|
|
65
|
+
}
|
|
66
|
+
return [...reached.values()];
|
|
67
|
+
}
|
|
68
|
+
const assetOf = (value) => value?.match(ASSET)?.[1];
|
|
69
|
+
function clientModules(manifest) {
|
|
70
|
+
const inlined = new Set((manifest.inlinedScripts ?? []).map(([specifier]) => specifier));
|
|
71
|
+
const modules = new Map();
|
|
72
|
+
for (const [specifier, value] of Object.entries(manifest.entryModules ?? {})) {
|
|
73
|
+
const asset = assetOf(value);
|
|
74
|
+
if (asset !== undefined && !inlined.has(specifier))
|
|
75
|
+
modules.set(specifier, asset);
|
|
76
|
+
}
|
|
77
|
+
return modules;
|
|
78
|
+
}
|
|
79
|
+
function routeEntries(page, texts, modules) {
|
|
80
|
+
const used = [...modules].filter(([specifier]) => !RENDERER_RUNTIME.test(specifier) && texts.some((text) => text.includes(`"${specifier}"`)));
|
|
81
|
+
const hasIsland = used.some(([specifier]) => !specifier.includes(COMPONENT_SCRIPT));
|
|
82
|
+
const runtimes = hasIsland ? [...modules].filter(([specifier]) => RENDERER_RUNTIME.test(specifier)) : [];
|
|
83
|
+
const scripts = page.scripts.filter((script) => script.type === 'external').map((script) => assetOf(script.value));
|
|
84
|
+
const names = [...scripts, ...[...used, ...runtimes].map(([, asset]) => asset)];
|
|
85
|
+
return [...new Set(names.filter((name) => name !== undefined))].sort();
|
|
86
|
+
}
|
|
87
|
+
const unknownIslands = (texts, modules) => [...new Set(texts.flatMap((text) => [...text.matchAll(ISLAND_PATH)].map(([, path]) => path)))]
|
|
88
|
+
.filter((path) => !modules.has(path))
|
|
89
|
+
.sort();
|
|
90
|
+
function ssrPage({ scripts = [], routeData }) {
|
|
91
|
+
if (routeData?.type !== 'page' || routeData.origin !== 'project' || routeData.prerender !== false)
|
|
92
|
+
return [];
|
|
93
|
+
const { route, component } = routeData;
|
|
94
|
+
return typeof route === 'string' && typeof component === 'string' ? [{ scripts, route, component }] : [];
|
|
95
|
+
}
|
|
96
|
+
export function ssrRoutes(files) {
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const manifest = parseManifest(file.source);
|
|
99
|
+
if (manifest !== null)
|
|
100
|
+
return routesOf(file, manifest, files);
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function routesOf(host, manifest, files) {
|
|
105
|
+
const sources = new Map(files.map((file) => [file.path, file.source]));
|
|
106
|
+
const chunks = pageChunks(host);
|
|
107
|
+
const modules = clientModules(manifest);
|
|
108
|
+
return (manifest.routes ?? []).flatMap(ssrPage).map((page) => {
|
|
109
|
+
const chunk = chunks.get(page.component);
|
|
110
|
+
const texts = chunk === undefined ? [] : serverClosure(chunk, sources, host.path);
|
|
111
|
+
return {
|
|
112
|
+
route: page.route,
|
|
113
|
+
entries: routeEntries(page, texts, modules),
|
|
114
|
+
unmapped: chunk === undefined,
|
|
115
|
+
unknownIslands: unknownIslands(texts, modules),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
package/dist/sh/lhci-local.sh
CHANGED
|
@@ -47,6 +47,13 @@ trap cleanup EXIT
|
|
|
47
47
|
echo "→ build equivalente alla produzione in ${OUT_DIR}"
|
|
48
48
|
VERCEL_ENV=production pnpm build --outDir "${OUT_DIR}"
|
|
49
49
|
|
|
50
|
+
if [[ -z "$(find "${OUT_DIR}/client" -name '*.html' -print -quit 2>/dev/null)" ]]; then
|
|
51
|
+
echo "✗ ${OUT_DIR}/client non contiene HTML: il sito è renderizzato a richiesta (output: 'server')," >&2
|
|
52
|
+
echo " e un server statico risponderebbe 404 su ogni rotta. Misura un sito già servito:" >&2
|
|
53
|
+
echo " pnpm run lhci https://… (produzione o anteprima)" >&2
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
50
57
|
if ! grep -qx 'Allow: /' "${OUT_DIR}/client/robots.txt"; then
|
|
51
58
|
echo "✗ ${OUT_DIR}/client/robots.txt non ha 'Allow: /': la build non equivale alla produzione." >&2
|
|
52
59
|
head -3 "${OUT_DIR}/client/robots.txt" >&2
|
package/package.json
CHANGED