@elia97/officina 0.3.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 +55 -4
- package/dist/checks/analytics.js +5 -5
- package/dist/checks/bundle.js +8 -8
- package/dist/checks/doctor.d.ts +1 -1
- package/dist/checks/doctor.js +45 -8
- package/dist/checks/lighthouse.js +1 -1
- package/dist/checks/smoke.js +8 -8
- package/dist/checks/vercel-cli.d.ts +1 -1
- package/dist/checks/vercel-cli.js +8 -6
- package/dist/lib/alignment.d.ts +23 -0
- package/dist/lib/alignment.js +166 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.js +2 -1
- package/dist/lib/routes.js +3 -3
- package/dist/lib/smoke-production.js +17 -17
- package/dist/sh/lhci-local.sh +10 -10
- package/package.json +4 -2
- package/presets/biome.base.json +100 -0
- package/presets/lefthook.yml +11 -0
package/README.md
CHANGED
|
@@ -18,7 +18,6 @@ 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
|
-
"check:vercel-cli": "officina check vercel-cli",
|
|
22
21
|
"perf:bundle": "officina check bundle",
|
|
23
22
|
"smoke:prod": "officina check smoke",
|
|
24
23
|
"analytics:verify": "officina check analytics",
|
|
@@ -43,9 +42,9 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
|
|
|
43
42
|
|---|---|---|
|
|
44
43
|
| `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages` |
|
|
45
44
|
| `check smoke [url]` | la produzione viva: pagine, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
|
|
46
|
-
| `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi di
|
|
45
|
+
| `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
|
|
47
46
|
| `check lighthouse [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; `--local` fa build, server e Chrome da sé | `src/pages` |
|
|
48
|
-
| `gen icons` | le icone del manifest, disegnate
|
|
47
|
+
| `gen icons` | le icone del manifest, disegnate dal favicon SVG in `public/` del progetto | — |
|
|
49
48
|
|
|
50
49
|
Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
|
|
51
50
|
|
|
@@ -69,6 +68,58 @@ Ogni voce è facoltativa, tranne `siteUrl` per `check smoke` e `icons.background
|
|
|
69
68
|
|
|
70
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.
|
|
71
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.
|
|
122
|
+
|
|
72
123
|
## Sviluppo
|
|
73
124
|
|
|
74
125
|
```sh
|
|
@@ -91,7 +142,7 @@ pnpm publish # prepublishOnly lancia typecheck, test e build
|
|
|
91
142
|
|
|
92
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.
|
|
93
144
|
|
|
94
|
-
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.
|
|
95
146
|
|
|
96
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)`.
|
|
97
148
|
|
package/dist/checks/analytics.js
CHANGED
|
@@ -18,30 +18,30 @@ function reportCoverage(coverage) {
|
|
|
18
18
|
}
|
|
19
19
|
const missing = coverage.filter(({ covered }) => !covered);
|
|
20
20
|
if (missing.length > 0) {
|
|
21
|
-
console.error(`\n✗ ${missing.length}
|
|
21
|
+
console.error(`\n✗ ${missing.length} evento/i che il sito emette e nessun trigger ascolta.`);
|
|
22
22
|
return 1;
|
|
23
23
|
}
|
|
24
|
-
console.log('\n✓
|
|
24
|
+
console.log('\n✓ Ogni evento che il sito emette ha un trigger.');
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
export async function main(args = []) {
|
|
28
28
|
const { analytics = {} } = await loadConfig(process.cwd());
|
|
29
29
|
const gtmId = resolveGtmId(args);
|
|
30
30
|
if (gtmId === null) {
|
|
31
|
-
console.log('
|
|
31
|
+
console.log('Nessun container GTM da controllare: passane uno come argomento o imposta PUBLIC_GTM_ID.');
|
|
32
32
|
return 0;
|
|
33
33
|
}
|
|
34
34
|
const response = await fetch(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`, {
|
|
35
35
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
36
36
|
});
|
|
37
37
|
if (!response.ok) {
|
|
38
|
-
console.error(`✗ container ${gtmId}
|
|
38
|
+
console.error(`✗ container ${gtmId} illeggibile: HTTP ${response.status}`);
|
|
39
39
|
return 1;
|
|
40
40
|
}
|
|
41
41
|
const container = parseContainerData(await response.text());
|
|
42
42
|
const source = readFileSync(analytics.linkTracking ?? LINK_TRACKING, 'utf8');
|
|
43
43
|
const coverage = coverageOf(extractLinkEvents(source), extractTriggers(container));
|
|
44
|
-
console.log(`\nGTM ${gtmId} —
|
|
44
|
+
console.log(`\nGTM ${gtmId} — ID di misurazione: ${googleTagIds(container).join(', ') || '—'}\n`);
|
|
45
45
|
return reportCoverage(coverage);
|
|
46
46
|
}
|
|
47
47
|
if (import.meta.main)
|
package/dist/checks/bundle.js
CHANGED
|
@@ -48,18 +48,18 @@ function overBudgetFailures(pages) {
|
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
function printPages(pages, width) {
|
|
51
|
-
console.log('\
|
|
52
|
-
console.log(`${'
|
|
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
53
|
for (const page of pages) {
|
|
54
54
|
const deferred = page.deferredGzip > 0 ? gz(page.deferredGzip) : '—';
|
|
55
55
|
const line = `${page.route.padEnd(width)} ${gz(page.gzip).padStart(9)} ${gz(page.budget.maxGzip).padStart(9)} ${deferred.padStart(9)}`;
|
|
56
56
|
console.log(page.gzip > page.budget.maxGzip ? `${line} ✗` : line);
|
|
57
57
|
}
|
|
58
|
-
console.log('\
|
|
58
|
+
console.log('\nDIFFERITO = raggiungibile solo da `await import()` (caricato dopo il primo paint, dietro una guardia a runtime).');
|
|
59
59
|
}
|
|
60
60
|
function printStylesheets(stylesheets, cssMaxGzip, failed, width) {
|
|
61
61
|
const worst = heaviestStylesheet(stylesheets);
|
|
62
|
-
console.log(`\nCSS (
|
|
62
|
+
console.log(`\nCSS (blocca il rendering: il foglio più pesante che una rotta collega)`);
|
|
63
63
|
for (const sheet of [...stylesheets].sort((a, b) => b.gzip - a.gzip)) {
|
|
64
64
|
const mark = sheet === worst && failed ? ' ✗' : '';
|
|
65
65
|
console.log(` ${sheet.file.padEnd(width - 2)} ${gz(sheet.gzip).padStart(9)} ${gz(cssMaxGzip).padStart(9)}${mark}`);
|
|
@@ -68,7 +68,7 @@ function printStylesheets(stylesheets, cssMaxGzip, failed, width) {
|
|
|
68
68
|
function printSsrNote(expected) {
|
|
69
69
|
if (expected.ssr.length === 0)
|
|
70
70
|
return;
|
|
71
|
-
console.log(`\
|
|
71
|
+
console.log(`\nNOTA ${expected.ssr.length} pagina/e con \`export const prerender = false\`, fuori da questo budget:`);
|
|
72
72
|
for (const file of expected.ssr)
|
|
73
73
|
console.log(` - ${file}`);
|
|
74
74
|
}
|
|
@@ -79,7 +79,7 @@ export async function main() {
|
|
|
79
79
|
const expected = expectedRoutes(readPageFiles(PAGES), PAGES);
|
|
80
80
|
const failures = missingRouteFailures(expected, pages.map((page) => page.route), DIST);
|
|
81
81
|
failures.push(...overBudgetFailures(pages));
|
|
82
|
-
const width = Math.max('
|
|
82
|
+
const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
|
|
83
83
|
printPages(pages, width);
|
|
84
84
|
const stylesheets = readStylesheets();
|
|
85
85
|
const cssFailure = cssBudgetFailure(stylesheets, cssMaxGzip);
|
|
@@ -88,10 +88,10 @@ export async function main() {
|
|
|
88
88
|
printStylesheets(stylesheets, cssMaxGzip, cssFailure !== null, width);
|
|
89
89
|
printSsrNote(expected);
|
|
90
90
|
if (failures.length > 0) {
|
|
91
|
-
console.error(`\n✗
|
|
91
|
+
console.error(`\n✗ Budget di bundle:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
|
|
92
92
|
return 1;
|
|
93
93
|
}
|
|
94
|
-
console.log('\n✓
|
|
94
|
+
console.log('\n✓ Budget di bundle rispettato su ogni rotta attesa.\n');
|
|
95
95
|
return 0;
|
|
96
96
|
}
|
|
97
97
|
if (import.meta.main)
|
package/dist/checks/doctor.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
export declare function main(): number
|
|
2
|
+
export declare function main(): Promise<number>;
|
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());
|
|
@@ -22,7 +22,7 @@ export function main(args = []) {
|
|
|
22
22
|
const rc = JSON.parse(readFileSync(RC_FILE, 'utf8'));
|
|
23
23
|
const routes = auditRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR));
|
|
24
24
|
const resolved = lighthouseConfig(rc, routes, process.env);
|
|
25
|
-
console.log(`\nLighthouse CI — ${routes.length}
|
|
25
|
+
console.log(`\nLighthouse CI — ${routes.length} rotta/e derivate da ${PAGES_DIR}\n`);
|
|
26
26
|
for (const url of resolved.ci.collect.url ?? [])
|
|
27
27
|
console.log(` ${url}`);
|
|
28
28
|
console.log();
|
package/dist/checks/smoke.js
CHANGED
|
@@ -10,26 +10,26 @@ function printResults(results) {
|
|
|
10
10
|
if (status === 'pass')
|
|
11
11
|
console.log(`✓ ${check}`);
|
|
12
12
|
else if (status === 'skip')
|
|
13
|
-
console.log(`- ${check} (
|
|
13
|
+
console.log(`- ${check} (saltato: ${detail})`);
|
|
14
14
|
else
|
|
15
15
|
console.error(`✗ ${check} — ${detail}`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
function printFailures(failures) {
|
|
19
|
-
console.error(`\n✗ ${failures.length}
|
|
19
|
+
console.error(`\n✗ ${failures.length} controllo/i falliti:`);
|
|
20
20
|
console.error(`${failures.map(({ check, detail }) => ` - ${check} — ${detail}`).join('\n')}\n`);
|
|
21
|
-
console.error('
|
|
22
|
-
console.error('Deployments →
|
|
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
23
|
}
|
|
24
24
|
export async function main(args = []) {
|
|
25
25
|
const { siteUrl, smoke = {} } = await loadConfig(process.cwd());
|
|
26
26
|
const [url] = args;
|
|
27
27
|
if (siteUrl === undefined) {
|
|
28
|
-
console.error('\n✗ officina.config.ts
|
|
28
|
+
console.error('\n✗ officina.config.ts non dichiara siteUrl: il controllo dell’host canonico non ha con cosa confrontarsi.\n');
|
|
29
29
|
return 1;
|
|
30
30
|
}
|
|
31
31
|
if (url === undefined && new URL(siteUrl).host === 'example.com') {
|
|
32
|
-
console.error('\n✗ siteUrl
|
|
32
|
+
console.error('\n✗ siteUrl è ancora il segnaposto del template: passa un URL, pnpm smoke:prod https://…\n');
|
|
33
33
|
return 1;
|
|
34
34
|
}
|
|
35
35
|
// L'apice, non l'URL *.vercel.app che stampa `vercel deploy`: lì la regola `has: host` di vercel.json mette noindex.
|
|
@@ -41,7 +41,7 @@ export async function main(args = []) {
|
|
|
41
41
|
securityHeaders: smoke.securityHeaders ?? SECURITY_HEADERS,
|
|
42
42
|
};
|
|
43
43
|
const pages = smokeRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR), smoke.nonHtmlRoutes);
|
|
44
|
-
console.log(`\
|
|
44
|
+
console.log(`\nSmoke di produzione — ${baseUrl}\n`);
|
|
45
45
|
await waitForAlias(context, (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
46
46
|
const results = await runChecks(context, pages, smoke.checks);
|
|
47
47
|
printResults(results);
|
|
@@ -50,7 +50,7 @@ export async function main(args = []) {
|
|
|
50
50
|
printFailures(failures);
|
|
51
51
|
return 1;
|
|
52
52
|
}
|
|
53
|
-
console.log(`\n✓
|
|
53
|
+
console.log(`\n✓ Tutti i controlli passati su ${baseUrl}.\n`);
|
|
54
54
|
return 0;
|
|
55
55
|
}
|
|
56
56
|
if (import.meta.main)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
export declare function main(): Promise<number>;
|
|
2
|
+
export declare function main(args?: string[]): Promise<number>;
|
|
@@ -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,23 @@
|
|
|
1
|
+
import type { OfficinaConfig } from './config.ts';
|
|
2
|
+
import type { ContractGap } from './contract.ts';
|
|
3
|
+
export interface ProjectFiles {
|
|
4
|
+
/** Tracciati e non tracciati, senza gli ignorati: un file locale fuori da git non è un residuo. */
|
|
5
|
+
paths: readonly string[];
|
|
6
|
+
read: (path: string) => string | undefined;
|
|
7
|
+
}
|
|
8
|
+
export interface Manifest {
|
|
9
|
+
scripts?: Record<string, string>;
|
|
10
|
+
dependencies?: Record<string, string>;
|
|
11
|
+
devDependencies?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
export declare const EXPECTED_SCRIPTS: Readonly<Record<string, string>>;
|
|
14
|
+
export declare const CI_STEPS: readonly string[];
|
|
15
|
+
export declare function scriptGaps({ scripts }: Manifest): ContractGap[];
|
|
16
|
+
export declare function dependencyGaps({ dependencies, devDependencies }: Manifest): ContractGap[];
|
|
17
|
+
export declare function leftoverGaps({ paths, read }: ProjectFiles): ContractGap[];
|
|
18
|
+
export declare const BIOME_PRESET = "@elia97/officina/biome";
|
|
19
|
+
export declare const LEFTHOOK_PRESET = "node_modules/@elia97/officina/presets/lefthook.yml";
|
|
20
|
+
export declare function presetGaps({ read }: ProjectFiles): ContractGap[];
|
|
21
|
+
export declare function workflowGaps({ read }: ProjectFiles): ContractGap[];
|
|
22
|
+
/** `loaded` è la configurazione letta, l'errore che il suo caricamento ha sollevato, o `undefined` se il file non c'è. */
|
|
23
|
+
export declare function configGaps(loaded: OfficinaConfig | Error | undefined): ContractGap[];
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
export const EXPECTED_SCRIPTS = {
|
|
2
|
+
'check:comments': 'officina check comments',
|
|
3
|
+
'check:language': 'officina check language',
|
|
4
|
+
'check:routes': 'officina check routes',
|
|
5
|
+
'check:roadmap': 'officina check roadmap',
|
|
6
|
+
'check:placeholders': 'officina check placeholders',
|
|
7
|
+
'perf:bundle': 'officina check bundle',
|
|
8
|
+
'smoke:prod': 'officina check smoke',
|
|
9
|
+
'analytics:verify': 'officina check analytics',
|
|
10
|
+
lhci: 'officina check lighthouse',
|
|
11
|
+
'lhci:local': 'officina check lighthouse --local',
|
|
12
|
+
doctor: 'officina doctor',
|
|
13
|
+
gen: 'officina gen',
|
|
14
|
+
'gen:section': 'officina gen section',
|
|
15
|
+
'gen:page': 'officina gen page',
|
|
16
|
+
'gen:component': 'officina gen component',
|
|
17
|
+
'gen:collection': 'officina gen collection',
|
|
18
|
+
'gen:icons': 'officina gen icons',
|
|
19
|
+
};
|
|
20
|
+
export const CI_STEPS = [
|
|
21
|
+
'check:language',
|
|
22
|
+
'check:routes',
|
|
23
|
+
'check:roadmap',
|
|
24
|
+
'check:comments',
|
|
25
|
+
'doctor',
|
|
26
|
+
];
|
|
27
|
+
const MANIFEST = 'package.json';
|
|
28
|
+
export function scriptGaps({ scripts = {} }) {
|
|
29
|
+
const gaps = [];
|
|
30
|
+
for (const [name, expected] of Object.entries(EXPECTED_SCRIPTS)) {
|
|
31
|
+
const actual = scripts[name];
|
|
32
|
+
if (actual === undefined)
|
|
33
|
+
gaps.push({ path: MANIFEST, message: `script \`${name}\` assente: atteso \`${expected}\`` });
|
|
34
|
+
else if (!actual.startsWith(expected))
|
|
35
|
+
gaps.push({ path: MANIFEST, message: `script \`${name}\` è \`${actual}\`: atteso \`${expected}\`` });
|
|
36
|
+
}
|
|
37
|
+
const ci = scripts.ci ?? '';
|
|
38
|
+
for (const step of CI_STEPS) {
|
|
39
|
+
if (!ci.includes(`pnpm run ${step}`))
|
|
40
|
+
gaps.push({ path: MANIFEST, message: `script \`ci\` non lancia \`${step}\`` });
|
|
41
|
+
}
|
|
42
|
+
return gaps;
|
|
43
|
+
}
|
|
44
|
+
const RETIRED_DEPENDENCIES = ['plop', 'ts-morph'];
|
|
45
|
+
export function dependencyGaps({ dependencies = {}, devDependencies = {} }) {
|
|
46
|
+
const declared = { ...dependencies, ...devDependencies };
|
|
47
|
+
const gaps = RETIRED_DEPENDENCIES.filter((name) => name in declared).map((name) => ({
|
|
48
|
+
path: MANIFEST,
|
|
49
|
+
message: `\`${name}\` non serve più al progetto: è una dipendenza di officina`,
|
|
50
|
+
}));
|
|
51
|
+
if (!('@elia97/officina' in devDependencies)) {
|
|
52
|
+
gaps.push({ path: MANIFEST, message: '`@elia97/officina` non è fra le devDependencies' });
|
|
53
|
+
}
|
|
54
|
+
return gaps;
|
|
55
|
+
}
|
|
56
|
+
const FROM_PLUGIN = 'arriva dal plugin `metodo`';
|
|
57
|
+
const FROM_PACKAGE = 'arriva da officina';
|
|
58
|
+
const OUT_OF_REPOSITORY = 'sta nella cartella del progetto, fuori dal repository';
|
|
59
|
+
// Una cartella si nomina con la barra finale: corrisponde a qualunque file sotto di lei.
|
|
60
|
+
const LEFTOVERS = [
|
|
61
|
+
{ path: '.claude/agents/', reason: FROM_PLUGIN },
|
|
62
|
+
{ path: '.claude/commands/', reason: FROM_PLUGIN },
|
|
63
|
+
{ path: '.claude/scripts/', reason: FROM_PLUGIN },
|
|
64
|
+
{ path: '.claude/hooks/', reason: FROM_PLUGIN },
|
|
65
|
+
{ path: 'HOW_TO_USE.md', reason: FROM_PLUGIN },
|
|
66
|
+
{ path: 'docs/milestone-templates/', reason: FROM_PLUGIN },
|
|
67
|
+
{ path: 'docs/proposal-templates/', reason: 'i modelli commerciali stanno nel sistema, non nel repository' },
|
|
68
|
+
{ path: 'plopfile.mjs', reason: FROM_PACKAGE },
|
|
69
|
+
{ path: 'scripts/gen/', reason: FROM_PACKAGE },
|
|
70
|
+
{ path: 'scripts/templates/', reason: FROM_PACKAGE },
|
|
71
|
+
{ path: 'scripts/check-comments.mjs', reason: FROM_PACKAGE },
|
|
72
|
+
{ path: 'scripts/check-language.mjs', reason: FROM_PACKAGE },
|
|
73
|
+
{ path: 'scripts/bundle-budget.mjs', reason: FROM_PACKAGE },
|
|
74
|
+
{ path: 'scripts/smoke-production.mjs', reason: FROM_PACKAGE },
|
|
75
|
+
{ path: 'scripts/lighthouse.mjs', reason: FROM_PACKAGE },
|
|
76
|
+
{ path: 'scripts/verify-analytics-coverage.mjs', reason: FROM_PACKAGE },
|
|
77
|
+
{ path: 'scripts/gen-icons.mjs', reason: FROM_PACKAGE },
|
|
78
|
+
{ path: 'scripts/gen-icons.ts', reason: FROM_PACKAGE },
|
|
79
|
+
{ path: 'scripts/lhci-local.sh', reason: FROM_PACKAGE },
|
|
80
|
+
{ path: 'scripts/lib/bundle-budget.ts', reason: FROM_PACKAGE },
|
|
81
|
+
{ path: 'scripts/lib/smoke-production.ts', reason: FROM_PACKAGE },
|
|
82
|
+
{ path: 'scripts/lib/routes.ts', reason: FROM_PACKAGE },
|
|
83
|
+
{ path: 'scripts/lib/check-comments.ts', reason: FROM_PACKAGE },
|
|
84
|
+
{ path: 'scripts/lib/check-language.ts', reason: FROM_PACKAGE },
|
|
85
|
+
{ path: 'scripts/lib/gtm-container.ts', reason: FROM_PACKAGE },
|
|
86
|
+
{ path: 'scripts/lib/analytics-coverage.ts', reason: FROM_PACKAGE },
|
|
87
|
+
{ path: 'scripts/lib/icons.ts', reason: FROM_PACKAGE },
|
|
88
|
+
{ path: '.github/workflows/vercel-cli.yml', reason: "il pin di Vercel sta nell'action di deploy di officina" },
|
|
89
|
+
{ path: 'docs/PROJECT.md', reason: OUT_OF_REPOSITORY },
|
|
90
|
+
{ path: 'docs/DECISIONS.md', reason: OUT_OF_REPOSITORY },
|
|
91
|
+
{ path: 'docs/RISKS.md', reason: OUT_OF_REPOSITORY },
|
|
92
|
+
{ path: 'docs/INPUTS.md', reason: OUT_OF_REPOSITORY },
|
|
93
|
+
{ path: 'docs/TASK-CONTEXT.md', reason: OUT_OF_REPOSITORY },
|
|
94
|
+
{ path: 'docs/ESTIMATE.md', reason: OUT_OF_REPOSITORY },
|
|
95
|
+
];
|
|
96
|
+
const holds = (paths, target) => target.endsWith('/') ? paths.some((path) => path.startsWith(target)) : paths.includes(target);
|
|
97
|
+
export function leftoverGaps({ paths, read }) {
|
|
98
|
+
const gaps = LEFTOVERS.filter(({ path }) => holds(paths, path)).map(({ path, reason }) => ({
|
|
99
|
+
path,
|
|
100
|
+
message: `residuo: ${reason}`,
|
|
101
|
+
}));
|
|
102
|
+
if (read('.claude/settings.json')?.includes('"hooks"')) {
|
|
103
|
+
gaps.push({ path: '.claude/settings.json', message: `blocco \`hooks\` residuo: il guard ${FROM_PLUGIN}` });
|
|
104
|
+
}
|
|
105
|
+
if (read('.mcp.json')?.includes('astro-docs')) {
|
|
106
|
+
gaps.push({ path: '.mcp.json', message: `server \`astro-docs\` residuo: ${FROM_PLUGIN}` });
|
|
107
|
+
}
|
|
108
|
+
return gaps;
|
|
109
|
+
}
|
|
110
|
+
export const BIOME_PRESET = '@elia97/officina/biome';
|
|
111
|
+
export const LEFTHOOK_PRESET = 'node_modules/@elia97/officina/presets/lefthook.yml';
|
|
112
|
+
export function presetGaps({ read }) {
|
|
113
|
+
const gaps = [];
|
|
114
|
+
if (!read('biome.json')?.includes(BIOME_PRESET)) {
|
|
115
|
+
gaps.push({ path: 'biome.json', message: `non estende \`${BIOME_PRESET}\`` });
|
|
116
|
+
}
|
|
117
|
+
if (!read('lefthook.yml')?.includes(LEFTHOOK_PRESET)) {
|
|
118
|
+
gaps.push({ path: 'lefthook.yml', message: `non estende \`${LEFTHOOK_PRESET}\`` });
|
|
119
|
+
}
|
|
120
|
+
return gaps;
|
|
121
|
+
}
|
|
122
|
+
const ACTIONS = [
|
|
123
|
+
{ workflow: '.github/workflows/ci.yml', action: 'ci' },
|
|
124
|
+
{ workflow: '.github/workflows/ci.yml', action: 'review' },
|
|
125
|
+
{ workflow: '.github/workflows/deploy.yml', action: 'deploy' },
|
|
126
|
+
{ workflow: '.github/workflows/lighthouse.yml', action: 'lighthouse' },
|
|
127
|
+
];
|
|
128
|
+
// Un tag di versione o uno SHA intero: `@main` farebbe girare in produzione passi mai collaudati.
|
|
129
|
+
const PINNED_REF = /^(v\d+\.\d+\.\d+|[0-9a-f]{40})$/;
|
|
130
|
+
function actionGap(source, action) {
|
|
131
|
+
const name = `Elia97/officina/actions/${action}`;
|
|
132
|
+
if (source === undefined)
|
|
133
|
+
return `manca: i suoi passi arrivano da \`${name}\``;
|
|
134
|
+
const ref = new RegExp(`${name}@(\\S+)`).exec(source)?.[1];
|
|
135
|
+
if (ref === undefined)
|
|
136
|
+
return `non usa \`${name}\``;
|
|
137
|
+
return PINNED_REF.test(ref)
|
|
138
|
+
? undefined
|
|
139
|
+
: `\`${name}@${ref}\`: il riferimento va fissato a un tag di versione o a uno SHA`;
|
|
140
|
+
}
|
|
141
|
+
export function workflowGaps({ read }) {
|
|
142
|
+
return ACTIONS.flatMap(({ workflow, action }) => {
|
|
143
|
+
const message = actionGap(read(workflow), action);
|
|
144
|
+
return message === undefined ? [] : [{ path: workflow, message }];
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const CONFIG = 'officina.config.ts';
|
|
148
|
+
function siteUrlGap(siteUrl) {
|
|
149
|
+
if (siteUrl === undefined)
|
|
150
|
+
return '`siteUrl` assente: `check smoke` non ha un host canonico';
|
|
151
|
+
if (!URL.canParse(siteUrl))
|
|
152
|
+
return `\`siteUrl\` non è un URL: \`${siteUrl}\``;
|
|
153
|
+
return siteUrl.endsWith('/') ? `\`siteUrl\` finisce con una barra: \`${siteUrl}\`` : undefined;
|
|
154
|
+
}
|
|
155
|
+
/** `loaded` è la configurazione letta, l'errore che il suo caricamento ha sollevato, o `undefined` se il file non c'è. */
|
|
156
|
+
export function configGaps(loaded) {
|
|
157
|
+
if (loaded === undefined)
|
|
158
|
+
return [{ path: CONFIG, message: 'manca: i valori del progetto per officina stanno qui' }];
|
|
159
|
+
if (loaded instanceof Error)
|
|
160
|
+
return [{ path: CONFIG, message: `non si carica: ${loaded.message}` }];
|
|
161
|
+
const messages = [
|
|
162
|
+
siteUrlGap(loaded.siteUrl),
|
|
163
|
+
loaded.icons === undefined ? '`icons.background` assente: `gen icons` non ha un colore di fondo' : undefined,
|
|
164
|
+
];
|
|
165
|
+
return messages.filter((message) => message !== undefined).map((message) => ({ path: CONFIG, message }));
|
|
166
|
+
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -21,4 +21,5 @@ export interface OfficinaConfig {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
export declare const defineConfig: (config: OfficinaConfig) => OfficinaConfig;
|
|
24
|
+
export declare const findConfigFile: (root: string) => string | undefined;
|
|
24
25
|
export declare function loadConfig(root: string): Promise<OfficinaConfig>;
|
package/dist/lib/config.js
CHANGED
|
@@ -12,8 +12,9 @@ import { pathToFileURL } from 'node:url';
|
|
|
12
12
|
const CONFIG_FILES = ['officina.config.ts', 'officina.config.mjs', 'officina.config.js'];
|
|
13
13
|
export const defineConfig = (config) => config;
|
|
14
14
|
// Fuori da node_modules Node toglie i tipi da solo: il file del progetto può essere TypeScript.
|
|
15
|
+
export const findConfigFile = (root) => CONFIG_FILES.map((name) => join(root, name)).find((path) => existsSync(path));
|
|
15
16
|
export async function loadConfig(root) {
|
|
16
|
-
const file =
|
|
17
|
+
const file = findConfigFile(root);
|
|
17
18
|
if (file === undefined)
|
|
18
19
|
return {};
|
|
19
20
|
const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
|
package/dist/lib/routes.js
CHANGED
|
@@ -66,18 +66,18 @@ export function expectedRoutes(pages, pagesDir) {
|
|
|
66
66
|
/** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
|
|
67
67
|
export function missingRouteFailures(expected, emitted, dist) {
|
|
68
68
|
if (emitted.length === 0) {
|
|
69
|
-
return [`${dist}
|
|
69
|
+
return [`${dist} non contiene nessun .html: nessuna rotta misurata, quindi i budget per rotta non affermano niente`];
|
|
70
70
|
}
|
|
71
71
|
const failures = [];
|
|
72
72
|
const routes = new Set(emitted);
|
|
73
73
|
for (const { route, file } of expected.exact) {
|
|
74
74
|
if (!routes.has(route))
|
|
75
|
-
failures.push(`
|
|
75
|
+
failures.push(`rotta mancante ${route}: ${file} è prerenderizzata ma non ha emesso HTML`);
|
|
76
76
|
}
|
|
77
77
|
for (const { pattern, label, file } of expected.patterns) {
|
|
78
78
|
if (emitted.some((route) => pattern.test(route)))
|
|
79
79
|
continue;
|
|
80
|
-
failures.push(`
|
|
80
|
+
failures.push(`rotta mancante ${label}: ${file} è prerenderizzata ma getStaticPaths non ha emesso niente`);
|
|
81
81
|
}
|
|
82
82
|
return failures;
|
|
83
83
|
}
|
|
@@ -33,9 +33,9 @@ export async function checkPages({ get, baseUrl }, pages) {
|
|
|
33
33
|
const response = await get(`${baseUrl}${path}`);
|
|
34
34
|
const contentType = response.headers.get('content-type') ?? '';
|
|
35
35
|
if (response.status !== 200)
|
|
36
|
-
results.push(fail(check, `
|
|
36
|
+
results.push(fail(check, `atteso 200, ricevuto ${response.status}`));
|
|
37
37
|
else if (!contentType.includes(type))
|
|
38
|
-
results.push(fail(check, `
|
|
38
|
+
results.push(fail(check, `atteso un content-type ${type}, ricevuto "${contentType}"`));
|
|
39
39
|
else
|
|
40
40
|
results.push(pass(check));
|
|
41
41
|
}
|
|
@@ -51,29 +51,29 @@ export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
|
|
|
51
51
|
response = await get(baseUrl);
|
|
52
52
|
}
|
|
53
53
|
catch (error) {
|
|
54
|
-
return [fail('
|
|
54
|
+
return [fail('header di sicurezza', messageOf(error))];
|
|
55
55
|
}
|
|
56
56
|
const results = Object.entries(securityHeaders).map(([header, expected]) => {
|
|
57
57
|
const check = `header ${header}`;
|
|
58
58
|
const value = response.headers.get(header);
|
|
59
59
|
if (value === null)
|
|
60
|
-
return fail(check, '
|
|
60
|
+
return fail(check, 'assente');
|
|
61
61
|
if (expected !== null && value !== expected)
|
|
62
|
-
return fail(check, `
|
|
62
|
+
return fail(check, `atteso "${expected}", ricevuto "${value}"`);
|
|
63
63
|
return pass(check);
|
|
64
64
|
});
|
|
65
65
|
// Il noindex `has: host = *.vercel.app` di vercel.json applicato al dominio vero farebbe sparire il sito da ogni indice.
|
|
66
|
-
const check = '
|
|
66
|
+
const check = 'nessun x-robots-tag sull’host di produzione';
|
|
67
67
|
const robots = response.headers.get('x-robots-tag');
|
|
68
|
-
results.push(robots === null ? pass(check) : fail(check, `
|
|
68
|
+
results.push(robots === null ? pass(check) : fail(check, `presente su ${baseUrl}: "${robots}"`));
|
|
69
69
|
return results;
|
|
70
70
|
}
|
|
71
71
|
export async function checkBotIdChallenge({ get, baseUrl }) {
|
|
72
|
-
const check = 'BotID
|
|
72
|
+
const check = 'challenge di BotID servita dalla stessa origine';
|
|
73
73
|
try {
|
|
74
74
|
const response = await get(`${baseUrl}${BOTID_CHALLENGE}`);
|
|
75
75
|
if (response.status !== 200)
|
|
76
|
-
return [fail(check, `
|
|
76
|
+
return [fail(check, `atteso 200 dal rewrite, ricevuto ${response.status}`)];
|
|
77
77
|
return [pass(check)];
|
|
78
78
|
}
|
|
79
79
|
catch (error) {
|
|
@@ -82,16 +82,16 @@ export async function checkBotIdChallenge({ get, baseUrl }) {
|
|
|
82
82
|
}
|
|
83
83
|
/** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
|
|
84
84
|
export async function checkCanonicalHost({ get, baseUrl, siteUrl }) {
|
|
85
|
-
const check = 'www →
|
|
85
|
+
const check = 'www → apice 308';
|
|
86
86
|
if (baseUrl !== siteUrl)
|
|
87
|
-
return [skip(check, `base
|
|
87
|
+
return [skip(check, `l’URL di base non è ${siteUrl}`)];
|
|
88
88
|
try {
|
|
89
89
|
const response = await get(`https://www.${new URL(siteUrl).host}/`);
|
|
90
90
|
const location = response.headers.get('location') ?? '';
|
|
91
91
|
if (response.status !== 308)
|
|
92
|
-
return [fail(check, `
|
|
92
|
+
return [fail(check, `atteso 308, ricevuto ${response.status}`)];
|
|
93
93
|
if (!location.startsWith(siteUrl))
|
|
94
|
-
return [fail(check, `location "${location}"
|
|
94
|
+
return [fail(check, `location "${location}" non punta a ${siteUrl}`)];
|
|
95
95
|
return [pass(check)];
|
|
96
96
|
}
|
|
97
97
|
catch (error) {
|
|
@@ -100,16 +100,16 @@ export async function checkCanonicalHost({ get, baseUrl, siteUrl }) {
|
|
|
100
100
|
}
|
|
101
101
|
export async function checkTrailingSlash({ get, baseUrl }, pages) {
|
|
102
102
|
const page = pages.find(({ path, type }) => type === 'text/html' && path !== '/');
|
|
103
|
-
const check = '
|
|
103
|
+
const check = 'barra finale → 308';
|
|
104
104
|
if (page === undefined)
|
|
105
|
-
return [skip(check, '
|
|
105
|
+
return [skip(check, 'nessuna pagina HTML oltre a / da sondare')];
|
|
106
106
|
try {
|
|
107
107
|
const response = await get(`${baseUrl}${page.path}/`);
|
|
108
108
|
if (response.status !== 308)
|
|
109
|
-
return [fail(check, `
|
|
109
|
+
return [fail(check, `atteso 308 su ${page.path}/, ricevuto ${response.status}`)];
|
|
110
110
|
const location = response.headers.get('location') ?? '';
|
|
111
111
|
if (!location.endsWith(page.path))
|
|
112
|
-
return [fail(check, `location "${location}"
|
|
112
|
+
return [fail(check, `location "${location}" non punta a ${page.path}`)];
|
|
113
113
|
return [pass(check)];
|
|
114
114
|
}
|
|
115
115
|
catch (error) {
|
package/dist/sh/lhci-local.sh
CHANGED
|
@@ -25,9 +25,9 @@ detect_chrome() {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
if ! CHROME_PATH="$(detect_chrome)"; then
|
|
28
|
-
echo "✗
|
|
29
|
-
echo "
|
|
30
|
-
echo "
|
|
28
|
+
echo "✗ nessun Chrome/Chromium per Linux utilizzabile." >&2
|
|
29
|
+
echo " Installane uno con: pnpm dlx playwright@latest install chromium" >&2
|
|
30
|
+
echo " Oppure indica il binario: CHROME_PATH=/percorso/di/chrome pnpm run lhci:local" >&2
|
|
31
31
|
exit 1
|
|
32
32
|
fi
|
|
33
33
|
export CHROME_PATH
|
|
@@ -44,17 +44,17 @@ cleanup() {
|
|
|
44
44
|
}
|
|
45
45
|
trap cleanup EXIT
|
|
46
46
|
|
|
47
|
-
echo "→
|
|
47
|
+
echo "→ build equivalente alla produzione in ${OUT_DIR}"
|
|
48
48
|
VERCEL_ENV=production pnpm build --outDir "${OUT_DIR}"
|
|
49
49
|
|
|
50
50
|
if ! grep -qx 'Allow: /' "${OUT_DIR}/client/robots.txt"; then
|
|
51
|
-
echo "✗ ${OUT_DIR}/client/robots.txt
|
|
51
|
+
echo "✗ ${OUT_DIR}/client/robots.txt non ha 'Allow: /': la build non equivale alla produzione." >&2
|
|
52
52
|
head -3 "${OUT_DIR}/client/robots.txt" >&2
|
|
53
53
|
exit 1
|
|
54
54
|
fi
|
|
55
55
|
echo "✓ robots.txt: Allow: /"
|
|
56
56
|
|
|
57
|
-
echo "→
|
|
57
|
+
echo "→ avvio del server statico su :${PORT}"
|
|
58
58
|
setsid pnpm dlx serve@14 "${OUT_DIR}/client" --listen "${PORT}" --no-clipboard >/dev/null 2>&1 &
|
|
59
59
|
SERVER_PID=$!
|
|
60
60
|
|
|
@@ -63,11 +63,11 @@ for _ in $(seq 1 60); do
|
|
|
63
63
|
sleep 1
|
|
64
64
|
done
|
|
65
65
|
if ! curl -fs -o /dev/null "http://localhost:${PORT}/"; then
|
|
66
|
-
echo "✗
|
|
67
|
-
echo "
|
|
66
|
+
echo "✗ il server non ha risposto su :${PORT} entro 60s." >&2
|
|
67
|
+
echo " Riprova su un’altra porta: LHCI_PORT=4400 pnpm run lhci:local" >&2
|
|
68
68
|
exit 1
|
|
69
69
|
fi
|
|
70
|
-
echo "✓ server
|
|
70
|
+
echo "✓ server pronto"
|
|
71
71
|
|
|
72
72
|
rm -rf "${REPORT_DIR}"
|
|
73
73
|
|
|
@@ -79,7 +79,7 @@ LH_CHROME_FLAGS='--no-sandbox --disable-dev-shm-usage' \
|
|
|
79
79
|
node "${OFFICINA_BIN}" check lighthouse
|
|
80
80
|
|
|
81
81
|
echo
|
|
82
|
-
echo "→
|
|
82
|
+
echo "→ punteggio per URL (mediana su ${LHCI_RUNS:-1} giro/i, come la calcola LHCI)"
|
|
83
83
|
node -e '
|
|
84
84
|
const fs = require("fs")
|
|
85
85
|
const dir = process.argv[1]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elia97/officina",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Gli strumenti dei progetti nati dai template di Elia: gate di qualità, generatori e verifiche di build e di produzione.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,13 +12,15 @@
|
|
|
12
12
|
".": {
|
|
13
13
|
"types": "./dist/index.d.ts",
|
|
14
14
|
"default": "./dist/index.js"
|
|
15
|
-
}
|
|
15
|
+
},
|
|
16
|
+
"./biome": "./presets/biome.base.json"
|
|
16
17
|
},
|
|
17
18
|
"bin": {
|
|
18
19
|
"officina": "dist/bin.js"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"dist",
|
|
23
|
+
"presets",
|
|
22
24
|
"README.md"
|
|
23
25
|
],
|
|
24
26
|
"engines": {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"vcs": {
|
|
3
|
+
"enabled": true,
|
|
4
|
+
"clientKind": "git",
|
|
5
|
+
"useIgnoreFile": true
|
|
6
|
+
},
|
|
7
|
+
"files": {
|
|
8
|
+
"includes": [
|
|
9
|
+
"**",
|
|
10
|
+
"!!**/dist",
|
|
11
|
+
"!!**/public",
|
|
12
|
+
"!!.claude/worktrees",
|
|
13
|
+
"!!src/assets/**/*.svg",
|
|
14
|
+
"!!.release-please-manifest.json"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"formatter": {
|
|
18
|
+
"enabled": true,
|
|
19
|
+
"indentStyle": "space",
|
|
20
|
+
"lineWidth": 120
|
|
21
|
+
},
|
|
22
|
+
"linter": {
|
|
23
|
+
"enabled": true,
|
|
24
|
+
"rules": {
|
|
25
|
+
"preset": "recommended",
|
|
26
|
+
"style": {
|
|
27
|
+
"noExcessiveLinesPerFile": {
|
|
28
|
+
"level": "error",
|
|
29
|
+
"options": {
|
|
30
|
+
"maxLines": 200
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"complexity": {
|
|
35
|
+
"noExcessiveLinesPerFunction": "error"
|
|
36
|
+
},
|
|
37
|
+
"nursery": {
|
|
38
|
+
"useSortedClasses": {
|
|
39
|
+
"level": "error",
|
|
40
|
+
"options": {
|
|
41
|
+
"functions": ["cn", "cva"]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"javascript": {
|
|
48
|
+
"formatter": {
|
|
49
|
+
"quoteStyle": "single",
|
|
50
|
+
"semicolons": "asNeeded"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"css": {
|
|
54
|
+
"parser": {
|
|
55
|
+
"tailwindDirectives": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"assist": {
|
|
59
|
+
"enabled": true,
|
|
60
|
+
"actions": {
|
|
61
|
+
"source": {
|
|
62
|
+
"organizeImports": {
|
|
63
|
+
"level": "on",
|
|
64
|
+
"options": {
|
|
65
|
+
"groups": [
|
|
66
|
+
[":PACKAGE_WITH_PROTOCOL:", ":PACKAGE:"],
|
|
67
|
+
":BLANK_LINE:",
|
|
68
|
+
["@/components/**"],
|
|
69
|
+
":BLANK_LINE:",
|
|
70
|
+
["@/layouts/**"],
|
|
71
|
+
":BLANK_LINE:",
|
|
72
|
+
["@/lib/**"],
|
|
73
|
+
":BLANK_LINE:",
|
|
74
|
+
["@/i18n", "@/i18n/**"],
|
|
75
|
+
":BLANK_LINE:",
|
|
76
|
+
["@/actions", "@/actions/**"],
|
|
77
|
+
":BLANK_LINE:",
|
|
78
|
+
["@/emails/**"],
|
|
79
|
+
":BLANK_LINE:",
|
|
80
|
+
":PATH:"
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"overrides": [
|
|
88
|
+
{
|
|
89
|
+
"includes": ["**/*.astro"],
|
|
90
|
+
"linter": {
|
|
91
|
+
"rules": {
|
|
92
|
+
"correctness": {
|
|
93
|
+
"noUnusedVariables": "off",
|
|
94
|
+
"noUnusedImports": "off"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|