@elia97/officina 0.14.0 → 0.15.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 CHANGED
@@ -76,6 +76,8 @@ Per Lighthouse un sito SSR si misura con `check lighthouse <url>`, sulla produzi
76
76
 
77
77
  Lo smoke legge gli header di sicurezza sulla pagina a cui arriva la radice. Se `/` è un redirect sulla stessa origine — il 302 di lingua che Astro emette con `i18n.routing.redirectToDefaultLocale`, per esempio — li cerca sulla destinazione, perché il redirect non porta la CSP delle pagine renderizzate, e il nome del controllo dice dove li ha letti: `header content-security-policy su /it`. Un redirect verso un'altra origine, o senza `location`, è un fallimento. Anche l'attesa che l'alias di produzione punti al deployment nuovo conta un 3xx come una risposta.
78
78
 
79
+ **Prima del dominio.** Un sito può andare in produzione sull'indirizzo di Vercel prima che il DNS punti a Vercel, per esempio per formare sul sito vero chi lo gestirà. Finché il dominio di `siteUrl` non serve la produzione, `smoke.url` in `officina.config.ts` dice dove la serve: l'origine di produzione che Vercel dà al progetto, come `https://<progetto>.vercel.app`, senza percorso né query. Lo smoke senza argomenti prova quella al posto di `siteUrl`, che resta obbligatorio come host canonico, e l'intestazione dell'uscita lo ricorda; così fa anche il deploy, che lancia lo smoke senza indirizzo. Un indirizzo passato a mano vince su `smoke.url`. Su un indirizzo `*.vercel.app` che non è `siteUrl` il controllo del noindex si rovescia: la regola `has: host` di `vercel.json` mette `X-Robots-Tag: noindex` su ogni host di Vercel, quindi lì il noindex deve esserci, per tutti i motori, e un indirizzo di Vercel indicizzabile è il guasto. Vale anche per un indirizzo passato a mano, e un progetto il cui `vercel.json` non ha quella regola lì fallisce. Il controllo dell'host canonico si salta, come per ogni URL di base che non è `siteUrl`. L'URL che stampa `vercel deploy` non si usa: è quello del singolo deployment, che la Standard Protection di Vercel protegge, e lo smoke riceverebbe 401 su ogni pagina, mentre il dominio di produzione resta pubblico. Con il dominio la voce esce, e lo smoke torna sull'apice.
80
+
79
81
  Lo smoke visita anche le pagine rese a richiesta, quelle di `src/pages` con `export const prerender = false`. Ognuna risponde 200, oppure con un redirect sulla stessa origine, come una pagina protetta che rimanda all'accesso; un 200 è HTML e porta il meta `Content-Security-Policy` con la direttiva `script-src`, quello che il middleware dei template scrive nelle pagine rese a richiesta. Una pagina dinamica si visita col suo rappresentante di `routes.representatives`, altrimenti si salta dicendolo. Queste pagine non entrano nel controllo della barra finale né in Lighthouse. Un'anomalia fa fallire lo smoke. officina riconosce una pagina resa a richiesta dalla dichiarazione: con `output: 'server'`, una pagina senza `prerender = false` resta per tutti i gate una pagina prerenderizzata, e lo smoke la visita come le altre, senza cercarne la CSP.
80
82
 
81
83
  Quando fallisce, lo smoke dice che la produzione è online e rotta, e consiglia di tornare indietro dalla dashboard di Vercel promuovendo l'ultimo deployment di produzione sano. Con `features.database` accesa aggiunge che lo schema non torna indietro: le migrazioni già applicate restano, e il deployment promosso regge solo se sono compatibili con il suo codice, la regola di «Migrazioni compatibili con la produzione» in § Action per i workflow.
@@ -109,7 +111,7 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
109
111
  | `@elia97/officina/bundle` | `CSS_BUDGET_GZIP` |
110
112
  | `@elia97/officina/icons` | `ICON_SPECS` |
111
113
 
112
- `siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi ne toglie un controllo la ricompone da `DEFAULT_CHECKS`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`.
114
+ `siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi ne toglie un controllo la ricompone da `DEFAULT_CHECKS`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`; `smoke.url` prende il posto di `siteUrl` come indirizzo da provare, finché il dominio non serve la produzione, come in «Prima del dominio».
113
115
 
