@elia97/officina 0.1.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 (96) hide show
  1. package/README.md +57 -3
  2. package/dist/bin.d.ts +8 -0
  3. package/dist/bin.js +28 -6
  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/doctor.js +15 -0
  11. package/dist/checks/language.d.ts +2 -0
  12. package/dist/checks/lighthouse.d.ts +2 -0
  13. package/dist/checks/lighthouse.js +37 -0
  14. package/dist/checks/placeholders.d.ts +2 -0
  15. package/dist/checks/roadmap.d.ts +2 -0
  16. package/dist/checks/routes.d.ts +2 -0
  17. package/dist/checks/smoke.d.ts +2 -0
  18. package/dist/checks/smoke.js +57 -0
  19. package/dist/checks/vercel-cli.d.ts +2 -0
  20. package/dist/gen/collection.d.mts +1 -0
  21. package/dist/gen/collection.mjs +76 -0
  22. package/dist/gen/component.d.mts +1 -0
  23. package/dist/gen/component.mjs +41 -0
  24. package/dist/gen/icons.d.ts +2 -0
  25. package/dist/gen/icons.js +27 -0
  26. package/dist/gen/identifier.d.mts +1 -0
  27. package/dist/gen/identifier.mjs +52 -0
  28. package/dist/gen/inject-config.d.mts +12 -0
  29. package/dist/gen/inject-config.mjs +73 -0
  30. package/dist/gen/inject-section.d.mts +16 -0
  31. package/dist/gen/inject-section.mjs +140 -0
  32. package/dist/gen/inject-strings.d.mts +8 -0
  33. package/dist/gen/inject-strings.mjs +56 -0
  34. package/dist/gen/page.d.mts +10 -0
  35. package/dist/gen/page.mjs +68 -0
  36. package/dist/gen/plopfile.d.mts +2 -0
  37. package/dist/gen/plopfile.mjs +30 -0
  38. package/dist/gen/post-gen.d.mts +1 -0
  39. package/dist/gen/post-gen.mjs +16 -0
  40. package/dist/gen/run.d.ts +2 -0
  41. package/dist/gen/run.js +17 -0
  42. package/dist/gen/section-targets.d.mts +23 -0
  43. package/dist/gen/section-targets.mjs +46 -0
  44. package/dist/gen/section.d.mts +1 -0
  45. package/dist/gen/section.mjs +68 -0
  46. package/dist/gen/sectioned-collection.d.mts +26 -0
  47. package/dist/gen/sectioned-collection.mjs +81 -0
  48. package/dist/gen/templates/collection/data.yml.hbs +2 -0
  49. package/dist/gen/templates/collection/doc.md.hbs +8 -0
  50. package/dist/gen/templates/collection/schema.ts.hbs +11 -0
  51. package/dist/gen/templates/component/component.astro.hbs +21 -0
  52. package/dist/gen/templates/page/dynamic.astro.hbs +24 -0
  53. package/dist/gen/templates/page/static.astro.hbs +20 -0
  54. package/dist/gen/templates/section/component.astro.hbs +45 -0
  55. package/dist/gen/templates/section/content.yml.hbs +10 -0
  56. package/dist/gen/templates/section/schema.ts.hbs +20 -0
  57. package/dist/gen/templates/sections/data-layer.ts.hbs +13 -0
  58. package/dist/gen/templates/sections/index.ts.hbs +12 -0
  59. package/dist/gen/templates/sections/page.astro.hbs +30 -0
  60. package/dist/gen/ts-morph-utils.d.mts +1 -0
  61. package/dist/gen/ts-morph-utils.mjs +8 -0
  62. package/dist/index.d.ts +10 -0
  63. package/dist/index.js +5 -0
  64. package/dist/lib/analytics-coverage.d.ts +12 -0
  65. package/dist/lib/analytics-coverage.js +15 -0
  66. package/dist/lib/bundle-budget.d.ts +24 -0
  67. package/dist/lib/bundle-budget.js +50 -0
  68. package/dist/lib/check-comments.d.ts +10 -0
  69. package/dist/lib/check-language.d.ts +11 -0
  70. package/dist/lib/check-placeholders.d.ts +6 -0
  71. package/dist/lib/check-roadmap.d.ts +30 -0
  72. package/dist/lib/check-routes.d.ts +6 -0
  73. package/dist/lib/cli.d.ts +26 -0
  74. package/dist/lib/comment-syntax.d.ts +11 -0
  75. package/dist/lib/config.d.ts +24 -0
  76. package/dist/lib/config.js +21 -0
  77. package/dist/lib/contract.d.ts +22 -0
  78. package/dist/lib/contract.js +59 -0
  79. package/dist/lib/git.d.ts +6 -0
  80. package/dist/lib/gtm-container.d.ts +41 -0
  81. package/dist/lib/gtm-container.js +145 -0
  82. package/dist/lib/icon-specs.d.ts +6 -0
  83. package/dist/lib/icon-specs.js +6 -0
  84. package/dist/lib/icons.d.ts +6 -0
  85. package/dist/lib/icons.js +32 -0
  86. package/dist/lib/language-words.d.ts +2 -0
  87. package/dist/lib/lighthouse.d.ts +17 -0
  88. package/dist/lib/lighthouse.js +23 -0
  89. package/dist/lib/past-tense.d.ts +1 -0
  90. package/dist/lib/routes.d.ts +31 -0
  91. package/dist/lib/routes.js +97 -0
  92. package/dist/lib/smoke-production.d.ts +35 -0
  93. package/dist/lib/smoke-production.js +132 -0
  94. package/dist/lib/vercel-cli-pin.d.ts +2 -0
  95. package/dist/sh/lhci-local.sh +106 -0
  96. package/package.json +21 -3
