@elia97/officina 0.9.1 → 0.10.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
@@ -38,6 +38,8 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
38
38
 
39
39
  `check language` vuole in italiano i commenti del codice e i Markdown di documentazione. Lascia fuori per intero ciò che non è documentazione del progetto: i dizionari di `src/i18n/strings/` e i Markdown sotto `src/content/`, che sono copy nelle lingue del sito; le fonti del cliente in `docs/sources/`; i `CHANGELOG.md`, generati in inglese. Un file YAML di `src/content/` invece è codice, e i suoi commenti si controllano come altrove.
40
40
 
41
+ `check routes` segue i rimandi dei Markdown del progetto: i percorsi fra apici inversi dentro `docs`, `src`, `scripts`, `test`, `e2e`, `public`, `.claude` e `.github`; i rimandi a una sezione, nella forma `file.md` § Titolo; i link e le immagini con una destinazione relativa, anche a riferimento e verso una cartella; e due tipi di nomi senza cartella, i documenti in maiuscolo come `HOW_TO_USE.md`, cercati accanto a chi cita, alla radice e in `docs/`, e i file di configurazione che vetrina tiene alla radice, come `vercel.json`. Un link che esce dal repository è un rimando morto, perché in CI la cartella sopra non c'è. Restano fuori gli altri nomi senza cartella, come `head.astro`, gli URL, le ancore, il codice negli span e nei blocchi recintati, un nome che fa da testo a un link, dove conta la destinazione, i link e i nomi dei Markdown di `src/content/`, che sono copy del sito, e i nomi citati in `docs/ROADMAP.md` e nei `CHANGELOG.md`, che raccontano documenti usciti o da scrivere. Un blocco rientrato di quattro spazi non si riconosce come codice: un esempio va in un blocco recintato.
42
+
41
43
  ## Verifiche di build e di produzione
42
44
 
43
45
  | Comando | Cosa guarda | Da dove prende le rotte |
@@ -48,7 +50,9 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
48
50
  | `check lighthouse [url] [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; con `url` misura un sito già servito, `--local` fa build, server statico e Chrome da sé | `src/pages` |
49
51
  | `gen icons` | le icone del manifest, disegnate da `public/favicon.svg`, che è il suo ingresso obbligatorio | — |
50
52
 
51
- Una rotta renderizzata a richiesta non emette HTML. Con l'adapter Vercel, `check bundle` la legge dal manifest che Astro scrive in `.vercel/output/_functions` e la misura con gli stessi budget delle pagine statiche: gli script della rotta, le isole e gli script dei componenti che i chunk server della pagina nominano, e il runtime del framework quando c'è un'isola. Il JavaScript che Astro incorpora nell'HTML non si conta, né qui né per le pagine statiche. Due limiti: le isole raggiungibili solo attraverso un `import()` dinamico del server — un'isola dentro un contenuto MDX, per esempio — restano fuori, perché seguire quegli import attribuirebbe a ogni pagina il contenuto di tutto il sito; e un'isola si attribuisce per chunk, quindi una pagina eredita le isole dei componenti che condividono un chunk con quelli che usa. Il manifest è un formato interno di Astro, già cambiato una volta sotto i piedi del misuratore che c'era prima: quando non lo riconosce il check lo dice, e fallisce se non gli resta nessuna rotta da misurare; un'isola che non sa collegare al client è un fallimento, non una misura più bassa. Con un altro adapter le rotte SSR restano fuori, dichiarate, e il check misura soltanto il CSS.
53
+ Gli script di una pagina statica si leggono dal suo HTML, anche sotto una `base`, da un `assetsPrefix` o con una query. Una rotta renderizzata a richiesta non emette HTML. Con l'adapter Vercel, `check bundle` la legge dal manifest che Astro scrive in `.vercel/output/_functions` e la misura con gli stessi budget delle pagine statiche: gli script della rotta, le isole e gli script dei componenti che i chunk server della pagina nominano, e il runtime del framework quando c'è un'isola. Il JavaScript che Astro incorpora nell'HTML non si conta, né qui né per le pagine statiche. Due limiti: le isole raggiungibili solo attraverso un `import()` dinamico del server — un'isola dentro un contenuto MDX, per esempio — restano fuori, perché seguire quegli import attribuirebbe a ogni pagina il contenuto di tutto il sito; e un'isola si attribuisce per chunk, quindi una pagina eredita le isole dei componenti che condividono un chunk con quelli che usa. Il manifest è un formato interno di Astro, già cambiato una volta sotto i piedi del misuratore che c'era prima: quando non lo riconosce il check lo dice, e fallisce se non gli resta nessuna rotta da misurare; un'isola che non sa collegare al client è un fallimento, non una misura più bassa. Con un altro adapter, o con un manifest che non riconosce, le rotte SSR restano fuori, dichiarate: si misurano le pagine statiche, e il CSS come dice il paragrafo che segue.
54
+
55
+ 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.
52
56
 
53
57
  Per Lighthouse un sito SSR si misura con `check lighthouse <url>`, sulla produzione o su un'anteprima: al posto del server del progetto visita quell'indirizzo, ed è l'unico modo di avere numeri di produzione. `--local` serve i file statici della build, quindi su un sito renderizzato a richiesta si ferma e lo dice, invece di misurare dei 404. Un'anteprima Vercel con la Deployment Protection risponde con la pagina di accesso, e il check non la può misurare: serve la produzione o un'anteprima non protetta. La porta del server locale segue `LH_PORT` sia con `--listen` sia con `--port`.
54
58
 
@@ -103,7 +107,7 @@ Le regole che valgono per tutti i progetti stanno nel pacchetto; il progetto est
103
107
 
104
108
  ```json
105
109
  {
106
- "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
110
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
107
111
  "extends": ["@elia97/officina/biome"]
108
112
  }
109
113
  ```
@@ -114,6 +118,8 @@ extends:
114
118
  - node_modules/@elia97/officina/presets/lefthook.yml
115
119
  ```
116
120
 
121
+ `$schema` punta allo schema che il pacchetto di Biome installa, che è sempre quello della CLI. L'URL di biomejs.dev porta invece la versione, che Dependabot non aggiorna quando alza `@biomejs/biome`, e da lì Biome segnala lo scarto a ogni esecuzione: `doctor` lo segnala come errore.
122
+
117
123
  ## Action per i workflow
118
124
 
119
125
  I passi dei workflow stanno in `actions/` di questo repository, come composite action: girano dentro il job di chi le chiama, quindi il nome del check e l'`environment` restano del progetto. Il riferimento va fissato a un tag di versione o a uno SHA, mai a `@main`, e deve essere la stessa versione del pacchetto installato — `doctor` lo verifica.
@@ -122,7 +128,7 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
122
128
  |---|---|---|
123
129
  | `actions/ci` | checkout, node, install, `astro sync`, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
