@elia97/officina 0.12.0 → 0.13.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
@@ -46,7 +46,7 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
46
46
 
47
47
  | Comando | Cosa guarda | Da dove prende le rotte |
48
48
  |---|---|---|
49
- | `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages`; sotto SSR, il manifest della build Vercel |
49
+ | `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta; nel CSS, le media query in sintassi di intervallo | `src/pages`; sotto SSR, il manifest della build Vercel |
50
50
  | `check links` | gli `<a href>` di `dist/client`: ogni link interno porta a qualcosa che la build serve, e nessuno a `#` | i file di `dist/client` e le rotte a richiesta del manifest della build Vercel |
51
51
  | `check secrets [--canaries]` | i file di `dist/client`: nessuno contiene il valore o il canary di una chiave server e secret di `env.schema` | — |
52
52
  | `check smoke [url]` | la produzione viva: pagine, pagine rese a richiesta, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
@@ -58,6 +58,16 @@ Gli script di una pagina statica si leggono dal suo HTML, anche sotto una `base`
58
58
 
59
59
  Il CSS di una rotta è la somma dei fogli di `dist/client/_astro` che collega, ognuno contato una volta: per una pagina statica i `<link rel="stylesheet">` del suo HTML, anche sotto una `base`, da un `assetsPrefix` o con una query; per una rotta renderizzata a richiesta i fogli esterni che il manifest le assegna, e una rotta per cui il manifest non li dice è un fallimento. Il tetto è `bundle.cssMaxGzip`, e il rapporto ha una riga per ogni combinazione di fogli, perché le rotte che collegano gli stessi fogli pesano uguale. Non si contano il CSS dentro l'HTML, nei `<style>`, come il JavaScript incorporato, i link che non bloccano il rendering (`preload`, `prefetch`) e i fogli fuori da `_astro`, da `public/` o da un CDN. Un foglio citato che in `_astro` non c'è è un fallimento. Un foglio che nessuna rotta misurata collega si pesa da solo contro lo stesso tetto, e nel rapporto porta «da solo»: il CSS di un `import()` dinamico, quello dei componenti dentro un contenuto MDX reso a richiesta, che il manifest non assegna alla pagina, o tutti i fogli di una build le cui rotte non si leggono. Da solo pesa meno del vero, ma nessun foglio oltre il tetto passa in silenzio.
60
60
 