114
116
  `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.
115
117
 
@@ -285,7 +287,9 @@ I sorgenti sono TypeScript, ma il pacchetto pubblica JavaScript: Node toglie i t
285
287
 
286
288
  ## Pubblicare
287
289
 
288
- **Una release a settimana**, salvo una rottura che ferma un progetto: le correzioni si accumulano su `main` ed escono insieme. Ogni release è un aggiornamento da portare in ogni progetto e nel template, e il gruppo `officina` di Dependabot lo propone una volta a settimana: rilasciare più spesso non lo fa arrivare prima, moltiplica soltanto le PR da seguire.
290
+ **Finché officina cambia spesso, una release quando una modifica è pronta.** I progetti la ricevono a mano, perché la PR del gruppo `officina` di Dependabot non passa `doctor`: aspettare la settimana rimanderebbe soltanto la correzione che un progetto aspetta.
291
+
292
+ **Poi una release a settimana**, salvo una rottura che ferma un progetto. Il passaggio si decide, non scatta da solo. Le correzioni si accumuleranno su `main` per uscire insieme: ogni release è un aggiornamento da portare in ogni progetto e nel template, e il gruppo `officina` di Dependabot lo propone una volta a settimana. Rilasciare più spesso non lo fa arrivare prima, moltiplica soltanto le PR da seguire.
289
293
 
290
294
  ```sh
291
295
  npm pkg set version=X.Y.Z
@@ -22,6 +22,9 @@ function printResults(results) {
22
22
  console.error(`✗ ${check} — ${detail}`);
23
23
  }
24
24
  }
25
+ const declaredNote = (argument, { siteUrl, smoke }) => argument === undefined && smoke?.url !== undefined
26
+ ? `\nda smoke.url, al posto di ${siteUrl}: con il dominio la voce esce`
27
+ : '';
25
28
  const disabledResults = ({ disabled }) => disabled.map(({ label }) => ({ check: `pagina ${label}`, status: 'skip', detail: 'spenta da `routes.disabled`' }));