124
130
  | `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
125
- | `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, controllo della build, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono |
131
+ | `actions/deploy` | risoluzione del tag, stessa versione di officina, gate, `vercel pull`, `build`, controllo della build, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono |
126
132
  | `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
127
133
 
128
134
  ```yaml
@@ -133,7 +139,7 @@ jobs:
133
139
  name: production
134
140
  url: ${{ steps.deploy.outputs.url }}
135
141
  steps:
136
- - uses: Elia97/officina/actions/deploy@v0.4.0
142
+ - uses: Elia97/officina/actions/deploy@vX.Y.Z
137
143
  id: deploy
138
144
  with:
139
145
  ref: ${{ inputs.ref }}
@@ -146,6 +152,8 @@ jobs:
146
152
 
147
153
  I tre segreti sono input obbligatori e l'action li mette nell'`env` dei soli tre passi che chiamano `vercel`. Nell'`env` del job li vedrebbero anche `pnpm install` e gli script di installazione di ogni dipendenza, che girano codice di terzi con in mano un token di deploy.
148
154
 
155
+ **Il deploy lanciato a mano.** Con `workflow_dispatch` il workflow viene dal ramo scelto in «Use workflow from», e il pacchetto dal lockfile del tag che si deploya: le due metà possono avere due versioni di officina diverse, e un passo dell'action più nuova può chiamare un'opzione che gli script del tag non conoscono. `actions/deploy` confronta le due versioni subito dopo l'install, prima di ogni script del progetto e di `vercel pull`, e si ferma dicendole se non coincidono o se il tag non installa officina. Un tag più vecchio si deploya lanciando Deploy da quel tag: il suo `deploy.yml` dichiara l'action della sua versione, con i gate di allora. Perché si possa, l'environment `production` del progetto deve ammettere i tag fra i ref da cui si deploya.
156
+
149
157
  Quello che si scarica al volo è fissato a una versione esatta, in un posto solo: la CLI di Vercel in `actions/deploy/action.yml`, `@lhci/cli` in `src/checks/lighthouse.ts`, `serve` in `src/sh/lhci-local.sh`. Che i primi due siano esatti, uguali ovunque e non indietro di una major su npm lo guarda ogni lunedì `.github/workflows/vercel-cli.yml`, con `officina check vercel-cli`.
150
158
 
151
159
  Perché le action non restino ferme per sempre, Dependabot di questo repository copre l'ecosistema `github-actions` anche dentro `/actions/*`, dove le action di terze parti sono fissate allo SHA intero con la versione nel commento.
@@ -154,7 +162,7 @@ Perché le action non restino ferme per sempre, Dependabot di questo repository
154
162
 
155
163
  `officina doctor` dice, in qualunque repository, cosa manca perché il progetto prenda tutto da fuori: i punti di aggancio dei generatori, gli ancoraggi delle pagine a sezioni e dei dizionari, gli script, le dipendenze e gli strumenti di `package.json`, i residui di ciò che è uscito (copia del metodo, script, documenti commerciali), i preset, i workflow di GitHub e Dependabot, `officina.config.ts`. Sta dentro `ci`, quindi un progetto allineato non torna indietro senza che il gate lo dica.
156
164
 
157
- Di `.github/dependabot.yml` guarda la **forma**, non la presenza delle chiavi: lo legge come YAML e pretende che le chiavi del gruppo stiano sul gruppo — Dependabot rifiuta l'intero file se `commit-message` o `open-pull-requests-limit` stanno su una voce che dichiara `multi-ecosystem-group` — e che ogni ecosistema del gruppo abbia una seconda voce per il resto. Senza quella, `patterns` restringe l'intera voce e tutto il resto smette di aggiornarsi senza che niente diventi rosso.
165
+ Di `.github/dependabot.yml` guarda la **forma**, non la presenza delle chiavi: lo legge come YAML e pretende che le chiavi del gruppo stiano sul gruppo — Dependabot rifiuta l'intero file se `commit-message` o `open-pull-requests-limit` stanno su una voce che dichiara `multi-ecosystem-group` — e che ogni ecosistema del gruppo abbia una seconda voce per il resto. Senza quella, `patterns` restringe l'intera voce e tutto il resto smette di aggiornarsi senza che niente diventi rosso. Le action le cerca con il nome che dà loro Dependabot, quello del repository senza la sottocartella: `Elia97/officina`. `Elia97/officina/*` non corrisponde a nessuna dipendenza, in `patterns` come in `ignore` o in `exclude-patterns`, e `doctor` lo segnala ovunque compaia.
158
166
 
159
167
  Le prime due sezioni rispondono a due domande diverse. I punti di aggancio sono l'elenco di `src/lib/contract.ts`, e chiedono che una cosa esista: i moduli che il codice generato importa, le cartelle in cui i generatori scrivono, `class-variance-authority` fra le dipendenze, lo script `check` che il post-gen lancia. Gli ancoraggi guardano dentro quei file, e chiedono che abbiano ancora la forma su cui l'iniezione conta:
160
168
 
@@ -166,12 +174,12 @@ Sono gli stessi controlli del pre-volo dei generatori, non una copia: `doctor` c
166
174
  Le altre sezioni misurano che il progetto e il pacchetto si muovano insieme:
167
175
 
168
176
  - **stessa versione**: il riferimento delle action nei workflow — `@v<x.y.z>`, oppure uno SHA con `# v<x.y.z>` nel commento — deve essere la versione del pacchetto installato. Un progetto col pacchetto alla 0.8 e le action alla 0.6 non è un progetto allineato;
169
- - **una PR sola**: il `.github/dependabot.yml` del progetto deve avere un gruppo `multi-ecosystem-groups` che prende `@elia97/officina` da npm e `Elia97/officina/*` da `github-actions`. Senza, Dependabot ne apre due e il controllo qui sopra le boccia entrambe;
177
+ - **una PR sola**: il `.github/dependabot.yml` del progetto deve avere un gruppo `multi-ecosystem-groups` che prende `@elia97/officina` da npm e `Elia97/officina` da `github-actions`. Senza, Dependabot ne apre due e il controllo qui sopra le boccia entrambe;
170
178
  - **gli strumenti che preset e action danno per scontati**: `@biomejs/biome`, `lefthook`, `@commitlint/cli`, `@commitlint/config-conventional` e `fallow` fra le `devDependencies`, con la versione di Biome dentro l'intervallo che il preset dichiara di reggere.
171
179
 
172
180
  `package.json`, `.claude/settings.json`, `biome.json` e `.mcp.json` si leggono come JSON, non cercandoci dentro delle stringhe: `"hooks"` in un valore qualunque non è un blocco `hooks`. I workflow restano una ricerca testuale — non c'è un parser YAML nel pacchetto — ma una riga commentata non soddisfa nessun controllo.
173
181
 
