@elia97/officina 0.2.0 → 0.3.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.
Files changed (68) hide show
  1. package/README.md +40 -2
  2. package/dist/bin.d.ts +8 -0
  3. package/dist/bin.js +12 -3
  4. package/dist/checks/analytics.d.ts +2 -0
  5. package/dist/checks/analytics.js +48 -0
  6. package/dist/checks/bundle.d.ts +2 -0
  7. package/dist/checks/bundle.js +98 -0
  8. package/dist/checks/comments.d.ts +2 -0
  9. package/dist/checks/doctor.d.ts +2 -0
  10. package/dist/checks/language.d.ts +2 -0
  11. package/dist/checks/lighthouse.d.ts +2 -0
  12. package/dist/checks/lighthouse.js +37 -0
  13. package/dist/checks/placeholders.d.ts +2 -0
  14. package/dist/checks/roadmap.d.ts +2 -0
  15. package/dist/checks/routes.d.ts +2 -0
  16. package/dist/checks/smoke.d.ts +2 -0
  17. package/dist/checks/smoke.js +57 -0
  18. package/dist/checks/vercel-cli.d.ts +2 -0
  19. package/dist/gen/collection.d.mts +1 -0
  20. package/dist/gen/component.d.mts +1 -0
  21. package/dist/gen/icons.d.ts +2 -0
  22. package/dist/gen/icons.js +27 -0
  23. package/dist/gen/identifier.d.mts +1 -0
  24. package/dist/gen/inject-config.d.mts +12 -0
  25. package/dist/gen/inject-section.d.mts +16 -0
  26. package/dist/gen/inject-strings.d.mts +8 -0
  27. package/dist/gen/page.d.mts +10 -0
  28. package/dist/gen/plopfile.d.mts +2 -0
  29. package/dist/gen/post-gen.d.mts +1 -0
  30. package/dist/gen/run.d.ts +2 -0
  31. package/dist/gen/section-targets.d.mts +23 -0
  32. package/dist/gen/section.d.mts +1 -0
  33. package/dist/gen/sectioned-collection.d.mts +26 -0
  34. package/dist/gen/ts-morph-utils.d.mts +1 -0
  35. package/dist/index.d.ts +10 -0
  36. package/dist/index.js +5 -0
  37. package/dist/lib/analytics-coverage.d.ts +12 -0
  38. package/dist/lib/analytics-coverage.js +15 -0
  39. package/dist/lib/bundle-budget.d.ts +24 -0
  40. package/dist/lib/bundle-budget.js +50 -0
  41. package/dist/lib/check-comments.d.ts +10 -0
  42. package/dist/lib/check-language.d.ts +11 -0
  43. package/dist/lib/check-placeholders.d.ts +6 -0
  44. package/dist/lib/check-roadmap.d.ts +30 -0
  45. package/dist/lib/check-routes.d.ts +6 -0
  46. package/dist/lib/cli.d.ts +26 -0
  47. package/dist/lib/comment-syntax.d.ts +11 -0
  48. package/dist/lib/config.d.ts +24 -0
  49. package/dist/lib/config.js +21 -0
  50. package/dist/lib/contract.d.ts +22 -0
  51. package/dist/lib/git.d.ts +6 -0
  52. package/dist/lib/gtm-container.d.ts +41 -0
  53. package/dist/lib/gtm-container.js +145 -0
  54. package/dist/lib/icon-specs.d.ts +6 -0
  55. package/dist/lib/icon-specs.js +6 -0
  56. package/dist/lib/icons.d.ts +6 -0
  57. package/dist/lib/icons.js +32 -0
  58. package/dist/lib/language-words.d.ts +2 -0
  59. package/dist/lib/lighthouse.d.ts +17 -0
  60. package/dist/lib/lighthouse.js +23 -0
  61. package/dist/lib/past-tense.d.ts +1 -0
  62. package/dist/lib/routes.d.ts +31 -0
  63. package/dist/lib/routes.js +97 -0
  64. package/dist/lib/smoke-production.d.ts +35 -0
  65. package/dist/lib/smoke-production.js +132 -0
  66. package/dist/lib/vercel-cli-pin.d.ts +2 -0
  67. package/dist/sh/lhci-local.sh +106 -0
  68. package/package.json +17 -3
