@elia97/officina 0.2.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 +93 -4
- package/dist/bin.d.ts +8 -0
- package/dist/bin.js +12 -3
- package/dist/checks/analytics.d.ts +2 -0
- package/dist/checks/analytics.js +48 -0
- package/dist/checks/bundle.d.ts +2 -0
- package/dist/checks/bundle.js +98 -0
- package/dist/checks/comments.d.ts +2 -0
- package/dist/checks/doctor.d.ts +2 -0
- package/dist/checks/doctor.js +45 -8
- package/dist/checks/language.d.ts +2 -0
- package/dist/checks/lighthouse.d.ts +2 -0
- package/dist/checks/lighthouse.js +37 -0
- package/dist/checks/placeholders.d.ts +2 -0
- package/dist/checks/roadmap.d.ts +2 -0
- package/dist/checks/routes.d.ts +2 -0
- package/dist/checks/smoke.d.ts +2 -0
- package/dist/checks/smoke.js +57 -0
- package/dist/checks/vercel-cli.d.ts +2 -0
- package/dist/checks/vercel-cli.js +8 -6
- package/dist/gen/collection.d.mts +1 -0
- package/dist/gen/component.d.mts +1 -0
- package/dist/gen/icons.d.ts +2 -0
- package/dist/gen/icons.js +27 -0
- package/dist/gen/identifier.d.mts +1 -0
- package/dist/gen/inject-config.d.mts +12 -0
- package/dist/gen/inject-section.d.mts +16 -0
- package/dist/gen/inject-strings.d.mts +8 -0
- package/dist/gen/page.d.mts +10 -0
- package/dist/gen/plopfile.d.mts +2 -0
- package/dist/gen/post-gen.d.mts +1 -0
- package/dist/gen/run.d.ts +2 -0
- package/dist/gen/section-targets.d.mts +23 -0
- package/dist/gen/section.d.mts +1 -0
- package/dist/gen/sectioned-collection.d.mts +26 -0
- package/dist/gen/ts-morph-utils.d.mts +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -0
- package/dist/lib/alignment.d.ts +23 -0
- package/dist/lib/alignment.js +166 -0
- package/dist/lib/analytics-coverage.d.ts +12 -0
- package/dist/lib/analytics-coverage.js +15 -0
- package/dist/lib/bundle-budget.d.ts +24 -0
- package/dist/lib/bundle-budget.js +50 -0
- package/dist/lib/check-comments.d.ts +10 -0
- package/dist/lib/check-language.d.ts +11 -0
- package/dist/lib/check-placeholders.d.ts +6 -0
- package/dist/lib/check-roadmap.d.ts +30 -0
- package/dist/lib/check-routes.d.ts +6 -0
- package/dist/lib/cli.d.ts +26 -0
- package/dist/lib/comment-syntax.d.ts +11 -0
- package/dist/lib/config.d.ts +25 -0
- package/dist/lib/config.js +22 -0
- package/dist/lib/contract.d.ts +22 -0
- package/dist/lib/git.d.ts +6 -0
- package/dist/lib/gtm-container.d.ts +41 -0
- package/dist/lib/gtm-container.js +145 -0
- package/dist/lib/icon-specs.d.ts +6 -0
- package/dist/lib/icon-specs.js +6 -0
- package/dist/lib/icons.d.ts +6 -0
- package/dist/lib/icons.js +32 -0
- package/dist/lib/language-words.d.ts +2 -0
- package/dist/lib/lighthouse.d.ts +17 -0
- package/dist/lib/lighthouse.js +23 -0
- package/dist/lib/past-tense.d.ts +1 -0
- package/dist/lib/routes.d.ts +31 -0
- package/dist/lib/routes.js +97 -0
- package/dist/lib/smoke-production.d.ts +35 -0
- package/dist/lib/smoke-production.js +132 -0
- package/dist/lib/vercel-cli-pin.d.ts +2 -0
- package/dist/sh/lhci-local.sh +106 -0
- package/package.json +19 -3
- package/presets/biome.base.json +100 -0
- package/presets/lefthook.yml +11 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Lettore del gtm.js pubblico di un container GTM. Dati dentro, dati fuori: la fetch, il
|
|
2
|
+
// rapporto e il codice di uscita restano in scripts/verify-analytics-coverage.mjs.
|
|
3
|
+
// Tag evento GA4. Il tag Google vero e proprio (`__googtag`) configura la proprietà e scatta
|
|
4
|
+
// all'inizializzazione: non traccia nessuna superficie, quindi qui non è un trigger.
|
|
5
|
+
const GA4_EVENT_TAG = '__gaawe';
|
|
6
|
+
// Contabilità interna di GTM: ogni listener nativo scrive gli id dei trigger per cui è
|
|
7
|
+
// scattato, e il tag li rilegge con un `_re`. Da parte nostra non c'è niente da verificare.
|
|
8
|
+
const INTERNAL_VARIABLE = 'gtm.triggers';
|
|
9
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
const records = (value) => (Array.isArray(value) ? value.filter(isRecord) : []);
|
|
11
|
+
const arrays = (value) => (Array.isArray(value) ? value.filter(Array.isArray) : []);
|
|
12
|
+
function matchingBrace(source, start) {
|
|
13
|
+
let depth = 0;
|
|
14
|
+
let inString = false;
|
|
15
|
+
let escaped = false;
|
|
16
|
+
for (let index = start; index < source.length; index++) {
|
|
17
|
+
const char = source[index];
|
|
18
|
+
if (inString) {
|
|
19
|
+
if (escaped)
|
|
20
|
+
escaped = false;
|
|
21
|
+
else if (char === '\\')
|
|
22
|
+
escaped = true;
|
|
23
|
+
else if (char === '"')
|
|
24
|
+
inString = false;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (char === '"')
|
|
28
|
+
inString = true;
|
|
29
|
+
else if (char === '{')
|
|
30
|
+
depth++;
|
|
31
|
+
else if (char === '}' && --depth === 0)
|
|
32
|
+
return index + 1;
|
|
33
|
+
}
|
|
34
|
+
throw new Error('the container literal never closes — truncated download?');
|
|
35
|
+
}
|
|
36
|
+
/** Il container arriva come un solo file JS che contiene un unico letterale `var data = {…}`,
|
|
37
|
+
* e a isolarlo è solo un conteggio di graffe che conosce le stringhe: i valori ne contengono. */
|
|
38
|
+
export function parseContainerData(source) {
|
|
39
|
+
const marker = source.indexOf('var data = {');
|
|
40
|
+
if (marker === -1)
|
|
41
|
+
throw new Error('no `var data = {` in the container — the reader is broken, not the container');
|
|
42
|
+
const start = source.indexOf('{', marker);
|
|
43
|
+
// La fetta comincia con una `{`, quindi JSON.parse o solleva un errore o restituisce un oggetto.
|
|
44
|
+
const parsed = JSON.parse(source.slice(start, matchingBrace(source, start)));
|
|
45
|
+
if (!isRecord(parsed.resource))
|
|
46
|
+
throw new Error('the container literal carries no `resource` — the reader is broken');
|
|
47
|
+
const resource = parsed.resource;
|
|
48
|
+
return {
|
|
49
|
+
macros: records(resource.macros),
|
|
50
|
+
tags: records(resource.tags),
|
|
51
|
+
predicates: records(resource.predicates),
|
|
52
|
+
rules: arrays(resource.rules),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** L'id del container come lo serve la produzione, incorporato con `define:vars`. Pubblico per
|
|
56
|
+
* costruzione, ed è l'unica risposta a "quale container gira su questo host". */
|
|
57
|
+
export function extractGtmId(html) {
|
|
58
|
+
const at = html.indexOf('__rsAnalyticsConfig');
|
|
59
|
+
const block = at === -1 ? '' : html.slice(Math.max(0, html.lastIndexOf('<script', at)), at);
|
|
60
|
+
const id = /gtmId\s*=\s*"([^"]+)"/.exec(block)?.[1];
|
|
61
|
+
if (id === undefined)
|
|
62
|
+
throw new Error('no GTM id in the served markup — tracking is off on this host');
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
/** Le proprietà GA4 che il container configura. Senza nemmeno una, ogni tag evento scatta nel
|
|
66
|
+
* vuoto: un elenco vuoto è un fallimento, non una nota. */
|
|
67
|
+
export function googleTagIds(container) {
|
|
68
|
+
const ids = container.tags.filter((tag) => tag.function === '__googtag').map((tag) => tag.vtp_tagId);
|
|
69
|
+
return [...new Set(ids.filter((id) => typeof id === 'string'))];
|
|
70
|
+
}
|
|
71
|
+
const macroIndex = (arg) => {
|
|
72
|
+
if (!Array.isArray(arg) || arg[0] !== 'macro')
|
|
73
|
+
return null;
|
|
74
|
+
const index = arg[1];
|
|
75
|
+
return typeof index === 'number' ? index : null;
|
|
76
|
+
};
|
|
77
|
+
/** Cosa legge un predicato: `event` per il nome dell'evento dataLayer (`__e`), altrimenti il
|
|
78
|
+
* nome della variabile stessa (`gtm.element`, `gtm.elementUrl`, `gtm.triggers`). */
|
|
79
|
+
function macroKind(macros, index) {
|
|
80
|
+
const macro = index === null ? undefined : macros[index];
|
|
81
|
+
if (macro === undefined)
|
|
82
|
+
return null;
|
|
83
|
+
if (macro.function === '__e')
|
|
84
|
+
return 'event';
|
|
85
|
+
const name = macro.vtp_name;
|
|
86
|
+
return typeof name === 'string' ? name : null;
|
|
87
|
+
}
|
|
88
|
+
function applyPredicate(trigger, container, index) {
|
|
89
|
+
const predicate = typeof index === 'number' ? container.predicates[index] : undefined;
|
|
90
|
+
if (predicate === undefined) {
|
|
91
|
+
trigger.unsupported.push(`predicate #${String(index)} does not exist`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const fn = predicate.function;
|
|
95
|
+
const kind = macroKind(container.macros, macroIndex(predicate.arg0));
|
|
96
|
+
const value = predicate.arg1;
|
|
97
|
+
if (kind === INTERNAL_VARIABLE)
|
|
98
|
+
return;
|
|
99
|
+
const label = `${String(fn)} on ${kind ?? 'an unreadable variable'}`;
|
|
100
|
+
if (fn === '_eq' && kind === 'event' && typeof value === 'string')
|
|
101
|
+
trigger.firesOn.push(value);
|
|
102
|
+
else if (fn === '_css' && kind === 'gtm.element' && typeof value === 'string')
|
|
103
|
+
trigger.selectors.push(value);
|
|
104
|
+
else if (fn === '_cn' && kind === 'gtm.elementUrl' && typeof value === 'string')
|
|
105
|
+
trigger.urlContains.push(value);
|
|
106
|
+
else
|
|
107
|
+
trigger.unsupported.push(`${label} = ${JSON.stringify(value)}`);
|
|
108
|
+
}
|
|
109
|
+
const clausesOf = (rule) => rule.filter(Array.isArray).map((clause) => ({ op: clause[0], args: clause.slice(1) }));
|
|
110
|
+
const targets = (clauses, op, tag) => clauses.some((clause) => clause.op === op && clause.args.includes(tag));
|
|
111
|
+
function conditionsOf(clauses, container, trigger) {
|
|
112
|
+
for (const clause of clauses) {
|
|
113
|
+
if (clause.op === 'add' || clause.op === 'block')
|
|
114
|
+
continue;
|
|
115
|
+
// `unless` nega i suoi predicati; leggerli come requisiti invertirebbe il senso del
|
|
116
|
+
// trigger, quindi l'intera clausola si dichiara illeggibile.
|
|
117
|
+
if (clause.op !== 'if') {
|
|
118
|
+
trigger.unsupported.push(`\`${String(clause.op)}\` clause`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
for (const index of clause.args)
|
|
122
|
+
applyPredicate(trigger, container, index);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** GTM tiene separati tag e condizioni: una regola elenca clausole, con `if` e `unless` che
|
|
126
|
+
* nominano predicati e `add` e `block` che nominano tag. Un trigger per regola che aggiunge il tag. */
|
|
127
|
+
export function extractTriggers(container) {
|
|
128
|
+
const triggers = [];
|
|
129
|
+
for (const [tag, definition] of container.tags.entries()) {
|
|
130
|
+
if (definition.function !== GA4_EVENT_TAG)
|
|
131
|
+
continue;
|
|
132
|
+
const name = definition.vtp_eventName;
|
|
133
|
+
const eventName = typeof name === 'string' ? name : `tag #${String(tag)}`;
|
|
134
|
+
const rules = container.rules.map(clausesOf);
|
|
135
|
+
const blocked = rules.some((clauses) => targets(clauses, 'block', tag));
|
|
136
|
+
for (const clauses of rules.filter((clauses) => targets(clauses, 'add', tag))) {
|
|
137
|
+
const trigger = { eventName, firesOn: [], selectors: [], urlContains: [], unsupported: [] };
|
|
138
|
+
if (blocked)
|
|
139
|
+
trigger.unsupported.push('a `block` rule targets this tag');
|
|
140
|
+
conditionsOf(clauses, container, trigger);
|
|
141
|
+
triggers.push(trigger);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return triggers;
|
|
145
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Senza sharp: il manifest del sito le confronta con le proprie icone senza caricare il disegnatore.
|
|
2
|
+
export const ICON_SPECS = [
|
|
3
|
+
{ file: 'icon-192.png', size: 192, purpose: 'any' },
|
|
4
|
+
{ file: 'icon-512.png', size: 512, purpose: 'any' },
|
|
5
|
+
{ file: 'icon-maskable-512.png', size: 512, purpose: 'maskable' },
|
|
6
|
+
];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type IconSpec } from './icon-specs.ts';
|
|
2
|
+
export declare function renderIcon(svg: Buffer, spec: IconSpec, background: string): Promise<Buffer>;
|
|
3
|
+
export declare function writeIcons(root: string, background: string): Promise<{
|
|
4
|
+
path: string;
|
|
5
|
+
bytes: number;
|
|
6
|
+
}[]>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { ICON_SPECS } from './icon-specs.js';
|
|
5
|
+
// Il launcher ritaglia la maskable: resta intatto solo il cerchio centrale di diametro pari all'80%
|
|
6
|
+
// del lato (W3C Web App Manifest, `purpose`), e il glifo sta nel quadrato inscritto in quel cerchio.
|
|
7
|
+
const MASKABLE_GLYPH_SCALE = 0.8 / Math.SQRT2;
|
|
8
|
+
async function rasterize(svg, side) {
|
|
9
|
+
const { width } = await sharp(svg).metadata();
|
|
10
|
+
return sharp(svg, { density: (72 * side) / width })
|
|
11
|
+
.resize(side, side, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
12
|
+
.png()
|
|
13
|
+
.toBuffer();
|
|
14
|
+
}
|
|
15
|
+
export async function renderIcon(svg, spec, background) {
|
|
16
|
+
const glyphSide = Math.round(spec.purpose === 'maskable' ? spec.size * MASKABLE_GLYPH_SCALE : spec.size);
|
|
17
|
+
return sharp({ create: { width: spec.size, height: spec.size, channels: 4, background } })
|
|
18
|
+
.composite([{ input: await rasterize(svg, glyphSide), gravity: 'centre' }])
|
|
19
|
+
.png()
|
|
20
|
+
.toBuffer();
|
|
21
|
+
}
|
|
22
|
+
export async function writeIcons(root, background) {
|
|
23
|
+
const svg = readFileSync(join(root, 'public', 'favicon.svg'));
|
|
24
|
+
const written = [];
|
|
25
|
+
for (const spec of ICON_SPECS) {
|
|
26
|
+
const path = `public/${spec.file}`;
|
|
27
|
+
const png = await renderIcon(svg, spec, background);
|
|
28
|
+
writeFileSync(join(root, path), png);
|
|
29
|
+
written.push({ path, bytes: png.length });
|
|
30
|
+
}
|
|
31
|
+
return written;
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface LighthouseRc {
|
|
2
|
+
ci: {
|
|
3
|
+
collect: {
|
|
4
|
+
url?: string[];
|
|
5
|
+
numberOfRuns?: number;
|
|
6
|
+
startServerCommand?: string;
|
|
7
|
+
startServerReadyPattern?: string;
|
|
8
|
+
startServerReadyTimeout?: number;
|
|
9
|
+
settings?: Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
upload?: Record<string, unknown>;
|
|
12
|
+
[section: string]: unknown;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export type LighthouseEnv = Partial<Record<'LH_PORT' | 'LH_RUNS' | 'LH_EXTERNAL_SERVER' | 'LH_CHROME_FLAGS' | 'LH_OUT', string>>;
|
|
16
|
+
/** Le rotte arrivano da src/pages, il resto dal `.lighthouserc.json` del progetto; l'ambiente lo piega al giro locale. */
|
|
17
|
+
export declare function lighthouseConfig(rc: LighthouseRc, routes: readonly string[], env: LighthouseEnv): LighthouseRc;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const DEFAULT_PORT = '4321';
|
|
2
|
+
/** Le rotte arrivano da src/pages, il resto dal `.lighthouserc.json` del progetto; l'ambiente lo piega al giro locale. */
|
|
3
|
+
export function lighthouseConfig(rc, routes, env) {
|
|
4
|
+
const port = env.LH_PORT ?? DEFAULT_PORT;
|
|
5
|
+
const collect = { ...rc.ci.collect, url: routes.map((route) => `http://localhost:${port}${route}`) };
|
|
6
|
+
if (env.LH_RUNS)
|
|
7
|
+
collect.numberOfRuns = Number(env.LH_RUNS);
|
|
8
|
+
// Il server è già in ascolto: lo avvia lhci-local.sh, per servire una directory diversa.
|
|
9
|
+
if (env.LH_EXTERNAL_SERVER) {
|
|
10
|
+
delete collect.startServerCommand;
|
|
11
|
+
delete collect.startServerReadyPattern;
|
|
12
|
+
delete collect.startServerReadyTimeout;
|
|
13
|
+
}
|
|
14
|
+
else if (collect.startServerCommand !== undefined) {
|
|
15
|
+
collect.startServerCommand = collect.startServerCommand.replace(/--listen \d+/, `--listen ${port}`);
|
|
16
|
+
}
|
|
17
|
+
if (env.LH_CHROME_FLAGS)
|
|
18
|
+
collect.settings = { ...collect.settings, chromeFlags: env.LH_CHROME_FLAGS };
|
|
19
|
+
const ci = { ...rc.ci, collect };
|
|
20
|
+
if (env.LH_OUT)
|
|
21
|
+
ci.upload = { target: 'filesystem', outputDir: env.LH_OUT };
|
|
22
|
+
return { ci };
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const PAST_TENSE: RegExp[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type PageFile = {
|
|
2
|
+
file: string;
|
|
3
|
+
source: string;
|
|
4
|
+
};
|
|
5
|
+
export type Expectations = {
|
|
6
|
+
exact: {
|
|
7
|
+
route: string;
|
|
8
|
+
file: string;
|
|
9
|
+
}[];
|
|
10
|
+
patterns: {
|
|
11
|
+
pattern: RegExp;
|
|
12
|
+
label: string;
|
|
13
|
+
file: string;
|
|
14
|
+
}[];
|
|
15
|
+
ssr: string[];
|
|
16
|
+
};
|
|
17
|
+
export type VerifiedRoute = {
|
|
18
|
+
path: string;
|
|
19
|
+
type: string;
|
|
20
|
+
};
|
|
21
|
+
export declare const ERROR_PAGES: readonly string[];
|
|
22
|
+
export declare const NON_HTML_ROUTES: readonly VerifiedRoute[];
|
|
23
|
+
export declare function filesWithExtension(dir: string, extension: string): string[];
|
|
24
|
+
export declare function readPageFiles(pagesDir: string): PageFile[];
|
|
25
|
+
export declare function routeOf(htmlPath: string, dist: string): string;
|
|
26
|
+
export declare function expectedRoutes(pages: readonly PageFile[], pagesDir: string): Expectations;
|
|
27
|
+
/** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
|
|
28
|
+
export declare function missingRouteFailures(expected: Expectations, emitted: readonly string[], dist: string): string[];
|
|
29
|
+
export declare function auditRoutes(expected: Expectations): string[];
|
|
30
|
+
export declare function smokeRoutes(expected: Expectations, nonHtmlRoutes?: readonly VerifiedRoute[]): VerifiedRoute[];
|
|
31
|
+
export declare function orphanExceptions(expected: Expectations): string[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const ERROR_PAGES = ['/404', '/500'];
|
|
4
|
+
// Non escono da una pagina .astro: `robots.txt.ts` e `site.webmanifest.ts` sono endpoint,
|
|
5
|
+
// `sitemap-index.xml` lo emette @astrojs/sitemap e `/api/health` è `prerender = false`.
|
|
6
|
+
export const NON_HTML_ROUTES = [
|
|
7
|
+
{ path: '/robots.txt', type: 'text/plain' },
|
|
8
|
+
{ path: '/sitemap-index.xml', type: 'xml' },
|
|
9
|
+
// In produzione il MIME viene dall'estensione .webmanifest, non dall'intestazione che
|
|
10
|
+
// l'endpoint imposta: si verifica la famiglia json invece di fissarne una delle due forme.
|
|
11
|
+
{ path: '/site.webmanifest', type: 'json' },
|
|
12
|
+
{ path: '/api/health', type: 'application/json' },
|
|
13
|
+
];
|
|
14
|
+
export function filesWithExtension(dir, extension) {
|
|
15
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
16
|
+
const path = join(dir, entry.name);
|
|
17
|
+
if (entry.isDirectory())
|
|
18
|
+
return filesWithExtension(path, extension);
|
|
19
|
+
return entry.name.endsWith(extension) ? [path] : [];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function readPageFiles(pagesDir) {
|
|
23
|
+
return filesWithExtension(pagesDir, '.astro').map((file) => ({ file, source: readFileSync(file, 'utf8') }));
|
|
24
|
+
}
|
|
25
|
+
const toPosix = (path, prefix) => path.slice(prefix.length).split(/[\\/]/).join('/');
|
|
26
|
+
export function routeOf(htmlPath, dist) {
|
|
27
|
+
const route = toPosix(htmlPath, dist).replace(/\/index\.html$/, '');
|
|
28
|
+
return route.replace(/\.html$/, '') || '/';
|
|
29
|
+
}
|
|
30
|
+
const SSR_OPT_OUT = /^\s*export\s+const\s+prerender\s*=\s*false\b/m;
|
|
31
|
+
function pageRouteOf(file, pagesDir) {
|
|
32
|
+
return (toPosix(file, pagesDir)
|
|
33
|
+
.replace(/\.astro$/, '')
|
|
34
|
+
.replace(/\/index$/, '') || '/');
|
|
35
|
+
}
|
|
36
|
+
function segmentPattern(segment) {
|
|
37
|
+
if (segment.startsWith('['))
|
|
38
|
+
return '[^/]+';
|
|
39
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
40
|
+
}
|
|
41
|
+
const TRAILING_REST_SEGMENT = /\/\[\.\.\.[^/]*\]$/;
|
|
42
|
+
// Anche un segmento rest finale fa match col vuoto: `paginate()` emette la prima pagina come
|
|
43
|
+
// percorso nudo (`/news`, mai `/news/1`), quindi esigere un segmento boccia un archivio di una pagina.
|
|
44
|
+
function routePattern(route) {
|
|
45
|
+
const body = (path) => path.split('/').map(segmentPattern).join('/');
|
|
46
|
+
if (!TRAILING_REST_SEGMENT.test(route))
|
|
47
|
+
return new RegExp(`^${body(route)}$`);
|
|
48
|
+
return new RegExp(`^${body(route.replace(TRAILING_REST_SEGMENT, ''))}(?:/.*)?$`);
|
|
49
|
+
}
|
|
50
|
+
export function expectedRoutes(pages, pagesDir) {
|
|
51
|
+
const expectations = { exact: [], patterns: [], ssr: [] };
|
|
52
|
+
for (const { file, source } of pages) {
|
|
53
|
+
if (SSR_OPT_OUT.test(source)) {
|
|
54
|
+
expectations.ssr.push(file);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const route = pageRouteOf(file, pagesDir);
|
|
58
|
+
if (!route.includes('[')) {
|
|
59
|
+
expectations.exact.push({ route, file });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
expectations.patterns.push({ pattern: routePattern(route), label: route, file });
|
|
63
|
+
}
|
|
64
|
+
return expectations;
|
|
65
|
+
}
|
|
66
|
+
/** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
|
|
67
|
+
export function missingRouteFailures(expected, emitted, dist) {
|
|
68
|
+
if (emitted.length === 0) {
|
|
69
|
+
return [`${dist} non contiene nessun .html: nessuna rotta misurata, quindi i budget per rotta non affermano niente`];
|
|
70
|
+
}
|
|
71
|
+
const failures = [];
|
|
72
|
+
const routes = new Set(emitted);
|
|
73
|
+
for (const { route, file } of expected.exact) {
|
|
74
|
+
if (!routes.has(route))
|
|
75
|
+
failures.push(`rotta mancante ${route}: ${file} è prerenderizzata ma non ha emesso HTML`);
|
|
76
|
+
}
|
|
77
|
+
for (const { pattern, label, file } of expected.patterns) {
|
|
78
|
+
if (emitted.some((route) => pattern.test(route)))
|
|
79
|
+
continue;
|
|
80
|
+
failures.push(`rotta mancante ${label}: ${file} è prerenderizzata ma getStaticPaths non ha emesso niente`);
|
|
81
|
+
}
|
|
82
|
+
return failures;
|
|
83
|
+
}
|
|
84
|
+
// Ordinate: l'ordine nativo di readdirSync cambia da un filesystem all'altro.
|
|
85
|
+
export function auditRoutes(expected) {
|
|
86
|
+
return expected.exact
|
|
87
|
+
.map(({ route }) => route)
|
|
88
|
+
.filter((route) => !ERROR_PAGES.includes(route))
|
|
89
|
+
.sort();
|
|
90
|
+
}
|
|
91
|
+
export function smokeRoutes(expected, nonHtmlRoutes = NON_HTML_ROUTES) {
|
|
92
|
+
return [...auditRoutes(expected).map((path) => ({ path, type: 'text/html' })), ...nonHtmlRoutes];
|
|
93
|
+
}
|
|
94
|
+
export function orphanExceptions(expected) {
|
|
95
|
+
const routes = new Set(expected.exact.map(({ route }) => route));
|
|
96
|
+
return ERROR_PAGES.filter((page) => !routes.has(page));
|
|
97
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { VerifiedRoute } from './routes.ts';
|
|
2
|
+
export interface SmokeResponse {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
status: number;
|
|
5
|
+
headers: {
|
|
6
|
+
get: (name: string) => string | null;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export type Fetcher = (url: string) => Promise<SmokeResponse>;
|
|
10
|
+
export interface SmokeContext {
|
|
11
|
+
get: Fetcher;
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
siteUrl: string;
|
|
14
|
+
securityHeaders: Record<string, string | null>;
|
|
15
|
+
}
|
|
16
|
+
export type SmokeCheck = (context: SmokeContext, pages: readonly VerifiedRoute[]) => Promise<CheckResult[]>;
|
|
17
|
+
export interface CheckResult {
|
|
18
|
+
check: string;
|
|
19
|
+
status: 'pass' | 'fail' | 'skip';
|
|
20
|
+
detail?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare const SECURITY_HEADERS: Record<string, string | null>;
|
|
23
|
+
/** Deve corrispondere al `source` del rewrite in vercel.json. */
|
|
24
|
+
export declare const BOTID_CHALLENGE = "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js";
|
|
25
|
+
/** L'alias di produzione di Vercel impiega un attimo a puntare al deployment appena caricato. */
|
|
26
|
+
export declare function waitForAlias({ get, baseUrl }: SmokeContext, sleep: (ms: number) => Promise<void>, attempts?: number): Promise<void>;
|
|
27
|
+
export declare function checkPages({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
|
|
28
|
+
export declare function checkSecurityHeaders({ get, baseUrl, securityHeaders }: SmokeContext): Promise<CheckResult[]>;
|
|
29
|
+
export declare function checkBotIdChallenge({ get, baseUrl }: SmokeContext): Promise<CheckResult[]>;
|
|
30
|
+
/** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
|
|
31
|
+
export declare function checkCanonicalHost({ get, baseUrl, siteUrl }: SmokeContext): Promise<CheckResult[]>;
|
|
32
|
+
export declare function checkTrailingSlash({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
|
|
33
|
+
export declare const DEFAULT_CHECKS: readonly SmokeCheck[];
|
|
34
|
+
/** In sequenza, non in parallelo: l'ordine dei risultati è quello dei controlli. */
|
|
35
|
+
export declare function runChecks(context: SmokeContext, pages: readonly VerifiedRoute[], checks?: readonly SmokeCheck[]): Promise<CheckResult[]>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const pass = (check) => ({ check, status: 'pass' });
|
|
2
|
+
const fail = (check, detail) => ({ check, status: 'fail', detail });
|
|
3
|
+
const skip = (check, detail) => ({ check, status: 'skip', detail });
|
|
4
|
+
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
5
|
+
// Dalla regola globale `/(.*)` di vercel.json. `null` = verifica solo la presenza; src/vercel-headers.test.ts fissa i valori.
|
|
6
|
+
export const SECURITY_HEADERS = {
|
|
7
|
+
'content-security-policy': null,
|
|
8
|
+
'strict-transport-security': null,
|
|
9
|
+
'x-content-type-options': 'nosniff',
|
|
10
|
+
'x-frame-options': 'DENY',
|
|
11
|
+
'referrer-policy': null,
|
|
12
|
+
'permissions-policy': null,
|
|
13
|
+
};
|
|
14
|
+
/** Deve corrispondere al `source` del rewrite in vercel.json. */
|
|
15
|
+
export const BOTID_CHALLENGE = '/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js';
|
|
16
|
+
/** L'alias di produzione di Vercel impiega un attimo a puntare al deployment appena caricato. */
|
|
17
|
+
export async function waitForAlias({ get, baseUrl }, sleep, attempts = 5) {
|
|
18
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
19
|
+
try {
|
|
20
|
+
if ((await get(baseUrl)).ok)
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
if (attempt < attempts)
|
|
25
|
+
await sleep(attempt * 2000);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function checkPages({ get, baseUrl }, pages) {
|
|
29
|
+
const results = [];
|
|
30
|
+
for (const { path, type } of pages) {
|
|
31
|
+
const check = `GET ${path}`;
|
|
32
|
+
try {
|
|
33
|
+
const response = await get(`${baseUrl}${path}`);
|
|
34
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
35
|
+
if (response.status !== 200)
|
|
36
|
+
results.push(fail(check, `atteso 200, ricevuto ${response.status}`));
|
|
37
|
+
else if (!contentType.includes(type))
|
|
38
|
+
results.push(fail(check, `atteso un content-type ${type}, ricevuto "${contentType}"`));
|
|
39
|
+
else
|
|
40
|
+
results.push(pass(check));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
results.push(fail(check, messageOf(error)));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
|
|
49
|
+
let response;
|
|
50
|
+
try {
|
|
51
|
+
response = await get(baseUrl);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return [fail('header di sicurezza', messageOf(error))];
|
|
55
|
+
}
|
|
56
|
+
const results = Object.entries(securityHeaders).map(([header, expected]) => {
|
|
57
|
+
const check = `header ${header}`;
|
|
58
|
+
const value = response.headers.get(header);
|
|
59
|
+
if (value === null)
|
|
60
|
+
return fail(check, 'assente');
|
|
61
|
+
if (expected !== null && value !== expected)
|
|
62
|
+
return fail(check, `atteso "${expected}", ricevuto "${value}"`);
|
|
63
|
+
return pass(check);
|
|
64
|
+
});
|
|
65
|
+
// Il noindex `has: host = *.vercel.app` di vercel.json applicato al dominio vero farebbe sparire il sito da ogni indice.
|
|
66
|
+
const check = 'nessun x-robots-tag sull’host di produzione';
|
|
67
|
+
const robots = response.headers.get('x-robots-tag');
|
|
68
|
+
results.push(robots === null ? pass(check) : fail(check, `presente su ${baseUrl}: "${robots}"`));
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
export async function checkBotIdChallenge({ get, baseUrl }) {
|
|
72
|
+
const check = 'challenge di BotID servita dalla stessa origine';
|
|
73
|
+
try {
|
|
74
|
+
const response = await get(`${baseUrl}${BOTID_CHALLENGE}`);
|
|
75
|
+
if (response.status !== 200)
|
|
76
|
+
return [fail(check, `atteso 200 dal rewrite, ricevuto ${response.status}`)];
|
|
77
|
+
return [pass(check)];
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
return [fail(check, messageOf(error))];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
|
|
84
|
+
export async function checkCanonicalHost({ get, baseUrl, siteUrl }) {
|
|
85
|
+
const check = 'www → apice 308';
|
|
86
|
+
if (baseUrl !== siteUrl)
|
|
87
|
+
return [skip(check, `l’URL di base non è ${siteUrl}`)];
|
|
88
|
+
try {
|
|
89
|
+
const response = await get(`https://www.${new URL(siteUrl).host}/`);
|
|
90
|
+
const location = response.headers.get('location') ?? '';
|
|
91
|
+
if (response.status !== 308)
|
|
92
|
+
return [fail(check, `atteso 308, ricevuto ${response.status}`)];
|
|
93
|
+
if (!location.startsWith(siteUrl))
|
|
94
|
+
return [fail(check, `location "${location}" non punta a ${siteUrl}`)];
|
|
95
|
+
return [pass(check)];
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
return [fail(check, messageOf(error))];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export async function checkTrailingSlash({ get, baseUrl }, pages) {
|
|
102
|
+
const page = pages.find(({ path, type }) => type === 'text/html' && path !== '/');
|
|
103
|
+
const check = 'barra finale → 308';
|
|
104
|
+
if (page === undefined)
|
|
105
|
+
return [skip(check, 'nessuna pagina HTML oltre a / da sondare')];
|
|
106
|
+
try {
|
|
107
|
+
const response = await get(`${baseUrl}${page.path}/`);
|
|
108
|
+
if (response.status !== 308)
|
|
109
|
+
return [fail(check, `atteso 308 su ${page.path}/, ricevuto ${response.status}`)];
|
|
110
|
+
const location = response.headers.get('location') ?? '';
|
|
111
|
+
if (!location.endsWith(page.path))
|
|
112
|
+
return [fail(check, `location "${location}" non punta a ${page.path}`)];
|
|
113
|
+
return [pass(check)];
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return [fail(check, messageOf(error))];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export const DEFAULT_CHECKS = [
|
|
120
|
+
checkPages,
|
|
121
|
+
checkSecurityHeaders,
|
|
122
|
+
checkBotIdChallenge,
|
|
123
|
+
checkCanonicalHost,
|
|
124
|
+
checkTrailingSlash,
|
|
125
|
+
];
|
|
126
|
+
/** In sequenza, non in parallelo: l'ordine dei risultati è quello dei controlli. */
|
|
127
|
+
export async function runChecks(context, pages, checks = DEFAULT_CHECKS) {
|
|
128
|
+
const results = [];
|
|
129
|
+
for (const check of checks)
|
|
130
|
+
results.push(...(await check(context, pages)));
|
|
131
|
+
return results;
|
|
132
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
PORT="${LHCI_PORT:-4399}"
|
|
5
|
+
OUT_DIR='dist/lh-prod'
|
|
6
|
+
REPORT_DIR="${LHCI_REPORT_DIR:-.lighthouseci/local}"
|
|
7
|
+
|
|
8
|
+
export TMPDIR="${TMPDIR:-/tmp}"
|
|
9
|
+
|
|
10
|
+
detect_chrome() {
|
|
11
|
+
local candidate
|
|
12
|
+
for candidate in \
|
|
13
|
+
"${CHROME_PATH:-}" \
|
|
14
|
+
$(ls -d "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux/chrome 2>/dev/null | sort -V -r) \
|
|
15
|
+
$(ls -d "$HOME"/.cache/puppeteer/chrome/*/chrome-linux*/chrome 2>/dev/null | sort -V -r) \
|
|
16
|
+
/usr/bin/google-chrome /usr/bin/chromium /usr/bin/chromium-browser
|
|
17
|
+
do
|
|
18
|
+
[[ -n "${candidate}" && -x "${candidate}" && "${candidate}" != *.exe ]] || continue
|
|
19
|
+
if "${candidate}" --version >/dev/null 2>&1; then
|
|
20
|
+
echo "${candidate}"
|
|
21
|
+
return 0
|
|
22
|
+
fi
|
|
23
|
+
done
|
|
24
|
+
return 1
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if ! CHROME_PATH="$(detect_chrome)"; then
|
|
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
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
export CHROME_PATH
|
|
34
|
+
echo "✓ chrome: ${CHROME_PATH} ($("${CHROME_PATH}" --version 2>/dev/null))"
|
|
35
|
+
|
|
36
|
+
SERVER_PID=''
|
|
37
|
+
|
|
38
|
+
cleanup() {
|
|
39
|
+
if [[ -n "${SERVER_PID}" ]]; then
|
|
40
|
+
kill -- "-${SERVER_PID}" 2>/dev/null || kill "${SERVER_PID}" 2>/dev/null || true
|
|
41
|
+
pkill -f "serve@14 .*--listen ${PORT}" 2>/dev/null || true
|
|
42
|
+
fi
|
|
43
|
+
find . -maxdepth 1 \( -name '*wsl.localhost*' -o -name 'undefined*' \) -exec rm -rf {} + 2>/dev/null || true
|
|
44
|
+
}
|
|
45
|
+
trap cleanup EXIT
|
|
46
|
+
|
|
47
|
+
echo "→ build equivalente alla produzione in ${OUT_DIR}"
|
|
48
|
+
VERCEL_ENV=production pnpm build --outDir "${OUT_DIR}"
|
|
49
|
+
|
|
50
|
+
if ! grep -qx 'Allow: /' "${OUT_DIR}/client/robots.txt"; then
|
|
51
|
+
echo "✗ ${OUT_DIR}/client/robots.txt non ha 'Allow: /': la build non equivale alla produzione." >&2
|
|
52
|
+
head -3 "${OUT_DIR}/client/robots.txt" >&2
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
echo "✓ robots.txt: Allow: /"
|
|
56
|
+
|
|
57
|
+
echo "→ avvio del server statico su :${PORT}"
|
|
58
|
+
setsid pnpm dlx serve@14 "${OUT_DIR}/client" --listen "${PORT}" --no-clipboard >/dev/null 2>&1 &
|
|
59
|
+
SERVER_PID=$!
|
|
60
|
+
|
|
61
|
+
for _ in $(seq 1 60); do
|
|
62
|
+
if curl -fs -o /dev/null "http://localhost:${PORT}/"; then break; fi
|
|
63
|
+
sleep 1
|
|
64
|
+
done
|
|
65
|
+
if ! curl -fs -o /dev/null "http://localhost:${PORT}/"; then
|
|
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
|
+
exit 1
|
|
69
|
+
fi
|
|
70
|
+
echo "✓ server pronto"
|
|
71
|
+
|
|
72
|
+
rm -rf "${REPORT_DIR}"
|
|
73
|
+
|
|
74
|
+
LH_PORT="${PORT}" \
|
|
75
|
+
LH_RUNS="${LHCI_RUNS:-1}" \
|
|
76
|
+
LH_OUT="${REPORT_DIR}" \
|
|
77
|
+
LH_EXTERNAL_SERVER=1 \
|
|
78
|
+
LH_CHROME_FLAGS='--no-sandbox --disable-dev-shm-usage' \
|
|
79
|
+
node "${OFFICINA_BIN}" check lighthouse
|
|
80
|
+
|
|
81
|
+
echo
|
|
82
|
+
echo "→ punteggio per URL (mediana su ${LHCI_RUNS:-1} giro/i, come la calcola LHCI)"
|
|
83
|
+
node -e '
|
|
84
|
+
const fs = require("fs")
|
|
85
|
+
const dir = process.argv[1]
|
|
86
|
+
const byUrl = new Map()
|
|
87
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".report.json"))) {
|
|
88
|
+
const r = JSON.parse(fs.readFileSync(`${dir}/${f}`))
|
|
89
|
+
const url = String(r.finalDisplayedUrl)
|
|
90
|
+
if (!byUrl.has(url)) byUrl.set(url, [])
|
|
91
|
+
byUrl.get(url).push(r.categories)
|
|
92
|
+
}
|
|
93
|
+
const median = (xs) => {
|
|
94
|
+
const s = [...xs].sort((a, b) => a - b)
|
|
95
|
+
return s[Math.floor(s.length / 2)]
|
|
96
|
+
}
|
|
97
|
+
for (const [url, runs] of [...byUrl].sort()) {
|
|
98
|
+
const pick = (k) => median(runs.map((c) => c[k].score)).toFixed(2)
|
|
99
|
+
console.log(
|
|
100
|
+
url.padEnd(46),
|
|
101
|
+
"perf", pick("performance"),
|
|
102
|
+
"a11y", pick("accessibility"),
|
|
103
|
+
"bp", pick("best-practices"),
|
|
104
|
+
"seo", pick("seo"),
|
|
105
|
+
)
|
|
106
|
+
}' "${REPORT_DIR}"
|