174
- **Un requisito nuovo nasce come avviso.** Quando `doctor` impara a chiedere una cosa nuova, la mancanza è un `warning` nella versione che la introduce e diventa un `error` dalla successiva. `doctor` gira dentro `pnpm run ci` senza `--strict`, quindi la PR di Dependabot che porta la versione nuova passa, e il progetto ha una versione di tempo per adeguarsi.
182
+ **Un requisito nuovo nasce come avviso.** Quando `doctor` impara a chiedere una cosa nuova, la mancanza è un `warning` nella versione che la introduce e diventa un `error` dalla successiva. `doctor` gira dentro `pnpm run ci` senza `--strict`, quindi la PR di Dependabot che porta la versione nuova passa, e il progetto ha una versione di tempo per adeguarsi. L'avviso serve a far passare quella PR, e dove non può passare comunque il requisito nasce come errore: con il filtro `Elia97/officina/*` l'aggiornamento arriva diviso in due PR, e nessuna delle due passa il controllo sulla stessa versione.
175
183
 
176
184
  ## Sviluppo
177
185
 
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  // Gate del budget di bundle su dist/client — richiede un `astro build` completato.
3
3
  import { readdirSync, readFileSync } from 'node:fs';
4
- import { join } from 'node:path';
4
+ import { join, relative } from 'node:path';
5
5
  import process from 'node:process';
6
6
  import { gzipSync } from 'node:zlib';