package/README.md CHANGED
@@ -19,17 +19,55 @@ pnpm add -D @elia97/officina
19
19
  "check:roadmap": "officina check roadmap",
20
20
  "check:placeholders": "officina check placeholders",
21
21
  "check:vercel-cli": "officina check vercel-cli",
22
+ "perf:bundle": "officina check bundle",
23
+ "smoke:prod": "officina check smoke",
24
+ "analytics:verify": "officina check analytics",
25
+ "lhci": "officina check lighthouse",
26
+ "lhci:local": "officina check lighthouse --local",
22
27
  "gen": "officina gen",
23
28
  "gen:section": "officina gen section",
24
29
  "gen:page": "officina gen page",
25
30
  "gen:component": "officina gen component",
26
31
  "gen:collection": "officina gen collection",
32
+ "gen:icons": "officina gen icons",
27
33
  "doctor": "officina doctor"
28
34
  }
29
35
  }
30
36
  ```
31
37
 
32
- I gate leggono dalla cartella corrente, che deve essere la radice del repository. Opzioni comuni: `--diff`, `--base <ref>`, `--head <ref>`, `--strict`, `--format text|github`.
38
+ I gate leggono dalla cartella corrente, che deve essere la radice del repository. Opzioni comuni dei gate sui sorgenti: `--diff`, `--base <ref>`, `--head <ref>`, `--strict`, `--format text|github`.
39
+
40
+ ## Verifiche di build e di produzione
41
+
42
+ | Comando | Cosa guarda | Da dove prende le rotte |
43
+ |---|---|---|
44
+ | `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages` |
45
+ | `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` | — |
47
+ | `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` | — |
49
+
50
+ Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
51
+
52
+ ```ts
53
+ import { type Budget, DEFAULT_CHECKS, defineConfig, type SmokeCheck } from '@elia97/officina'
54
+
55
+ import { SITE } from './src/lib/site.ts'
56
+
57
+ const motion: Budget = { label: 'motion', matches: (route) => route === '/', maxGzip: 72 * 1024 }
58
+ const checkLanguageRedirect: SmokeCheck = async ({ get, baseUrl }) => []
59
+
60
+ export default defineConfig({
61
+ siteUrl: SITE.url,
62
+ icons: { background: SITE.themeColor.light },
63
+ bundle: { budgets: [motion], cssMaxGzip: 14 * 1024 },
64
+ smoke: { checks: [...DEFAULT_CHECKS, checkLanguageRedirect] },
65
+ })
66
+ ```
67
+
68
+ Ogni voce è facoltativa, tranne `siteUrl` per `check smoke` e `icons.background` per `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi non ha BotID la ricompone senza `checkBotIdChallenge`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`. Il file lo carica Node, che fuori da `node_modules` toglie i tipi da sé: niente `enum`, niente alias `@/`.
69
+
70
+ `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.
33
71
 
34
72
  ## Sviluppo
35
73
 
@@ -61,4 +99,4 @@ I test dei generatori girano sui punti di aggancio del progetto di prova, copiat
61
99
 
62
100
  ## Cosa arriverà
63
101
 
64
- Bundle budget, smoke di produzione, lighthouse, verifica analytics e icone. Poi i preset di `ecommerce` e `monorepo`.
102
+ I preset di `ecommerce` e `monorepo`.
package/dist/bin.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ type Main = (args?: string[]) => number | Promise<number>;
3
+ type Loader = () => Promise<{
4
+ main: Main;
5
+ }>;
6
+ export declare const CHECKS: Record<string, Loader>;
7
+ export declare function run(argv: string[]): Promise<number>;
8
+ export {};
package/dist/bin.js CHANGED
@@ -1,26 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
  import process from 'node:process';
3
3
  // Un modulo per gate, caricato solo quando serve: `officina check routes` non paga il costo
