@elia97/officina 0.9.1 → 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 +23 -9
- package/dist/bin.js +2 -1
- package/dist/checks/bundle.js +42 -26
- package/dist/checks/doctor.js +20 -7
- 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/placeholders.js +2 -2
- package/dist/checks/smoke.js +3 -2
- package/dist/gen/hook-points.mjs +0 -3
- package/dist/lib/alignment.d.ts +1 -0
- package/dist/lib/alignment.js +19 -3
- package/dist/lib/bundle-budget.d.ts +1 -2
- package/dist/lib/bundle-budget.js +8 -13
- package/dist/lib/bundle-css.d.ts +16 -0
- package/dist/lib/bundle-css.js +33 -0
- package/dist/lib/bundle-ssr.d.ts +3 -0
- package/dist/lib/bundle-ssr.js +4 -3
- package/dist/lib/check-placeholders.js +4 -3
- package/dist/lib/check-routes.js +87 -18
- 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/contract.d.ts +1 -0
- package/dist/lib/dependabot.js +19 -2
- package/dist/lib/links.d.ts +30 -0
- package/dist/lib/links.js +101 -0
- package/dist/lib/markdown.d.ts +7 -0
- package/dist/lib/markdown.js +41 -0
- package/dist/lib/past-tense.js +1 -1
- package/dist/lib/routes.d.ts +19 -7
- package/dist/lib/routes.js +34 -10
- package/dist/lib/validate.js +0 -2
- package/dist/lib/vercel-build.d.ts +2 -0
- package/dist/lib/vercel-build.js +41 -8
- package/dist/smoke.js +0 -2
- package/package.json +3 -2
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",
|
|
@@ -38,17 +39,24 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
|
|
|
38
39
|
|
|
39
40
|
`check language` vuole in italiano i commenti del codice e i Markdown di documentazione. Lascia fuori per intero ciò che non è documentazione del progetto: i dizionari di `src/i18n/strings/` e i Markdown sotto `src/content/`, che sono copy nelle lingue del sito; le fonti del cliente in `docs/sources/`; i `CHANGELOG.md`, generati in inglese. Un file YAML di `src/content/` invece è codice, e i suoi commenti si controllano come altrove.
|
|
40
41
|
|
|
42
|
+
`check routes` segue i rimandi dei Markdown del progetto: i percorsi fra apici inversi dentro `docs`, `src`, `scripts`, `test`, `e2e`, `public`, `.claude` e `.github`; i rimandi a una sezione, nella forma `file.md` § Titolo; i link e le immagini con una destinazione relativa, anche a riferimento e verso una cartella; e due tipi di nomi senza cartella, i documenti in maiuscolo come `HOW_TO_USE.md`, cercati accanto a chi cita, alla radice e in `docs/`, e i file di configurazione che vetrina tiene alla radice, come `vercel.json`. Un link che esce dal repository è un rimando morto, perché in CI la cartella sopra non c'è. Restano fuori gli altri nomi senza cartella, come `head.astro`, gli URL, le ancore, il codice negli span e nei blocchi recintati, un nome che fa da testo a un link, dove conta la destinazione, i link e i nomi dei Markdown di `src/content/`, che sono copy del sito, e i nomi citati in `docs/ROADMAP.md` e nei `CHANGELOG.md`, che raccontano documenti usciti o da scrivere. Un blocco rientrato di quattro spazi non si riconosce come codice: un esempio va in un blocco recintato.
|
|
43
|
+
|
|
41
44
|
## Verifiche di build e di produzione
|
|
42
45
|
|
|
43
46
|
| Comando | Cosa guarda | Da dove prende le rotte |
|
|
44
47
|
|---|---|---|
|
|
45
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 |
|
|
46
50
|
| `check smoke [url]` | la produzione viva: pagine, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
|
|
47
51
|
| `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
|
|
48
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` |
|
|
49
53
|
| `gen icons` | le icone del manifest, disegnate da `public/favicon.svg`, che è il suo ingresso obbligatorio | — |
|
|
50
54
|
|
|
51
|
-
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
|
|
55
|
+
Gli script di una pagina statica si leggono dal suo HTML, anche sotto una `base`, da un `assetsPrefix` o con una query. 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, o con un manifest che non riconosce, le rotte SSR restano fuori, dichiarate: si misurano le pagine statiche, e il CSS come dice il paragrafo che segue.
|
|
56
|
+
|
|
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.
|
|
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.
|
|
52
60
|
|
|
53
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`.
|
|
54
62
|
|
|
@@ -70,7 +78,7 @@ export default defineConfig({
|
|
|
70
78
|
icons: { background: SITE.themeColor.light },
|
|
71
79
|
bundle: { budgets: [motion], cssMaxGzip: 14 * 1024 },
|
|
72
80
|
smoke: { checks: [...DEFAULT_CHECKS, checkLanguageRedirect] },
|
|
73
|
-
features: { analytics: 'required', roadmap: false, botId: 'required' },
|
|
81
|
+
features: { analytics: 'required', roadmap: false, botId: 'required', links: 'required' },
|
|
74
82
|
routes: { representatives: { '/blog/[slug]': '/blog/ciao-mondo' } },
|
|
75
83
|
})
|
|
76
84
|
```
|
|
@@ -87,6 +95,8 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
|
|
|
87
95
|
|
|
88
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.
|
|
89
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
|
+
|
|
90
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.
|
|
91
101
|
|
|
92
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.
|
|
@@ -103,7 +113,7 @@ Le regole che valgono per tutti i progetti stanno nel pacchetto; il progetto est
|
|
|
103
113
|
|
|
104
114
|
```json
|
|
105
115
|
{
|
|
106
|
-
"$schema": "
|
|
116
|
+
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
|
107
117
|
"extends": ["@elia97/officina/biome"]
|
|
108
118
|
}
|
|
109
119
|
```
|
|
@@ -114,15 +124,17 @@ extends:
|
|
|
114
124
|
- node_modules/@elia97/officina/presets/lefthook.yml
|
|
115
125
|
```
|
|
116
126
|
|
|
127
|
+
`$schema` punta allo schema che il pacchetto di Biome installa, che è sempre quello della CLI. L'URL di biomejs.dev porta invece la versione, che Dependabot non aggiorna quando alza `@biomejs/biome`, e da lì Biome segnala lo scarto a ogni esecuzione: `doctor` lo segnala come errore.
|
|
128
|
+
|
|
117
129
|
## Action per i workflow
|
|
118
130
|
|
|
119
131
|
I passi dei workflow stanno in `actions/` di questo repository, come composite action: girano dentro il job di chi le chiama, quindi il nome del check e l'`environment` restano del progetto. Il riferimento va fissato a un tag di versione o a uno SHA, mai a `@main`, e deve essere la stessa versione del pacchetto installato — `doctor` lo verifica.
|
|
120
132
|
|
|
121
133
|
| Action | Passi | Cosa resta nel workflow del progetto |
|
|
122
134
|
|---|---|---|
|
|
123
|
-
| `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` |
|
|
124
136
|
| `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
|
|
125
|
-
| `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 |
|
|
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 |
|
|
126
138
|
| `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
|
|
127
139
|
|
|
128
140
|
```yaml
|
|
@@ -133,7 +145,7 @@ jobs:
|
|
|
133
145
|
name: production
|
|
134
146
|
url: ${{ steps.deploy.outputs.url }}
|
|
135
147
|
steps:
|
|
136
|
-
- uses: Elia97/officina/actions/deploy@
|
|
148
|
+
- uses: Elia97/officina/actions/deploy@vX.Y.Z
|
|
137
149
|
id: deploy
|
|
138
150
|
with:
|
|
139
151
|
ref: ${{ inputs.ref }}
|
|
@@ -146,6 +158,8 @@ jobs:
|
|
|
146
158
|
|
|
147
159
|
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.
|
|
148
160
|
|
|
161
|
+
**Il deploy lanciato a mano.** Con `workflow_dispatch` il workflow viene dal ramo scelto in «Use workflow from», e il pacchetto dal lockfile del tag che si deploya: le due metà possono avere due versioni di officina diverse, e un passo dell'action più nuova può chiamare un'opzione che gli script del tag non conoscono. `actions/deploy` confronta le due versioni subito dopo l'install, prima di ogni script del progetto e di `vercel pull`, e si ferma dicendole se non coincidono o se il tag non installa officina. Un tag più vecchio si deploya lanciando Deploy da quel tag: il suo `deploy.yml` dichiara l'action della sua versione, con i gate di allora. Perché si possa, l'environment `production` del progetto deve ammettere i tag fra i ref da cui si deploya.
|
|
162
|
+
|
|
149
163
|
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`.
|
|
150
164
|
|
|
151
165
|
Perché le action non restino ferme per sempre, Dependabot di questo repository copre l'ecosistema `github-actions` anche dentro `/actions/*`, dove le action di terze parti sono fissate allo SHA intero con la versione nel commento.
|
|
@@ -154,7 +168,7 @@ Perché le action non restino ferme per sempre, Dependabot di questo repository
|
|
|
154
168
|
|
|
155
169
|
`officina doctor` dice, in qualunque repository, cosa manca perché il progetto prenda tutto da fuori: i punti di aggancio dei generatori, gli ancoraggi delle pagine a sezioni e dei dizionari, gli script, le dipendenze e gli strumenti di `package.json`, i residui di ciò che è uscito (copia del metodo, script, documenti commerciali), i preset, i workflow di GitHub e Dependabot, `officina.config.ts`. Sta dentro `ci`, quindi un progetto allineato non torna indietro senza che il gate lo dica.
|
|
156
170
|
|
|
157
|
-
Di `.github/dependabot.yml` guarda la **forma**, non la presenza delle chiavi: lo legge come YAML e pretende che le chiavi del gruppo stiano sul gruppo — Dependabot rifiuta l'intero file se `commit-message` o `open-pull-requests-limit` stanno su una voce che dichiara `multi-ecosystem-group` — e che ogni ecosistema del gruppo abbia una seconda voce per il resto. Senza quella, `patterns` restringe l'intera voce e tutto il resto smette di aggiornarsi senza che niente diventi rosso.
|
|
171
|
+
Di `.github/dependabot.yml` guarda la **forma**, non la presenza delle chiavi: lo legge come YAML e pretende che le chiavi del gruppo stiano sul gruppo — Dependabot rifiuta l'intero file se `commit-message` o `open-pull-requests-limit` stanno su una voce che dichiara `multi-ecosystem-group` — e che ogni ecosistema del gruppo abbia una seconda voce per il resto. Senza quella, `patterns` restringe l'intera voce e tutto il resto smette di aggiornarsi senza che niente diventi rosso. Le action le cerca con il nome che dà loro Dependabot, quello del repository senza la sottocartella: `Elia97/officina`. `Elia97/officina/*` non corrisponde a nessuna dipendenza, in `patterns` come in `ignore` o in `exclude-patterns`, e `doctor` lo segnala ovunque compaia.
|
|
158
172
|
|
|
159
173
|
Le prime due sezioni rispondono a due domande diverse. I punti di aggancio sono l'elenco di `src/lib/contract.ts`, e chiedono che una cosa esista: i moduli che il codice generato importa, le cartelle in cui i generatori scrivono, `class-variance-authority` fra le dipendenze, lo script `check` che il post-gen lancia. Gli ancoraggi guardano dentro quei file, e chiedono che abbiano ancora la forma su cui l'iniezione conta:
|
|
160
174
|
|
|
@@ -166,12 +180,12 @@ Sono gli stessi controlli del pre-volo dei generatori, non una copia: `doctor` c
|
|
|
166
180
|
Le altre sezioni misurano che il progetto e il pacchetto si muovano insieme:
|
|
167
181
|
|
|
168
182
|
- **stessa versione**: il riferimento delle action nei workflow — `@v<x.y.z>`, oppure uno SHA con `# v<x.y.z>` nel commento — deve essere la versione del pacchetto installato. Un progetto col pacchetto alla 0.8 e le action alla 0.6 non è un progetto allineato;
|
|
169
|
-
- **una PR sola**: il `.github/dependabot.yml` del progetto deve avere un gruppo `multi-ecosystem-groups` che prende `@elia97/officina` da npm e `Elia97/officina
|
|
183
|
+
- **una PR sola**: il `.github/dependabot.yml` del progetto deve avere un gruppo `multi-ecosystem-groups` che prende `@elia97/officina` da npm e `Elia97/officina` da `github-actions`. Senza, Dependabot ne apre due e il controllo qui sopra le boccia entrambe;
|
|
170
184
|
- **gli strumenti che preset e action danno per scontati**: `@biomejs/biome`, `lefthook`, `@commitlint/cli`, `@commitlint/config-conventional` e `fallow` fra le `devDependencies`, con la versione di Biome dentro l'intervallo che il preset dichiara di reggere.
|
|
171
185
|
|
|
172
186
|
`package.json`, `.claude/settings.json`, `biome.json` e `.mcp.json` si leggono come JSON, non cercandoci dentro delle stringhe: `"hooks"` in un valore qualunque non è un blocco `hooks`. I workflow restano una ricerca testuale — non c'è un parser YAML nel pacchetto — ma una riga commentata non soddisfa nessun controllo.
|
|
173
187
|
|
|
174
|
-
**Un requisito nuovo nasce come avviso.** Quando `doctor` impara a chiedere una cosa nuova, la mancanza è un `warning` nella versione che la introduce e diventa un `error` dalla successiva. `doctor` gira dentro `pnpm run ci` senza `--strict`, quindi la PR di Dependabot che porta la versione nuova passa, e il progetto ha una versione di tempo per adeguarsi.
|
|
188
|
+
**Un requisito nuovo nasce come avviso.** Quando `doctor` impara a chiedere una cosa nuova, la mancanza è un `warning` nella versione che la introduce e diventa un `error` dalla successiva. `doctor` gira dentro `pnpm run ci` senza `--strict`, quindi la PR di Dependabot che porta la versione nuova passa, e il progetto ha una versione di tempo per adeguarsi. L'avviso serve a far passare quella PR, e dove non può passare comunque il requisito nasce come errore: con il filtro `Elia97/officina/*` l'aggiornamento arriva diviso in due PR, e nessuna delle due passa il controllo sulla stessa versione.
|
|
175
189
|
|
|
176
190
|
## Sviluppo
|
|
177
191
|
|
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
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Gate del budget di bundle su dist/client — richiede un `astro build` completato.
|
|
3
3
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import { gzipSync } from 'node:zlib';
|
|
7
|
-
import { budgetFor, CSS_BUDGET_GZIP,
|
|
7
|
+
import { budgetFor, CSS_BUDGET_GZIP, deferredClosure, htmlEntries, htmlStylesheets, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
|
|
8
|
+
import { measureCss, routeCount } from '../lib/bundle-css.js';
|
|
8
9
|
import { readSsr } from '../lib/bundle-ssr.js';
|
|
9
10
|
import { missingInput } from '../lib/cli.js';
|
|
10
11
|
import { loadConfig } from '../lib/config.js';
|
|
11
|
-
import { expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
|
|
12
|
+
import { disabledRouteFailures, expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
|
|
12
13
|
const DIST = 'dist/client';
|
|
13
14
|
const ASSETS = join(DIST, '_astro');
|
|
14
15
|
const PAGES = 'src/pages';
|
|
15
16
|
const gz = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
16
17
|
function readStylesheets() {
|
|
17
|
-
return
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
return filesWithExtension(ASSETS, '.css').map((path) => ({
|
|
19
|
+
file: relative(ASSETS, path),
|
|
20
|
+
gzip: gzipSync(readFileSync(path)).length,
|
|
21
|
+
}));
|
|
20
22
|
}
|
|
21
23
|
function readChunks() {
|
|
22
24
|
const chunks = new Map();
|
|
@@ -27,10 +29,10 @@ function readChunks() {
|
|
|
27
29
|
return chunks;
|
|
28
30
|
}
|
|
29
31
|
function htmlPages() {
|
|
30
|
-
return filesWithExtension(DIST, '.html').map((htmlPath) =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
})
|
|
32
|
+
return filesWithExtension(DIST, '.html').map((htmlPath) => {
|
|
33
|
+
const html = readFileSync(htmlPath, 'utf8');
|
|
34
|
+
return { route: routeOf(htmlPath, DIST), entries: htmlEntries(html), stylesheets: htmlStylesheets(html) };
|
|
35
|
+
});
|
|
34
36
|
}
|
|
35
37
|
function measurePages(pages, chunks, budgets) {
|
|
36
38
|
const total = (names) => [...names].reduce((sum, name) => sum + (chunks.get(name)?.gzip ?? 0), 0);
|
|
@@ -72,13 +74,20 @@ function printPages(pages, width) {
|
|
|
72
74
|
}
|
|
73
75
|
console.log('\nDIFFERITO = raggiungibile solo da `await import()` (caricato dopo il primo paint, dietro una guardia a runtime).');
|
|
74
76
|
}
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
console.log(
|
|
77
|
+
function printCss({ groups }, cssMaxGzip, width) {
|
|
78
|
+
console.log('\nCSS per rotta (blocca il rendering: gzip dei fogli che la rotta collega)');
|
|
79
|
+
for (const { stylesheets, gzip, routes } of groups) {
|
|
80
|
+
const count = routes.length === 0 ? 'da solo' : routeCount(routes.length);
|
|
81
|
+
const line = ` ${stylesheets.join(' + ').padEnd(width - 2)} ${gz(gzip).padStart(9)} ${gz(cssMaxGzip).padStart(9)} ${count}`;
|
|
82
|
+
console.log(gzip > cssMaxGzip ? `${line} ✗` : line);
|
|
81
83
|
}
|
|
84
|
+
if (groups.some(({ routes }) => routes.length === 0))
|
|
85
|
+
console.log('\nda solo = nessuna rotta misurata collega il foglio: si pesa contro il tetto per conto suo.');
|
|
86
|
+
}
|
|
87
|
+
function checkCss(pages, cssMaxGzip, width) {
|
|
88
|
+
const css = measureCss(pages, readStylesheets(), cssMaxGzip, ASSETS);
|
|
89
|
+
printCss(css, cssMaxGzip, width);
|
|
90
|
+
return css.failures;
|
|
82
91
|
}
|
|
83
92
|
function printSsrNote(expected, ssr) {
|
|
84
93
|
for (const note of ssr.notes)
|
|
@@ -89,8 +98,15 @@ function printSsrNote(expected, ssr) {
|
|
|
89
98
|
for (const file of expected.ssr)
|
|
90
99
|
console.log(` - ${file}`);
|
|
91
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
|
+
}
|
|
92
108
|
export async function main() {
|
|
93
|
-
const { bundle = {} } = await loadConfig(process.cwd());
|
|
109
|
+
const { bundle = {}, routes: { disabled } = {} } = await loadConfig(process.cwd());
|
|
94
110
|
if (missingInput(ASSETS, 'è la build da misurare: prima `pnpm build`'))
|
|
95
111
|
return 1;
|
|
96
112
|
if (missingInput(PAGES, 'le rotte attese si derivano da lì'))
|
|
@@ -98,19 +114,19 @@ export async function main() {
|
|
|
98
114
|
const cssMaxGzip = bundle.cssMaxGzip ?? CSS_BUDGET_GZIP;
|
|
99
115
|
const html = htmlPages();
|
|
100
116
|
const ssr = readSsr(html.length > 0);
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const
|
|
117
|
+
const routes = [...html, ...ssr.pages];
|
|
118
|
+
const pages = measurePages(routes, readChunks(), bundle.budgets ?? []);
|
|
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
|
+
: [];
|
|
105
124
|
failures.push(...ssr.failures, ...unknownChunkFailures(pages), ...overBudgetFailures(pages));
|
|
106
125
|
const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
|
|
107
126
|
printPages(pages, width);
|
|
108
|
-
|
|
109
|
-
const cssFailure = cssBudgetFailure(stylesheets, cssMaxGzip);
|
|
110
|
-
if (cssFailure)
|
|
111
|
-
failures.push(cssFailure);
|
|
112
|
-
printStylesheets(stylesheets, cssMaxGzip, cssFailure !== null, width);
|
|
127
|
+
failures.push(...checkCss(routes, cssMaxGzip, width));
|
|
113
128
|
printSsrNote(expected, ssr);
|
|
129
|
+
printDisabled(expected);
|
|
114
130
|
if (failures.length > 0) {
|
|
115
131
|
console.error(`\n✗ Budget di bundle:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
|
|
116
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 = [];
|
|
@@ -99,8 +110,10 @@ export async function main() {
|
|
|
99
110
|
}
|
|
100
111
|
const { title, gaps } = section;
|
|
101
112
|
console.log(gaps.length === 0 ? ` ✓ ${title}` : ` ${title}: ${gaps.length}`);
|
|
102
|
-
const sectionFindings = gaps.map((gap) => ({ ...gap, severity: 'error' }));
|
|
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());
|
|
@@ -37,7 +37,7 @@ function scan(env, output, config) {
|
|
|
37
37
|
scope: `ambiente in ${env}`,
|
|
38
38
|
findings: envScan(env, contactEnvKeys(config)),
|
|
39
39
|
clean: 'Nessun segnaposto del template.',
|
|
40
|
-
advice: 'Da
|
|
40
|
+
advice: 'Da impostare su Vercel, fra le variabili di produzione, prima del deploy: quali pretendere lo dice `placeholders.contactEnvKeys` in officina.config.ts',
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
const paths = placeholderSources(config);
|
|
@@ -45,7 +45,7 @@ function scan(env, output, config) {
|
|
|
45
45
|
scope: `${paths.length} sorgenti`,
|
|
46
46
|
findings: sourceScan(paths),
|
|
47
47
|
clean: 'Nessun segnaposto del template.',
|
|
48
|
-
advice: 'Da sostituire prima del deploy:
|
|
48
|
+
advice: 'Da sostituire con i dati del progetto prima del deploy: i file da guardare li dice `placeholders.sources` in officina.config.ts',
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
export async function main(args = process.argv.slice(2)) {
|
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/gen/hook-points.mjs
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Un ancoraggio rotto ha un percorso e un problema. Il pre-volo li impagina nel messaggio che
|
|
2
|
-
// ferma il generatore; `doctor`, che gli stessi controlli li lancia senza generare niente, li
|
|
3
|
-
// rimette in un ritrovamento.
|
|
4
1
|
export class HookPointError extends Error {
|
|
5
2
|
constructor(message, path, problem) {
|
|
6
3
|
super(message);
|
package/dist/lib/alignment.d.ts
CHANGED
|
@@ -15,5 +15,6 @@ export declare function scriptGaps({ scripts }: Manifest): ContractGap[];
|
|
|
15
15
|
export declare function dependencyGaps({ dependencies, devDependencies }: Manifest): ContractGap[];
|
|
16
16
|
export declare function leftoverGaps(files: ProjectFiles): ContractGap[];
|
|
17
17
|
export declare const BIOME_PRESET = "@elia97/officina/biome";
|
|
18
|
+
export declare const BIOME_SCHEMA = "./node_modules/@biomejs/biome/configuration_schema.json";
|
|
18
19
|
export declare const LEFTHOOK_PRESET = "node_modules/@elia97/officina/presets/lefthook.yml";
|
|
19
20
|
export declare function presetGaps(files: ProjectFiles): ContractGap[];
|
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) {
|
|
@@ -125,13 +129,25 @@ export function leftoverGaps(files) {
|
|
|
125
129
|
return gaps;
|
|
126
130
|
}
|
|
127
131
|
export const BIOME_PRESET = '@elia97/officina/biome';
|
|
132
|
+
export const BIOME_SCHEMA = './node_modules/@biomejs/biome/configuration_schema.json';
|
|
128
133
|
export const LEFTHOOK_PRESET = 'node_modules/@elia97/officina/presets/lefthook.yml';
|
|
134
|
+
// Dependabot aggiorna @biomejs/biome ma non questo URL: con lo schema versionato `biome ci` stampa
|
|
135
|
+
// «The configuration schema version does not match the CLI version»; con quello del pacchetto no.
|
|
136
|
+
const VERSIONED_SCHEMA = /^https:\/\/biomejs\.dev\/schemas\/[^/]+\/schema\.json$/;
|
|
129
137
|
export function presetGaps(files) {
|
|
130
138
|
const gaps = [];
|
|
131
|
-
const
|
|
139
|
+
const biome = readJson(files, 'biome.json');
|
|
140
|
+
const extended = biome?.extends;
|
|
132
141
|
if (!(Array.isArray(extended) && extended.includes(BIOME_PRESET))) {
|
|
133
142
|
gaps.push({ path: 'biome.json', message: `non estende \`${BIOME_PRESET}\`` });
|
|
134
143
|
}
|
|
144
|
+
const schema = biome?.$schema;
|
|
145
|
+
if (typeof schema === 'string' && VERSIONED_SCHEMA.test(schema)) {
|
|
146
|
+
gaps.push({
|
|
147
|
+
path: 'biome.json',
|
|
148
|
+
message: `\`$schema\` porta la versione di Biome, che Dependabot non aggiorna: va su \`${BIOME_SCHEMA}\``,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
135
151
|
if (!mentions(files.read('lefthook.yml'), LEFTHOOK_PRESET)) {
|
|
136
152
|
gaps.push({ path: 'lefthook.yml', message: `non estende \`${LEFTHOOK_PRESET}\`` });
|
|
137
153
|
}
|
|
@@ -13,6 +13,7 @@ export declare function budgetFor(route: string, budgets?: readonly Budget[]): B
|
|
|
13
13
|
/** Rollup racchiude gli specificatori statici con `"` e i dinamici in un template literal. */
|
|
14
14
|
export declare function parseEdges(source: string): Pick<Chunk, 'static' | 'dynamic'>;
|
|
15
15
|
export declare function htmlEntries(html: string): string[];
|
|
16
|
+
export declare function htmlStylesheets(html: string): string[];
|
|
16
17
|
/** `unknown`: i nomi citati che in `dist/client/_astro` non ci sono — saltarli vorrebbe dire misurare meno del vero. */
|
|
17
18
|
export type Closure = {
|
|
18
19
|
reached: Set<string>;
|
|
@@ -25,5 +26,3 @@ export type Stylesheet = {
|
|
|
25
26
|
file: string;
|
|
26
27
|
gzip: number;
|
|
27
28
|
};
|
|
28
|
-
export declare function heaviestStylesheet(sheets: readonly Stylesheet[]): Stylesheet | null;
|
|
29
|
-
export declare function cssBudgetFailure(sheets: readonly Stylesheet[], maxGzip?: number): string | null;
|
|
@@ -14,7 +14,14 @@ export function parseEdges(source) {
|
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
16
|
export function htmlEntries(html) {
|
|
17
|
-
return [...captured(html,
|
|
17
|
+
return [...captured(html, /\b(?:src|href)=["']?[^"'\s>]*?\/_astro\/([^"'\s>?#]+\.js)/g, 1)];
|
|
18
|
+
}
|
|
19
|
+
const LINK_TAG = /<link\b[^>]*>/gi;
|
|
20
|
+
const STYLESHEET_REL = /\brel=["']?stylesheet\b/i;
|
|
21
|
+
const LOCAL_STYLESHEET = /\bhref=["']?[^"'\s>]*?\/_astro\/([^"'\s>?#]+\.css)/i;
|
|
22
|
+
export function htmlStylesheets(html) {
|
|
23
|
+
const links = [...html.matchAll(LINK_TAG)].map(([tag]) => tag).filter((tag) => STYLESHEET_REL.test(tag));
|
|
24
|
+
return [...new Set(links.map((tag) => tag.match(LOCAL_STYLESHEET)?.[1]).filter((name) => name !== undefined))];
|
|
18
25
|
}
|
|
19
26
|
export function staticClosure(entries, chunks) {
|
|
20
27
|
const reached = new Set();
|
|
@@ -39,15 +46,3 @@ export function deferredClosure(reached, chunks) {
|
|
|
39
46
|
return new Set([...staticClosure(entries, chunks).reached].filter((name) => !reached.has(name)));
|
|
40
47
|
}
|
|
41
48
|
export const CSS_BUDGET_GZIP = 12 * 1024;
|
|
42
|
-
export function heaviestStylesheet(sheets) {
|
|
43
|
-
return sheets.reduce((worst, sheet) => (worst && worst.gzip >= sheet.gzip ? worst : sheet), null);
|
|
44
|
-
}
|
|
45
|
-
// Astro emette un foglio di stile per gruppo di pagine e una rotta ne collega esattamente uno,
|
|
46
|
-
// quindi la loro somma è fatta di byte che nessun visitatore scarica mai insieme.
|
|
47
|
-
export function cssBudgetFailure(sheets, maxGzip = CSS_BUDGET_GZIP) {
|
|
48
|
-
const heaviest = heaviestStylesheet(sheets);
|
|
49
|
-
if (heaviest === null || heaviest.gzip <= maxGzip)
|
|
50
|
-
return null;
|
|
51
|
-
const over = heaviest.gzip - maxGzip;
|
|
52
|
-
return `CSS ${(heaviest.gzip / 1024).toFixed(1)} KB gz > ${(maxGzip / 1024).toFixed(1)} KB (+${(over / 1024).toFixed(1)} KB) in ${heaviest.file}`;
|
|
53
|
-
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Stylesheet } from './bundle-budget.ts';
|
|
2
|
+
export type RouteStylesheets = {
|
|
3
|
+
route: string;
|
|
4
|
+
stylesheets: readonly string[];
|
|
5
|
+
};
|
|
6
|
+
export type CssGroup = {
|
|
7
|
+
stylesheets: string[];
|
|
8
|
+
gzip: number;
|
|
9
|
+
routes: string[];
|
|
10
|
+
};
|
|
11
|
+
export type CssMeasure = {
|
|
12
|
+
groups: CssGroup[];
|
|
13
|
+
failures: string[];
|
|
14
|
+
};
|
|
15
|
+
export declare const routeCount: (count: number) => string;
|
|
16
|
+
export declare function measureCss(routes: readonly RouteStylesheets[], sheets: readonly Stylesheet[], maxGzip: number, assets: string): CssMeasure;
|