61
+ Il CSS costruito non deve contenere media query in sintassi di intervallo, come `@media (width>=40rem)`. Safari le legge solo dalla 16.4, e un Safari più vecchio ignora la regola intera: un sito scritto mobile-first si impagina come su un telefono. Le lascia Lightning CSS, che minifica il CSS della build, quando non sa per quali browser scrive: Astro 7 costruisce con `build.target: 'esnext'`, Vite 8 ne ricava `cssTarget`, e il minificatore non abbassa la sintassi e toglie anche i prefissi, come `-webkit-backdrop-filter`. Nessun browser recente mostra il difetto, e `astro dev` non minifica: si vede solo nel CSS costruito. `check bundle` legge i fogli di `dist/client/_astro` e i `<style>` dell'HTML delle pagine, dove Astro mette i fogli piccoli, e fallisce su ogni media query con un confronto nel preludio, `<`, `>` o `=`, i segni che soltanto la sintassi di intervallo usa; i commenti non contano, e il rapporto nomina il file e le query. Restano fuori le query di `@container`, i fogli fuori da `_astro`, i `<style>` delle pagine rese a richiesta, che nella build non hanno HTML, e i prefissi tolti, che il controllo non vede.
62
+
63
+ Il controllo non si spegne, e fallisce dalla versione che lo porta, senza un giro di avvisi: un progetto senza `cssTarget` ha già il difetto, e la correzione è una riga in `astro.config.mjs`, con i browser che il progetto sostiene. `min-width` e `max-width` sono la stessa query in ogni browser, quindi abbassare non costa niente.
64
+
65
+ ```js
66
+ export default defineConfig({
67
+ vite: { build: { cssTarget: ['chrome111', 'edge111', 'firefox114', 'safari15.4', 'ios15.4'] } },
68
+ })
69
+ ```
70
+
61
71
  `check links` legge gli `<a href>` di ogni HTML di `dist/client`, fuori dai commenti, dagli `<script>`, dagli `<style>` e dai valori degli attributi, dove Astro lascia `<` e `>` come sono. È interno un link relativo, uno che comincia con `/` o uno assoluto verso l'origine di `siteUrl`, e se ne confronta il percorso, senza query né ancora, con o senza barra finale; un link relativo si risolve come con `trailingSlash: 'never'`, che è quello dei template. Vale se porta a un file della build, pagina o altro file, o a una rotta che il manifest della build Vercel rende a richiesta, endpoint e `/` di lingua compresi, confrontata con il pattern che Astro scrive lì; senza manifest, o con un altro adapter, a una pagina `.astro` con `prerender = false`, e il rapporto lo dice. Che la pagina dietro una rotta a richiesta esista lo sa solo il server, e una rotta come `/[...slug]` accetta ogni percorso: il rapporto conta i link che passano così. `href="#"`, `href=""` e un `href` senza valore sono segnaposto, e falliscono come un link rotto. Il rapporto raggruppa per destinazione e nomina le pagine che la citano: un link rotto nel footer è un problema solo, non uno per pagina. Restano fuori, e il rapporto li conta, i link esterni, che chiederebbero la rete, `mailto:`, `tel:` e gli altri schemi, e l'id a cui punta un'ancora. Una `dist/client` senza HTML è un fallimento, perché il controllo non affermerebbe niente: un progetto che rende ogni pagina a richiesta lo spegne con `features.links: false`. Due limiti: con una `base` i link non si risolvono, e un redirect di `vercel.json` non è una pagina, quindi un link che ci porta passa per rotto, e si corregge puntando alla destinazione.
62
72
 
63
73
  `check secrets` legge le chiavi di `env.schema` con `context: 'server'` e `access: 'secret'`, dal sorgente di `astro.config.mjs` come `doctor`, e le cerca in ogni file di `dist/client`: un import sbagliato basta a portare un segreto nel browser. Per ogni chiave cerca il valore che ha nell'ambiente, anche nelle forme in cui Astro lo scrive nel markup e negli script, e il suo canary, `officina-<CHIAVE>-canary`, che `--canaries` stampa come righe `CHIAVE=valore` da mettere nell'ambiente della build. `actions/ci` lo fa dopo `pnpm run ci`, così i test che `ci` lancia non vedono i canary e la build sì. Il canary si dà solo ai campi `envField.string` senza validazioni (`max`, `min`, `length`, `url`, `includes`, `startsWith`, `endsWith`), perché Astro valida ogni variabile sul suo tipo e su quelle, e un canary che non le rispetta fermerebbe la build: una chiave così, in CI, resta da verificare. Una chiave che compare in un file è un fallimento che nomina la chiave e il file. Una chiave senza valore né canary nell'ambiente, o con un valore di meno di 8 caratteri, che cercato darebbe falsi positivi, non si verifica: il rapporto la dice con un avviso, invece di contarla fra le verificate. Uno schema senza chiavi segrete non ha niente da verificare, e una `dist/client` vuota è un fallimento.
@@ -66,7 +76,7 @@ Per Lighthouse un sito SSR si misura con `check lighthouse <url>`, sulla produzi
66
76
 
67
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.
68
78
 
69
- 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. Nella 0.12.0 un'anomalia è un avviso, che lo smoke stampa con `·` senza fallire; dalla 0.13.0 sarà un fallimento. 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.
79
+ 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. Nella 0.12.0 e nella 0.13.0 un'anomalia è un avviso, che lo smoke stampa con `·` senza fallire; dalla 0.14.0 sarà un fallimento. 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.
70
80
 