4
- // degli altri cinque.
4
+ // degli altri.
5
5
  export const CHECKS = {
6
+ analytics: () => import('./checks/analytics.js'),
7
+ bundle: () => import('./checks/bundle.js'),
6
8
  comments: () => import('./checks/comments.js'),
7
9
  language: () => import('./checks/language.js'),
10
+ lighthouse: () => import('./checks/lighthouse.js'),
8
11
  placeholders: () => import('./checks/placeholders.js'),
9
12
  roadmap: () => import('./checks/roadmap.js'),
10
13
  routes: () => import('./checks/routes.js'),
14
+ smoke: () => import('./checks/smoke.js'),
11
15
  'vercel-cli': () => import('./checks/vercel-cli.js'),
12
16
  };
13
17
  const USAGE = `Uso:
14
18
  officina check <gate> [opzioni] gate: ${Object.keys(CHECKS).join(', ')}
15
19
  officina gen [generatore] section, page, component, collection, più quelli del progetto
20
+ officina gen icons le icone del manifest da public/favicon.svg
16
21
  officina doctor cosa manca al progetto perché i generatori funzionino
17
22
 
18
- Opzioni dei gate: --diff, --base <ref>, --head <ref>, --strict, --format text|github
23
+ Opzioni dei gate sui sorgenti: --diff, --base <ref>, --head <ref>, --strict, --format text|github
24
+ bundle legge dist/client; smoke [url] e analytics [GTM-…] vanno in rete; lighthouse [--local]
25
+ I valori del progetto (URL, budget, header, controlli propri) stanno in officina.config.ts
19
26
  `;
20
27
  // Ogni gruppo dice quale modulo carica e quali argomenti gli passa.
21
28
  const GROUPS = {
22
29
  check: ([name = '', ...rest]) => ({ load: CHECKS[name], args: rest }),
23
- gen: (args) => ({ load: () => import('./gen/run.js'), args }),
30
+ gen: (args) => args[0] === 'icons'
31
+ ? { load: () => import('./gen/icons.js'), args: args.slice(1) }
32
+ : { load: () => import('./gen/run.js'), args },
24
33
  doctor: (args) => ({ load: () => import('./checks/doctor.js'), args }),
25
34
  };