package/README.md CHANGED
@@ -18,12 +18,56 @@ 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"
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",
27
+ "gen": "officina gen",
28
+ "gen:section": "officina gen section",
29
+ "gen:page": "officina gen page",
30
+ "gen:component": "officina gen component",
31
+ "gen:collection": "officina gen collection",
32
+ "gen:icons": "officina gen icons",
33
+ "doctor": "officina doctor"
22
34
  }
23
35
  }
24
36
  ```
25
37
 
26
- 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.
27
71
 
28
72
  ## Sviluppo
29
73
 
@@ -43,6 +87,16 @@ pnpm version <patch|minor|major>
43
87
  pnpm publish # prepublishOnly lancia typecheck, test e build
44
88
  ```
45
89
 
90
+ ## Generatori
91
+
92
+ `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
+
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.
95
+
96
+ 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
+
98
+ I test dei generatori girano sui punti di aggancio del progetto di prova, copiati da `vetrina`. Che un progetto vero li abbia ancora lo verifica `doctor`, non i test.
99
+
46
100
  ## Cosa arriverà
47
101
 
48
- I generatori (`officina gen …`) con i loro template, poi bundle budget, smoke di produzione, lighthouse, verifica analytics e icone.
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,25 +1,47 @@
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.
5
- const CHECKS = {
4
+ // degli altri.
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
- const USAGE = `Uso: officina check <gate> [opzioni]\n\nGate: ${Object.keys(CHECKS).join(', ')}\nOpzioni comuni: --diff, --base <ref>, --head <ref>, --strict, --format text|github\n`;
17
+ const USAGE = `Uso:
18
+ officina check <gate> [opzioni] gate: ${Object.keys(CHECKS).join(', ')}
19
+ officina gen [generatore] section, page, component, collection, più quelli del progetto
20
+ officina gen icons le icone del manifest da public/favicon.svg
21
+ officina doctor cosa manca al progetto perché i generatori funzionino
22
+
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
26
+ `;
27
+ // Ogni gruppo dice quale modulo carica e quali argomenti gli passa.
28
+ const GROUPS = {
29
+ check: ([name = '', ...rest]) => ({ load: CHECKS[name], args: rest }),
30
+ gen: (args) => args[0] === 'icons'
31
+ ? { load: () => import('./gen/icons.js'), args: args.slice(1) }
32
+ : { load: () => import('./gen/run.js'), args },
33
+ doctor: (args) => ({ load: () => import('./checks/doctor.js'), args }),
34
+ };
14
35
  export async function run(argv) {
15
- const [group, name, ...rest] = argv;
16
- const load = group === 'check' && name !== undefined ? CHECKS[name] : undefined;
36
+ const [group = '', ...rest] = argv;
37
+ const { load, args } = GROUPS[group]?.(rest) ?? { load: undefined, args: [] };
17
38
  if (!load) {
18
39
  console.error(USAGE);
19
40
  return 2;
20
41
  }
21
42
  const { main } = await load();
22
- return main(rest);
43
+ return main(args);
23
44
  }
45
+ /* v8 ignore next -- eseguito solo quando il file è il punto d'ingresso del processo */
24
46
  if (import.meta.main)
25
47
  process.exit(await run(process.argv.slice(2)));
@@ -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,15 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ 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);
9
+ 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`);
12
+ return exitCode(findings, false);
13
+ }
14
+ if (import.meta.main)
15
+ process.exit(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(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,76 @@
1
+ import { isValidIdentifier } from './identifier.mjs';
2
+ import { assertInjectable, injectCollection } from './inject-config.mjs';
3
+ import { postGenAction } from './post-gen.mjs';
4
+ import { sectionedCollectionActions, sectionedCollectionPrompts } from './sectioned-collection.mjs';
5
+ function flatCollectionActions(root, answers) {
6
+ const tpl = 'templates/collection';
7
+ return [
8
+ (a, _config, api) => {
9
+ assertInjectable({
10
+ root,
11
+ camel: api.getHelper('camelCase')(a.name),
12
+ kebab: api.getHelper('dashCase')(a.name),
13
+ });
14
+ return 'content.config.ts contract checks passed';
15
+ },
16
+ {
17
+ type: 'add',
18
+ path: 'src/lib/schemas/{{dashCase name}}.ts',
19
+ templateFile: `${tpl}/schema.ts.hbs`,
20
+ },
21
+ {
22
+ type: 'add',
23
+ path: answers.document ? 'src/content/{{dashCase name}}/example.md' : 'src/content/{{dashCase name}}/example.yml',
24
+ templateFile: answers.document ? `${tpl}/doc.md.hbs` : `${tpl}/data.yml.hbs`,
25
+ },
26
+ (a, _config, api) => {
27
+ injectCollection({
28
+ root,
29
+ camel: api.getHelper('camelCase')(a.name),
30
+ kebab: api.getHelper('dashCase')(a.name),
31
+ document: a.document,
32
+ });
33
+ return `injected collection in content.config.ts: ${a.name}`;
34
+ },
35
+ postGenAction(root),
36
+ ];
37
+ }
38
+ export default function collectionGenerator(plop) {
39
+ const root = process.cwd();
40
+ plop.setGenerator('collection', {
41
+ description: 'New content collection (YAML data, MD documents, or a page built from sections)',
42
+ prompts: [
43
+ {
44
+ type: 'input',
45
+ name: 'name',
46
+ message: 'Collection name (e.g. services, blog):',
47
+ validate: (value) => {
48
+ const camel = plop.getHelper('camelCase')(String(value));
49
+ if (!camel)
50
+ return 'Collection name is required';
51
+ if (!isValidIdentifier(camel)) {
52
+ return `"${value}" would generate an invalid identifier (const ${camel}) — use ASCII letters/digits, starting with a letter, not a JS reserved word`;
53
+ }
54
+ return true;
55
+ },
56
+ },
57
+ {
58
+ type: 'confirm',
59
+ name: 'sections',
60
+ message: 'A page built from sections, like the homepage?',
61
+ default: false,
62
+ },
63
+ {
64
+ type: 'confirm',
65
+ name: 'document',
66
+ message: 'MD documents with a renderable body? (No = YAML data)',
67
+ default: false,
68
+ when: (answers) => answers.sections !== true,
69
+ },
70
+ ...sectionedCollectionPrompts(plop),
71
+ ],
72
+ actions: (answers) => answers.sections === true
73
+ ? sectionedCollectionActions(plop, answers, root)
74
+ : flatCollectionActions(root, answers),
75
+ });
76
+ }
@@ -0,0 +1 @@
1
+ export default function componentGenerator(plop: any): void;
@@ -0,0 +1,41 @@
1
+ import { postGenAction } from './post-gen.mjs';
2
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
3
+ export default function componentGenerator(plop) {
4
+ const root = process.cwd();
5
+ const tpl = 'templates/component';
6
+ plop.setGenerator('component', {
7
+ description: 'New native Astro component (cva + cn recipe)',
8
+ prompts: [
9
+ {
10
+ type: 'input',
11
+ name: 'name',
12
+ message: 'Component name (e.g. PriceCard):',
13
+ validate: (value) => {
14
+ const camel = plop.getHelper('camelCase')(String(value));
15
+ if (!camel)
16
+ return 'Component name is required';
17
+ if (!IDENTIFIER.test(camel)) {
18
+ return `"${value}" would generate an invalid identifier (${camel}Variants) — use ASCII letters/digits, starting with a letter`;
19
+ }
20
+ return true;
21
+ },
22
+ },
23
+ {
24
+ type: 'input',
25
+ name: 'area',
26
+ message: 'Folder under src/components/ (e.g. ui, marketing):',
27
+ default: 'ui',
28
+ validate: (value) => plop.getHelper('dashCase')(String(value)) !== '' ||
29
+ 'Area folder must contain at least one letter or digit (e.g. ui)',
30
+ },
31
+ ],
32
+ actions: [
33
+ {
34
+ type: 'add',
35
+ path: 'src/components/{{dashCase area}}/{{dashCase name}}.astro',
36
+ templateFile: `${tpl}/component.astro.hbs`,
37
+ },
38
+ postGenAction(root),
39
+ ],
40
+ });
41
+ }
@@ -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;