@elia97/officina 0.10.0 → 0.11.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 +8 -2
- package/dist/bin.js +2 -1
- package/dist/checks/bundle.js +15 -5
- package/dist/checks/doctor.js +19 -6
- package/dist/checks/lighthouse.js +4 -1
- package/dist/checks/links.d.ts +2 -0
- package/dist/checks/links.js +69 -0
- package/dist/checks/smoke.js +3 -2
- package/dist/lib/alignment.js +6 -2
- package/dist/lib/bundle-ssr.d.ts +2 -0
- package/dist/lib/bundle-ssr.js +1 -1
- package/dist/lib/check-placeholders.js +1 -1
- package/dist/lib/config-gaps.d.ts +5 -5
- package/dist/lib/config-gaps.js +14 -8
- package/dist/lib/config.d.ts +5 -2
- package/dist/lib/config.js +3 -3
- package/dist/lib/links.d.ts +30 -0
- package/dist/lib/links.js +101 -0
- package/dist/lib/routes.d.ts +19 -7
- package/dist/lib/routes.js +34 -10
- package/dist/lib/vercel-build.d.ts +1 -0
- package/dist/lib/vercel-build.js +27 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ pnpm add -D @elia97/officina
|
|
|
19
19
|
"check:roadmap": "officina check roadmap",
|
|
20
20
|
"check:placeholders": "officina check placeholders",
|
|
21
21
|
"perf:bundle": "officina check bundle",
|
|
22
|
+
"check:links": "officina check links",
|
|
22
23
|
"smoke:prod": "officina check smoke",
|
|
23
24
|
"analytics:verify": "officina check analytics",
|
|
24
25
|
"lhci": "officina check lighthouse",
|
|
@@ -45,6 +46,7 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
|
|
|
45
46
|
| Comando | Cosa guarda | Da dove prende le rotte |
|
|
46
47
|
|---|---|---|
|
|
47
48
|
| `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 |
|
|
49
|
+
| `check links` | gli `<a href>` di `dist/client`: ogni link interno porta a qualcosa che la build serve, e nessuno a `#` | i file di `dist/client` e le rotte a richiesta del manifest della build Vercel |
|
|
48
50
|
| `check smoke [url]` | la produzione viva: pagine, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
|
|
49
51
|
| `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
|
|
50
52
|
| `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` |
|
|
@@ -54,6 +56,8 @@ Gli script di una pagina statica si leggono dal suo HTML, anche sotto una `base`
|
|
|
54
56
|
|
|
55
57
|
Il CSS di una rotta è la somma dei fogli di `dist/client/_astro` che collega, ognuno contato una volta: per una pagina statica i `<link rel="stylesheet">` del suo HTML, anche sotto una `base`, da un `assetsPrefix` o con una query; per una rotta renderizzata a richiesta i fogli esterni che il manifest le assegna, e una rotta per cui il manifest non li dice è un fallimento. Il tetto è `bundle.cssMaxGzip`, e il rapporto ha una riga per ogni combinazione di fogli, perché le rotte che collegano gli stessi fogli pesano uguale. Non si contano il CSS dentro l'HTML, nei `<style>`, come il JavaScript incorporato, i link che non bloccano il rendering (`preload`, `prefetch`) e i fogli fuori da `_astro`, da `public/` o da un CDN. Un foglio citato che in `_astro` non c'è è un fallimento. Un foglio che nessuna rotta misurata collega si pesa da solo contro lo stesso tetto, e nel rapporto porta «da solo»: il CSS di un `import()` dinamico, quello dei componenti dentro un contenuto MDX reso a richiesta, che il manifest non assegna alla pagina, o tutti i fogli di una build le cui rotte non si leggono. Da solo pesa meno del vero, ma nessun foglio oltre il tetto passa in silenzio.
|
|
56
58
|
|
|
59
|
+
`check links` legge gli `<a href>` di ogni HTML di `dist/client`, fuori dai commenti, dagli `<script>`, dagli `<style>` e dai valori degli attributi, dove Astro lascia `<` e `>` come sono. È interno un link relativo, uno che comincia con `/` o uno assoluto verso l'origine di `siteUrl`, e se ne confronta il percorso, senza query né ancora, con o senza barra finale; un link relativo si risolve come con `trailingSlash: 'never'`, che è quello dei template. Vale se porta a un file della build, pagina o altro file, o a una rotta che il manifest della build Vercel rende a richiesta, endpoint e `/` di lingua compresi, confrontata con il pattern che Astro scrive lì; senza manifest, o con un altro adapter, a una pagina `.astro` con `prerender = false`, e il rapporto lo dice. Che la pagina dietro una rotta a richiesta esista lo sa solo il server, e una rotta come `/[...slug]` accetta ogni percorso: il rapporto conta i link che passano così. `href="#"`, `href=""` e un `href` senza valore sono segnaposto, e falliscono come un link rotto. Il rapporto raggruppa per destinazione e nomina le pagine che la citano: un link rotto nel footer è un problema solo, non uno per pagina. Restano fuori, e il rapporto li conta, i link esterni, che chiederebbero la rete, `mailto:`, `tel:` e gli altri schemi, e l'id a cui punta un'ancora. Una `dist/client` senza HTML è un fallimento, perché il controllo non affermerebbe niente: un progetto che rende ogni pagina a richiesta lo spegne con `features.links: false`. Due limiti: con una `base` i link non si risolvono, e un redirect di `vercel.json` non è una pagina, quindi un link che ci porta passa per rotto, e si corregge puntando alla destinazione.
|
|
60
|
+
|
|
57
61
|
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`.
|
|
58
62
|
|
|
59
63
|
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.
|
|
@@ -74,7 +78,7 @@ export default defineConfig({
|
|
|
74
78
|
icons: { background: SITE.themeColor.light },
|
|
75
79
|
bundle: { budgets: [motion], cssMaxGzip: 14 * 1024 },
|
|
76
80
|
smoke: { checks: [...DEFAULT_CHECKS, checkLanguageRedirect] },
|
|
77
|
-
features: { analytics: 'required', roadmap: false, botId: 'required' },
|
|
81
|
+
features: { analytics: 'required', roadmap: false, botId: 'required', links: 'required' },
|
|
78
82
|
routes: { representatives: { '/blog/[slug]': '/blog/ciao-mondo' } },
|
|
79
83
|
})
|
|
80
84
|
```
|
|
@@ -91,6 +95,8 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
|
|
|
91
95
|
|
|
92
96
|
`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.
|
|
93
97
|
|
|
98
|
+
`routes.disabled` elenca i pattern dinamici che il progetto spegne per scelta, scritti come le chiavi di `routes.representatives`: le news tenute spente finché non ci sono articoli veri, con un `getStaticPaths` che non emette niente. Come per `features`, una pagina si spegne scrivendolo, non dimenticandola: un pattern dinamico che non emette niente senza essere dichiarato resta un fallimento di `check bundle`. Su un pattern spento `check bundle` non pretende pagine e lo elenca come spento, `doctor` non chiede il rappresentante e lo elenca fra le spente, smoke e Lighthouse non lo visitano, anche se il rappresentante c'è ancora, e lo scrivono nell'uscita: quando il progetto riaccende la pagina, qui basta togliere la voce. Il contrario è un fallimento di `check bundle`, perché dichiarazione e build non coincidono: una rotta emessa che soltanto un pattern spento spiega, o una pagina spenta che la build rende a richiesta, come una pagina senza `prerender` con `output: 'server'`. Si spegne solo una pagina prerenderizzata. `doctor` legge il sorgente, e una voce che lì non è una pagina dinamica prerenderizzata di `src/pages` è un suo errore: una pagina statica Astro la genera sempre, e un nome sbagliato non spegne niente. `check bundle` conosce solo le pagine `.astro`: una pagina Markdown di `src/pages`, o un `.html` di `public/`, che cade sotto un pattern spento passa per emessa da quello.
|
|
99
|
+
|
|
94
100
|
`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.
|
|
95
101
|
|
|
96
102
|
`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.
|
|
@@ -126,7 +132,7 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
|
|
|
126
132
|
|
|
127
133
|
| Action | Passi | Cosa resta nel workflow del progetto |
|
|
128
134
|
|---|---|---|
|
|
129
|
-
| `actions/ci` | checkout, node, install, `astro sync`, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
|
|
135
|
+
| `actions/ci` | checkout, node, install, `astro sync`, `pnpm run ci`, build, `perf:bundle`, `check:links`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
|
|
130
136
|
| `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
|
|
131
137
|
| `actions/deploy` | risoluzione del tag, stessa versione di officina, 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 |
|
|
132
138
|
| `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
|
package/dist/bin.js
CHANGED
|
@@ -8,6 +8,7 @@ export const CHECKS = {
|
|
|
8
8
|
comments: () => import('./checks/comments.js'),
|
|
9
9
|
language: () => import('./checks/language.js'),
|
|
10
10
|
lighthouse: () => import('./checks/lighthouse.js'),
|
|
11
|
+
links: () => import('./checks/links.js'),
|
|
11
12
|
placeholders: () => import('./checks/placeholders.js'),
|
|
12
13
|
roadmap: () => import('./checks/roadmap.js'),
|
|
13
14
|
routes: () => import('./checks/routes.js'),
|
|
@@ -21,7 +22,7 @@ const USAGE = `Uso:
|
|
|
21
22
|
officina doctor cosa manca al progetto perché i generatori funzionino
|
|
22
23
|
|
|
23
24
|
Opzioni dei gate sui sorgenti: --diff, --base <ref>, --head <ref>, --strict, --format text|github
|
|
24
|
-
bundle
|
|
25
|
+
bundle e links leggono dist/client; smoke [url] e analytics [GTM-…] vanno in rete; lighthouse [--local]
|
|
25
26
|
I valori del progetto (URL, budget, header, controlli propri) stanno in officina.config.ts
|
|
26
27
|
`;
|
|
27
28
|
// Ogni gruppo dice quale modulo carica e quali argomenti gli passa.
|
package/dist/checks/bundle.js
CHANGED
|
@@ -9,7 +9,7 @@ import { measureCss, routeCount } from '../lib/bundle-css.js';
|
|
|
9
9
|
import { readSsr } from '../lib/bundle-ssr.js';
|
|
10
10
|
import { missingInput } from '../lib/cli.js';
|
|
11
11
|
import { loadConfig } from '../lib/config.js';
|
|
12
|
-
import { expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
|
|
12
|
+
import { disabledRouteFailures, expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
|
|
13
13
|
const DIST = 'dist/client';
|
|
14
14
|
const ASSETS = join(DIST, '_astro');
|
|
15
15
|
const PAGES = 'src/pages';
|
|
@@ -98,8 +98,15 @@ function printSsrNote(expected, ssr) {
|
|
|
98
98
|
for (const file of expected.ssr)
|
|
99
99
|
console.log(` - ${file}`);
|
|
100
100
|
}
|
|
101
|
+
function printDisabled({ disabled }) {
|
|
102
|
+
if (disabled.length === 0)
|
|
103
|
+
return;
|
|
104
|
+
console.log('\nSPENTE da `routes.disabled`, nessuna pagina attesa:');
|
|
105
|
+
for (const { label, file } of disabled)
|
|
106
|
+
console.log(` - ${label} (${file})`);
|
|
107
|
+
}
|
|
101
108
|
export async function main() {
|
|
102
|
-
const { bundle = {} } = await loadConfig(process.cwd());
|
|
109
|
+
const { bundle = {}, routes: { disabled } = {} } = await loadConfig(process.cwd());
|
|
103
110
|
if (missingInput(ASSETS, 'è la build da misurare: prima `pnpm build`'))
|
|
104
111
|
return 1;
|
|
105
112
|
if (missingInput(PAGES, 'le rotte attese si derivano da lì'))
|
|
@@ -109,14 +116,17 @@ export async function main() {
|
|
|
109
116
|
const ssr = readSsr(html.length > 0);
|
|
110
117
|
const routes = [...html, ...ssr.pages];
|
|
111
118
|
const pages = measurePages(routes, readChunks(), bundle.budgets ?? []);
|
|
112
|
-
const expected = expectedRoutes(readPageFiles(PAGES), PAGES);
|
|
113
|
-
const
|
|
114
|
-
const failures = ssr.comparesExpectedRoutes
|
|
119
|
+
const expected = expectedRoutes(readPageFiles(PAGES), PAGES, disabled);
|
|
120
|
+
const emitted = { html: html.map((page) => page.route), ssr: ssr.pages.map((page) => page.route) };
|
|
121
|
+
const failures = ssr.comparesExpectedRoutes
|
|
122
|
+
? [...missingRouteFailures(expected, emitted, DIST), ...disabledRouteFailures(expected, emitted)]
|
|
123
|
+
: [];
|
|
115
124
|
failures.push(...ssr.failures, ...unknownChunkFailures(pages), ...overBudgetFailures(pages));
|
|
116
125
|
const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
|
|
117
126
|
printPages(pages, width);
|
|
118
127
|
failures.push(...checkCss(routes, cssMaxGzip, width));
|
|
119
128
|
printSsrNote(expected, ssr);
|
|
129
|
+
printDisabled(expected);
|
|
120
130
|
if (failures.length > 0) {
|
|
121
131
|
console.error(`\n✗ Budget di bundle:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
|
|
122
132
|
return 1;
|
package/dist/checks/doctor.js
CHANGED
|
@@ -11,7 +11,7 @@ import { configGaps } from '../lib/config-gaps.js';
|
|
|
11
11
|
import { contractGaps } from '../lib/contract.js';
|
|
12
12
|
import { dependabotGaps } from '../lib/dependabot.js';
|
|
13
13
|
import { trackedAndUntracked } from '../lib/git.js';
|
|
14
|
-
import { expectedRoutes, missingRepresentatives, readPageFiles } from '../lib/routes.js';
|
|
14
|
+
import { expectedRoutes, missingRepresentatives, readPageFiles, unknownDisabled } from '../lib/routes.js';
|
|
15
15
|
import { toolingGaps } from '../lib/tooling.js';
|
|
16
16
|
import { packageVersion } from '../lib/versions.js';
|
|
17
17
|
import { workflowGaps } from '../lib/workflows.js';
|
|
@@ -64,11 +64,22 @@ function generatorSections(root, setting) {
|
|
|
64
64
|
{ title: ANCHORS, off },
|
|
65
65
|
];
|
|
66
66
|
}
|
|
67
|
-
function
|
|
67
|
+
function routeGaps(root, config) {
|
|
68
|
+
if (config === undefined || config instanceof Error)
|
|
69
|
+
return { disabled: [] };
|
|
68
70
|
const pages = join(root, PAGES);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return
|
|
71
|
+
const { representatives, disabled: declared } = config.routes ?? {};
|
|
72
|
+
const expected = expectedRoutes(existsSync(pages) ? readPageFiles(pages) : [], pages, declared);
|
|
73
|
+
return {
|
|
74
|
+
missingRepresentatives: missingRepresentatives(expected, representatives),
|
|
75
|
+
unknownDisabled: unknownDisabled(expected, declared),
|
|
76
|
+
disabled: expected.disabled.map(({ label }) => label),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function configSection(root, config) {
|
|
80
|
+
const { disabled, ...gaps } = routeGaps(root, config);
|
|
81
|
+
const notes = disabled.length === 0 ? [] : [`spente da \`routes.disabled\`: ${disabled.join(', ')}`];
|
|
82
|
+
return { title: 'officina.config.ts', gaps: configGaps(config, gaps), notes };
|
|
72
83
|
}
|
|
73
84
|
export async function main() {
|
|
74
85
|
const root = process.cwd();
|
|
@@ -88,7 +99,7 @@ export async function main() {
|
|
|
88
99
|
title: 'workflow di GitHub e Dependabot',
|
|
89
100
|
gaps: [...workflowGaps(files, version), ...dependabotGaps(files)],
|
|
90
101
|
},
|
|
91
|
-
|
|
102
|
+
configSection(root, config),
|
|
92
103
|
];
|
|
93
104
|
console.log(`\nofficina doctor ${version} — cosa manca al progetto per prendere tutto da fuori\n`);
|
|
94
105
|
const findings = [];
|
|
@@ -101,6 +112,8 @@ export async function main() {
|
|
|
101
112
|
console.log(gaps.length === 0 ? ` ✓ ${title}` : ` ${title}: ${gaps.length}`);
|
|
102
113
|
const sectionFindings = gaps.map((gap) => ({ ...gap, severity: gap.severity ?? 'error' }));
|
|
103
114
|
printFindings(sectionFindings, cliOptions([]).format);
|
|
115
|
+
for (const note of section.notes ?? [])
|
|
116
|
+
console.log(` · ${note}`);
|
|
104
117
|
findings.push(...sectionFindings);
|
|
105
118
|
}
|
|
106
119
|
console.log(findings.length === 0
|
|
@@ -25,13 +25,16 @@ async function resolveConfig(url) {
|
|
|
25
25
|
if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
|
|
26
26
|
return null;
|
|
27
27
|
const rc = JSON.parse(readFileSync(RC_FILE, 'utf8'));
|
|
28
|
-
const
|
|
28
|
+
const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR, configured.disabled);
|
|
29
|
+
const routes = auditRoutes(expected, configured.representatives);
|
|
29
30
|
const env = url === undefined ? process.env : { ...process.env, LH_BASE_URL: url };
|
|
30
31
|
const resolved = lighthouseConfig(rc, routes, env);
|
|
31
32
|
const where = url === undefined ? '' : ` su ${url}`;
|
|
32
33
|
console.log(`\nLighthouse CI — ${routes.length} rotta/e derivate da ${PAGES_DIR}${where}\n`);
|
|
33
34
|
for (const page of resolved.ci.collect.url ?? [])
|
|
34
35
|
console.log(` ${page}`);
|
|
36
|
+
for (const { label } of expected.disabled)
|
|
37
|
+
console.log(` ${label} — spenta da \`routes.disabled\`, non misurata`);
|
|
35
38
|
console.log();
|
|
36
39
|
return resolved;
|
|
37
40
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { readServerFiles } from '../lib/bundle-ssr.js';
|
|
6
|
+
import { missingInput } from '../lib/cli.js';
|
|
7
|
+
import { isRequired, loadConfig } from '../lib/config.js';
|
|
8
|
+
import { brokenLines, htmlLinks, linkReport, servedPaths, siteOrigin } from '../lib/links.js';
|
|
9
|
+
import { expectedRoutes, filesWithExtension, pageRouteOf, readPageFiles, routeOf, routePattern } from '../lib/routes.js';
|
|
10
|
+
import { onDemandPatterns } from '../lib/vercel-build.js';
|
|
11
|
+
const DIST = 'dist/client';
|
|
12
|
+
const PAGES = 'src/pages';
|
|
13
|
+
const SERVER_OUTPUT = 'dist/server';
|
|
14
|
+
const FROM_SOURCE = 'valgono le pagine `.astro` con `prerender = false`';
|
|
15
|
+
const servedPath = (file) => `/${relative(DIST, file).split(sep).join('/')}`;
|
|
16
|
+
function onDemand() {
|
|
17
|
+
const files = readServerFiles();
|
|
18
|
+
const fromBuild = files.length === 0 ? null : onDemandPatterns(files);
|
|
19
|
+
if (fromBuild !== null)
|
|
20
|
+
return { patterns: fromBuild };
|
|
21
|
+
const ssr = existsSync(PAGES) ? expectedRoutes(readPageFiles(PAGES), PAGES).ssr : [];
|
|
22
|
+
const patterns = ssr.map((file) => routePattern(pageRouteOf(file, PAGES)));
|
|
23
|
+
if (files.length > 0)
|
|
24
|
+
return { patterns, note: `il manifest della build Vercel non si riconosce: ${FROM_SOURCE}` };
|
|
25
|
+
if (existsSync(SERVER_OUTPUT))
|
|
26
|
+
return { patterns, note: `build SSR senza l'adapter Vercel: ${FROM_SOURCE}` };
|
|
27
|
+
return { patterns };
|
|
28
|
+
}
|
|
29
|
+
function printReport(report, pages, note) {
|
|
30
|
+
console.log(`\ncheck:links — ${report.internal} link interni su ${pages} pagine di ${DIST}\n`);
|
|
31
|
+
const { external, scheme, anchor } = report.outside;
|
|
32
|
+
console.log(`Restano fuori: ${external} link esterni, ${scheme} con un altro schema (mailto:, tel:, …), ${anchor} ancore, di cui l'id non si verifica.`);
|
|
33
|
+
if (report.onDemand > 0) {
|
|
34
|
+
console.log(`${report.onDemand} link portano a una rotta resa a richiesta: che la pagina esista lo sa solo il server.`);
|
|
35
|
+
}
|
|
36
|
+
if (note !== undefined)
|
|
37
|
+
console.log(`\nNOTA ${note}`);
|
|
38
|
+
}
|
|
39
|
+
export async function main() {
|
|
40
|
+
const config = await loadConfig(process.cwd());
|
|
41
|
+
if (!isRequired(config, 'links')) {
|
|
42
|
+
console.log('\ncheck:links — spento da `features.links: false`\n');
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
if (missingInput(DIST, 'è la build di cui si controllano i link: prima `pnpm build`'))
|
|
46
|
+
return 1;
|
|
47
|
+
const files = filesWithExtension(DIST, '').map(servedPath);
|
|
48
|
+
const html = files.filter((file) => file.endsWith('.html'));
|
|
49
|
+
if (html.length === 0) {
|
|
50
|
+
console.error(`\n✗ ${DIST} non contiene nessun .html: senza pagine il controllo dei link non afferma niente.\n`);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
const pages = html.map((file) => ({
|
|
54
|
+
route: routeOf(file, ''),
|
|
55
|
+
hrefs: htmlLinks(readFileSync(join(DIST, file), 'utf8')),
|
|
56
|
+
}));
|
|
57
|
+
const { patterns, note } = onDemand();
|
|
58
|
+
const report = linkReport(pages, siteOrigin(config.siteUrl), servedPaths(files), patterns);
|
|
59
|
+
printReport(report, pages.length, note);
|
|
60
|
+
if (report.broken.length > 0) {
|
|
61
|
+
const lines = brokenLines(report).map((line) => ` - ${line}`);
|
|
62
|
+
console.error(`\n✗ Link interni rotti:\n${lines.join('\n')}\n`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
console.log('\n✓ Ogni link interno porta a qualcosa che la build serve.\n');
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
if (import.meta.main)
|
|
69
|
+
process.exit(await main());
|
package/dist/checks/smoke.js
CHANGED
|
@@ -16,6 +16,7 @@ function printResults(results) {
|
|
|
16
16
|
console.error(`✗ ${check} — ${detail}`);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
const disabledResults = ({ disabled }) => disabled.map(({ label }) => ({ check: `pagina ${label}`, status: 'skip', detail: 'spenta da `routes.disabled`' }));
|
|
19
20
|
function printFailures(failures) {
|
|
20
21
|
console.error(`\n✗ ${failures.length} controllo/i falliti:`);
|
|
21
22
|
console.error(`${failures.map(({ check, detail }) => ` - ${check} — ${detail}`).join('\n')}\n`);
|
|
@@ -45,12 +46,12 @@ export async function main(args = []) {
|
|
|
45
46
|
};
|
|
46
47
|
if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
|
|
47
48
|
return 1;
|
|
48
|
-
const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR);
|
|
49
|
+
const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR, routes.disabled);
|
|
49
50
|
const pages = smokeRoutes(expected, smoke.nonHtmlRoutes, routes.representatives);
|
|
50
51
|
console.log(`\nSmoke di produzione — ${baseUrl}\n`);
|
|
51
52
|
await waitForAlias(context, (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
52
53
|
const results = await runChecks(context, pages, smoke.checks);
|
|
53
|
-
printResults(results);
|
|
54
|
+
printResults([...results, ...disabledResults(expected)]);
|
|
54
55
|
const failures = results.filter(({ status }) => status === 'fail');
|
|
55
56
|
if (failures.length > 0) {
|
|
56
57
|
printFailures(failures);
|
package/dist/lib/alignment.js
CHANGED
|
@@ -6,6 +6,7 @@ export const EXPECTED_SCRIPTS = {
|
|
|
6
6
|
'check:roadmap': 'officina check roadmap',
|
|
7
7
|
'check:placeholders': 'officina check placeholders',
|
|
8
8
|
'perf:bundle': 'officina check bundle',
|
|
9
|
+
'check:links': 'officina check links',
|
|
9
10
|
'smoke:prod': 'officina check smoke',
|
|
10
11
|
'analytics:verify': 'officina check analytics',
|
|
11
12
|
lhci: 'officina check lighthouse',
|
|
@@ -26,14 +27,17 @@ export const CI_STEPS = [
|
|
|
26
27
|
'doctor',
|
|
27
28
|
];
|
|
28
29
|
const MANIFEST = 'package.json';
|
|
30
|
+
// TODO: dalla 0.12.0 `check:links` è un errore di doctor: toglilo da NEW_SCRIPTS.
|
|
31
|
+
const NEW_SCRIPTS = new Set(['check:links']);
|
|
29
32
|
export function scriptGaps({ scripts = {} }) {
|
|
30
33
|
const gaps = [];
|
|
31
34
|
for (const [name, expected] of Object.entries(EXPECTED_SCRIPTS)) {
|
|
32
35
|
const actual = scripts[name];
|
|
36
|
+
const severity = NEW_SCRIPTS.has(name) ? { severity: 'warning' } : {};
|
|
33
37
|
if (actual === undefined)
|
|
34
|
-
gaps.push({ path: MANIFEST, message: `script \`${name}\` assente: atteso \`${expected}
|
|
38
|
+
gaps.push({ path: MANIFEST, message: `script \`${name}\` assente: atteso \`${expected}\``, ...severity });
|
|
35
39
|
else if (!actual.startsWith(expected))
|
|
36
|
-
gaps.push({ path: MANIFEST, message: `script \`${name}\` è \`${actual}\`: atteso \`${expected}
|
|
40
|
+
gaps.push({ path: MANIFEST, message: `script \`${name}\` è \`${actual}\`: atteso \`${expected}\``, ...severity });
|
|
37
41
|
}
|
|
38
42
|
const ci = scripts.ci ?? '';
|
|
39
43
|
for (const step of CI_STEPS) {
|
package/dist/lib/bundle-ssr.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ServerFile } from './vercel-build.ts';
|
|
1
2
|
export type PageEntries = {
|
|
2
3
|
route: string;
|
|
3
4
|
entries: string[];
|
|
@@ -10,5 +11,6 @@ export type SsrReading = {
|
|
|
10
11
|
comparesExpectedRoutes: boolean;
|
|
11
12
|
measured: boolean;
|
|
12
13
|
};
|
|
14
|
+
export declare function readServerFiles(): ServerFile[];
|
|
13
15
|
/** Le rotte renderizzate a richiesta non emettono HTML: le dà il manifest che Astro scrive nella build Vercel. */
|
|
14
16
|
export declare function readSsr(hasHtml: boolean): SsrReading;
|
package/dist/lib/bundle-ssr.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { ssrRoutes } from './vercel-build.js';
|
|
4
4
|
const FUNCTIONS = '.vercel/output/_functions';
|
|
5
5
|
const SERVER_OUTPUT = 'dist/server';
|
|
6
|
-
function readServerFiles() {
|
|
6
|
+
export function readServerFiles() {
|
|
7
7
|
const read = (path) => ({ path, source: readFileSync(join(FUNCTIONS, path), 'utf8') });
|
|
8
8
|
const chunks = existsSync(join(FUNCTIONS, 'chunks')) ? readdirSync(join(FUNCTIONS, 'chunks')) : [];
|
|
9
9
|
const entry = existsSync(join(FUNCTIONS, 'entry.mjs')) ? ['entry.mjs'] : [];
|
|
@@ -11,7 +11,7 @@ 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@
|
|
14
|
+
// `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@62.2.0: `vercel pull` lo scrive al posto del valore di una Sensitive.
|
|
15
15
|
const SENSITIVE = '[SENSITIVE]';
|
|
16
16
|
const WITHOUT_VALUE = 'il deploy andrebbe online senza il suo valore';
|
|
17
17
|
const dictionaries = () => existsSync(DICTIONARIES)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type OfficinaConfig } from './config.ts';
|
|
2
2
|
import type { ContractGap } from './contract.ts';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export declare function configGaps(loaded: OfficinaConfig | Error | undefined,
|
|
3
|
+
export type RouteGaps = {
|
|
4
|
+
missingRepresentatives?: readonly string[];
|
|
5
|
+
unknownDisabled?: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
export declare function configGaps(loaded: OfficinaConfig | Error | undefined, { missingRepresentatives, unknownDisabled }?: RouteGaps): ContractGap[];
|
package/dist/lib/config-gaps.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FEATURES } from './config.js';
|
|
2
2
|
const CONFIG = 'officina.config.ts';
|
|
3
|
+
const asGap = (message) => ({ path: CONFIG, message });
|
|
3
4
|
function siteUrlGap(siteUrl) {
|
|
4
5
|
if (siteUrl === undefined)
|
|
5
6
|
return '`siteUrl` assente: `check smoke` non ha un host canonico';
|
|
@@ -7,14 +8,16 @@ function siteUrlGap(siteUrl) {
|
|
|
7
8
|
return `\`siteUrl\` non è un URL: \`${siteUrl}\``;
|
|
8
9
|
return siteUrl.endsWith('/') ? `\`siteUrl\` finisce con una barra: \`${siteUrl}\`` : undefined;
|
|
9
10
|
}
|
|
11
|
+
// TODO: dalla 0.12.0 `links` è un errore di doctor: toglila da NEW_FEATURES.
|
|
12
|
+
const NEW_FEATURES = new Set(['links']);
|
|
10
13
|
// Il silenzio vale `'required'`: un controllo si spegne dichiarandolo, non dimenticandolo.
|
|
11
|
-
const featureGaps = (config) => FEATURES.filter((name) => config.features?.[name] === undefined).map((name) =>
|
|
14
|
+
const featureGaps = (config) => FEATURES.filter((name) => config.features?.[name] === undefined).map((name) => ({
|
|
15
|
+
...asGap(`\`features.${name}\` non dichiarata: vale \`'required'\`, e il gate fallisce se non trova cosa controllare`),
|
|
16
|
+
...(NEW_FEATURES.has(name) ? { severity: 'warning' } : {}),
|
|
17
|
+
}));
|
|
12
18
|
const representativeGaps = (patterns) => patterns.map((label) => `\`routes.representatives\` non ha un percorso per \`${label}\`: smoke e Lighthouse non guardano quella pagina`);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* il file non c'è; `patterns` i pattern dinamici di `src/pages` rimasti senza rappresentante.
|
|
16
|
-
*/
|
|
17
|
-
export function configGaps(loaded, patterns = []) {
|
|
19
|
+
const disabledGaps = (labels) => labels.map((label) => `\`routes.disabled\` nomina \`${label}\`, che non è una pagina dinamica prerenderizzata di \`src/pages\`: non spegne niente`);
|
|
20
|
+
export function configGaps(loaded, { missingRepresentatives = [], unknownDisabled = [] } = {}) {
|
|
18
21
|
if (loaded === undefined)
|
|
19
22
|
return [{ path: CONFIG, message: 'manca: i valori del progetto per officina stanno qui' }];
|
|
20
23
|
if (loaded instanceof Error)
|
|
@@ -22,8 +25,11 @@ export function configGaps(loaded, patterns = []) {
|
|
|
22
25
|
const messages = [
|
|
23
26
|
siteUrlGap(loaded.siteUrl),
|
|
24
27
|
loaded.icons === undefined ? '`icons.background` assente: `gen icons` non ha un colore di fondo' : undefined,
|
|
28
|
+
].filter((message) => message !== undefined);
|
|
29
|
+
return [
|
|
30
|
+
...messages.map(asGap),
|
|
25
31
|
...featureGaps(loaded),
|
|
26
|
-
...representativeGaps(
|
|
32
|
+
...representativeGaps(missingRepresentatives).map(asGap),
|
|
33
|
+
...disabledGaps(unknownDisabled).map(asGap),
|
|
27
34
|
];
|
|
28
|
-
return messages.filter((message) => message !== undefined).map((message) => ({ path: CONFIG, message }));
|
|
29
35
|
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -28,17 +28,20 @@ export interface OfficinaConfig {
|
|
|
28
28
|
roadmap?: FeatureSetting;
|
|
29
29
|
generators?: GeneratorsSetting;
|
|
30
30
|
botId?: FeatureSetting;
|
|
31
|
+
links?: FeatureSetting;
|
|
31
32
|
};
|
|
32
33
|
placeholders?: {
|
|
33
34
|
sources?: readonly string[];
|
|
34
35
|
contactEnvKeys?: readonly string[];
|
|
35
36
|
};
|
|
36
|
-
/** Per ogni pattern dinamico di `src/pages`, un percorso vero che smoke e Lighthouse visitano. */
|
|
37
37
|
routes?: {
|
|
38
|
+
/** Per ogni pattern dinamico di `src/pages`, un percorso vero che smoke e Lighthouse visitano. */
|
|
38
39
|
representatives?: Representatives;
|
|
40
|
+
/** I pattern dinamici di `src/pages` spenti per scelta: non emettono pagine, e nessun gate le pretende. */
|
|
41
|
+
disabled?: readonly string[];
|
|
39
42
|
};
|
|
40
43
|
}
|
|
41
|
-
export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators', 'botId'];
|
|
44
|
+
export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators', 'botId', 'links'];
|
|
42
45
|
export type FeatureName = (typeof FEATURES)[number];
|
|
43
46
|
/** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
|
|
44
47
|
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', 'botId'];
|
|
13
|
+
export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId', 'links'];
|
|
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,9 +28,9 @@ const SHAPE = aShape({
|
|
|
28
28
|
checks: anArrayOf(aFunction),
|
|
29
29
|
}),
|
|
30
30
|
analytics: aShape({ linkTracking: aString }),
|
|
31
|
-
features: aShape({ analytics: feature, roadmap: feature, generators, botId: feature }),
|
|
31
|
+
features: aShape({ analytics: feature, roadmap: feature, generators, botId: feature, links: feature }),
|
|
32
32
|
placeholders: aShape({ sources: anArrayOf(aString), contactEnvKeys: anArrayOf(aString) }),
|
|
33
|
-
routes: aShape({ representatives: aRecordOf(aString) }),
|
|
33
|
+
routes: aShape({ representatives: aRecordOf(aString), disabled: anArrayOf(aString) }),
|
|
34
34
|
});
|
|
35
35
|
const CONFIG_FILES = ['officina.config.ts', 'officina.config.mjs', 'officina.config.js'];
|
|
36
36
|
export const defineConfig = (config) => config;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type LinkTarget = {
|
|
2
|
+
kind: 'placeholder' | 'anchor' | 'scheme' | 'external';
|
|
3
|
+
} | {
|
|
4
|
+
kind: 'internal';
|
|
5
|
+
path: string;
|
|
6
|
+
};
|
|
7
|
+
export type PageLinks = {
|
|
8
|
+
route: string;
|
|
9
|
+
hrefs: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
export type LinkReport = {
|
|
12
|
+
broken: {
|
|
13
|
+
destination: string;
|
|
14
|
+
pages: string[];
|
|
15
|
+
}[];
|
|
16
|
+
internal: number;
|
|
17
|
+
onDemand: number;
|
|
18
|
+
outside: {
|
|
19
|
+
external: number;
|
|
20
|
+
scheme: number;
|
|
21
|
+
anchor: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export declare function htmlLinks(html: string): string[];
|
|
25
|
+
export declare const siteOrigin: (siteUrl: string | undefined) => string;
|
|
26
|
+
export declare function linkTarget(href: string, page: URL): LinkTarget;
|
|
27
|
+
export declare function servedPaths(files: readonly string[]): Set<string>;
|
|
28
|
+
export declare function servedBy(path: string, served: ReadonlySet<string>, onDemand: readonly RegExp[]): 'build' | 'on-demand' | undefined;
|
|
29
|
+
export declare function linkReport(pages: readonly PageLinks[], origin: string, served: ReadonlySet<string>, onDemand: readonly RegExp[]): LinkReport;
|
|
30
|
+
export declare const brokenLines: ({ broken }: LinkReport) => string[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { listed } from './routes.js';
|
|
2
|
+
const VALUE = String.raw `(?:"[^"]*"|'[^']*'|[^\s"'>]+)`;
|
|
3
|
+
const ATTRIBUTES = String.raw `(?:\s+[^\s"'>/=]+(?:\s*=\s*${VALUE})?)*`;
|
|
4
|
+
// Astro conserva i commenti HTML, e nei valori dinamici degli attributi scappa solo `&` e `"`.
|
|
5
|
+
const MARKUP = new RegExp(String.raw `<!--[\s\S]*?-->|<(script|style)\b${ATTRIBUTES}\s*\/?>[\s\S]*?<\/\1\s*>|<a(${ATTRIBUTES})\s*\/?>|<[a-z][^\s/>]*${ATTRIBUTES}\s*\/?>`, 'gi');
|
|
6
|
+
const ATTRIBUTE = /\s+([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
7
|
+
const ENTITY = /&(?:#(\d{1,6})|#x([\da-f]{1,5})|(amp|lt|gt|quot|apos));/gi;
|
|
8
|
+
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
9
|
+
const SCHEME = /^[a-z][a-z\d+.-]*:/i;
|
|
10
|
+
const WEB = /^https?:/i;
|
|
11
|
+
const NO_SITE = 'https://officina.invalid';
|
|
12
|
+
const decodeEntities = (value) => value.replace(ENTITY, (_, decimal, hex, name) => {
|
|
13
|
+
if (decimal !== undefined)
|
|
14
|
+
return String.fromCodePoint(Number(decimal));
|
|
15
|
+
if (hex !== undefined)
|
|
16
|
+
return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
17
|
+
return NAMED[name.toLowerCase()];
|
|
18
|
+
});
|
|
19
|
+
function hrefOf(attributes) {
|
|
20
|
+
for (const [, name, double, single, bare] of attributes.matchAll(ATTRIBUTE)) {
|
|
21
|
+
if (name.toLowerCase() === 'href')
|
|
22
|
+
return decodeEntities(double ?? single ?? bare ?? '');
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
export function htmlLinks(html) {
|
|
27
|
+
return [...html.matchAll(MARKUP)]
|
|
28
|
+
.map(([, , attributes]) => (attributes === undefined ? undefined : hrefOf(attributes)))
|
|
29
|
+
.filter((href) => href !== undefined);
|
|
30
|
+
}
|
|
31
|
+
export const siteOrigin = (siteUrl) => siteUrl !== undefined && URL.canParse(siteUrl) ? new URL(siteUrl).origin : NO_SITE;
|
|
32
|
+
function decoded(path) {
|
|
33
|
+
try {
|
|
34
|
+
return decodeURIComponent(path);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function linkTarget(href, page) {
|
|
41
|
+
const value = href.trim();
|
|
42
|
+
if (value === '' || value === '#')
|
|
43
|
+
return { kind: 'placeholder' };
|
|
44
|
+
if (value.startsWith('#'))
|
|
45
|
+
return { kind: 'anchor' };
|
|
46
|
+
if (SCHEME.test(value) && !WEB.test(value))
|
|
47
|
+
return { kind: 'scheme' };
|
|
48
|
+
if (!URL.canParse(value, page))
|
|
49
|
+
return { kind: 'internal', path: value };
|
|
50
|
+
const url = new URL(value, page);
|
|
51
|
+
return url.origin === page.origin ? { kind: 'internal', path: decoded(url.pathname) } : { kind: 'external' };
|
|
52
|
+
}
|
|
53
|
+
export function servedPaths(files) {
|
|
54
|
+
const served = new Set();
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
served.add(file);
|
|
57
|
+
if (file.endsWith('/index.html'))
|
|
58
|
+
served.add(file.slice(0, -'/index.html'.length) || '/');
|
|
59
|
+
else if (file.endsWith('.html'))
|
|
60
|
+
served.add(file.slice(0, -'.html'.length));
|
|
61
|
+
}
|
|
62
|
+
return served;
|
|
63
|
+
}
|
|
64
|
+
const withoutTrailingSlash = (path) => (path.length > 1 ? path.replace(/\/+$/, '') : path);
|
|
65
|
+
export function servedBy(path, served, onDemand) {
|
|
66
|
+
const bare = withoutTrailingSlash(path);
|
|
67
|
+
if (served.has(path) || served.has(bare))
|
|
68
|
+
return 'build';
|
|
69
|
+
return onDemand.some((pattern) => pattern.test(bare)) ? 'on-demand' : undefined;
|
|
70
|
+
}
|
|
71
|
+
const placeholderLabel = (href) => `segnaposto href="${href.trim()}"`;
|
|
72
|
+
export function linkReport(pages, origin, served, onDemand) {
|
|
73
|
+
const citing = new Map();
|
|
74
|
+
const report = { broken: [], internal: 0, onDemand: 0, outside: { external: 0, scheme: 0, anchor: 0 } };
|
|
75
|
+
const cite = (destination, route) => citing.set(destination, (citing.get(destination) ?? new Set()).add(route));
|
|
76
|
+
const checkInternal = (path, route) => {
|
|
77
|
+
report.internal++;
|
|
78
|
+
const by = servedBy(path, served, onDemand);
|
|
79
|
+
if (by === undefined)
|
|
80
|
+
cite(withoutTrailingSlash(path), route);
|
|
81
|
+
if (by === 'on-demand')
|
|
82
|
+
report.onDemand++;
|
|
83
|
+
};
|
|
84
|
+
for (const { route, hrefs } of pages) {
|
|
85
|
+
const page = new URL(route, origin);
|
|
86
|
+
for (const href of hrefs) {
|
|
87
|
+
const target = linkTarget(href, page);
|
|
88
|
+
if (target.kind === 'placeholder')
|
|
89
|
+
cite(placeholderLabel(href), route);
|
|
90
|
+
else if (target.kind === 'internal')
|
|
91
|
+
checkInternal(target.path, route);
|
|
92
|
+
else
|
|
93
|
+
report.outside[target.kind]++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
report.broken = [...citing]
|
|
97
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
98
|
+
.map(([destination, routes]) => ({ destination, pages: [...routes].sort() }));
|
|
99
|
+
return report;
|
|
100
|
+
}
|
|
101
|
+
export const brokenLines = ({ broken }) => broken.map(({ destination, pages }) => `${destination} ← ${listed(pages)}`);
|
package/dist/lib/routes.d.ts
CHANGED
|
@@ -2,16 +2,18 @@ export type PageFile = {
|
|
|
2
2
|
file: string;
|
|
3
3
|
source: string;
|
|
4
4
|
};
|
|
5
|
+
type DynamicRoute = {
|
|
6
|
+
pattern: RegExp;
|
|
7
|
+
label: string;
|
|
8
|
+
file: string;
|
|
9
|
+
};
|
|
5
10
|
export type Expectations = {
|
|
6
11
|
exact: {
|
|
7
12
|
route: string;
|
|
8
13
|
file: string;
|
|
9
14
|
}[];
|
|
10
|
-
patterns:
|
|
11
|
-
|
|
12
|
-
label: string;
|
|
13
|
-
file: string;
|
|
14
|
-
}[];
|
|
15
|
+
patterns: DynamicRoute[];
|
|
16
|
+
disabled: DynamicRoute[];
|
|
15
17
|
ssr: string[];
|
|
16
18
|
};
|
|
17
19
|
export type VerifiedRoute = {
|
|
@@ -25,11 +27,21 @@ export declare const NON_HTML_ROUTES: readonly VerifiedRoute[];
|
|
|
25
27
|
export declare function filesWithExtension(dir: string, extension: string): string[];
|
|
26
28
|
export declare function readPageFiles(pagesDir: string): PageFile[];
|
|
27
29
|
export declare function routeOf(htmlPath: string, dist: string): string;
|
|
28
|
-
export declare function
|
|
30
|
+
export declare function pageRouteOf(file: string, pagesDir: string): string;
|
|
31
|
+
export declare function routePattern(route: string): RegExp;
|
|
32
|
+
export declare function expectedRoutes(pages: readonly PageFile[], pagesDir: string, disabled?: readonly string[]): Expectations;
|
|
33
|
+
export type EmittedRoutes = {
|
|
34
|
+
html: readonly string[];
|
|
35
|
+
ssr: readonly string[];
|
|
36
|
+
};
|
|
29
37
|
/** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
|
|
30
|
-
export declare function missingRouteFailures(expected: Expectations, emitted:
|
|
38
|
+
export declare function missingRouteFailures(expected: Expectations, emitted: EmittedRoutes, dist: string): string[];
|
|
39
|
+
export declare const listed: (routes: readonly string[]) => string;
|
|
40
|
+
export declare function disabledRouteFailures(expected: Expectations, { html, ssr }: EmittedRoutes): string[];
|
|
41
|
+
export declare function unknownDisabled(expected: Expectations, disabled?: readonly string[]): string[];
|
|
31
42
|
/** I pattern dinamici che `officina.config.ts` non ha ancora mandato nessuno a visitare. */
|
|
32
43
|
export declare function missingRepresentatives(expected: Expectations, representatives?: Representatives): string[];
|
|
33
44
|
export declare function auditRoutes(expected: Expectations, representatives?: Representatives): string[];
|
|
34
45
|
export declare function smokeRoutes(expected: Expectations, nonHtmlRoutes?: readonly VerifiedRoute[], representatives?: Representatives): VerifiedRoute[];
|
|
35
46
|
export declare function orphanExceptions(expected: Expectations): string[];
|
|
47
|
+
export {};
|
package/dist/lib/routes.js
CHANGED
|
@@ -28,7 +28,7 @@ export function routeOf(htmlPath, dist) {
|
|
|
28
28
|
return route.replace(/\.html$/, '') || '/';
|
|
29
29
|
}
|
|
30
30
|
const SSR_OPT_OUT = /^\s*export\s+const\s+prerender\s*=\s*false\b/m;
|
|
31
|
-
function pageRouteOf(file, pagesDir) {
|
|
31
|
+
export function pageRouteOf(file, pagesDir) {
|
|
32
32
|
return (toPosix(file, pagesDir)
|
|
33
33
|
.replace(/\.astro$/, '')
|
|
34
34
|
.replace(/\/index$/, '') || '/');
|
|
@@ -41,14 +41,14 @@ function segmentPattern(segment) {
|
|
|
41
41
|
const TRAILING_REST_SEGMENT = /\/\[\.\.\.[^/]*\]$/;
|
|
42
42
|
// Anche un segmento rest finale fa match col vuoto: `paginate()` emette la prima pagina come
|
|
43
43
|
// percorso nudo (`/news`, mai `/news/1`), quindi esigere un segmento boccia un archivio di una pagina.
|
|
44
|
-
function routePattern(route) {
|
|
44
|
+
export function routePattern(route) {
|
|
45
45
|
const body = (path) => path.split('/').map(segmentPattern).join('/');
|
|
46
46
|
if (!TRAILING_REST_SEGMENT.test(route))
|
|
47
47
|
return new RegExp(`^${body(route)}$`);
|
|
48
48
|
return new RegExp(`^${body(route.replace(TRAILING_REST_SEGMENT, ''))}(?:/.*)?$`);
|
|
49
49
|
}
|
|
50
|
-
export function expectedRoutes(pages, pagesDir) {
|
|
51
|
-
const expectations = { exact: [], patterns: [], ssr: [] };
|
|
50
|
+
export function expectedRoutes(pages, pagesDir, disabled = []) {
|
|
51
|
+
const expectations = { exact: [], patterns: [], disabled: [], ssr: [] };
|
|
52
52
|
for (const { file, source } of pages) {
|
|
53
53
|
if (SSR_OPT_OUT.test(source)) {
|
|
54
54
|
expectations.ssr.push(file);
|
|
@@ -59,28 +59,52 @@ export function expectedRoutes(pages, pagesDir) {
|
|
|
59
59
|
expectations.exact.push({ route, file });
|
|
60
60
|
continue;
|
|
61
61
|
}
|
|
62
|
-
|
|
62
|
+
const target = disabled.includes(route) ? expectations.disabled : expectations.patterns;
|
|
63
|
+
target.push({ pattern: routePattern(route), label: route, file });
|
|
63
64
|
}
|
|
64
65
|
return expectations;
|
|
65
66
|
}
|
|
67
|
+
// Il manifest di Astro nomina una rotta resa a richiesta col suo pattern (`/blog/[slug]`), non coi percorsi che serve.
|
|
68
|
+
const emits = ({ pattern, label }, { html, ssr }) => html.some((route) => pattern.test(route)) || ssr.includes(label);
|
|
66
69
|
/** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
|
|
67
70
|
export function missingRouteFailures(expected, emitted, dist) {
|
|
68
|
-
if (emitted.length === 0) {
|
|
71
|
+
if (emitted.html.length + emitted.ssr.length === 0) {
|
|
69
72
|
return [`${dist} non contiene nessun .html: nessuna rotta misurata, quindi i budget per rotta non affermano niente`];
|
|
70
73
|
}
|
|
71
74
|
const failures = [];
|
|
72
|
-
const routes = new Set(emitted);
|
|
75
|
+
const routes = new Set([...emitted.html, ...emitted.ssr]);
|
|
73
76
|
for (const { route, file } of expected.exact) {
|
|
74
77
|
if (!routes.has(route))
|
|
75
78
|
failures.push(`rotta mancante ${route}: ${file} è prerenderizzata ma non ha emesso HTML`);
|
|
76
79
|
}
|
|
77
|
-
for (const
|
|
78
|
-
if (
|
|
80
|
+
for (const page of expected.patterns) {
|
|
81
|
+
if (emits(page, emitted))
|
|
79
82
|
continue;
|
|
80
|
-
failures.push(`rotta mancante ${label}: ${file} è prerenderizzata ma getStaticPaths non ha emesso niente`);
|
|
83
|
+
failures.push(`rotta mancante ${page.label}: ${page.file} è prerenderizzata ma getStaticPaths non ha emesso niente`);
|
|
81
84
|
}
|
|
82
85
|
return failures;
|
|
83
86
|
}
|
|
87
|
+
const SHOWN = 3;
|
|
88
|
+
export const listed = (routes) => routes.length <= SHOWN ? routes.join(', ') : `${routes.slice(0, SHOWN).join(', ')} e altre ${routes.length - SHOWN}`;
|
|
89
|
+
export function disabledRouteFailures(expected, { html, ssr }) {
|
|
90
|
+
const exact = new Set(expected.exact.map(({ route }) => route));
|
|
91
|
+
const unexplained = html.filter((route) => !exact.has(route) && !expected.patterns.some(({ pattern }) => pattern.test(route)));
|
|
92
|
+
return expected.disabled.flatMap(({ pattern, label }) => {
|
|
93
|
+
if (ssr.includes(label)) {
|
|
94
|
+
return [
|
|
95
|
+
`rotta spenta ${label}: è in \`routes.disabled\`, ma la build la rende a richiesta, e si spegne solo una pagina prerenderizzata`,
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
const routes = unexplained.filter((route) => pattern.test(route)).sort();
|
|
99
|
+
if (routes.length === 0)
|
|
100
|
+
return [];
|
|
101
|
+
return [`rotta spenta ${label}: è in \`routes.disabled\`, ma la build ha emesso ${listed(routes)}`];
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export function unknownDisabled(expected, disabled = []) {
|
|
105
|
+
const known = new Set(expected.disabled.map(({ label }) => label));
|
|
106
|
+
return disabled.filter((label) => !known.has(label));
|
|
107
|
+
}
|
|
84
108
|
/** I pattern dinamici che `officina.config.ts` non ha ancora mandato nessuno a visitare. */
|
|
85
109
|
export function missingRepresentatives(expected, representatives = {}) {
|
|
86
110
|
return expected.patterns
|
package/dist/lib/vercel-build.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { posix } from 'node:path';
|
|
2
|
+
import { routePattern } from './routes.js';
|
|
2
3
|
const MANIFEST_CALL = 'deserializeManifest({';
|
|
3
4
|
const ASSET = /^(?:.*\/)?_astro\/([^/?#]+\.js)(?:[?#].*)?$/;
|
|
4
5
|
const STYLESHEET = /^(?:.*\/)?_astro\/([^?#]+\.css)(?:[?#].*)?$/;
|
|
@@ -102,14 +103,36 @@ function ssrPage({ scripts = [], styles, routeData }) {
|
|
|
102
103
|
const { route, component } = routeData;
|
|
103
104
|
return typeof route === 'string' && typeof component === 'string' ? [{ scripts, styles, route, component }] : [];
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
for (const
|
|
107
|
-
const manifest = parseManifest(
|
|
106
|
+
function firstManifest(files) {
|
|
107
|
+
for (const host of files) {
|
|
108
|
+
const manifest = parseManifest(host.source);
|
|
108
109
|
if (manifest !== null)
|
|
109
|
-
return
|
|
110
|
+
return { host, manifest };
|
|
110
111
|
}
|
|
111
112
|
return null;
|
|
112
113
|
}
|
|
114
|
+
export function ssrRoutes(files) {
|
|
115
|
+
const found = firstManifest(files);
|
|
116
|
+
return found === null ? null : routesOf(found.host, found.manifest, files);
|
|
117
|
+
}
|
|
118
|
+
// `routePattern` non legge i segmenti misti (`[slug].md`, `post-[id]`), che il pattern del manifest di Astro sì.
|
|
119
|
+
function astroPattern(route, pattern) {
|
|
120
|
+
if (typeof pattern === 'string') {
|
|
121
|
+
try {
|
|
122
|
+
return new RegExp(pattern);
|
|
123
|
+
}
|
|
124
|
+
catch { }
|
|
125
|
+
}
|
|
126
|
+
return routePattern(route);
|
|
127
|
+
}
|
|
128
|
+
export function onDemandPatterns(files) {
|
|
129
|
+
const found = firstManifest(files);
|
|
130
|
+
if (found === null)
|
|
131
|
+
return null;
|
|
132
|
+
return (found.manifest.routes ?? []).flatMap(({ routeData }) => routeData?.prerender === false && typeof routeData.route === 'string'
|
|
133
|
+
? [astroPattern(routeData.route, routeData.pattern)]
|
|
134
|
+
: []);
|
|
135
|
+
}
|
|
113
136
|
function routesOf(host, manifest, files) {
|
|
114
137
|
const sources = new Map(files.map((file) => [file.path, file.source]));
|
|
115
138
|
const chunks = pageChunks(host);
|
package/package.json
CHANGED