@elia97/officina 0.2.0 → 0.4.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 +93 -4
- package/dist/bin.d.ts +8 -0
- package/dist/bin.js +12 -3
- package/dist/checks/analytics.d.ts +2 -0
- package/dist/checks/analytics.js +48 -0
- package/dist/checks/bundle.d.ts +2 -0
- package/dist/checks/bundle.js +98 -0
- package/dist/checks/comments.d.ts +2 -0
- package/dist/checks/doctor.d.ts +2 -0
- package/dist/checks/doctor.js +45 -8
- package/dist/checks/language.d.ts +2 -0
- package/dist/checks/lighthouse.d.ts +2 -0
- package/dist/checks/lighthouse.js +37 -0
- package/dist/checks/placeholders.d.ts +2 -0
- package/dist/checks/roadmap.d.ts +2 -0
- package/dist/checks/routes.d.ts +2 -0
- package/dist/checks/smoke.d.ts +2 -0
- package/dist/checks/smoke.js +57 -0
- package/dist/checks/vercel-cli.d.ts +2 -0
- package/dist/checks/vercel-cli.js +8 -6
- package/dist/gen/collection.d.mts +1 -0
- package/dist/gen/component.d.mts +1 -0
- package/dist/gen/icons.d.ts +2 -0
- package/dist/gen/icons.js +27 -0
- package/dist/gen/identifier.d.mts +1 -0
- package/dist/gen/inject-config.d.mts +12 -0
- package/dist/gen/inject-section.d.mts +16 -0
- package/dist/gen/inject-strings.d.mts +8 -0
- package/dist/gen/page.d.mts +10 -0
- package/dist/gen/plopfile.d.mts +2 -0
- package/dist/gen/post-gen.d.mts +1 -0
- package/dist/gen/run.d.ts +2 -0
- package/dist/gen/section-targets.d.mts +23 -0
- package/dist/gen/section.d.mts +1 -0
- package/dist/gen/sectioned-collection.d.mts +26 -0
- package/dist/gen/ts-morph-utils.d.mts +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -0
- package/dist/lib/alignment.d.ts +23 -0
- package/dist/lib/alignment.js +166 -0
- package/dist/lib/analytics-coverage.d.ts +12 -0
- package/dist/lib/analytics-coverage.js +15 -0
- package/dist/lib/bundle-budget.d.ts +24 -0
- package/dist/lib/bundle-budget.js +50 -0
- package/dist/lib/check-comments.d.ts +10 -0
- package/dist/lib/check-language.d.ts +11 -0
- package/dist/lib/check-placeholders.d.ts +6 -0
- package/dist/lib/check-roadmap.d.ts +30 -0
- package/dist/lib/check-routes.d.ts +6 -0
- package/dist/lib/cli.d.ts +26 -0
- package/dist/lib/comment-syntax.d.ts +11 -0
- package/dist/lib/config.d.ts +25 -0
- package/dist/lib/config.js +22 -0
- package/dist/lib/contract.d.ts +22 -0
- package/dist/lib/git.d.ts +6 -0
- package/dist/lib/gtm-container.d.ts +41 -0
- package/dist/lib/gtm-container.js +145 -0
- package/dist/lib/icon-specs.d.ts +6 -0
- package/dist/lib/icon-specs.js +6 -0
- package/dist/lib/icons.d.ts +6 -0
- package/dist/lib/icons.js +32 -0
- package/dist/lib/language-words.d.ts +2 -0
- package/dist/lib/lighthouse.d.ts +17 -0
- package/dist/lib/lighthouse.js +23 -0
- package/dist/lib/past-tense.d.ts +1 -0
- package/dist/lib/routes.d.ts +31 -0
- package/dist/lib/routes.js +97 -0
- package/dist/lib/smoke-production.d.ts +35 -0
- package/dist/lib/smoke-production.js +132 -0
- package/dist/lib/vercel-cli-pin.d.ts +2 -0
- package/dist/sh/lhci-local.sh +106 -0
- package/package.json +19 -3
- package/presets/biome.base.json +100 -0
- package/presets/lefthook.yml +11 -0
package/README.md
CHANGED
|
@@ -18,18 +18,107 @@ pnpm add -D @elia97/officina
|
|
|
18
18
|
"check:routes": "officina check routes",
|
|
19
19
|
"check:roadmap": "officina check roadmap",
|
|
20
20
|
"check:placeholders": "officina check placeholders",
|
|
21
|
-
"
|
|
21
|
+
"perf:bundle": "officina check bundle",
|
|
22
|
+
"smoke:prod": "officina check smoke",
|
|
23
|
+
"analytics:verify": "officina check analytics",
|
|
24
|
+
"lhci": "officina check lighthouse",
|
|
25
|
+
"lhci:local": "officina check lighthouse --local",
|
|
22
26
|
"gen": "officina gen",
|
|
23
27
|
"gen:section": "officina gen section",
|
|
24
28
|
"gen:page": "officina gen page",
|
|
25
29
|
"gen:component": "officina gen component",
|
|
26
30
|
"gen:collection": "officina gen collection",
|
|
31
|
+
"gen:icons": "officina gen icons",
|
|
27
32
|
"doctor": "officina doctor"
|
|
28
33
|
}
|
|
29
34
|
}
|
|
30
35
|
```
|
|
31
36
|
|
|
32
|
-
I gate leggono dalla cartella corrente, che deve essere la radice del repository. Opzioni comuni: `--diff`, `--base <ref>`, `--head <ref>`, `--strict`, `--format text|github`.
|
|
37
|
+
I gate leggono dalla cartella corrente, che deve essere la radice del repository. Opzioni comuni dei gate sui sorgenti: `--diff`, `--base <ref>`, `--head <ref>`, `--strict`, `--format text|github`.
|
|
38
|
+
|
|
39
|
+
## Verifiche di build e di produzione
|
|
40
|
+
|
|
41
|
+
| Comando | Cosa guarda | Da dove prende le rotte |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages` |
|
|
44
|
+
| `check smoke [url]` | la produzione viva: pagine, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
|
|
45
|
+
| `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
|
|
46
|
+
| `check lighthouse [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; `--local` fa build, server e Chrome da sé | `src/pages` |
|
|
47
|
+
| `gen icons` | le icone del manifest, disegnate dal favicon SVG in `public/` del progetto | — |
|
|
48
|
+
|
|
49
|
+
Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { type Budget, DEFAULT_CHECKS, defineConfig, type SmokeCheck } from '@elia97/officina'
|
|
53
|
+
|
|
54
|
+
import { SITE } from './src/lib/site.ts'
|
|
55
|
+
|
|
56
|
+
const motion: Budget = { label: 'motion', matches: (route) => route === '/', maxGzip: 72 * 1024 }
|
|
57
|
+
const checkLanguageRedirect: SmokeCheck = async ({ get, baseUrl }) => []
|
|
58
|
+
|
|
59
|
+
export default defineConfig({
|
|
60
|
+
siteUrl: SITE.url,
|
|
61
|
+
icons: { background: SITE.themeColor.light },
|
|
62
|
+
bundle: { budgets: [motion], cssMaxGzip: 14 * 1024 },
|
|
63
|
+
smoke: { checks: [...DEFAULT_CHECKS, checkLanguageRedirect] },
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Ogni voce è facoltativa, tranne `siteUrl` per `check smoke` e `icons.background` per `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi non ha BotID la ricompone senza `checkBotIdChallenge`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`. Il file lo carica Node, che fuori da `node_modules` toglie i tipi da sé: niente `enum`, niente alias `@/`.
|
|
68
|
+
|
|
69
|
+
`sharp` è una dipendenza facoltativa del progetto, non del pacchetto: serve solo a `gen icons`, che senza lo dice ed esce 1. `ICON_SPECS` si importa senza caricarlo, per il test che confronta le icone con il manifest del sito.
|
|
70
|
+
|
|
71
|
+
## Preset di configurazione
|
|
72
|
+
|
|
73
|
+
Le regole che valgono per tutti i progetti stanno nel pacchetto; il progetto estende e tiene solo le proprie eccezioni.
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
|
|
78
|
+
"extends": ["@elia97/officina/biome"]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
# lefthook.yml
|
|
84
|
+
extends:
|
|
85
|
+
- node_modules/@elia97/officina/presets/lefthook.yml
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Action per i workflow
|
|
89
|
+
|
|
90
|
+
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, l'`environment` e i segreti restano del progetto. Il riferimento va fissato a un tag di versione o a uno SHA, mai a `@main`.
|
|
91
|
+
|
|
92
|
+
| Action | Passi | Cosa resta nel workflow del progetto |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `actions/ci` | checkout, node, install, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
|
|
95
|
+
| `actions/review` | `fallow review` sul diff contro il merge-base | il job informativo |
|
|
96
|
+
| `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel nell'`env` del job, il job che controlla se i segreti ci sono |
|
|
97
|
+
| `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
jobs:
|
|
101
|
+
deploy:
|
|
102
|
+
runs-on: ubuntu-latest
|
|
103
|
+
environment:
|
|
104
|
+
name: production
|
|
105
|
+
url: ${{ steps.deploy.outputs.url }}
|
|
106
|
+
env:
|
|
107
|
+
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
|
108
|
+
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
|
109
|
+
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
|
110
|
+
steps:
|
|
111
|
+
- uses: Elia97/officina/actions/deploy@v0.4.0
|
|
112
|
+
id: deploy
|
|
113
|
+
with:
|
|
114
|
+
ref: ${{ inputs.ref }}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Il pin `vercel@<major>` sta in un posto solo, `actions/deploy/action.yml`: lo guarda ogni lunedì `.github/workflows/vercel-cli.yml` di questo repository, con `officina check vercel-cli actions/deploy/action.yml`.
|
|
118
|
+
|
|
119
|
+
## doctor
|
|
120
|
+
|
|
121
|
+
`officina doctor` dice, in qualunque repository, cosa manca perché il progetto prenda tutto da fuori: i punti di aggancio dei generatori, gli script e le dipendenze di `package.json`, i residui di ciò che è uscito (copia del metodo, script, documenti commerciali), i preset, i workflow con il riferimento fissato, `officina.config.ts`. Sta dentro `ci`, quindi un progetto allineato non torna indietro senza che il gate lo dica.
|
|
33
122
|
|
|
34
123
|
## Sviluppo
|
|
35
124
|
|
|
@@ -53,7 +142,7 @@ pnpm publish # prepublishOnly lancia typecheck, test e build
|
|
|
53
142
|
|
|
54
143
|
`officina gen` avvolge plop con i generatori e i template del pacchetto, e scrive nel progetto da cui lo lanci: `section`, `page`, `component`, `collection`. `plop` e `ts-morph` sono dipendenze del pacchetto, non del progetto.
|
|
55
144
|
|
|
56
|
-
I generatori scrivono codice che deve incastrarsi nello scaffold, quindi il progetto deve avere i punti di aggancio che si aspettano: i moduli che il codice generato importa e i file in cui iniettano. L'elenco è `src/lib/contract.ts`,
|
|
145
|
+
I generatori scrivono codice che deve incastrarsi nello scaffold, quindi il progetto deve avere i punti di aggancio che si aspettano: i moduli che il codice generato importa e i file in cui iniettano. L'elenco è `src/lib/contract.ts`, ed è la prima sezione di `officina doctor`. Finché manca qualcosa il pre-volo del generatore si ferma prima di scrivere un solo file.
|
|
57
146
|
|
|
58
147
|
Un progetto aggiunge i propri generatori con un file `officina.generators.mjs` nella radice, con la firma di un plopfile: `export default function (plop)`.
|
|
59
148
|
|
|
@@ -61,4 +150,4 @@ I test dei generatori girano sui punti di aggancio del progetto di prova, copiat
|
|
|
61
150
|
|
|
62
151
|
## Cosa arriverà
|
|
63
152
|
|
|
64
|
-
|
|
153
|
+
I preset di `ecommerce` e `monorepo`.
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
CHANGED
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from 'node:process';
|
|
3
3
|
// Un modulo per gate, caricato solo quando serve: `officina check routes` non paga il costo
|
|
4
|
-
// degli altri
|
|
4
|
+
// degli altri.
|
|
5
5
|
export const CHECKS = {
|
|
6
|
+
analytics: () => import('./checks/analytics.js'),
|
|
7
|
+
bundle: () => import('./checks/bundle.js'),
|
|
6
8
|
comments: () => import('./checks/comments.js'),
|
|
7
9
|
language: () => import('./checks/language.js'),
|
|
10
|
+
lighthouse: () => import('./checks/lighthouse.js'),
|
|
8
11
|
placeholders: () => import('./checks/placeholders.js'),
|
|
9
12
|
roadmap: () => import('./checks/roadmap.js'),
|
|
10
13
|
routes: () => import('./checks/routes.js'),
|
|
14
|
+
smoke: () => import('./checks/smoke.js'),
|
|
11
15
|
'vercel-cli': () => import('./checks/vercel-cli.js'),
|
|
12
16
|
};
|
|
13
17
|
const USAGE = `Uso:
|
|
14
18
|
officina check <gate> [opzioni] gate: ${Object.keys(CHECKS).join(', ')}
|
|
15
19
|
officina gen [generatore] section, page, component, collection, più quelli del progetto
|
|
20
|
+
officina gen icons le icone del manifest da public/favicon.svg
|
|
16
21
|
officina doctor cosa manca al progetto perché i generatori funzionino
|
|
17
22
|
|
|
18
|
-
Opzioni dei gate: --diff, --base <ref>, --head <ref>, --strict, --format text|github
|
|
23
|
+
Opzioni dei gate sui sorgenti: --diff, --base <ref>, --head <ref>, --strict, --format text|github
|
|
24
|
+
bundle legge dist/client; smoke [url] e analytics [GTM-…] vanno in rete; lighthouse [--local]
|
|
25
|
+
I valori del progetto (URL, budget, header, controlli propri) stanno in officina.config.ts
|
|
19
26
|
`;
|
|
20
27
|
// Ogni gruppo dice quale modulo carica e quali argomenti gli passa.
|
|
21
28
|
const GROUPS = {
|
|
22
29
|
check: ([name = '', ...rest]) => ({ load: CHECKS[name], args: rest }),
|
|
23
|
-
gen: (args) =>
|
|
30
|
+
gen: (args) => args[0] === 'icons'
|
|
31
|
+
? { load: () => import('./gen/icons.js'), args: args.slice(1) }
|
|
32
|
+
: { load: () => import('./gen/run.js'), args },
|
|
24
33
|
doctor: (args) => ({ load: () => import('./checks/doctor.js'), args }),
|
|
25
34
|
};
|
|
26
35
|
export async function run(argv) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Il container GTM vivo scatta sugli eventi che questo sito emette? Legge il gtm.js pubblico
|
|
3
|
+
// del container: nessuna credenziale, niente da scrivere.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { coverageOf, extractLinkEvents } from '../lib/analytics-coverage.js';
|
|
7
|
+
import { loadConfig } from '../lib/config.js';
|
|
8
|
+
import { extractTriggers, googleTagIds, parseContainerData } from '../lib/gtm-container.js';
|
|
9
|
+
const LINK_TRACKING = 'src/lib/analytics/link-tracking.ts';
|
|
10
|
+
const TIMEOUT_MS = 15_000;
|
|
11
|
+
function resolveGtmId(args) {
|
|
12
|
+
const gtmId = args[0] ?? process.env.PUBLIC_GTM_ID ?? '';
|
|
13
|
+
return /^GTM-[A-Z0-9]+$/.test(gtmId) ? gtmId : null;
|
|
14
|
+
}
|
|
15
|
+
function reportCoverage(coverage) {
|
|
16
|
+
for (const { event, prefix, covered } of coverage) {
|
|
17
|
+
console.log(` ${covered ? '✓' : '✗'} ${event.padEnd(16)} (${prefix})`);
|
|
18
|
+
}
|
|
19
|
+
const missing = coverage.filter(({ covered }) => !covered);
|
|
20
|
+
if (missing.length > 0) {
|
|
21
|
+
console.error(`\n✗ ${missing.length} evento/i che il sito emette e nessun trigger ascolta.`);
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
console.log('\n✓ Ogni evento che il sito emette ha un trigger.');
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
export async function main(args = []) {
|
|
28
|
+
const { analytics = {} } = await loadConfig(process.cwd());
|
|
29
|
+
const gtmId = resolveGtmId(args);
|
|
30
|
+
if (gtmId === null) {
|
|
31
|
+
console.log('Nessun container GTM da controllare: passane uno come argomento o imposta PUBLIC_GTM_ID.');
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
const response = await fetch(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`, {
|
|
35
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
console.error(`✗ container ${gtmId} illeggibile: HTTP ${response.status}`);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
const container = parseContainerData(await response.text());
|
|
42
|
+
const source = readFileSync(analytics.linkTracking ?? LINK_TRACKING, 'utf8');
|
|
43
|
+
const coverage = coverageOf(extractLinkEvents(source), extractTriggers(container));
|
|
44
|
+
console.log(`\nGTM ${gtmId} — ID di misurazione: ${googleTagIds(container).join(', ') || '—'}\n`);
|
|
45
|
+
return reportCoverage(coverage);
|
|
46
|
+
}
|
|
47
|
+
if (import.meta.main)
|
|
48
|
+
process.exit(await main(process.argv.slice(2)));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Gate del budget di bundle su dist/client — richiede un `astro build` completato.
|
|
3
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { gzipSync } from 'node:zlib';
|
|
7
|
+
import { budgetFor, CSS_BUDGET_GZIP, cssBudgetFailure, deferredClosure, heaviestStylesheet, htmlEntries, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
|
|
8
|
+
import { loadConfig } from '../lib/config.js';
|
|
9
|
+
import { expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
|
|
10
|
+
const DIST = 'dist/client';
|
|
11
|
+
const ASSETS = join(DIST, '_astro');
|
|
12
|
+
const PAGES = 'src/pages';
|
|
13
|
+
const gz = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
14
|
+
function readStylesheets() {
|
|
15
|
+
return readdirSync(ASSETS)
|
|
16
|
+
.filter((f) => f.endsWith('.css'))
|
|
17
|
+
.map((file) => ({ file, gzip: gzipSync(readFileSync(join(ASSETS, file))).length }));
|
|
18
|
+
}
|
|
19
|
+
function readChunks() {
|
|
20
|
+
const chunks = new Map();
|
|
21
|
+
for (const name of readdirSync(ASSETS).filter((f) => f.endsWith('.js'))) {
|
|
22
|
+
const raw = readFileSync(join(ASSETS, name));
|
|
23
|
+
chunks.set(name, { gzip: gzipSync(raw).length, ...parseEdges(raw.toString('utf8')) });
|
|
24
|
+
}
|
|
25
|
+
return chunks;
|
|
26
|
+
}
|
|
27
|
+
function measurePages(chunks, budgets) {
|
|
28
|
+
const total = (names) => [...names].reduce((sum, name) => sum + (chunks.get(name)?.gzip ?? 0), 0);
|
|
29
|
+
return filesWithExtension(DIST, '.html')
|
|
30
|
+
.map((htmlPath) => {
|
|
31
|
+
const route = routeOf(htmlPath, DIST);
|
|
32
|
+
const reached = staticClosure(htmlEntries(readFileSync(htmlPath, 'utf8')), chunks);
|
|
33
|
+
return {
|
|
34
|
+
route,
|
|
35
|
+
gzip: total(reached),
|
|
36
|
+
deferredGzip: total(deferredClosure(reached, chunks)),
|
|
37
|
+
budget: budgetFor(route, budgets),
|
|
38
|
+
};
|
|
39
|
+
})
|
|
40
|
+
.sort((a, b) => b.gzip - a.gzip);
|
|
41
|
+
}
|
|
42
|
+
function overBudgetFailures(pages) {
|
|
43
|
+
return pages
|
|
44
|
+
.filter((page) => page.gzip > page.budget.maxGzip)
|
|
45
|
+
.map((page) => {
|
|
46
|
+
const over = page.gzip - page.budget.maxGzip;
|
|
47
|
+
return `${page.route}: ${gz(page.gzip)} > ${gz(page.budget.maxGzip)} (+${gz(over)})`;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function printPages(pages, width) {
|
|
51
|
+
console.log('\nBudget di bundle — JS del client per rotta (gzip, chiusura statica)\n');
|
|
52
|
+
console.log(`${'ROTTA'.padEnd(width)} ${'STATICO'.padStart(9)} ${'BUDGET'.padStart(9)} ${'DIFFERITO'.padStart(9)}`);
|
|
53
|
+
for (const page of pages) {
|
|
54
|
+
const deferred = page.deferredGzip > 0 ? gz(page.deferredGzip) : '—';
|
|
55
|
+
const line = `${page.route.padEnd(width)} ${gz(page.gzip).padStart(9)} ${gz(page.budget.maxGzip).padStart(9)} ${deferred.padStart(9)}`;
|
|
56
|
+
console.log(page.gzip > page.budget.maxGzip ? `${line} ✗` : line);
|
|
57
|
+
}
|
|
58
|
+
console.log('\nDIFFERITO = raggiungibile solo da `await import()` (caricato dopo il primo paint, dietro una guardia a runtime).');
|
|
59
|
+
}
|
|
60
|
+
function printStylesheets(stylesheets, cssMaxGzip, failed, width) {
|
|
61
|
+
const worst = heaviestStylesheet(stylesheets);
|
|
62
|
+
console.log(`\nCSS (blocca il rendering: il foglio più pesante che una rotta collega)`);
|
|
63
|
+
for (const sheet of [...stylesheets].sort((a, b) => b.gzip - a.gzip)) {
|
|
64
|
+
const mark = sheet === worst && failed ? ' ✗' : '';
|
|
65
|
+
console.log(` ${sheet.file.padEnd(width - 2)} ${gz(sheet.gzip).padStart(9)} ${gz(cssMaxGzip).padStart(9)}${mark}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function printSsrNote(expected) {
|
|
69
|
+
if (expected.ssr.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
console.log(`\nNOTA ${expected.ssr.length} pagina/e con \`export const prerender = false\`, fuori da questo budget:`);
|
|
72
|
+
for (const file of expected.ssr)
|
|
73
|
+
console.log(` - ${file}`);
|
|
74
|
+
}
|
|
75
|
+
export async function main() {
|
|
76
|
+
const { bundle = {} } = await loadConfig(process.cwd());
|
|
77
|
+
const cssMaxGzip = bundle.cssMaxGzip ?? CSS_BUDGET_GZIP;
|
|
78
|
+
const pages = measurePages(readChunks(), bundle.budgets ?? []);
|
|
79
|
+
const expected = expectedRoutes(readPageFiles(PAGES), PAGES);
|
|
80
|
+
const failures = missingRouteFailures(expected, pages.map((page) => page.route), DIST);
|
|
81
|
+
failures.push(...overBudgetFailures(pages));
|
|
82
|
+
const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
|
|
83
|
+
printPages(pages, width);
|
|
84
|
+
const stylesheets = readStylesheets();
|
|
85
|
+
const cssFailure = cssBudgetFailure(stylesheets, cssMaxGzip);
|
|
86
|
+
if (cssFailure)
|
|
87
|
+
failures.push(cssFailure);
|
|
88
|
+
printStylesheets(stylesheets, cssMaxGzip, cssFailure !== null, width);
|
|
89
|
+
printSsrNote(expected);
|
|
90
|
+
if (failures.length > 0) {
|
|
91
|
+
console.error(`\n✗ Budget di bundle:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
console.log('\n✓ Budget di bundle rispettato su ogni rotta attesa.\n');
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
if (import.meta.main)
|
|
98
|
+
process.exit(await main());
|
package/dist/checks/doctor.js
CHANGED
|
@@ -1,15 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import process from 'node:process';
|
|
5
|
+
import { configGaps, dependencyGaps, leftoverGaps, presetGaps, scriptGaps, workflowGaps, } from '../lib/alignment.js';
|
|
3
6
|
import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import { findConfigFile, loadConfig } from '../lib/config.js';
|
|
8
|
+
import { contractGaps } from '../lib/contract.js';
|
|
9
|
+
import { trackedAndUntracked } from '../lib/git.js';
|
|
10
|
+
function projectFiles(root) {
|
|
11
|
+
return {
|
|
12
|
+
paths: trackedAndUntracked(),
|
|
13
|
+
read: (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
async function loadedConfig(root) {
|
|
17
|
+
if (findConfigFile(root) === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
try {
|
|
20
|
+
return await loadConfig(root);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function main() {
|
|
27
|
+
const root = process.cwd();
|
|
28
|
+
const files = projectFiles(root);
|
|
29
|
+
const manifest = JSON.parse(files.read('package.json') ?? '{}');
|
|
30
|
+
const sections = [
|
|
31
|
+
['punti di aggancio dei generatori', contractGaps(root)],
|
|
32
|
+
['script e dipendenze di package.json', [...scriptGaps(manifest), ...dependencyGaps(manifest)]],
|
|
33
|
+
['residui di ciò che è uscito dal repository', leftoverGaps(files)],
|
|
34
|
+
['preset di configurazione', presetGaps(files)],
|
|
35
|
+
['workflow di GitHub', workflowGaps(files)],
|
|
36
|
+
['officina.config.ts', configGaps(await loadedConfig(root))],
|
|
37
|
+
];
|
|
38
|
+
console.log('\nofficina doctor — cosa manca al progetto per prendere tutto da fuori\n');
|
|
39
|
+
const findings = [];
|
|
40
|
+
for (const [title, gaps] of sections) {
|
|
41
|
+
console.log(gaps.length === 0 ? ` ✓ ${title}` : ` ${title}: ${gaps.length}`);
|
|
42
|
+
const sectionFindings = gaps.map((gap) => ({ ...gap, severity: 'error' }));
|
|
43
|
+
printFindings(sectionFindings, cliOptions([]).format);
|
|
44
|
+
findings.push(...sectionFindings);
|
|
45
|
+
}
|
|
9
46
|
console.log(findings.length === 0
|
|
10
|
-
? ' Il progetto
|
|
11
|
-
: `\n ${findings.length}
|
|
47
|
+
? '\n Il progetto è allineato: metodo, gate, generatori e verifiche arrivano da fuori.\n'
|
|
48
|
+
: `\n ${findings.length} da sistemare.\n`);
|
|
12
49
|
return exitCode(findings, false);
|
|
13
50
|
}
|
|
14
51
|
if (import.meta.main)
|
|
15
|
-
process.exit(main());
|
|
52
|
+
process.exit(await main());
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { lighthouseConfig } from '../lib/lighthouse.js';
|
|
9
|
+
import { auditRoutes, expectedRoutes, readPageFiles } from '../lib/routes.js';
|
|
10
|
+
const PAGES_DIR = 'src/pages';
|
|
11
|
+
const RC_FILE = '.lighthouserc.json';
|
|
12
|
+
// Build equivalente alla produzione, server statico e Chrome di Linux: lo script chiama di nuovo
|
|
13
|
+
// questo comando, senza `--local`, quando il server risponde.
|
|
14
|
+
function runLocal() {
|
|
15
|
+
const script = fileURLToPath(new URL('../sh/lhci-local.sh', import.meta.url));
|
|
16
|
+
const env = { ...process.env, OFFICINA_BIN: process.argv[1] };
|
|
17
|
+
return spawnSync('bash', [script], { stdio: 'inherit', env }).status ?? 1;
|
|
18
|
+
}
|
|
19
|
+
export function main(args = []) {
|
|
20
|
+
if (args.includes('--local'))
|
|
21
|
+
return runLocal();
|
|
22
|
+
const rc = JSON.parse(readFileSync(RC_FILE, 'utf8'));
|
|
23
|
+
const routes = auditRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR));
|
|
24
|
+
const resolved = lighthouseConfig(rc, routes, process.env);
|
|
25
|
+
console.log(`\nLighthouse CI — ${routes.length} rotta/e derivate da ${PAGES_DIR}\n`);
|
|
26
|
+
for (const url of resolved.ci.collect.url ?? [])
|
|
27
|
+
console.log(` ${url}`);
|
|
28
|
+
console.log();
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), 'lhci-'));
|
|
30
|
+
const config = join(dir, 'lighthouserc.json');
|
|
31
|
+
writeFileSync(config, JSON.stringify(resolved, null, 2));
|
|
32
|
+
const { status } = spawnSync('pnpm', ['dlx', '@lhci/cli', 'autorun', `--config=${config}`], { stdio: 'inherit' });
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
return status ?? 1;
|
|
35
|
+
}
|
|
36
|
+
if (import.meta.main)
|
|
37
|
+
process.exit(main(process.argv.slice(2)));
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// L'unico controllo che vede cosa serve davvero il bordo: i test del progetto fissano solo vercel.json.
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { expectedRoutes, readPageFiles, smokeRoutes } from '../lib/routes.js';
|
|
6
|
+
import { runChecks, SECURITY_HEADERS, waitForAlias, } from '../lib/smoke-production.js';
|
|
7
|
+
const PAGES_DIR = 'src/pages';
|
|
8
|
+
function printResults(results) {
|
|
9
|
+
for (const { check, status, detail } of results) {
|
|
10
|
+
if (status === 'pass')
|
|
11
|
+
console.log(`✓ ${check}`);
|
|
12
|
+
else if (status === 'skip')
|
|
13
|
+
console.log(`- ${check} (saltato: ${detail})`);
|
|
14
|
+
else
|
|
15
|
+
console.error(`✗ ${check} — ${detail}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function printFailures(failures) {
|
|
19
|
+
console.error(`\n✗ ${failures.length} controllo/i falliti:`);
|
|
20
|
+
console.error(`${failures.map(({ check, detail }) => ` - ${check} — ${detail}`).join('\n')}\n`);
|
|
21
|
+
console.error('La produzione è online e rotta. Torna indietro dalla dashboard di Vercel:');
|
|
22
|
+
console.error('Deployments → l’ultimo deployment di produzione sano → Promote to Production.\n');
|
|
23
|
+
}
|
|
24
|
+
export async function main(args = []) {
|
|
25
|
+
const { siteUrl, smoke = {} } = await loadConfig(process.cwd());
|
|
26
|
+
const [url] = args;
|
|
27
|
+
if (siteUrl === undefined) {
|
|
28
|
+
console.error('\n✗ officina.config.ts non dichiara siteUrl: il controllo dell’host canonico non ha con cosa confrontarsi.\n');
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
if (url === undefined && new URL(siteUrl).host === 'example.com') {
|
|
32
|
+
console.error('\n✗ siteUrl è ancora il segnaposto del template: passa un URL, pnpm smoke:prod https://…\n');
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
// L'apice, non l'URL *.vercel.app che stampa `vercel deploy`: lì la regola `has: host` di vercel.json mette noindex.
|
|
36
|
+
const baseUrl = (url ?? siteUrl).replace(/\/+$/, '');
|
|
37
|
+
const context = {
|
|
38
|
+
get: (target) => fetch(target, { redirect: 'manual' }),
|
|
39
|
+
baseUrl,
|
|
40
|
+
siteUrl,
|
|
41
|
+
securityHeaders: smoke.securityHeaders ?? SECURITY_HEADERS,
|
|
42
|
+
};
|
|
43
|
+
const pages = smokeRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR), smoke.nonHtmlRoutes);
|
|
44
|
+
console.log(`\nSmoke di produzione — ${baseUrl}\n`);
|
|
45
|
+
await waitForAlias(context, (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
46
|
+
const results = await runChecks(context, pages, smoke.checks);
|
|
47
|
+
printResults(results);
|
|
48
|
+
const failures = results.filter(({ status }) => status === 'fail');
|
|
49
|
+
if (failures.length > 0) {
|
|
50
|
+
printFailures(failures);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
console.log(`\n✓ Tutti i controlli passati su ${baseUrl}.\n`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (import.meta.main)
|
|
57
|
+
process.exit(await main(process.argv.slice(2)));
|
|
@@ -3,19 +3,21 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
|
|
5
5
|
import { pinFindings } from '../lib/vercel-cli-pin.js';
|
|
6
|
-
const
|
|
6
|
+
const DEFAULT_WORKFLOW = '.github/workflows/deploy.yml';
|
|
7
7
|
// Il documento `latest` porta la sola versione pubblicata; quello del pacchetto intero pesa 8 MB.
|
|
8
8
|
const REGISTRY = 'https://registry.npmjs.org/vercel/latest';
|
|
9
9
|
const TIMEOUT_MS = 15_000;
|
|
10
|
-
|
|
10
|
+
// Il file è un argomento perché officina controlla il pin della propria action di deploy.
|
|
11
|
+
export async function main(args = []) {
|
|
12
|
+
const workflow = args[0] ?? DEFAULT_WORKFLOW;
|
|
11
13
|
const response = await fetch(REGISTRY, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
12
14
|
if (!response.ok) {
|
|
13
15
|
console.error(`✗ registry npm illeggibile: HTTP ${response.status}`);
|
|
14
16
|
return 1;
|
|
15
17
|
}
|
|
16
18
|
const { version } = (await response.json());
|
|
17
|
-
const findings = pinFindings(readFileSync(
|
|
18
|
-
path:
|
|
19
|
+
const findings = pinFindings(readFileSync(workflow, 'utf8'), version).map((hit) => ({
|
|
20
|
+
path: workflow,
|
|
19
21
|
...hit,
|
|
20
22
|
severity: 'error',
|
|
21
23
|
}));
|
|
@@ -23,8 +25,8 @@ export async function main() {
|
|
|
23
25
|
printFindings(findings, cliOptions([]).format);
|
|
24
26
|
console.log(findings.length === 0
|
|
25
27
|
? ' Il pin è sulla major pubblicata.\n'
|
|
26
|
-
: `\n Alza il pin in ${
|
|
28
|
+
: `\n Alza il pin in ${workflow} e allinea la CLI locale alla stessa major.\n`);
|
|
27
29
|
return exitCode(findings, false);
|
|
28
30
|
}
|
|
29
31
|
if (import.meta.main)
|
|
30
|
-
process.exit(await main());
|
|
32
|
+
process.exit(await main(process.argv.slice(2)));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function collectionGenerator(plop: any): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function componentGenerator(plop: any): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { loadConfig } from '../lib/config.js';
|
|
4
|
+
const isMissingSharp = (error) => error instanceof Error && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND' && error.message.includes('sharp');
|
|
5
|
+
export async function main() {
|
|
6
|
+
const { icons } = await loadConfig(process.cwd());
|
|
7
|
+
if (icons === undefined) {
|
|
8
|
+
console.error('\n✗ officina.config.ts non dichiara icons.background: è il colore di fondo delle icone.\n');
|
|
9
|
+
return 1;
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
// Caricato qui e non in testa: sharp è del progetto, e chi non genera icone non lo deve avere.
|
|
13
|
+
const { writeIcons } = await import('../lib/icons.js');
|
|
14
|
+
for (const { path, bytes } of await writeIcons(process.cwd(), icons.background)) {
|
|
15
|
+
console.log(`${path} ${(bytes / 1024).toFixed(1)} KB`);
|
|
16
|
+
}
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (!isMissingSharp(error))
|
|
21
|
+
throw error;
|
|
22
|
+
console.error('\n✗ sharp non è fra le dipendenze del progetto: `officina gen icons` lo usa per disegnare i PNG.\n');
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (import.meta.main)
|
|
27
|
+
process.exit(await main());
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isValidIdentifier(camel: any): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function assertInjectable({ root, camel, kebab }: {
|
|
2
|
+
camel: any;
|
|
3
|
+
kebab: any;
|
|
4
|
+
root: any;
|
|
5
|
+
}): void;
|
|
6
|
+
export declare function injectCollection({ root, camel, kebab, document, sections }: {
|
|
7
|
+
camel: any;
|
|
8
|
+
document: any;
|
|
9
|
+
kebab: any;
|
|
10
|
+
root: any;
|
|
11
|
+
sections?: boolean | undefined;
|
|
12
|
+
}): void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function assertSectionInjectable({ root, collection, camel, kebab, pascal, image }: {
|
|
2
|
+
camel: any;
|
|
3
|
+
collection: any;
|
|
4
|
+
image?: boolean | undefined;
|
|
5
|
+
kebab: any;
|
|
6
|
+
pascal: any;
|
|
7
|
+
root: any;
|
|
8
|
+
}): void;
|
|
9
|
+
export declare function injectSection({ root, collection, camel, kebab, pascal, image }: {
|
|
10
|
+
camel: any;
|
|
11
|
+
collection: any;
|
|
12
|
+
image?: boolean | undefined;
|
|
13
|
+
kebab: any;
|
|
14
|
+
pascal: any;
|
|
15
|
+
root: any;
|
|
16
|
+
}): void;
|