@elia97/officina 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -2
- 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/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/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/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/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 +17 -3
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// 20 KB è ~1,5× la rotta più pesante misurata nello starter (/contatti, 13,4 KB gz).
|
|
2
|
+
const DEFAULT_BUDGET = { label: 'default', matches: () => true, maxGzip: 20 * 1024 };
|
|
3
|
+
/** Le classi del progetto vengono prima, nell'ordine in cui `officina.config.ts` le dichiara. */
|
|
4
|
+
export function budgetFor(route, budgets = []) {
|
|
5
|
+
/* v8 ignore next -- DEFAULT_BUDGET accetta ogni rotta, quindi find() non torna mai undefined */
|
|
6
|
+
return [...budgets, DEFAULT_BUDGET].find((budget) => budget.matches(route)) ?? DEFAULT_BUDGET;
|
|
7
|
+
}
|
|
8
|
+
const captured = (source, pattern, group) => new Set([...source.matchAll(pattern)].map((match) => match[group]).filter((name) => name !== undefined));
|
|
9
|
+
/** Rollup racchiude gli specificatori statici con `"` e i dinamici in un template literal. */
|
|
10
|
+
export function parseEdges(source) {
|
|
11
|
+
return {
|
|
12
|
+
static: captured(source, /(?:from|import)\s*(["'`])\.\/([^"'`]+\.js)\1/g, 2),
|
|
13
|
+
dynamic: captured(source, /import\(\s*(["'`])\.\/([^"'`]+\.js)\1\s*\)/g, 2),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function htmlEntries(html) {
|
|
17
|
+
return [...captured(html, /(?:src|href)="\/_astro\/([^"]+\.js)"/g, 1)];
|
|
18
|
+
}
|
|
19
|
+
export function staticClosure(entries, chunks) {
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
const queue = [...entries];
|
|
22
|
+
while (queue.length > 0) {
|
|
23
|
+
const name = queue.pop();
|
|
24
|
+
if (name === undefined || seen.has(name))
|
|
25
|
+
continue;
|
|
26
|
+
const chunk = chunks.get(name);
|
|
27
|
+
if (chunk === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
seen.add(name);
|
|
30
|
+
queue.push(...chunk.static);
|
|
31
|
+
}
|
|
32
|
+
return seen;
|
|
33
|
+
}
|
|
34
|
+
export function deferredClosure(reached, chunks) {
|
|
35
|
+
const entries = [...reached].flatMap((name) => [...(chunks.get(name)?.dynamic ?? [])]);
|
|
36
|
+
return new Set([...staticClosure(entries, chunks)].filter((name) => !reached.has(name)));
|
|
37
|
+
}
|
|
38
|
+
export const CSS_BUDGET_GZIP = 12 * 1024;
|
|
39
|
+
export function heaviestStylesheet(sheets) {
|
|
40
|
+
return sheets.reduce((worst, sheet) => (worst && worst.gzip >= sheet.gzip ? worst : sheet), null);
|
|
41
|
+
}
|
|
42
|
+
// Astro emette un foglio di stile per gruppo di pagine e una rotta ne collega esattamente uno,
|
|
43
|
+
// quindi la loro somma è fatta di byte che nessun visitatore scarica mai insieme.
|
|
44
|
+
export function cssBudgetFailure(sheets, maxGzip = CSS_BUDGET_GZIP) {
|
|
45
|
+
const heaviest = heaviestStylesheet(sheets);
|
|
46
|
+
if (heaviest === null || heaviest.gzip <= maxGzip)
|
|
47
|
+
return null;
|
|
48
|
+
const over = heaviest.gzip - maxGzip;
|
|
49
|
+
return `CSS ${(heaviest.gzip / 1024).toFixed(1)} KB gz > ${(maxGzip / 1024).toFixed(1)} KB (+${(over / 1024).toFixed(1)} KB) in ${heaviest.file}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Hit } from './cli.ts';
|
|
2
|
+
import { type Line } from './comment-syntax.ts';
|
|
3
|
+
export interface FileReport {
|
|
4
|
+
findings: Hit[];
|
|
5
|
+
comments: number;
|
|
6
|
+
total: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const SCANNED: RegExp;
|
|
9
|
+
export declare function report(file: string, lines: Line[]): FileReport;
|
|
10
|
+
export declare function isNoisy({ comments, total }: FileReport): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Hit } from './cli.ts';
|
|
2
|
+
export declare function isScanned(path: string): boolean;
|
|
3
|
+
export interface Tally {
|
|
4
|
+
italian: number;
|
|
5
|
+
english: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function tally(prose: string): Tally;
|
|
8
|
+
export type Verdict = 'english' | 'italian' | 'undecided';
|
|
9
|
+
export declare function classify({ italian, english }: Tally, minHits?: number): Verdict;
|
|
10
|
+
/** In un sorgente la prosa sta solo nei commenti: il codice è inglese per costruzione. */
|
|
11
|
+
export declare function findingsFor(path: string, source: string): Hit[];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Finding, type Hit } from './cli.ts';
|
|
2
|
+
type EnvHit = Omit<Finding, 'path' | 'severity'>;
|
|
3
|
+
export declare function placeholderSources(): string[];
|
|
4
|
+
export declare function sourceFindings(source: string): Hit[];
|
|
5
|
+
export declare function envFindings(content: string): EnvHit[];
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Hit } from './cli.ts';
|
|
2
|
+
export interface Row {
|
|
3
|
+
line: number;
|
|
4
|
+
number: number;
|
|
5
|
+
name: string;
|
|
6
|
+
phase: number;
|
|
7
|
+
days: number;
|
|
8
|
+
}
|
|
9
|
+
export interface Subtotal {
|
|
10
|
+
line: number;
|
|
11
|
+
label: string;
|
|
12
|
+
phase: number | null;
|
|
13
|
+
days: number;
|
|
14
|
+
}
|
|
15
|
+
export interface Section {
|
|
16
|
+
line: number;
|
|
17
|
+
number: number;
|
|
18
|
+
name: string;
|
|
19
|
+
phase: number;
|
|
20
|
+
days: number;
|
|
21
|
+
}
|
|
22
|
+
/** «1,5» è una giornata e mezza. Una cella vuota non è zero giornate: `Number('')` sarebbe 0. */
|
|
23
|
+
export declare function parseDays(raw: string): number;
|
|
24
|
+
/** Le righe numerate della tabella Status: una per milestone. */
|
|
25
|
+
export declare function statusRows(source: string): Row[];
|
|
26
|
+
/** Le righe di somma: un subtotale per fase, più il totale. */
|
|
27
|
+
export declare function subtotals(source: string): Subtotal[];
|
|
28
|
+
/** Le sezioni `## Milestone N`, con la fase e le giornate della loro testata. */
|
|
29
|
+
export declare function sections(source: string): Section[];
|
|
30
|
+
export declare function findingsFor(source: string): Hit[];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Hit } from './cli.ts';
|
|
2
|
+
export declare const isScanned: (path: string) => boolean;
|
|
3
|
+
export declare function normalizeTitle(raw: string): string;
|
|
4
|
+
export declare function headings(source: string): string[];
|
|
5
|
+
export declare function resolveTarget(from: string, cited: string): string;
|
|
6
|
+
export declare function findingsFor(path: string, source: string): Hit[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface Hit {
|
|
2
|
+
line: number;
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
export type Finding = {
|
|
6
|
+
path: string;
|
|
7
|
+
line?: number;
|
|
8
|
+
message: string;
|
|
9
|
+
severity: 'error' | 'warning';
|
|
10
|
+
};
|
|
11
|
+
export declare function cliOptions(args?: string[]): {
|
|
12
|
+
base?: string;
|
|
13
|
+
head?: string;
|
|
14
|
+
strict: boolean;
|
|
15
|
+
format: string;
|
|
16
|
+
diff: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type CliOptions = ReturnType<typeof cliOptions>;
|
|
19
|
+
export declare const isGenerated: (path: string) => boolean;
|
|
20
|
+
export declare function scopedPaths(options: CliOptions, isScanned: (path: string) => boolean): string[];
|
|
21
|
+
export declare function printFindings(findings: Finding[], format: string): void;
|
|
22
|
+
export declare function exitCode(findings: Finding[], strict: boolean): number;
|
|
23
|
+
export declare const readLines: (text: string) => {
|
|
24
|
+
n: number;
|
|
25
|
+
text: string;
|
|
26
|
+
}[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface Line {
|
|
2
|
+
n: number;
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
export interface CommentBlock {
|
|
6
|
+
lines: Line[];
|
|
7
|
+
trailing: boolean;
|
|
8
|
+
}
|
|
9
|
+
export type CommentStyle = 'hash' | 'css' | 'slash' | 'astro';
|
|
10
|
+
export declare function styleOf(file: string): CommentStyle;
|
|
11
|
+
export declare function commentBlocks(lines: Line[], style: CommentStyle): CommentBlock[];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Budget } from './bundle-budget.ts';
|
|
2
|
+
import type { VerifiedRoute } from './routes.ts';
|
|
3
|
+
import type { SmokeCheck } from './smoke-production.ts';
|
|
4
|
+
export interface OfficinaConfig {
|
|
5
|
+
/** L'apice di produzione, senza barra finale: lo usa `check smoke` quando non riceve un URL. */
|
|
6
|
+
siteUrl?: string;
|
|
7
|
+
icons?: {
|
|
8
|
+
background: string;
|
|
9
|
+
};
|
|
10
|
+
bundle?: {
|
|
11
|
+
budgets?: readonly Budget[];
|
|
12
|
+
cssMaxGzip?: number;
|
|
13
|
+
};
|
|
14
|
+
smoke?: {
|
|
15
|
+
securityHeaders?: Record<string, string | null>;
|
|
16
|
+
nonHtmlRoutes?: readonly VerifiedRoute[];
|
|
17
|
+
checks?: readonly SmokeCheck[];
|
|
18
|
+
};
|
|
19
|
+
analytics?: {
|
|
20
|
+
linkTracking?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare const defineConfig: (config: OfficinaConfig) => OfficinaConfig;
|
|
24
|
+
export declare function loadConfig(root: string): Promise<OfficinaConfig>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
const CONFIG_FILES = ['officina.config.ts', 'officina.config.mjs', 'officina.config.js'];
|
|
13
|
+
export const defineConfig = (config) => config;
|
|
14
|
+
// Fuori da node_modules Node toglie i tipi da solo: il file del progetto può essere TypeScript.
|
|
15
|
+
export async function loadConfig(root) {
|
|
16
|
+
const file = CONFIG_FILES.map((name) => join(root, name)).find((path) => existsSync(path));
|
|
17
|
+
if (file === undefined)
|
|
18
|
+
return {};
|
|
19
|
+
const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
|
|
20
|
+
return module.default ?? {};
|
|
21
|
+
}
|
|
@@ -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,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[];
|