7
- import { budgetFor, CSS_BUDGET_GZIP, cssBudgetFailure, deferredClosure, heaviestStylesheet, htmlEntries, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
7
+ import { budgetFor, CSS_BUDGET_GZIP, deferredClosure, htmlEntries, htmlStylesheets, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
8
+ import { measureCss, routeCount } from '../lib/bundle-css.js';
8
9
  import { readSsr } from '../lib/bundle-ssr.js';
9
10
  import { missingInput } from '../lib/cli.js';
10
11
  import { loadConfig } from '../lib/config.js';
@@ -14,9 +15,10 @@ const ASSETS = join(DIST, '_astro');
14
15
  const PAGES = 'src/pages';
15
16
  const gz = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
16
17
  function readStylesheets() {
17
- return readdirSync(ASSETS)
18
- .filter((f) => f.endsWith('.css'))
19
- .map((file) => ({ file, gzip: gzipSync(readFileSync(join(ASSETS, file))).length }));
18
+ return filesWithExtension(ASSETS, '.css').map((path) => ({
19
+ file: relative(ASSETS, path),
20
+ gzip: gzipSync(readFileSync(path)).length,
21
+ }));
20
22
  }
21
23
  function readChunks() {
22
24
  const chunks = new Map();
@@ -27,10 +29,10 @@ function readChunks() {
27
29
  return chunks;
28
30
  }
29
31
  function htmlPages() {
30
- return filesWithExtension(DIST, '.html').map((htmlPath) => ({
31
- route: routeOf(htmlPath, DIST),
32
- entries: htmlEntries(readFileSync(htmlPath, 'utf8')),
33
- }));
32
+ return filesWithExtension(DIST, '.html').map((htmlPath) => {
33
+ const html = readFileSync(htmlPath, 'utf8');
34
+ return { route: routeOf(htmlPath, DIST), entries: htmlEntries(html), stylesheets: htmlStylesheets(html) };
35
+ });
34
36
  }
35
37
  function measurePages(pages, chunks, budgets) {
36
38
  const total = (names) => [...names].reduce((sum, name) => sum + (chunks.get(name)?.gzip ?? 0), 0);
@@ -72,13 +74,20 @@ function printPages(pages, width) {
72
74
  }
73
75
  console.log('\nDIFFERITO = raggiungibile solo da `await import()` (caricato dopo il primo paint, dietro una guardia a runtime).');
74
76
  }
75
- function printStylesheets(stylesheets, cssMaxGzip, failed, width) {
76
- const worst = heaviestStylesheet(stylesheets);
77
- console.log(`\nCSS (blocca il rendering: il foglio più pesante che una rotta collega)`);
78
- for (const sheet of [...stylesheets].sort((a, b) => b.gzip - a.gzip)) {
79
- const mark = sheet === worst && failed ? ' ✗' : '';
80
- console.log(` ${sheet.file.padEnd(width - 2)} ${gz(sheet.gzip).padStart(9)} ${gz(cssMaxGzip).padStart(9)}${mark}`);
77
+ function printCss({ groups }, cssMaxGzip, width) {
78
+ console.log('\nCSS per rotta (blocca il rendering: gzip dei fogli che la rotta collega)');
79
+ for (const { stylesheets, gzip, routes } of groups) {
80
+ const count = routes.length === 0 ? 'da solo' : routeCount(routes.length);
81
+ const line = ` ${stylesheets.join(' + ').padEnd(width - 2)} ${gz(gzip).padStart(9)} ${gz(cssMaxGzip).padStart(9)} ${count}`;
82
+ console.log(gzip > cssMaxGzip ? `${line} ✗` : line);
81
83
  }
84
+ if (groups.some(({ routes }) => routes.length === 0))
85
+ console.log('\nda solo = nessuna rotta misurata collega il foglio: si pesa contro il tetto per conto suo.');
86
+ }
87
+ function checkCss(pages, cssMaxGzip, width) {
88
+ const css = measureCss(pages, readStylesheets(), cssMaxGzip, ASSETS);
89
+ printCss(css, cssMaxGzip, width);
90
+ return css.failures;
82
91
  }
83
92
  function printSsrNote(expected, ssr) {
84
93
  for (const note of ssr.notes)
@@ -98,18 +107,15 @@ export async function main() {
98
107
  const cssMaxGzip = bundle.cssMaxGzip ?? CSS_BUDGET_GZIP;
99
108
  const html = htmlPages();
100
109
  const ssr = readSsr(html.length > 0);
101
- const pages = measurePages([...html, ...ssr.pages], readChunks(), bundle.budgets ?? []);
110
+ const routes = [...html, ...ssr.pages];
111
+ const pages = measurePages(routes, readChunks(), bundle.budgets ?? []);
102
112
  const expected = expectedRoutes(readPageFiles(PAGES), PAGES);
103
113
  const measured = pages.map((page) => page.route);
104
114
  const failures = ssr.comparesExpectedRoutes ? missingRouteFailures(expected, measured, DIST) : [];
105
115
  failures.push(...ssr.failures, ...unknownChunkFailures(pages), ...overBudgetFailures(pages));
106
116
  const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
107
117
  printPages(pages, width);
108
- const stylesheets = readStylesheets();
109
- const cssFailure = cssBudgetFailure(stylesheets, cssMaxGzip);
110
- if (cssFailure)
111
- failures.push(cssFailure);
112
- printStylesheets(stylesheets, cssMaxGzip, cssFailure !== null, width);
118
+ failures.push(...checkCss(routes, cssMaxGzip, width));
113
119
  printSsrNote(expected, ssr);
114
120
  if (failures.length > 0) {
115
121
  console.error(`\n✗ Budget di bundle:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
@@ -99,7 +99,7 @@ export async function main() {
99
99
  }
100
100
  const { title, gaps } = section;
101
101
  console.log(gaps.length === 0 ? ` ✓ ${title}` : ` ${title}: ${gaps.length}`);
102
- const sectionFindings = gaps.map((gap) => ({ ...gap, severity: 'error' }));
102
+ const sectionFindings = gaps.map((gap) => ({ ...gap, severity: gap.severity ?? 'error' }));
103
103
  printFindings(sectionFindings, cliOptions([]).format);
104
104
  findings.push(...sectionFindings);
105
105
  }
@@ -37,7 +37,7 @@ function scan(env, output, config) {
37
37
  scope: `ambiente in ${env}`,
38
38
  findings: envScan(env, contactEnvKeys(config)),
39
39
  clean: 'Nessun segnaposto del template.',
40
- advice: 'Da sostituire prima del deploy: docs/guides/deploy-ops.md § Checklist per il go-live',
40
+ advice: 'Da impostare su Vercel, fra le variabili di produzione, prima del deploy: quali pretendere lo dice `placeholders.contactEnvKeys` in officina.config.ts',
41
41
  };
42
42
  }
43
43
  const paths = placeholderSources(config);
@@ -45,7 +45,7 @@ function scan(env, output, config) {
45
45
  scope: `${paths.length} sorgenti`,
46
46
  findings: sourceScan(paths),
47
47
  clean: 'Nessun segnaposto del template.',
48
- advice: 'Da sostituire prima del deploy: README.md § Cosa tocca il rebranding',
48
+ advice: 'Da sostituire con i dati del progetto prima del deploy: i file da guardare li dice `placeholders.sources` in officina.config.ts',
49
49
  };
50
50
  }
51
51
  export async function main(args = process.argv.slice(2)) {
@@ -1,6 +1,3 @@
1
- // Un ancoraggio rotto ha un percorso e un problema. Il pre-volo li impagina nel messaggio che
2
- // ferma il generatore; `doctor`, che gli stessi controlli li lancia senza generare niente, li
3
- // rimette in un ritrovamento.
4
1
  export class HookPointError extends Error {
5
2
  constructor(message, path, problem) {
6
3
  super(message);
@@ -15,5 +15,6 @@ export declare function scriptGaps({ scripts }: Manifest): ContractGap[];
15
15
  export declare function dependencyGaps({ dependencies, devDependencies }: Manifest): ContractGap[];
16
16
  export declare function leftoverGaps(files: ProjectFiles): ContractGap[];
17
17
  export declare const BIOME_PRESET = "@elia97/officina/biome";
18
+ export declare const BIOME_SCHEMA = "./node_modules/@biomejs/biome/configuration_schema.json";
18
19
  export declare const LEFTHOOK_PRESET = "node_modules/@elia97/officina/presets/lefthook.yml";
19
20
  export declare function presetGaps(files: ProjectFiles): ContractGap[];
@@ -125,13 +125,25 @@ export function leftoverGaps(files) {
125
125
  return gaps;
126
126
  }
127
127
  export const BIOME_PRESET = '@elia97/officina/biome';
128
+ export const BIOME_SCHEMA = './node_modules/@biomejs/biome/configuration_schema.json';
128
129
  export const LEFTHOOK_PRESET = 'node_modules/@elia97/officina/presets/lefthook.yml';
130
+ // Dependabot aggiorna @biomejs/biome ma non questo URL: con lo schema versionato `biome ci` stampa
131
+ // «The configuration schema version does not match the CLI version»; con quello del pacchetto no.
132
+ const VERSIONED_SCHEMA = /^https:\/\/biomejs\.dev\/schemas\/[^/]+\/schema\.json$/;
129
133
  export function presetGaps(files) {
130
134
  const gaps = [];
131
- const extended = readJson(files, 'biome.json')?.extends;
135
+ const biome = readJson(files, 'biome.json');
136
+ const extended = biome?.extends;
132
137
  if (!(Array.isArray(extended) && extended.includes(BIOME_PRESET))) {
133
138
  gaps.push({ path: 'biome.json', message: `non estende \`${BIOME_PRESET}\`` });
134
139
  }
140
+ const schema = biome?.$schema;
141
+ if (typeof schema === 'string' && VERSIONED_SCHEMA.test(schema)) {
142
+ gaps.push({
143
+ path: 'biome.json',
144
+ message: `\`$schema\` porta la versione di Biome, che Dependabot non aggiorna: va su \`${BIOME_SCHEMA}\``,
145
+ });
146
+ }
135
147
  if (!mentions(files.read('lefthook.yml'), LEFTHOOK_PRESET)) {
136
148
  gaps.push({ path: 'lefthook.yml', message: `non estende \`${LEFTHOOK_PRESET}\`` });
137
149
  }
@@ -13,6 +13,7 @@ export declare function budgetFor(route: string, budgets?: readonly Budget[]): B
13
13
  /** Rollup racchiude gli specificatori statici con `"` e i dinamici in un template literal. */
14
14
  export declare function parseEdges(source: string): Pick<Chunk, 'static' | 'dynamic'>;
15
15
  export declare function htmlEntries(html: string): string[];
16
+ export declare function htmlStylesheets(html: string): string[];
16
17
  /** `unknown`: i nomi citati che in `dist/client/_astro` non ci sono — saltarli vorrebbe dire misurare meno del vero. */
17
18
  export type Closure = {
18
19
  reached: Set<string>;
@@ -25,5 +26,3 @@ export type Stylesheet = {
25
26
  file: string;
26
27
  gzip: number;
27
28
  };
28
- export declare function heaviestStylesheet(sheets: readonly Stylesheet[]): Stylesheet | null;
29
- export declare function cssBudgetFailure(sheets: readonly Stylesheet[], maxGzip?: number): string | null;
@@ -14,7 +14,14 @@ export function parseEdges(source) {
14
14
  };
15
15
  }
16
16
  export function htmlEntries(html) {
17
- return [...captured(html, /(?:src|href)="\/_astro\/([^"]+\.js)"/g, 1)];
17
+ return [...captured(html, /\b(?:src|href)=["']?[^"'\s>]*?\/_astro\/([^"'\s>?#]+\.js)/g, 1)];
18
+ }
19
+ const LINK_TAG = /<link\b[^>]*>/gi;
20
+ const STYLESHEET_REL = /\brel=["']?stylesheet\b/i;
21
+ const LOCAL_STYLESHEET = /\bhref=["']?[^"'\s>]*?\/_astro\/([^"'\s>?#]+\.css)/i;
22
+ export function htmlStylesheets(html) {
23
+ const links = [...html.matchAll(LINK_TAG)].map(([tag]) => tag).filter((tag) => STYLESHEET_REL.test(tag));
24
+ return [...new Set(links.map((tag) => tag.match(LOCAL_STYLESHEET)?.[1]).filter((name) => name !== undefined))];
18
25
  }
19
26
  export function staticClosure(entries, chunks) {
20
27
  const reached = new Set();
@@ -39,15 +46,3 @@ export function deferredClosure(reached, chunks) {
39
46
  return new Set([...staticClosure(entries, chunks).reached].filter((name) => !reached.has(name)));
40
47
  }
41
48
  export const CSS_BUDGET_GZIP = 12 * 1024;
42
- export function heaviestStylesheet(sheets) {
43
- return sheets.reduce((worst, sheet) => (worst && worst.gzip >= sheet.gzip ? worst : sheet), null);
44
- }
45
- // Astro emette un foglio di stile per gruppo di pagine e una rotta ne collega esattamente uno,
46
- // quindi la loro somma è fatta di byte che nessun visitatore scarica mai insieme.
47
- export function cssBudgetFailure(sheets, maxGzip = CSS_BUDGET_GZIP) {
48
- const heaviest = heaviestStylesheet(sheets);
49
- if (heaviest === null || heaviest.gzip <= maxGzip)
50
- return null;
51
- const over = heaviest.gzip - maxGzip;
52
- return `CSS ${(heaviest.gzip / 1024).toFixed(1)} KB gz > ${(maxGzip / 1024).toFixed(1)} KB (+${(over / 1024).toFixed(1)} KB) in ${heaviest.file}`;
53
- }
@@ -0,0 +1,16 @@
1
+ import type { Stylesheet } from './bundle-budget.ts';
2
+ export type RouteStylesheets = {
3
+ route: string;
4
+ stylesheets: readonly string[];
5
+ };
6
+ export type CssGroup = {
7
+ stylesheets: string[];
8
+ gzip: number;
9
+ routes: string[];
10
+ };
11
+ export type CssMeasure = {
12
+ groups: CssGroup[];
13
+ failures: string[];
14
+ };
15
+ export declare const routeCount: (count: number) => string;
16
+ export declare function measureCss(routes: readonly RouteStylesheets[], sheets: readonly Stylesheet[], maxGzip: number, assets: string): CssMeasure;
@@ -0,0 +1,33 @@
1
+ const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
2
+ export const routeCount = (count) => (count === 1 ? '1 rotta' : `${count} rotte`);
3
+ function overBudget({ stylesheets, gzip, routes }, maxGzip) {
4
+ const weight = `CSS ${kb(gzip)} > ${kb(maxGzip)} (+${kb(gzip - maxGzip)})`;
5
+ if (routes.length === 0)
6
+ return `${stylesheets.join(' + ')}: ${weight}, pesato da solo: nessuna rotta misurata lo collega`;
7
+ const [first] = [...routes].sort();
8
+ return `${first}: ${weight} — ${stylesheets.join(' + ')}, su ${routeCount(routes.length)}`;
9
+ }
10
+ export function measureCss(routes, sheets, maxGzip, assets) {
11
+ const weights = new Map(sheets.map(({ file, gzip }) => [file, gzip]));
12
+ const groups = new Map();
13
+ const failures = [];
14
+ for (const { route, stylesheets } of routes) {
15
+ const linked = [...new Set(stylesheets)].sort();
16
+ for (const name of linked.filter((sheet) => !weights.has(sheet)))
17
+ failures.push(`${route}: il foglio ${name} non è in ${assets}, e il CSS della rotta non si misura per intero`);
18
+ if (linked.length === 0)
19
+ continue;
20
+ const key = linked.join(' + ');
21
+ const gzip = linked.reduce((sum, sheet) => sum + (weights.get(sheet) ?? 0), 0);
22
+ const group = groups.get(key) ?? { stylesheets: linked, gzip, routes: [] };
23
+ group.routes.push(route);
24
+ groups.set(key, group);
25
+ }
26
+ const linkedAnywhere = new Set(routes.flatMap(({ stylesheets }) => stylesheets));
27
+ for (const { file, gzip } of sheets)
28
+ if (!linkedAnywhere.has(file))
29
+ groups.set(file, { stylesheets: [file], gzip, routes: [] });
30
+ const measured = [...groups.values()].sort((a, b) => b.gzip - a.gzip);
31
+ failures.push(...measured.filter((group) => group.gzip > maxGzip).map((group) => overBudget(group, maxGzip)));
32
+ return { groups: measured, failures };
33
+ }
@@ -1,6 +1,7 @@
1
1
  export type PageEntries = {
2
2
  route: string;
3
3
  entries: string[];
4
+ stylesheets: string[];
4
5
  };
5
6
  export type SsrReading = {
6
7
  pages: PageEntries[];
@@ -27,9 +27,10 @@ export function readSsr(hasHtml) {
27
27
  }
28
28
  return {
29
29
  ...none,
30
- pages: routes.map(({ route, entries }) => ({ route, entries })),
31
- failures: routes.flatMap(({ route, unmapped, unknownIslands }) => [
30
+ pages: routes.map(({ route, entries, stylesheets }) => ({ route, entries, stylesheets: stylesheets ?? [] })),
31
+ failures: routes.flatMap(({ route, unmapped, unknownIslands, stylesheets }) => [
32
32
  ...(unmapped ? [`${route}: nessun chunk server in ${FUNCTIONS}, la misura non coprirebbe le isole`] : []),
33
+ ...(stylesheets === null ? [`${route}: il manifest non dice quali fogli di stile collega la rotta`] : []),
33
34
  ...unknownIslands.map((island) => `${route}: l'isola ${island} non ha un chunk in dist/client/_astro fra quelli del manifest`),
34
35
  ]),
35
36
  measured: true,
@@ -13,6 +13,7 @@ const ZERO_POSTAL_CODE = /\bpostalCode:\s*(['"])00000\1/;
13
13
  const EMPTY_HREF = /\bhref:\s*(['"])#\1/;
14
14
  // `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@59.22.0: `vercel pull` lo scrive al posto del valore di una Sensitive.
15
15
  const SENSITIVE = '[SENSITIVE]';
16
+ const WITHOUT_VALUE = 'il deploy andrebbe online senza il suo valore';
16
17
  const dictionaries = () => existsSync(DICTIONARIES)
17
18
  ? readdirSync(DICTIONARIES)
18
19
  .filter((name) => name.endsWith('.ts'))
@@ -56,7 +57,7 @@ export function envFindings(content, keys) {
56
57
  const value = env[key]?.trim();
57
58
  const declaration = lines.find(({ text }) => new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`).test(text));
58
59
  if (value === undefined || declaration === undefined) {
59
- const message = `${key} non c'è fra le variabili di produzione su Vercel: vale il default di astro.config.mjs`;
60
+ const message = `${key} non c'è fra le variabili di produzione su Vercel: ${WITHOUT_VALUE}`;
60
61
  return [{ severity: 'error', message }];
61
62
  }
62
63
  const hit = (severity, message) => [
@@ -65,7 +66,7 @@ export function envFindings(content, keys) {
65
66
  if (value === SENSITIVE)
66
67
  return hit('warning', 'è Sensitive su Vercel: vercel pull non ne scarica il valore, e il deploy non lo può verificare');
67
68
  if (value === '')
68
- return hit('error', 'è vuota: vale il default di astro.config.mjs');
69
+ return hit('error', `è vuota: ${WITHOUT_VALUE}`);
69
70
  if (TOKEN.test(value) || DOMAIN.test(value))
70
71
  return hit('error', 'porta un segnaposto del template');
71
72
  return [];
@@ -1,5 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
+ import { posix } from 'node:path';
3
+ import { blankFences, blankSpans, linkDestinations } from './markdown.js';
2
4
  const SCANNED = /\.md$/;
5
+ const ROADMAP = 'docs/ROADMAP.md';
3
6
  const IGNORED = [
4
7
  // Scritti a runtime dai comandi, gitignored per costruzione.
5
8
  /^\.claude\/plans\//,
@@ -7,9 +10,39 @@ const IGNORED = [
7
10
  /\.pdf$/,
8
11
  /^docs\/sources\//,
9
12
  ];
10
- const PATH_REFERENCE = /`((?:docs|src|scripts|test|public|\.claude|\.github)\/[A-Za-z0-9._/-]+\.[a-z]+)`/g;
13
+ const PATH_REFERENCE = /`((?:docs|src|scripts|test|e2e|public|\.claude|\.github)\/[A-Za-z0-9._/-]+\.[a-z]+)`/g;
14
+ const NAME_REFERENCE = /`([A-Za-z0-9._-]+)`/g;
11
15
  // La virgola non delimita: i titoli la contengono.
12
16
  const SECTION_REFERENCE = /`([A-Za-z0-9._/-]+\.md)`\s*§\s*([^.·|)§]{1,80})/g;
17
+ const LINK_TEXT = /\[[^[\]]*\]\(/g;
18
+ const ROOT_DOCUMENT = /^[A-Z][A-Z0-9_-]*\.md$/;
19
+ // I file tracciati alla radice di template/vetrina (git ls-files), Markdown esclusi.
20
+ const ROOT_CONFIG = new Set([
21
+ '.editorconfig',
22
+ '.env.example',
23
+ '.fallowrc.jsonc',
24
+ '.gitignore',
25
+ '.lighthouserc.json',
26
+ '.npmrc',
27
+ '.nvmrc',
28
+ '.release-please-manifest.json',
29
+ 'astro.config.mjs',
30
+ 'biome.json',
31
+ 'commitlint.config.js',
32
+ 'lefthook.yml',
33
+ 'officina.config.ts',
34
+ 'package.json',
35
+ 'playwright.config.ts',
36
+ 'pnpm-lock.yaml',
37
+ 'pnpm-workspace.yaml',
38
+ 'release-please-config.json',
39
+ 'tsconfig.json',
40
+ 'vercel.json',
41
+ 'vitest.config.ts',
42
+ ]);
43
+ const CHANGELOG = /(?:^|\/)CHANGELOG\.md$/;
44
+ const SITE_CONTENT = /^src\/content\//;
45
+ const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|#|\/)/i;
13
46
  const isIgnored = (path) => IGNORED.some((re) => re.test(path));
14
47
  export const isScanned = (path) => SCANNED.test(path) && !isIgnored(path);
15
48
  export function normalizeTitle(raw) {
@@ -32,23 +65,37 @@ const resolves = (titles, cited) => titles.some((title) => cited === title || (c
32
65
  export function resolveTarget(from, cited) {
33
66
  if (cited.includes('/'))
34
67
  return cited;
35
- const sibling = `${from.slice(0, from.lastIndexOf('/') + 1)}${cited}`;
36
- return existsSync(sibling) ? sibling : cited;
68
+ const candidates = [`${from.slice(0, from.lastIndexOf('/') + 1)}${cited}`, cited, `docs/${cited}`];
69
+ return candidates.find((candidate) => existsSync(candidate)) ?? cited;
37
70
  }
38
- export function findingsFor(path, source) {
71
+ const references = (text, pattern) => [...text.matchAll(pattern)]
72
+ .map((match) => ({ index: match.index, target: match[1] }))
73
+ .filter((reference) => reference.target !== undefined);
74
+ const pathReferences = (flat) => references(flat, PATH_REFERENCE).filter(({ target }) => !target.includes('*'));
75
+ function nameReferences(path, flat) {
76
+ if (path === ROADMAP || CHANGELOG.test(path) || SITE_CONTENT.test(path))
77
+ return [];
78
+ const labels = [...flat.matchAll(LINK_TEXT)].map(({ index, 0: text }) => ({ start: index, end: index + text.length }));
79
+ const inLabel = (index) => labels.some(({ start, end }) => index > start && index < end);
80
+ return references(flat, NAME_REFERENCE)
81
+ .filter(({ index, target }) => (ROOT_DOCUMENT.test(target) || ROOT_CONFIG.has(target)) && !inLabel(index))
82
+ .map(({ index, target }) => ({ index, target: resolveTarget(path, target) }));
83
+ }
84
+ const linkTarget = (from, destination) => EXTERNAL.test(destination)
85
+ ? undefined
86
+ : posix.normalize(posix.join(posix.dirname(from), destination.replace(/[#?].*$/, '')));
87
+ function linkReferences(path, code) {
88
+ if (SITE_CONTENT.test(path))
89
+ return [];
90
+ return linkDestinations(code).flatMap(({ index, destination }) => {
91
+ const target = linkTarget(path, destination);
92
+ return target === undefined ? [] : [{ index, target }];
93
+ });
94
+ }
95
+ const escapes = (target) => target === '..' || target.startsWith('../');
96
+ const isMissing = (target) => !isIgnored(target) && (escapes(target) || !existsSync(target));
97
+ function sectionFindings(path, flat, lineOf, missing) {
39
98
  const findings = [];
40
- // Un titolo va a capo come qualunque prosa: si cerca su testo continuo. La sostituzione è uno a
41
- // uno, quindi gli offset restano quelli del sorgente e la riga si conta da lì.
42
- const flat = source.replace(/\n/g, ' ');
43
- const lineOf = (index) => source.slice(0, index).split('\n').length;
44
- const missingTargets = new Set();
45
- for (const match of flat.matchAll(PATH_REFERENCE)) {
46
- const target = match[1];
47
- if (!target || isIgnored(target) || target.includes('*') || existsSync(target))
48
- continue;
49
- missingTargets.add(target);
50
- findings.push({ line: lineOf(match.index), message: `percorso che non esiste: ${target}` });
51
- }
52
99
  for (const match of flat.matchAll(SECTION_REFERENCE)) {
53
100
  const [, file, title] = match;
54
101
  /* v8 ignore next -- i due gruppi sono obbligatori nella regex, ma noUncheckedIndexedAccess pretende la guardia */
@@ -56,7 +103,7 @@ export function findingsFor(path, source) {
56
103
  continue;
57
104
  const line = lineOf(match.index);
58
105
  const target = resolveTarget(path, file);
59
- if (isIgnored(target) || missingTargets.has(target))
106
+ if (isIgnored(target) || missing.has(`${line}:${target}`))
60
107
  continue;
61
108
  if (!existsSync(target)) {
62
109
  findings.push({ line, message: `sezione in un file che non esiste: ${target}` });
@@ -66,5 +113,27 @@ export function findingsFor(path, source) {
66
113
  if (!resolves(headings(readFileSync(target, 'utf8')), cited))
67
114
  findings.push({ line, message: `sezione che non esiste: ${target} § ${cited}` });
68
115
  }
69
- return findings.sort((a, b) => a.line - b.line);
116
+ return findings;
117
+ }
118
+ export function findingsFor(path, source) {
119
+ const fenced = blankFences(source);
120
+ // Un titolo va a capo come qualunque prosa: si cerca su testo continuo. La sostituzione è uno a
121
+ // uno, quindi gli offset restano quelli del sorgente e la riga si conta da lì.
122
+ const flat = fenced.replace(/\n/g, ' ');
123
+ const lineOf = (index) => source.slice(0, index).split('\n').length;
124
+ const missing = new Map();
125
+ for (const { index, target } of [
126
+ ...pathReferences(flat),
127
+ ...nameReferences(path, flat),
128
+ ...linkReferences(path, blankSpans(fenced)),
129
+ ]) {
130
+ const line = lineOf(index);
131
+ if (isMissing(target))
132
+ missing.set(`${line}:${target}`, { line, target });
133
+ }
134
+ const paths = [...missing.values()].map(({ line, target }) => ({
135
+ line,
136
+ message: `percorso che non esiste: ${target}`,
137
+ }));
138
+ return [...paths, ...sectionFindings(path, flat, lineOf, new Set(missing.keys()))].sort((a, b) => a.line - b.line);
70
139
  }
@@ -25,6 +25,7 @@ export declare const CONTRACT: Requirement[];
25
25
  export interface ContractGap {
26
26
  path: string;
27
27
  message: string;
28
+ severity?: 'warning';
28
29
  }
29
30
  export declare function contractGaps(root: string): ContractGap[];
30
31
  export {};
@@ -1,7 +1,8 @@
1
1
  import { parse } from 'yaml';
2
2
  const DEPENDABOT = '.github/dependabot.yml';
3
3
  const PACKAGE = '@elia97/officina';
4
- const ACTIONS = 'Elia97/officina/*';
4
+ const ACTIONS = 'Elia97/officina';
5
+ const PATH_FILTER = `${ACTIONS}/*`;
5
6
  const GROUP_ONLY = ['commit-message', 'open-pull-requests-limit'];
6
7
  const gap = (message) => ({ path: DEPENDABOT, message });
7
8
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -32,6 +33,16 @@ const coverageGaps = (updates) => [
32
33
  ]
33
34
  .filter(({ ecosystem, needle }) => !inGroup(updates).some((u) => u['package-ecosystem'] === ecosystem && covers(u, needle)))
34
35
  .map(({ needle, reason }) => gap(`nessuna voce del gruppo copre \`${needle}\`: ${reason}`));
36
+ const strings = (value) => {
37
+ if (typeof value === 'string')
38
+ return [value];
39
+ return typeof value === 'object' && value !== null ? Object.values(value).flatMap(strings) : [];
40
+ };
41
+ const pathFilterGaps = (document) => strings(document).includes(PATH_FILTER)
42
+ ? [
43
+ gap(`\`${PATH_FILTER}\` non corrisponde a nessuna dipendenza: Dependabot chiama le action \`${ACTIONS}\`, senza la sottocartella, e il filtro va corretto ovunque compaia`),
44
+ ]
45
+ : [];
35
46
  // `patterns` restringe l'intera voce: il resto dell'ecosistema smette di aggiornarsi e nessun check diventa rosso.
36
47
  function restGaps(updates) {
37
48
  const restrained = inGroup(updates).filter((update) => (update.patterns ?? []).length > 0);
@@ -55,5 +66,11 @@ export function dependabotGaps({ read: readFile }) {
55
66
  if (typeof document === 'string')
56
67
  return [gap(document)];
57
68
  const updates = document.updates ?? [];
58
- return [...groupGaps(document, updates), ...misplacedGaps(updates), ...coverageGaps(updates), ...restGaps(updates)];
69
+ return [
70
+ ...groupGaps(document, updates),
71
+ ...misplacedGaps(updates),
72
+ ...coverageGaps(updates),
73
+ ...pathFilterGaps(document),
74
+ ...restGaps(updates),
75
+ ];
59
76
  }
@@ -0,0 +1,7 @@
1
+ export interface Destination {
2
+ index: number;
3
+ destination: string;
4
+ }
5
+ export declare function blankFences(source: string): string;
6
+ export declare const blankSpans: (text: string) => string;
7
+ export declare const linkDestinations: (text: string) => Destination[];
@@ -0,0 +1,41 @@
1
+ // CommonMark § 4.5: recinto di tre o più ` o ~ (rientro fino a 3 spazi), chiuso da una riga dello stesso carattere,
2
+ // lunga almeno quanto l'apertura.
3
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
4
+ // CommonMark § 6.1: lo span si chiude alla prima stringa di apici inversi di pari lunghezza; una riga vuota lo interrompe.
5
+ const SPAN = /(?<!`)(`+)(?!`)((?:(?!\n[ \t]*\n)[\s\S])*?)(?<!`)\1(?!`)/g;
6
+ // CommonMark § 6.3: destinazione fra <> o senza spazi (parentesi bilanciate, qui un solo livello); titolo fra "", '' o ().
7
+ const INLINE = /\]\(\s*(?:<([^>\n]*)>|((?:[^()\s\\]|\\.|\([^()\s]*\))*))(?:\s+(?:"[^"]*"|'[^']*'|\([^()]*\)))?\s*\)/g;
8
+ const DEFINITION = /^ {0,3}\[[^\]\n]+\]:[ \t]*(?:<([^>\n]*)>|(\S+))/gm;
9
+ const spaces = (text) => text.replace(/[^\n]/g, ' ');
10
+ export function blankFences(source) {
11
+ let fence;
12
+ return source
13
+ .split('\n')
14
+ .map((line) => {
15
+ const run = FENCE.exec(line)?.[1];
16
+ if (fence === undefined) {
17
+ if (run === undefined)
18
+ return line;
19
+ fence = run;
20
+ }
21
+ else if (run !== undefined && run[0] === fence[0] && run.length >= fence.length && line.trim() === run) {
22
+ fence = undefined;
23
+ }
24
+ return spaces(line);
25
+ })
26
+ .join('\n');
27
+ }
28
+ export const blankSpans = (text) => text.replace(SPAN, spaces);
29
+ const unescaped = (text) => text.replace(/\\(.)/g, '$1');
30
+ function decoded(text) {
31
+ try {
32
+ return decodeURIComponent(text);
33
+ }
34
+ catch {
35
+ return text;
36
+ }
37
+ }
38
+ export const linkDestinations = (text) => [...text.matchAll(INLINE), ...text.matchAll(DEFINITION)].map((match) => ({
39
+ index: match.index,
40
+ destination: decoded(unescaped((match[1] ?? match[2]))),
41
+ }));
@@ -13,7 +13,7 @@ export const PAST_TENSE = [
13
13
  // "non più di 3" è una quantità, non un cambiamento.
14
14
  phrase(['non (?:è |viene |serve )?più(?! di)']),
15
15
  phrase(["ora (?:è|invece|lo|la|il|si|c'è|non)", 'adesso', 'per ora', 'temporaneamente']),
16
- phrase(['sostituisce', 'al posto di', 'vecchi[oa]']),
16
+ phrase(['sostituisce', 'vecchi[oa]']),
17
17
  phrase(['used to', 'previously', 'no longer', 'replaces the', 'replaced by', 'removed in', 'for now', 'temporarily']),
18
18
  /#\d{1,3}(?!\d)/,
19
19
  ];
@@ -25,8 +25,6 @@ export const aRecordOf = (item) => (value, path) => isRecord(value)
25
25
  ? Object.entries(value).flatMap(([key, entry]) => item(entry, `${path}.${key}`))
26
26
  : [problem(path, 'un oggetto', value)];
27
27
  export const oneOf = (expected, allowed) => (value, path) => allowed.includes(value) ? [] : [problem(path, expected, value)];
28
- // Una voce non dichiarata è un errore: `sitUrl` al posto di `siteUrl` non deve passare in
29
- // silenzio, che è il modo esatto in cui una configurazione smette di dire qualcosa.
30
28
  export const aShape = (fields) => (value, path) => {
31
29
  if (!isRecord(value))
32
30
  return [problem(path, 'un oggetto', value)];
@@ -5,6 +5,7 @@ export type ServerFile = {
5
5
  export type SsrRoute = {
6
6
  route: string;
7
7
  entries: string[];
8
+ stylesheets: string[] | null;
8
9
  unmapped: boolean;
9
10
  unknownIslands: string[];
10
11
  };
@@ -1,6 +1,7 @@
1
1
  import { posix } from 'node:path';
2
2
  const MANIFEST_CALL = 'deserializeManifest({';
3
- const ASSET = /^_astro\/([^/]+\.js)$/;
3
+ const ASSET = /^(?:.*\/)?_astro\/([^/?#]+\.js)(?:[?#].*)?$/;
4
+ const STYLESHEET = /^(?:.*\/)?_astro\/([^?#]+\.css)(?:[?#].*)?$/;
4
5
  const RENDERER_RUNTIME = /^@astrojs\/[\w-]+\/client\.js$/;
5
6
  const COMPONENT_SCRIPT = '?astro&type=script';
6
7
  const PAGE_ROUTE = /\["(src\/pages\/[^"]+)", (_page\d+)\]/g;
@@ -65,7 +66,7 @@ function serverClosure(start, files, manifestFile) {
65
66
  }
66
67
  return [...reached.values()];
67
68
  }
68
- const assetOf = (value) => value?.match(ASSET)?.[1];
69
+ const assetOf = (value, pattern = ASSET) => value?.match(pattern)?.[1];
69
70
  function clientModules(manifest) {
70
71
  const inlined = new Set((manifest.inlinedScripts ?? []).map(([specifier]) => specifier));
71
72
  const modules = new Map();
@@ -87,11 +88,19 @@ function routeEntries(page, texts, modules) {
87
88
  const unknownIslands = (texts, modules) => [...new Set(texts.flatMap((text) => [...text.matchAll(ISLAND_PATH)].map(([, path]) => path)))]
88
89
  .filter((path) => !modules.has(path))
89
90
  .sort();
90
- function ssrPage({ scripts = [], routeData }) {
91
+ const routeStylesheets = (styles) => !Array.isArray(styles)
92
+ ? null
93
+ : [
94
+ ...new Set(styles
95
+ .filter((style) => style.type === 'external')
96
+ .map((style) => assetOf(style.src, STYLESHEET))
97
+ .filter((name) => name !== undefined)),
98
+ ].sort();
99
+ function ssrPage({ scripts = [], styles, routeData }) {
91
100
  if (routeData?.type !== 'page' || routeData.origin !== 'project' || routeData.prerender !== false)
92
101
  return [];
93
102
  const { route, component } = routeData;
94
- return typeof route === 'string' && typeof component === 'string' ? [{ scripts, route, component }] : [];
103
+ return typeof route === 'string' && typeof component === 'string' ? [{ scripts, styles, route, component }] : [];
95
104
  }
96
105
  export function ssrRoutes(files) {
97
106
  for (const file of files) {
@@ -111,6 +120,7 @@ function routesOf(host, manifest, files) {
111
120
  return {
112
121
  route: page.route,
113
122
  entries: routeEntries(page, texts, modules),
123
+ stylesheets: routeStylesheets(page.styles),
114
124
  unmapped: chunk === undefined,
115
125
  unknownIslands: unknownIslands(texts, modules),
116
126
  };
package/dist/smoke.js CHANGED
@@ -1,4 +1,2 @@
1
- // I pezzi con cui un progetto compone i propri controlli di smoke: `smoke.checks` sostituisce la
2
- // lista del pacchetto, quindi chi ne toglie uno ricompone la lista da qui.
3
1
  export { NON_HTML_ROUTES } from './lib/routes.js';
4
2
  export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elia97/officina",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",
@@ -48,9 +48,10 @@
48
48
  "typecheck": "tsc --noEmit",
49
49
  "test": "vitest run --coverage",
50
50
  "format": "biome check --write .",
51
+ "check:comments": "node src/bin.ts check comments --strict",
51
52
  "check:deadcode": "fallow dead-code",
52
53
  "check:health": "fallow health --complexity --coverage coverage/coverage-final.json",
53
- "ci": "biome ci --error-on-warnings . && pnpm run typecheck && pnpm run check:deadcode && pnpm run test && pnpm run check:health",
54
+ "ci": "biome ci --error-on-warnings . && pnpm run typecheck && pnpm run check:deadcode && pnpm run check:comments && pnpm run test && pnpm run check:health",
54
55
  "test:pack": "bash scripts/pack-test.sh"
55
56
  },
56
57
  "devDependencies": {