@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.
- package/README.md +57 -3
- package/dist/bin.d.ts +8 -0
- package/dist/bin.js +28 -6
- 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 +15 -0
- 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/gen/collection.d.mts +1 -0
- package/dist/gen/collection.mjs +76 -0
- package/dist/gen/component.d.mts +1 -0
- package/dist/gen/component.mjs +41 -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/identifier.mjs +52 -0
- package/dist/gen/inject-config.d.mts +12 -0
- package/dist/gen/inject-config.mjs +73 -0
- package/dist/gen/inject-section.d.mts +16 -0
- package/dist/gen/inject-section.mjs +140 -0
- package/dist/gen/inject-strings.d.mts +8 -0
- package/dist/gen/inject-strings.mjs +56 -0
- package/dist/gen/page.d.mts +10 -0
- package/dist/gen/page.mjs +68 -0
- package/dist/gen/plopfile.d.mts +2 -0
- package/dist/gen/plopfile.mjs +30 -0
- package/dist/gen/post-gen.d.mts +1 -0
- package/dist/gen/post-gen.mjs +16 -0
- package/dist/gen/run.d.ts +2 -0
- package/dist/gen/run.js +17 -0
- package/dist/gen/section-targets.d.mts +23 -0
- package/dist/gen/section-targets.mjs +46 -0
- package/dist/gen/section.d.mts +1 -0
- package/dist/gen/section.mjs +68 -0
- package/dist/gen/sectioned-collection.d.mts +26 -0
- package/dist/gen/sectioned-collection.mjs +81 -0
- package/dist/gen/templates/collection/data.yml.hbs +2 -0
- package/dist/gen/templates/collection/doc.md.hbs +8 -0
- package/dist/gen/templates/collection/schema.ts.hbs +11 -0
- package/dist/gen/templates/component/component.astro.hbs +21 -0
- package/dist/gen/templates/page/dynamic.astro.hbs +24 -0
- package/dist/gen/templates/page/static.astro.hbs +20 -0
- package/dist/gen/templates/section/component.astro.hbs +45 -0
- package/dist/gen/templates/section/content.yml.hbs +10 -0
- package/dist/gen/templates/section/schema.ts.hbs +20 -0
- package/dist/gen/templates/sections/data-layer.ts.hbs +13 -0
- package/dist/gen/templates/sections/index.ts.hbs +12 -0
- package/dist/gen/templates/sections/page.astro.hbs +30 -0
- package/dist/gen/ts-morph-utils.d.mts +1 -0
- package/dist/gen/ts-morph-utils.mjs +8 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -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 +24 -0
- package/dist/lib/config.js +21 -0
- package/dist/lib/contract.d.ts +22 -0
- package/dist/lib/contract.js +59 -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 +21 -3
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
type Requirement = {
|
|
2
|
+
kind: 'file';
|
|
3
|
+
path: string;
|
|
4
|
+
reason: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'directory';
|
|
7
|
+
path: string;
|
|
8
|
+
extension: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'anchor';
|
|
12
|
+
path: string;
|
|
13
|
+
text: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
export declare const CONTRACT: Requirement[];
|
|
17
|
+
export interface ContractGap {
|
|
18
|
+
path: string;
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function contractGaps(root: string): ContractGap[];
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const CONTRACT = [
|
|
4
|
+
{ kind: 'file', path: 'src/components/ui/section.astro', reason: 'le sezioni generate la importano' },
|
|
5
|
+
{ kind: 'file', path: 'src/components/ui/container.astro', reason: 'le sezioni e le pagine generate lo importano' },
|
|
6
|
+
{ kind: 'file', path: 'src/components/ui/heading.astro', reason: 'le sezioni e le pagine generate lo importano' },
|
|
7
|
+
{ kind: 'file', path: 'src/components/ui/button.astro', reason: 'le sezioni generate lo importano' },
|
|
8
|
+
{ kind: 'file', path: 'src/layouts/main.astro', reason: 'le pagine generate lo usano come layout' },
|
|
9
|
+
{ kind: 'file', path: 'src/i18n/translate.ts', reason: 'le pagine generate traducono titolo e descrizione' },
|
|
10
|
+
{ kind: 'file', path: 'src/i18n/href.ts', reason: 'le sezioni generate localizzano i link delle CTA' },
|
|
11
|
+
{ kind: 'file', path: 'src/i18n/ui.ts', reason: 'gen:page registra lì le chiavi della pagina' },
|
|
12
|
+
{
|
|
13
|
+
kind: 'directory',
|
|
14
|
+
path: 'src/i18n/strings',
|
|
15
|
+
extension: '.ts',
|
|
16
|
+
reason: 'gen:page scrive le stringhe in ogni dizionario',
|
|
17
|
+
},
|
|
18
|
+
{ kind: 'file', path: 'src/lib/utils.ts', reason: 'i componenti generati usano cn()' },
|
|
19
|
+
{
|
|
20
|
+
kind: 'file',
|
|
21
|
+
path: 'src/lib/content/localized-sections.ts',
|
|
22
|
+
reason: 'lo strato dati di una pagina a sezioni lo usa',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
kind: 'directory',
|
|
26
|
+
path: 'src/lib/schemas',
|
|
27
|
+
extension: '.ts',
|
|
28
|
+
reason: 'gen:collection e gen:section scrivono lì gli schemi',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
kind: 'anchor',
|
|
32
|
+
path: 'src/content.config.ts',
|
|
33
|
+
text: 'export const collections',
|
|
34
|
+
reason: 'gen:collection e gen:section registrano lì la collection',
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
function hasFileWith(directory, extension) {
|
|
38
|
+
if (!existsSync(directory) || !statSync(directory).isDirectory())
|
|
39
|
+
return false;
|
|
40
|
+
return readdirSync(directory, { recursive: true }).some((entry) => String(entry).endsWith(extension));
|
|
41
|
+
}
|
|
42
|
+
function gapFor(root, requirement) {
|
|
43
|
+
const target = join(root, requirement.path);
|
|
44
|
+
if (requirement.kind === 'directory') {
|
|
45
|
+
return hasFileWith(target, requirement.extension) ? undefined : `manca, o è vuota: ${requirement.reason}`;
|
|
46
|
+
}
|
|
47
|
+
if (!existsSync(target))
|
|
48
|
+
return `manca: ${requirement.reason}`;
|
|
49
|
+
if (requirement.kind === 'anchor' && !readFileSync(target, 'utf8').includes(requirement.text)) {
|
|
50
|
+
return `manca l'ancoraggio \`${requirement.text}\`: ${requirement.reason}`;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
export function contractGaps(root) {
|
|
55
|
+
return CONTRACT.flatMap((requirement) => {
|
|
56
|
+
const message = gapFor(root, requirement);
|
|
57
|
+
return message === undefined ? [] : [{ path: requirement.path, message }];
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type Macro = {
|
|
2
|
+
function?: unknown;
|
|
3
|
+
vtp_name?: unknown;
|
|
4
|
+
};
|
|
5
|
+
export type Tag = {
|
|
6
|
+
function?: unknown;
|
|
7
|
+
vtp_eventName?: unknown;
|
|
8
|
+
vtp_tagId?: unknown;
|
|
9
|
+
};
|
|
10
|
+
export type Predicate = {
|
|
11
|
+
function?: unknown;
|
|
12
|
+
arg0?: unknown;
|
|
13
|
+
arg1?: unknown;
|
|
14
|
+
};
|
|
15
|
+
export type ContainerResource = {
|
|
16
|
+
macros: Macro[];
|
|
17
|
+
tags: Tag[];
|
|
18
|
+
predicates: Predicate[];
|
|
19
|
+
rules: unknown[][];
|
|
20
|
+
};
|
|
21
|
+
/** Un tag evento GA4 e le condizioni che lo fanno scattare. `unsupported` porta quello che
|
|
22
|
+
* il lettore non sa interpretare, così un trigger saltato a metà non risulta mai compreso. */
|
|
23
|
+
export type Trigger = {
|
|
24
|
+
eventName: string;
|
|
25
|
+
firesOn: string[];
|
|
26
|
+
selectors: string[];
|
|
27
|
+
urlContains: string[];
|
|
28
|
+
unsupported: string[];
|
|
29
|
+
};
|
|
30
|
+
/** Il container arriva come un solo file JS che contiene un unico letterale `var data = {…}`,
|
|
31
|
+
* e a isolarlo è solo un conteggio di graffe che conosce le stringhe: i valori ne contengono. */
|
|
32
|
+
export declare function parseContainerData(source: string): ContainerResource;
|
|
33
|
+
/** L'id del container come lo serve la produzione, incorporato con `define:vars`. Pubblico per
|
|
34
|
+
* costruzione, ed è l'unica risposta a "quale container gira su questo host". */
|
|
35
|
+
export declare function extractGtmId(html: string): string;
|
|
36
|
+
/** Le proprietà GA4 che il container configura. Senza nemmeno una, ogni tag evento scatta nel
|
|
37
|
+
* vuoto: un elenco vuoto è un fallimento, non una nota. */
|
|
38
|
+
export declare function googleTagIds(container: ContainerResource): string[];
|
|
39
|
+
/** GTM tiene separati tag e condizioni: una regola elenca clausole, con `if` e `unless` che
|
|
40
|
+
* nominano predicati e `add` e `block` che nominano tag. Un trigger per regola che aggiunge il tag. */
|
|
41
|
+
export declare function extractTriggers(container: ContainerResource): Trigger[];
|
|
@@ -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} holds no .html file — no route was measured, so the per-route budgets assert nothing`];
|
|
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(`missing route ${route} — ${file} is prerendered but emitted no HTML`);
|
|
76
|
+
}
|
|
77
|
+
for (const { pattern, label, file } of expected.patterns) {
|
|
78
|
+
if (emitted.some((route) => pattern.test(route)))
|
|
79
|
+
continue;
|
|
80
|
+
failures.push(`missing route ${label} — ${file} is prerendered but getStaticPaths emitted nothing`);
|
|
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[]>;
|