@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 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 `src/lib/analytics/link-tracking.ts` | — |
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 da `public/favicon.svg` | — |
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`, e `officina doctor` dice, in qualunque repository, cosa manca. Finché manca qualcosa il pre-volo del generatore si ferma prima di scrivere un solo file.
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
 
@@ -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} event(s) the site pushes and no trigger listens for.`);
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✓ Every event the site pushes has a trigger.');
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('No GTM container to check: pass one as an argument or set PUBLIC_GTM_ID.');
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} unreadable: HTTP ${response.status}`);
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} — measurement IDs: ${googleTagIds(container).join(', ') || '—'}\n`);
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)
@@ -48,18 +48,18 @@ function overBudgetFailures(pages) {
48
48
  });
49
49
  }
50
50
  function printPages(pages, width) {
51
- console.log('\nBundle budget — client JS per route (gzip, static closure)\n');
52
- console.log(`${'ROUTE'.padEnd(width)} ${'STATIC'.padStart(9)} ${'BUDGET'.padStart(9)} ${'DEFERRED'.padStart(9)}`);
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('\nDEFERRED = reachable only through `await import()` (loaded after paint, behind a runtime guard).');
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 (render-blocking, heaviest sheet a route links)`);
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(`\nNOTE ${expected.ssr.length} page(s) opted out with \`export const prerender = false\` and are outside this budget:`);
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('ROUTE'.length, ...pages.map((p) => p.route.length));
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✗ Bundle budget:\n${failures.map((f) => ` - ${f}`).join('\n')}\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✓ Bundle budget respected on every expected route.\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)
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export declare function main(): number;
2
+ export declare function main(): Promise<number>;
@@ -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 { CONTRACT, contractGaps } from '../lib/contract.js';
5
- export function main() {
6
- const findings = contractGaps(process.cwd()).map((gap) => ({ ...gap, severity: 'error' }));
7
- console.log(`\nofficina doctor — ${CONTRACT.length} punti di aggancio dei generatori\n`);
8
- printFindings(findings, cliOptions([]).format);
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 ha tutto quello che i generatori si aspettano.\n'
11
- : `\n ${findings.length} su ${CONTRACT.length} da sistemare prima che \`officina gen\` scriva codice che compila.\n`);
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} route(s) derived from ${PAGES_DIR}\n`);
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();
@@ -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} (skipped: ${detail})`);
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} check(s) failed:`);
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('Production is live and broken. Roll back from the Vercel dashboard:');
22
- console.error('Deployments → the last known-good production deployment → Promote to Production.\n');
21
+ console.error('La produzione è online e rotta. Torna indietro dalla dashboard di Vercel:');
22
+ console.error('Deployments → l’ultimo deployment di produzione sano → Promote to Production.\n');
23
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 declares no siteUrl — the canonical-host check has nothing to compare against.\n');
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 is still the template placeholder — pass a URL: pnpm smoke:prod https://…\n');
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(`\nProduction smoke — ${baseUrl}\n`);
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✓ Every check passed on ${baseUrl}.\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 WORKFLOW = '.github/workflows/deploy.yml';
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
- export async function main() {
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(WORKFLOW, 'utf8'), version).map((hit) => ({
18
- path: WORKFLOW,
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 ${WORKFLOW} e allinea la CLI locale alla stessa major.\n`);
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
+ }
@@ -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>;
@@ -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 = CONFIG_FILES.map((name) => join(root, name)).find((path) => existsSync(path));
17
+ const file = findConfigFile(root);
17
18
  if (file === undefined)
18
19
  return {};
19
20
  const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
@@ -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} holds no .html file — no route was measured, so the per-route budgets assert nothing`];
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(`missing route ${route} — ${file} is prerendered but emitted no HTML`);
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(`missing route ${label} — ${file} is prerendered but getStaticPaths emitted nothing`);
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, `expected 200, got ${response.status}`));
36
+ results.push(fail(check, `atteso 200, ricevuto ${response.status}`));
37
37
  else if (!contentType.includes(type))
38
- results.push(fail(check, `expected a ${type} content-type, got "${contentType}"`));
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('security headers', messageOf(error))];
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, 'missing');
60
+ return fail(check, 'assente');
61
61
  if (expected !== null && value !== expected)
62
- return fail(check, `expected "${expected}", got "${value}"`);
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 = 'no x-robots-tag on the production host';
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, `present on ${baseUrl}: "${robots}"`));
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 challenge proxied same-origin';
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, `expected 200 from the rewrite, got ${response.status}`)];
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 → apex 308';
85
+ const check = 'www → apice 308';
86
86
  if (baseUrl !== siteUrl)
87
- return [skip(check, `base URL is not ${siteUrl}`)];
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, `expected 308, got ${response.status}`)];
92
+ return [fail(check, `atteso 308, ricevuto ${response.status}`)];
93
93
  if (!location.startsWith(siteUrl))
94
- return [fail(check, `location "${location}" does not point at ${siteUrl}`)];
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 = 'trailing slash → 308';
103
+ const check = 'barra finale → 308';
104
104
  if (page === undefined)
105
- return [skip(check, 'no HTML page other than / to probe')];
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, `expected 308 on ${page.path}/, got ${response.status}`)];
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}" does not point at ${page.path}`)];
112
+ return [fail(check, `location "${location}" non punta a ${page.path}`)];
113
113
  return [pass(check)];
114
114
  }
115
115
  catch (error) {
@@ -25,9 +25,9 @@ detect_chrome() {
25
25
  }
26
26
 
27
27
  if ! CHROME_PATH="$(detect_chrome)"; then
28
- echo "✗ no usable Linux Chrome/Chromium found." >&2
29
- echo " Install one with: pnpm dlx playwright@latest install chromium" >&2
30
- echo " Or point at the binary: CHROME_PATH=/path/to/chrome pnpm run lhci:local" >&2
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 "→ production-equivalent build in ${OUT_DIR}"
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 has no 'Allow: /' — the build is not production-equivalent." >&2
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 "→ starting the static server on :${PORT}"
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 "✗ the server did not answer on :${PORT} within 60s." >&2
67
- echo " Retry on another port: LHCI_PORT=4400 pnpm run lhci:local" >&2
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 ready"
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 "→ score per URL (median over ${LHCI_RUNS:-1} run(s), the way LHCI asserts)"
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.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
+ }
@@ -0,0 +1,11 @@
1
+ pre-commit:
2
+ commands:
3
+ biome-check:
4
+ glob: "*.{js,jsx,ts,tsx,astro,json,jsonc,css}"
5
+ run: pnpm -s biome:fix {staged_files}
6
+ stage_fixed: true
7
+
8
+ commit-msg:
9
+ commands:
10
+ commitlint:
11
+ run: pnpm exec commitlint --edit {1}