26
29
  function printFailures(failures, database) {
27
30
  console.error(`\n✗ ${failures.length} controllo/i falliti:`);
@@ -37,7 +40,8 @@ function printFailures(failures, database) {
37
40
  export async function main(args = []) {
38
41
  const config = await loadConfig(process.cwd());
39
42
  const { siteUrl, smoke = {}, routes = {} } = config;
40
- const [url] = args;
43
+ const [argument] = args;
44
+ const url = argument ?? smoke.url;
41
45
  if (siteUrl === undefined) {
42
46
  console.error('\n✗ officina.config.ts non dichiara siteUrl: il controllo dell’host canonico non ha con cosa confrontarsi.\n');
43
47
  return 1;
@@ -46,7 +50,6 @@ export async function main(args = []) {
46
50
  console.error('\n✗ siteUrl è ancora il segnaposto del template: passa un URL, pnpm smoke:prod https://…\n');
47
51
  return 1;
48
52
  }
49
- // L'apice, non l'URL *.vercel.app che stampa `vercel deploy`: lì la regola `has: host` di vercel.json mette noindex.
50
53
  const baseUrl = (url ?? siteUrl).replace(/\/+$/, '');
51
54
  const context = {
52
55
  get: (target) => fetch(target, { redirect: 'manual' }),
@@ -59,7 +62,7 @@ export async function main(args = []) {
59
62
  return 1;
60
63
  const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR, routes.disabled);
61
64
  const pages = smokeRoutes(expected, smoke.nonHtmlRoutes, routes.representatives);
62
- console.log(`\nSmoke di produzione — ${baseUrl}\n`);
65
+ console.log(`\nSmoke di produzione — ${baseUrl}${declaredNote(argument, config)}\n`);
63
66
  await waitForAlias(context, (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
64
67
  const onDemand = onDemandPages(expected, PAGES_DIR, routes.representatives);
65
68
  const results = await runChecks({ ...context, onDemand }, pages, smoke.checks);
@@ -6,7 +6,7 @@ import type { SmokeCheck } from './smoke-production.ts';
6
6
  export type FeatureSetting = 'required' | false;
7
7
  export type GeneratorsSetting = FeatureSetting | 'project';
8
8
  export interface OfficinaConfig {
9
- /** L'apice di produzione, senza barra finale: lo usa `check smoke` quando non riceve un URL. */
9
+ /** L'apice di produzione, senza barra finale: lo usa `check smoke` quando non riceve un URL e `smoke.url` manca. */
10
10
  siteUrl?: string;
11
11
  icons?: {
12
12
  background: string;
@@ -16,6 +16,8 @@ export interface OfficinaConfig {
16
16
  cssMaxGzip?: number;
17
17
  };
18
18
  smoke?: {
19
+ /** Origine Vercel della produzione, come `https://<progetto>.vercel.app`: lo smoke senza URL prova questa. */
20
+ url?: string;
19
21
  securityHeaders?: Record<string, string | null>;
20
22
  nonHtmlRoutes?: readonly VerifiedRoute[];
21
23
  checks?: readonly SmokeCheck[];
@@ -9,7 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync } from 'node:fs';
10
10
  import { basename, join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
- import { aFunction, aNumber, anArrayOf, anEnvName, aRecordOf, aShape, aString, aStringOrNull, isRecord, oneOf, } from './validate.js';
12
+ import { aFunction, aNumber, anArrayOf, anEnvName, anHttpOrigin, aRecordOf, aShape, aString, aStringOrNull, isRecord, oneOf, } from './validate.js';
13
13
  export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId', 'links'];
14
14
  /** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
15
15
  export const isRequired = (config, name) => (config.features?.[name] ?? 'required') === 'required';
@@ -40,6 +40,7 @@ const SHAPE = aShape({
40
40
  icons: aShape({ background: aString }),
41
41
  bundle: aShape({ budgets: anArrayOf(budget), cssMaxGzip: aNumber }),
42
42
  smoke: aShape({
43
+ url: anHttpOrigin,
43
44
  securityHeaders: aRecordOf(aStringOrNull),
44
45
  nonHtmlRoutes: anArrayOf(verifiedRoute),
45
46
  checks: anArrayOf(aFunction),
@@ -29,7 +29,7 @@ export declare const BOTID_CHALLENGE = "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d
29
29
  /** L'alias di produzione di Vercel impiega un attimo a puntare al deployment appena caricato. */
30
30
  export declare function waitForAlias({ get, baseUrl }: SmokeContext, sleep: (ms: number) => Promise<void>, attempts?: number): Promise<void>;
31
31
  export declare function checkPages({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
32
- export declare function checkSecurityHeaders({ get, baseUrl, securityHeaders }: SmokeContext): Promise<CheckResult[]>;
32
+ export declare function checkSecurityHeaders(context: SmokeContext): Promise<CheckResult[]>;
33
33
  export declare function checkBotIdChallenge({ get, baseUrl, botIdRequired }: SmokeContext): Promise<CheckResult[]>;
34
34
  /** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
35
35
  export declare function checkCanonicalHost({ get, baseUrl, siteUrl }: SmokeContext): Promise<CheckResult[]>;
@@ -1,4 +1,5 @@
1
1
  import { checkOnDemandPages } from './smoke-on-demand.js';
2
+ import { checkRobotsTag } from './smoke-robots.js';
2
3
  const pass = (check) => ({ check, status: 'pass' });
3
4
  const fail = (check, detail) => ({ check, status: 'fail', detail });
4
5
  const skip = (check, detail) => ({ check, status: 'skip', detail });
@@ -68,7 +69,8 @@ export async function checkPages({ get, baseUrl }, pages) {
68
69
  }
69
70
  return results;
70
71
  }
71
- export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
72
+ export async function checkSecurityHeaders(context) {
73
+ const { get, baseUrl, securityHeaders } = context;
72
74
  let landing;
73
75
  try {
74
76
  landing = await landingPage(get, baseUrl);
@@ -87,10 +89,7 @@ export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
87
89
  return fail(check, `atteso "${expected}", ricevuto "${value}"`);
88
90
  return pass(check);
89
91
  });
90
- // Il noindex `has: host = *.vercel.app` di vercel.json applicato al dominio vero farebbe sparire il sito da ogni indice.
91
- const check = 'nessun x-robots-tag sull’host di produzione';
92
- const robots = response.headers.get('x-robots-tag');
93
- results.push(robots === null ? pass(check) : fail(check, `presente su ${url}: "${robots}"`));
92
+ results.push(checkRobotsTag(context, url, response.headers.get('x-robots-tag')));
94
93
  return results;
95
94
  }
96
95
  export async function checkBotIdChallenge({ get, baseUrl, botIdRequired }) {
@@ -0,0 +1,4 @@
1
+ import type { CheckResult, SmokeContext } from './smoke-production.ts';
2
+ type Addresses = Pick<SmokeContext, 'baseUrl' | 'siteUrl'>;
3
+ export declare function checkRobotsTag(addresses: Addresses, url: string, robots: string | null): CheckResult;
4
+ export {};
@@ -0,0 +1,22 @@
1
+ // La regola `has: host` del vercel.json dei progetti mette noindex su ogni `*.vercel.app`:
2
+ // lì deve esserci, e sul dominio vero toglierebbe il sito dagli indici senza nessun errore.
3
+ const onVercel = ({ baseUrl, siteUrl }) => {
4
+ const { hostname } = new URL(baseUrl);
5
+ return hostname !== URL.parse(siteUrl)?.hostname && hostname.endsWith('.vercel.app');
6
+ };
7
+ const OUT_OF_INDEX = new Set(['noindex', 'none']);
8
+ const keepsOutOfIndex = (robots) => robots.split(',').some((directive) => OUT_OF_INDEX.has(directive.trim().toLowerCase()));
9
+ export function checkRobotsTag(addresses, url, robots) {
10
+ if (!onVercel(addresses)) {
11
+ const check = 'nessun x-robots-tag sull’host di produzione';
12
+ if (robots === null)
13
+ return { check, status: 'pass' };
14
+ return { check, status: 'fail', detail: `presente su ${url}: "${robots}"` };
15
+ }
16
+ const check = 'x-robots-tag noindex sull’indirizzo di Vercel';
17
+ if (robots === null)
18
+ return { check, status: 'fail', detail: `assente su ${url}` };
19
+ if (keepsOutOfIndex(robots))
20
+ return { check, status: 'pass' };
21
+ return { check, status: 'fail', detail: `senza noindex su ${url}: "${robots}"` };
22
+ }
@@ -7,6 +7,7 @@ export declare const aNumber: Check;
7
7
  export declare const aFunction: Check;
8
8
  export declare const aStringOrNull: Check;
9
9
  export declare const anEnvName: Check;
10
+ export declare const anHttpOrigin: Check;
10
11
  export declare const anArrayOf: (item: Check) => Check;
11
12
  export declare const aRecordOf: (item: Check) => Check;
12
13
  export declare const oneOf: (expected: string, allowed: readonly unknown[]) => Check;
@@ -26,6 +26,15 @@ export const anEnvName = (value, path) => {
26
26
  return [];
27
27
  return [`${path}: atteso il nome di una variabile d'ambiente, ricevuto ${JSON.stringify(value)}`];
28
28
  };
29
+ const HTTP = new Set(['http:', 'https:']);
30
+ export const anHttpOrigin = (value, path) => {
31
+ if (typeof value !== 'string')
32
+ return [problem(path, 'una stringa', value)];
33
+ const url = URL.parse(value);
34
+ if (url !== null && HTTP.has(url.protocol) && url.href === `${url.origin}/`)
35
+ return [];
36
+ return [`${path}: atteso un'origine http o https, senza percorso né query, ricevuto ${JSON.stringify(value)}`];
37
+ };
29
38
  export const anArrayOf = (item) => (value, path) => Array.isArray(value)
30
39
  ? value.flatMap((entry, index) => item(entry, `${path}[${index}]`))
31
40
  : [problem(path, 'un array', value)];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elia97/officina",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",