71
81
  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.
72
82
 
@@ -5,7 +5,7 @@ import { join, relative } from 'node:path';
5
5
  import process from 'node:process';
6
6
  import { gzipSync } from 'node:zlib';
7
7
  import { budgetFor, CSS_BUDGET_GZIP, deferredClosure, htmlEntries, htmlStylesheets, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
8
- import { measureCss, routeCount } from '../lib/bundle-css.js';
8
+ import { inlineStyles, measureCss, rangeQueryFailures, routeCount } from '../lib/bundle-css.js';
9
9
  import { readSsr } from '../lib/bundle-ssr.js';
10
10
  import { missingInput } from '../lib/cli.js';
11
11
  import { loadConfig } from '../lib/config.js';
@@ -89,6 +89,17 @@ function checkCss(pages, cssMaxGzip, width) {
89
89
  printCss(css, cssMaxGzip, width);
90
90
  return css.failures;
91
91
  }
92
+ function checkRangeSyntax() {
93
+ const sheets = filesWithExtension(ASSETS, '.css').map((file) => ({ file, css: readFileSync(file, 'utf8') }));
94
+ const pages = filesWithExtension(DIST, '.html').map((file) => ({
95
+ file,
96
+ css: inlineStyles(readFileSync(file, 'utf8')),
97
+ }));
98
+ const failures = rangeQueryFailures([...sheets, ...pages]);
99
+ if (failures.length === 0)
100
+ console.log('\nMedia query in sintassi di intervallo: nessuna.');
101
+ return failures;
102
+ }
92
103
  function printSsrNote(expected, ssr) {
93
104
  for (const note of ssr.notes)
94
105
  console.log(`\nNOTA ${note}`);
@@ -124,7 +135,7 @@ export async function main() {
124
135
  failures.push(...ssr.failures, ...unknownChunkFailures(pages), ...overBudgetFailures(pages));
125
136
  const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
126
137
  printPages(pages, width);
127
- failures.push(...checkCss(routes, cssMaxGzip, width));
138
+ failures.push(...checkCss(routes, cssMaxGzip, width), ...checkRangeSyntax());
128
139
  printSsrNote(expected, ssr);
129
140
  printDisabled(expected);
130
141
  if (failures.length > 0) {
@@ -70,9 +70,9 @@ export async function main(args = []) {
70
70
  return 1;
71
71
  }
72
72
  const warnings = results.filter(({ status }) => status === 'warn').length;
73
- // TODO: dalla 0.13.0 le anomalie a richiesta sono `fail` (smoke-on-demand.ts): togli il log degli avvisi qui sotto.
73
+ // TODO: dalla 0.14.0 le anomalie a richiesta sono `fail` (smoke-on-demand.ts): togli il log degli avvisi qui sotto.
74
74
  if (warnings > 0)
75
- console.log(`\n· ${warnings} avviso/i: dalla 0.13.0 fanno fallire lo smoke.`);
75
+ console.log(`\n· ${warnings} avviso/i: dalla 0.14.0 fanno fallire lo smoke.`);
76
76
  console.log(`\n✓ Tutti i controlli passati su ${baseUrl}.\n`);
77
77
  return 0;
78
78
  }
@@ -28,7 +28,7 @@ export const CI_STEPS = [
28
28
  'doctor',
29
29
  ];
30
30
  const MANIFEST = 'package.json';
31
- // TODO: dalla 0.13.0 `check:secrets` è un errore di doctor: toglilo da NEW_SCRIPTS.
31
+ // TODO: dalla 0.14.0 `check:secrets` è un errore di doctor: toglilo da NEW_SCRIPTS.
32
32
  const NEW_SCRIPTS = new Set(['check:secrets']);
33
33
  export function scriptGaps({ scripts = {} }) {
34
34
  const gaps = [];
@@ -12,5 +12,12 @@ export type CssMeasure = {
12
12
  groups: CssGroup[];
13
13
  failures: string[];
14
14
  };
15
+ export type CssSource = {
16
+ file: string;
17
+ css: string;
18
+ };
15
19
  export declare const routeCount: (count: number) => string;
16
20
  export declare function measureCss(routes: readonly RouteStylesheets[], sheets: readonly Stylesheet[], maxGzip: number, assets: string): CssMeasure;
21
+ export declare function rangeMediaQueries(css: string): string[];
22
+ export declare const inlineStyles: (html: string) => string;
23
+ export declare function rangeQueryFailures(sources: readonly CssSource[]): string[];
@@ -1,3 +1,7 @@
1
+ const CSS_COMMENT = /\/\*[\s\S]*?\*\//g;
2
+ const MEDIA_PRELUDE = /(?<=@media\b)[^{;]*/gi;
3
+ const RANGE_COMPARISON = /[<>=]/;
4
+ const STYLE_ELEMENT = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
1
5
  const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
2
6
  export const routeCount = (count) => (count === 1 ? '1 rotta' : `${count} rotte`);
3
7
  function overBudget({ stylesheets, gzip, routes }, maxGzip) {
@@ -31,3 +35,18 @@ export function measureCss(routes, sheets, maxGzip, assets) {
31
35
  failures.push(...measured.filter((group) => group.gzip > maxGzip).map((group) => overBudget(group, maxGzip)));
32
36
  return { groups: measured, failures };
33
37
  }
38
+ export function rangeMediaQueries(css) {
39
+ const preludes = [...css.replace(CSS_COMMENT, ' ').matchAll(MEDIA_PRELUDE)].map(([prelude]) => prelude.trim());
40
+ return [...new Set(preludes.filter((prelude) => RANGE_COMPARISON.test(prelude)))];
41
+ }
42
+ export const inlineStyles = (html) => [...html.matchAll(STYLE_ELEMENT)].map(([, css]) => css).join('\n');
43
+ export function rangeQueryFailures(sources) {
44
+ return sources.flatMap(({ file, css }) => {
45
+ const queries = rangeMediaQueries(css);
46
+ if (queries.length === 0)
47
+ return [];
48
+ return [
49
+ `${file}: media query in sintassi di intervallo, che Safari legge solo dalla 16.4 — ${queries.join(', ')}; si abbassano con \`vite.build.cssTarget\``,
50
+ ];
51
+ });
52
+ }
@@ -72,7 +72,7 @@ function secretGaps(source, keys) {
72
72
  return message === undefined ? [] : [{ path: ASTRO_CONFIG, message }];
73
73
  });
74
74
  }
75
- // TODO: dalla 0.13.0 i requisiti del database e dell'autenticazione sono errori di doctor: togli `severity`.
75
+ // TODO: dalla 0.14.0 i requisiti del database e dell'autenticazione sono errori di doctor: togli `severity`.
76
76
  const asWarning = (gap) => ({ ...gap, severity: 'warning' });
77
77
  export function databaseAuthGaps(files, manifest, on) {
78
78
  const database = on.includes('database') ? databaseGaps(files, manifest) : [];
@@ -18,7 +18,7 @@ const attributeValue = (tag, attribute) => {
18
18
  };
19
19
  const hasCspMeta = (html) => [...html.matchAll(META)].some(([tag]) => attributeValue(tag, HTTP_EQUIV).toLowerCase() === 'content-security-policy' &&
20
20
  SCRIPT_SRC.test(attributeValue(tag, CONTENT)));
21
- // TODO: dalla 0.13.0 un'anomalia di una pagina resa a richiesta fa fallire lo smoke: qui `warn` diventa `fail`.
21
+ // TODO: dalla 0.14.0 un'anomalia di una pagina resa a richiesta fa fallire lo smoke: qui `warn` diventa `fail`.
22
22
  const warn = (check, detail) => ({ check, status: 'warn', detail });
23
23
  function redirect(check, response, url) {
24
24
  const location = response.headers.get('location');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elia97/officina",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",