26
35
  export async function run(argv) {
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): Promise<number>;
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // Il container GTM vivo scatta sugli eventi che questo sito emette? Legge il gtm.js pubblico
3
+ // del container: nessuna credenziale, niente da scrivere.
4
+ import { readFileSync } from 'node:fs';
5
+ import process from 'node:process';
6
+ import { coverageOf, extractLinkEvents } from '../lib/analytics-coverage.js';
7
+ import { loadConfig } from '../lib/config.js';
8
+ import { extractTriggers, googleTagIds, parseContainerData } from '../lib/gtm-container.js';
9
+ const LINK_TRACKING = 'src/lib/analytics/link-tracking.ts';
10
+ const TIMEOUT_MS = 15_000;
11
+ function resolveGtmId(args) {
12
+ const gtmId = args[0] ?? process.env.PUBLIC_GTM_ID ?? '';
13
+ return /^GTM-[A-Z0-9]+$/.test(gtmId) ? gtmId : null;
14
+ }
15
+ function reportCoverage(coverage) {
16
+ for (const { event, prefix, covered } of coverage) {
17
+ console.log(` ${covered ? '✓' : '✗'} ${event.padEnd(16)} (${prefix})`);
18
+ }
19
+ const missing = coverage.filter(({ covered }) => !covered);
20
+ if (missing.length > 0) {
21
+ console.error(`\n✗ ${missing.length} event(s) the site pushes and no trigger listens for.`);
22
+ return 1;
23
+ }
24
+ console.log('\n✓ Every event the site pushes has a trigger.');
25
+ return 0;
26
+ }
27
+ export async function main(args = []) {
28
+ const { analytics = {} } = await loadConfig(process.cwd());
29
+ const gtmId = resolveGtmId(args);
30
+ if (gtmId === null) {
31
+ console.log('No GTM container to check: pass one as an argument or set PUBLIC_GTM_ID.');
32
+ return 0;
33
+ }
34
+ const response = await fetch(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`, {
35
+ signal: AbortSignal.timeout(TIMEOUT_MS),
36
+ });
37
+ if (!response.ok) {
38
+ console.error(`✗ container ${gtmId} unreadable: HTTP ${response.status}`);
39
+ return 1;
40
+ }
41
+ const container = parseContainerData(await response.text());
42
+ const source = readFileSync(analytics.linkTracking ?? LINK_TRACKING, 'utf8');
43
+ const coverage = coverageOf(extractLinkEvents(source), extractTriggers(container));
44
+ console.log(`\nGTM ${gtmId} — measurement IDs: ${googleTagIds(container).join(', ') || '—'}\n`);
45
+ return reportCoverage(coverage);
46
+ }
47
+ if (import.meta.main)
48
+ process.exit(await main(process.argv.slice(2)));
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(): Promise<number>;
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // Gate del budget di bundle su dist/client — richiede un `astro build` completato.
3
+ import { readdirSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import process from 'node:process';
6
+ import { gzipSync } from 'node:zlib';
7
+ import { budgetFor, CSS_BUDGET_GZIP, cssBudgetFailure, deferredClosure, heaviestStylesheet, htmlEntries, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
8
+ import { loadConfig } from '../lib/config.js';
9
+ import { expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
10
+ const DIST = 'dist/client';
11
+ const ASSETS = join(DIST, '_astro');
12
+ const PAGES = 'src/pages';
13
+ const gz = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
14
+ function readStylesheets() {
15
+ return readdirSync(ASSETS)
16
+ .filter((f) => f.endsWith('.css'))
17
+ .map((file) => ({ file, gzip: gzipSync(readFileSync(join(ASSETS, file))).length }));
18
+ }
19
+ function readChunks() {
20
+ const chunks = new Map();
21
+ for (const name of readdirSync(ASSETS).filter((f) => f.endsWith('.js'))) {
22
+ const raw = readFileSync(join(ASSETS, name));
23
+ chunks.set(name, { gzip: gzipSync(raw).length, ...parseEdges(raw.toString('utf8')) });
24
+ }
25
+ return chunks;
26
+ }
27
+ function measurePages(chunks, budgets) {
28
+ const total = (names) => [...names].reduce((sum, name) => sum + (chunks.get(name)?.gzip ?? 0), 0);
29
+ return filesWithExtension(DIST, '.html')
30
+ .map((htmlPath) => {
31
+ const route = routeOf(htmlPath, DIST);
32
+ const reached = staticClosure(htmlEntries(readFileSync(htmlPath, 'utf8')), chunks);
33
+ return {
34
+ route,
35
+ gzip: total(reached),
36
+ deferredGzip: total(deferredClosure(reached, chunks)),
37
+ budget: budgetFor(route, budgets),
38
+ };
39
+ })
40
+ .sort((a, b) => b.gzip - a.gzip);
41
+ }
42
+ function overBudgetFailures(pages) {
43
+ return pages
44
+ .filter((page) => page.gzip > page.budget.maxGzip)
45
+ .map((page) => {
46
+ const over = page.gzip - page.budget.maxGzip;
47
+ return `${page.route}: ${gz(page.gzip)} > ${gz(page.budget.maxGzip)} (+${gz(over)})`;
48
+ });
49
+ }
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)}`);
53
+ for (const page of pages) {
54
+ const deferred = page.deferredGzip > 0 ? gz(page.deferredGzip) : '—';
55
+ const line = `${page.route.padEnd(width)} ${gz(page.gzip).padStart(9)} ${gz(page.budget.maxGzip).padStart(9)} ${deferred.padStart(9)}`;
56
+ console.log(page.gzip > page.budget.maxGzip ? `${line} ✗` : line);
57
+ }
58
+ console.log('\nDEFERRED = reachable only through `await import()` (loaded after paint, behind a runtime guard).');
59
+ }
60
+ function printStylesheets(stylesheets, cssMaxGzip, failed, width) {
61
+ const worst = heaviestStylesheet(stylesheets);
62
+ console.log(`\nCSS (render-blocking, heaviest sheet a route links)`);
63
+ for (const sheet of [...stylesheets].sort((a, b) => b.gzip - a.gzip)) {
64
+ const mark = sheet === worst && failed ? ' ✗' : '';
65
+ console.log(` ${sheet.file.padEnd(width - 2)} ${gz(sheet.gzip).padStart(9)} ${gz(cssMaxGzip).padStart(9)}${mark}`);
66
+ }
67
+ }
68
+ function printSsrNote(expected) {
69
+ if (expected.ssr.length === 0)
70
+ return;
71
+ console.log(`\nNOTE ${expected.ssr.length} page(s) opted out with \`export const prerender = false\` and are outside this budget:`);
72
+ for (const file of expected.ssr)
73
+ console.log(` - ${file}`);
74
+ }
75
+ export async function main() {
76
+ const { bundle = {} } = await loadConfig(process.cwd());
77
+ const cssMaxGzip = bundle.cssMaxGzip ?? CSS_BUDGET_GZIP;
78
+ const pages = measurePages(readChunks(), bundle.budgets ?? []);
79
+ const expected = expectedRoutes(readPageFiles(PAGES), PAGES);
80
+ const failures = missingRouteFailures(expected, pages.map((page) => page.route), DIST);
81
+ failures.push(...overBudgetFailures(pages));
82
+ const width = Math.max('ROUTE'.length, ...pages.map((p) => p.route.length));
83
+ printPages(pages, width);
84
+ const stylesheets = readStylesheets();
85
+ const cssFailure = cssBudgetFailure(stylesheets, cssMaxGzip);
86
+ if (cssFailure)
87
+ failures.push(cssFailure);
88
+ printStylesheets(stylesheets, cssMaxGzip, cssFailure !== null, width);
89
+ printSsrNote(expected);
90
+ if (failures.length > 0) {
91
+ console.error(`\n✗ Bundle budget:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
92
+ return 1;
93
+ }
94
+ console.log('\n✓ Bundle budget respected on every expected route.\n');
95
+ return 0;
96
+ }
97
+ if (import.meta.main)
98
+ process.exit(await main());
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): number;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(): number;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): number;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): number;
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import process from 'node:process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { lighthouseConfig } from '../lib/lighthouse.js';
9
+ import { auditRoutes, expectedRoutes, readPageFiles } from '../lib/routes.js';
10
+ const PAGES_DIR = 'src/pages';
11
+ const RC_FILE = '.lighthouserc.json';
12
+ // Build equivalente alla produzione, server statico e Chrome di Linux: lo script chiama di nuovo
13
+ // questo comando, senza `--local`, quando il server risponde.
14
+ function runLocal() {
15
+ const script = fileURLToPath(new URL('../sh/lhci-local.sh', import.meta.url));
16
+ const env = { ...process.env, OFFICINA_BIN: process.argv[1] };
17
+ return spawnSync('bash', [script], { stdio: 'inherit', env }).status ?? 1;
18
+ }
19
+ export function main(args = []) {
20
+ if (args.includes('--local'))
21
+ return runLocal();
22
+ const rc = JSON.parse(readFileSync(RC_FILE, 'utf8'));
23
+ const routes = auditRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR));
24
+ const resolved = lighthouseConfig(rc, routes, process.env);
25
+ console.log(`\nLighthouse CI — ${routes.length} route(s) derived from ${PAGES_DIR}\n`);
26
+ for (const url of resolved.ci.collect.url ?? [])
27
+ console.log(` ${url}`);
28
+ console.log();
29
+ const dir = mkdtempSync(join(tmpdir(), 'lhci-'));
30
+ const config = join(dir, 'lighthouserc.json');
31
+ writeFileSync(config, JSON.stringify(resolved, null, 2));
32
+ const { status } = spawnSync('pnpm', ['dlx', '@lhci/cli', 'autorun', `--config=${config}`], { stdio: 'inherit' });
33
+ rmSync(dir, { recursive: true, force: true });
34
+ return status ?? 1;
35
+ }
36
+ if (import.meta.main)
37
+ process.exit(main(process.argv.slice(2)));
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): number;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): number;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): number;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): Promise<number>;
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // L'unico controllo che vede cosa serve davvero il bordo: i test del progetto fissano solo vercel.json.
3
+ import process from 'node:process';
4
+ import { loadConfig } from '../lib/config.js';
5
+ import { expectedRoutes, readPageFiles, smokeRoutes } from '../lib/routes.js';
6
+ import { runChecks, SECURITY_HEADERS, waitForAlias, } from '../lib/smoke-production.js';
7
+ const PAGES_DIR = 'src/pages';
8
+ function printResults(results) {
9
+ for (const { check, status, detail } of results) {
10
+ if (status === 'pass')
11
+ console.log(`✓ ${check}`);
12
+ else if (status === 'skip')
13
+ console.log(`- ${check} (skipped: ${detail})`);
14
+ else
15
+ console.error(`✗ ${check} — ${detail}`);
16
+ }
17
+ }
18
+ function printFailures(failures) {
19
+ console.error(`\n✗ ${failures.length} check(s) failed:`);
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');
23
+ }
24
+ export async function main(args = []) {
25
+ const { siteUrl, smoke = {} } = await loadConfig(process.cwd());
26
+ const [url] = args;
27
+ if (siteUrl === undefined) {
28
+ console.error('\n✗ officina.config.ts declares no siteUrl — the canonical-host check has nothing to compare against.\n');
29
+ return 1;
30
+ }
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');
33
+ return 1;
34
+ }
35
+ // L'apice, non l'URL *.vercel.app che stampa `vercel deploy`: lì la regola `has: host` di vercel.json mette noindex.
36
+ const baseUrl = (url ?? siteUrl).replace(/\/+$/, '');
37
+ const context = {
38
+ get: (target) => fetch(target, { redirect: 'manual' }),
39
+ baseUrl,
40
+ siteUrl,
41
+ securityHeaders: smoke.securityHeaders ?? SECURITY_HEADERS,
42
+ };
43
+ const pages = smokeRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR), smoke.nonHtmlRoutes);
44
+ console.log(`\nProduction smoke — ${baseUrl}\n`);
45
+ await waitForAlias(context, (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
46
+ const results = await runChecks(context, pages, smoke.checks);
47
+ printResults(results);
48
+ const failures = results.filter(({ status }) => status === 'fail');
49
+ if (failures.length > 0) {
50
+ printFailures(failures);
51
+ return 1;
52
+ }
53
+ console.log(`\n✓ Every check passed on ${baseUrl}.\n`);
54
+ return 0;
55
+ }
56
+ if (import.meta.main)
57
+ process.exit(await main(process.argv.slice(2)));
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(): Promise<number>;
@@ -0,0 +1 @@
1
+ export default function collectionGenerator(plop: any): void;
@@ -0,0 +1 @@
1
+ export default function componentGenerator(plop: any): void;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(): Promise<number>;
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ import { loadConfig } from '../lib/config.js';
4
+ const isMissingSharp = (error) => error instanceof Error && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND' && error.message.includes('sharp');
5
+ export async function main() {
6
+ const { icons } = await loadConfig(process.cwd());
7
+ if (icons === undefined) {
8
+ console.error('\n✗ officina.config.ts non dichiara icons.background: è il colore di fondo delle icone.\n');
9
+ return 1;
10
+ }
11
+ try {
12
+ // Caricato qui e non in testa: sharp è del progetto, e chi non genera icone non lo deve avere.
13
+ const { writeIcons } = await import('../lib/icons.js');
14
+ for (const { path, bytes } of await writeIcons(process.cwd(), icons.background)) {
15
+ console.log(`${path} ${(bytes / 1024).toFixed(1)} KB`);
16
+ }
17
+ return 0;
18
+ }
19
+ catch (error) {
20
+ if (!isMissingSharp(error))
21
+ throw error;
22
+ console.error('\n✗ sharp non è fra le dipendenze del progetto: `officina gen icons` lo usa per disegnare i PNG.\n');
23
+ return 1;
24
+ }
25
+ }
26
+ if (import.meta.main)
27
+ process.exit(await main());
@@ -0,0 +1 @@
1
+ export declare function isValidIdentifier(camel: any): boolean;
@@ -0,0 +1,12 @@
1
+ export declare function assertInjectable({ root, camel, kebab }: {
2
+ camel: any;
3
+ kebab: any;
4
+ root: any;
5
+ }): void;
6
+ export declare function injectCollection({ root, camel, kebab, document, sections }: {
7
+ camel: any;
8
+ document: any;
9
+ kebab: any;
10
+ root: any;
11
+ sections?: boolean | undefined;
12
+ }): void;
@@ -0,0 +1,16 @@
1
+ export declare function assertSectionInjectable({ root, collection, camel, kebab, pascal, image }: {
2
+ camel: any;
3
+ collection: any;
4
+ image?: boolean | undefined;
5
+ kebab: any;
6
+ pascal: any;
7
+ root: any;
8
+ }): void;
9
+ export declare function injectSection({ root, collection, camel, kebab, pascal, image }: {
10
+ camel: any;
11
+ collection: any;
12
+ image?: boolean | undefined;
13
+ kebab: any;
14
+ pascal: any;
15
+ root: any;
16
+ }): void;
@@ -0,0 +1,8 @@
1
+ export declare function assertStringsInjectable({ root, keys }: {
2
+ keys: any;
3
+ root: any;
4
+ }): void;
5
+ export declare function injectStrings({ root, entries }: {
6
+ entries: any;
7
+ root: any;
8
+ }): void;
@@ -0,0 +1,10 @@
1
+ export declare function pathSegments(plop: any, value: any): any[];
2
+ export declare function pageStrings({ pageKey, pageTitle, dynamic }: {
3
+ dynamic: any;
4
+ pageKey: any;
5
+ pageTitle: any;
6
+ }): {
7
+ key: string;
8
+ value: any;
9
+ }[];
10
+ export default function pageGenerator(plop: any): void;
@@ -0,0 +1,2 @@
1
+ export declare const PROJECT_GENERATORS = "officina.generators.mjs";
2
+ export default function (plop: any): Promise<void>;
@@ -0,0 +1 @@
1
+ export declare function postGenAction(root: any, recoveryHint: any): () => string;
@@ -0,0 +1,2 @@
1
+ export declare function plopCommand(args: string[], cwd: string): string[];
2
+ export declare function main(args?: string[]): number;
@@ -0,0 +1,23 @@
1
+ export declare function casings(plop: any, value: any): {
2
+ camel: any;
3
+ kebab: any;
4
+ pascal: any;
5
+ };
6
+ export declare function sectionTargets({ camel, kebab, pascal }: {
7
+ camel: any;
8
+ kebab: any;
9
+ pascal: any;
10
+ }): {
11
+ barrel: string;
12
+ schemaFunction: string;
13
+ dataLayer: string;
14
+ dataFunction: string;
15
+ schemaDir: string;
16
+ contentDir: string;
17
+ componentDir: string;
18
+ importsMarker: string;
19
+ sectionsMarker: string;
20
+ };
21
+ export declare const sectionFiles: (targets: any, kebab: any) => string[];
22
+ export declare function findSectionedPages(root: any, marker: any): string[];
23
+ export declare function validateSectionName(plop: any): (value: any) => string | true;
@@ -0,0 +1 @@
1
+ export default function sectionGenerator(plop: any): void;
@@ -0,0 +1,26 @@
1
+ declare const forSections: (answers: any) => boolean;
2
+ export declare function sectionedCollectionPrompts(plop: any): ({
3
+ type: string;
4
+ name: string;
5
+ message: string;
6
+ when: typeof forSections;
7
+ validate: (value: any) => string | true;
8
+ default?: never;
9
+ } | {
10
+ validate?: never;
11
+ type: string;
12
+ name: string;
13
+ message: string;
14
+ default: boolean;
15
+ when: typeof forSections;
16
+ })[];
17
+ export declare function sectionedCollectionActions(plop: any, answers: any, root: any): ((() => string) | {
18
+ type: string;
19
+ path: string | undefined;
20
+ templateFile: string;
21
+ data: {
22
+ collection: any;
23
+ pageKey: any;
24
+ };
25
+ })[];
26
+ export {};
@@ -0,0 +1 @@
1
+ export declare function isNameTaken(sourceFile: any, name: any): any;
@@ -0,0 +1,10 @@
1
+ export type { Budget } from './lib/bundle-budget.ts';
2
+ export { CSS_BUDGET_GZIP } from './lib/bundle-budget.ts';
3
+ export type { OfficinaConfig } from './lib/config.ts';
4
+ export { defineConfig } from './lib/config.ts';
5
+ export type { IconSpec } from './lib/icon-specs.ts';
6
+ export { ICON_SPECS } from './lib/icon-specs.ts';
7
+ export type { VerifiedRoute } from './lib/routes.ts';
8
+ export { NON_HTML_ROUTES } from './lib/routes.ts';
9
+ export type { CheckResult, SmokeCheck, SmokeContext } from './lib/smoke-production.ts';
10
+ export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.ts';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { CSS_BUDGET_GZIP } from './lib/bundle-budget.js';
2
+ export { defineConfig } from './lib/config.js';
3
+ export { ICON_SPECS } from './lib/icon-specs.js';
4
+ export { NON_HTML_ROUTES } from './lib/routes.js';
5
+ export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.js';
@@ -0,0 +1,12 @@
1
+ import type { Trigger } from './gtm-container.ts';
2
+ export type LinkEvent = {
3
+ prefix: string;
4
+ event: string;
5
+ };
6
+ export type Coverage = {
7
+ event: string;
8
+ prefix: string;
9
+ covered: boolean;
10
+ };
11
+ export declare function extractLinkEvents(source: string): LinkEvent[];
12
+ export declare function coverageOf(events: readonly LinkEvent[], triggers: readonly Trigger[]): Coverage[];
@@ -0,0 +1,15 @@
1
+ const LINK_EVENT = /startsWith\('([^']+)'\)\)\s*return\s*'([^']+)'/g;
2
+ // Letto dal sorgente di src/lib/analytics/link-tracking.ts invece di essere riscritto qui: le
3
+ // due liste divergono il giorno in cui qualcuno aggiunge uno schema, e non fallirebbe niente.
4
+ export function extractLinkEvents(source) {
5
+ return [...source.matchAll(LINK_EVENT)].map(([, prefix, event]) => ({
6
+ /* v8 ignore start -- i due gruppi di LINK_EVENT sono obbligatori: il ripiego serve solo ai tipi */
7
+ prefix: prefix ?? '',
8
+ event: event ?? '',
9
+ /* v8 ignore stop */
10
+ }));
11
+ }
12
+ export function coverageOf(events, triggers) {
13
+ const fired = new Set(triggers.map(({ eventName }) => eventName));
14
+ return events.map(({ event, prefix }) => ({ event, prefix, covered: fired.has(event) }));
15
+ }
@@ -0,0 +1,24 @@
1
+ export type Chunk = {
2
+ gzip: number;
3
+ static: Set<string>;
4
+ dynamic: Set<string>;
5
+ };
6
+ export type Budget = {
7
+ label: string;
8
+ matches: (route: string) => boolean;
9
+ maxGzip: number;
10
+ };
11
+ /** Le classi del progetto vengono prima, nell'ordine in cui `officina.config.ts` le dichiara. */
12
+ export declare function budgetFor(route: string, budgets?: readonly Budget[]): Budget;
13
+ /** Rollup racchiude gli specificatori statici con `"` e i dinamici in un template literal. */
14
+ export declare function parseEdges(source: string): Pick<Chunk, 'static' | 'dynamic'>;
15
+ export declare function htmlEntries(html: string): string[];
16
+ export declare function staticClosure(entries: Iterable<string>, chunks: Map<string, Chunk>): Set<string>;
17
+ export declare function deferredClosure(reached: Set<string>, chunks: Map<string, Chunk>): Set<string>;
18
+ export declare const CSS_BUDGET_GZIP: number;
19
+ export type Stylesheet = {
20
+ file: string;
21
+ gzip: number;
22
+ };
23
+ export declare function heaviestStylesheet(sheets: readonly Stylesheet[]): Stylesheet | null;
24
+ export declare function cssBudgetFailure(sheets: readonly Stylesheet[], maxGzip?: number): string | null;