@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,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[]>;
|
|
@@ -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, `expected 200, got ${response.status}`));
|
|
37
|
+
else if (!contentType.includes(type))
|
|
38
|
+
results.push(fail(check, `expected a ${type} content-type, got "${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('security headers', 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, 'missing');
|
|
61
|
+
if (expected !== null && value !== expected)
|
|
62
|
+
return fail(check, `expected "${expected}", got "${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 = 'no x-robots-tag on the production host';
|
|
67
|
+
const robots = response.headers.get('x-robots-tag');
|
|
68
|
+
results.push(robots === null ? pass(check) : fail(check, `present on ${baseUrl}: "${robots}"`));
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
export async function checkBotIdChallenge({ get, baseUrl }) {
|
|
72
|
+
const check = 'BotID challenge proxied same-origin';
|
|
73
|
+
try {
|
|
74
|
+
const response = await get(`${baseUrl}${BOTID_CHALLENGE}`);
|
|
75
|
+
if (response.status !== 200)
|
|
76
|
+
return [fail(check, `expected 200 from the rewrite, got ${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 → apex 308';
|
|
86
|
+
if (baseUrl !== siteUrl)
|
|
87
|
+
return [skip(check, `base URL is not ${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, `expected 308, got ${response.status}`)];
|
|
93
|
+
if (!location.startsWith(siteUrl))
|
|
94
|
+
return [fail(check, `location "${location}" does not point at ${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 = 'trailing slash → 308';
|
|
104
|
+
if (page === undefined)
|
|
105
|
+
return [skip(check, 'no HTML page other than / to probe')];
|
|
106
|
+
try {
|
|
107
|
+
const response = await get(`${baseUrl}${page.path}/`);
|
|
108
|
+
if (response.status !== 308)
|
|
109
|
+
return [fail(check, `expected 308 on ${page.path}/, got ${response.status}`)];
|
|
110
|
+
const location = response.headers.get('location') ?? '';
|
|
111
|
+
if (!location.endsWith(page.path))
|
|
112
|
+
return [fail(check, `location "${location}" does not point at ${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 "✗ no usable Linux Chrome/Chromium found." >&2
|
|
29
|
+
echo " Install one with: pnpm dlx playwright@latest install chromium" >&2
|
|
30
|
+
echo " Or point at the binary: CHROME_PATH=/path/to/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 "→ production-equivalent build 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 has no 'Allow: /' — the build is not production-equivalent." >&2
|
|
52
|
+
head -3 "${OUT_DIR}/client/robots.txt" >&2
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
echo "✓ robots.txt: Allow: /"
|
|
56
|
+
|
|
57
|
+
echo "→ starting the static server on :${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 "✗ the server did not answer on :${PORT} within 60s." >&2
|
|
67
|
+
echo " Retry on another port: LHCI_PORT=4400 pnpm run lhci:local" >&2
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
70
|
+
echo "✓ server ready"
|
|
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 "→ score per URL (median over ${LHCI_RUNS:-1} run(s), the way LHCI asserts)"
|
|
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}"
|
package/package.json
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elia97/officina",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Gli strumenti dei progetti nati dai template di Elia: gate di
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Gli strumenti dei progetti nati dai template di Elia: gate di qualità, generatori e verifiche di build e di produzione.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/Elia97/officina.git"
|
|
10
10
|
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
11
17
|
"bin": {
|
|
12
18
|
"officina": "dist/bin.js"
|
|
13
19
|
},
|
|
@@ -33,8 +39,16 @@
|
|
|
33
39
|
"plop": "^4.0.5",
|
|
34
40
|
"ts-morph": "^28.0.0"
|
|
35
41
|
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"sharp": ">=0.34.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"sharp": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
36
50
|
"scripts": {
|
|
37
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/gen/templates dist/gen/templates",
|
|
51
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/gen/templates dist/gen/templates && cp -r src/sh dist/sh",
|
|
38
52
|
"typecheck": "tsc --noEmit",
|
|
39
53
|
"test": "vitest run --coverage",
|
|
40
54
|
"format": "biome check --write .",
|