@elia97/officina 0.7.0 → 0.8.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 +10 -4
- package/dist/checks/bundle.js +25 -12
- package/dist/checks/lighthouse.js +24 -10
- package/dist/lib/action-manifests.js +14 -3
- package/dist/lib/bundle-ssr.d.ts +13 -0
- package/dist/lib/bundle-ssr.js +37 -0
- package/dist/lib/lighthouse.d.ts +6 -1
- package/dist/lib/lighthouse.js +15 -4
- package/dist/lib/vercel-build.d.ts +11 -0
- package/dist/lib/vercel-build.js +118 -0
- package/dist/sh/lhci-local.sh +7 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -40,12 +40,16 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
|
|
|
40
40
|
|
|
41
41
|
| Comando | Cosa guarda | Da dove prende le rotte |
|
|
42
42
|
|---|---|---|
|
|
43
|
-
| `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages
|
|
43
|
+
| `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages`; sotto SSR, il manifest della build Vercel |
|
|
44
44
|
| `check smoke [url]` | la produzione viva: pagine, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
|
|
45
45
|
| `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
|
|
46
|
-
| `check lighthouse [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; `--local` fa build, server e Chrome da sé | `src/pages` |
|
|
46
|
+
| `check lighthouse [url] [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; con `url` misura un sito già servito, `--local` fa build, server statico e Chrome da sé | `src/pages` |
|
|
47
47
|
| `gen icons` | le icone del manifest, disegnate da `public/favicon.svg`, che è il suo ingresso obbligatorio | — |
|
|
48
48
|
|
|
49
|
+
Una rotta renderizzata a richiesta non emette HTML. Con l'adapter Vercel, `check bundle` la legge dal manifest che Astro scrive in `.vercel/output/_functions` e la misura con gli stessi budget delle pagine statiche: gli script della rotta, le isole e gli script dei componenti che i chunk server della pagina nominano, e il runtime del framework quando c'è un'isola. Il JavaScript che Astro incorpora nell'HTML non si conta, né qui né per le pagine statiche. Due limiti: le isole raggiungibili solo attraverso un `import()` dinamico del server — un'isola dentro un contenuto MDX, per esempio — restano fuori, perché seguire quegli import attribuirebbe a ogni pagina il contenuto di tutto il sito; e un'isola si attribuisce per chunk, quindi una pagina eredita le isole dei componenti che condividono un chunk con quelli che usa. Il manifest è un formato interno di Astro, già cambiato una volta sotto i piedi del misuratore che c'era prima: quando non lo riconosce il check lo dice, e fallisce se non gli resta nessuna rotta da misurare; un'isola che non sa collegare al client è un fallimento, non una misura più bassa. Con un altro adapter le rotte SSR restano fuori, dichiarate, e il check misura soltanto il CSS.
|
|
50
|
+
|
|
51
|
+
Per Lighthouse un sito SSR si misura con `check lighthouse <url>`, sulla produzione o su un'anteprima: al posto del server del progetto visita quell'indirizzo, ed è l'unico modo di avere numeri di produzione. `--local` serve i file statici della build, quindi su un sito renderizzato a richiesta si ferma e lo dice, invece di misurare dei 404. Un'anteprima Vercel con la Deployment Protection risponde con la pagina di accesso, e il check non la può misurare: serve la produzione o un'anteprima non protetta. La porta del server locale segue `LH_PORT` sia con `--listen` sia con `--port`.
|
|
52
|
+
|
|
49
53
|
Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
|
|
50
54
|
|
|
51
55
|
```ts
|
|
@@ -110,8 +114,8 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
|
|
|
110
114
|
|
|
111
115
|
| Action | Passi | Cosa resta nel workflow del progetto |
|
|
112
116
|
|---|---|---|
|
|
113
|
-
| `actions/ci` | checkout, node, install, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
|
|
114
|
-
| `actions/review` | `fallow review` sul diff contro il merge-base | il job informativo |
|
|
117
|
+
| `actions/ci` | checkout, node, install, `astro sync`, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
|
|
118
|
+
| `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
|
|
115
119
|
| `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono |
|
|
116
120
|
| `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
|
|
117
121
|
|
|
@@ -132,6 +136,8 @@ jobs:
|
|
|
132
136
|
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
|
|
133
137
|
```
|
|
134
138
|
|
|
139
|
+
`astro sync` genera i tipi di `.astro/`, che fallow pretende e un checkout pulito non ha; se fallisce il passo lo segnala con un avviso e il job prosegue.
|
|
140
|
+
|
|
135
141
|
I tre segreti sono input obbligatori e l'action li mette nell'`env` dei soli tre passi che chiamano `vercel`. Nell'`env` del job li vedrebbero anche `pnpm install` e gli script di installazione di ogni dipendenza, che girano codice di terzi con in mano un token di deploy.
|
|
136
142
|
|
|
137
143
|
Quello che si scarica al volo è fissato a una versione esatta, in un posto solo: la CLI di Vercel in `actions/deploy/action.yml`, `@lhci/cli` in `src/checks/lighthouse.ts`, `serve` in `src/sh/lhci-local.sh`. Che i primi due siano esatti, uguali ovunque e non indietro di una major su npm lo guarda ogni lunedì `.github/workflows/vercel-cli.yml`, con `officina check vercel-cli`.
|
package/dist/checks/bundle.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import { gzipSync } from 'node:zlib';
|
|
7
7
|
import { budgetFor, CSS_BUDGET_GZIP, cssBudgetFailure, deferredClosure, heaviestStylesheet, htmlEntries, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
|
|
8
|
+
import { readSsr } from '../lib/bundle-ssr.js';
|
|
8
9
|
import { missingInput } from '../lib/cli.js';
|
|
9
10
|
import { loadConfig } from '../lib/config.js';
|
|
10
11
|
import { expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
|
|
@@ -25,12 +26,17 @@ function readChunks() {
|
|
|
25
26
|
}
|
|
26
27
|
return chunks;
|
|
27
28
|
}
|
|
28
|
-
function
|
|
29
|
+
function htmlPages() {
|
|
30
|
+
return filesWithExtension(DIST, '.html').map((htmlPath) => ({
|
|
31
|
+
route: routeOf(htmlPath, DIST),
|
|
32
|
+
entries: htmlEntries(readFileSync(htmlPath, 'utf8')),
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
function measurePages(pages, chunks, budgets) {
|
|
29
36
|
const total = (names) => [...names].reduce((sum, name) => sum + (chunks.get(name)?.gzip ?? 0), 0);
|
|
30
|
-
return
|
|
31
|
-
.map((
|
|
32
|
-
const
|
|
33
|
-
const { reached, unknown } = staticClosure(htmlEntries(readFileSync(htmlPath, 'utf8')), chunks);
|
|
37
|
+
return pages
|
|
38
|
+
.map(({ route, entries }) => {
|
|
39
|
+
const { reached, unknown } = staticClosure(entries, chunks);
|
|
34
40
|
return {
|
|
35
41
|
route,
|
|
36
42
|
gzip: total(reached),
|
|
@@ -74,8 +80,10 @@ function printStylesheets(stylesheets, cssMaxGzip, failed, width) {
|
|
|
74
80
|
console.log(` ${sheet.file.padEnd(width - 2)} ${gz(sheet.gzip).padStart(9)} ${gz(cssMaxGzip).padStart(9)}${mark}`);
|
|
75
81
|
}
|
|
76
82
|
}
|
|
77
|
-
function printSsrNote(expected) {
|
|
78
|
-
|
|
83
|
+
function printSsrNote(expected, ssr) {
|
|
84
|
+
for (const note of ssr.notes)
|
|
85
|
+
console.log(`\nNOTA ${note}`);
|
|
86
|
+
if (ssr.measured || expected.ssr.length === 0)
|
|
79
87
|
return;
|
|
80
88
|
console.log(`\nNOTA ${expected.ssr.length} pagina/e con \`export const prerender = false\`, fuori da questo budget:`);
|
|
81
89
|
for (const file of expected.ssr)
|
|
@@ -88,10 +96,13 @@ export async function main() {
|
|
|
88
96
|
if (missingInput(PAGES, 'le rotte attese si derivano da lì'))
|
|
89
97
|
return 1;
|
|
90
98
|
const cssMaxGzip = bundle.cssMaxGzip ?? CSS_BUDGET_GZIP;
|
|
91
|
-
const
|
|
99
|
+
const html = htmlPages();
|
|
100
|
+
const ssr = readSsr(html.length > 0);
|
|
101
|
+
const pages = measurePages([...html, ...ssr.pages], readChunks(), bundle.budgets ?? []);
|
|
92
102
|
const expected = expectedRoutes(readPageFiles(PAGES), PAGES);
|
|
93
|
-
const
|
|
94
|
-
failures.
|
|
103
|
+
const measured = pages.map((page) => page.route);
|
|
104
|
+
const failures = ssr.comparesExpectedRoutes ? missingRouteFailures(expected, measured, DIST) : [];
|
|
105
|
+
failures.push(...ssr.failures, ...unknownChunkFailures(pages), ...overBudgetFailures(pages));
|
|
95
106
|
const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
|
|
96
107
|
printPages(pages, width);
|
|
97
108
|
const stylesheets = readStylesheets();
|
|
@@ -99,12 +110,14 @@ export async function main() {
|
|
|
99
110
|
if (cssFailure)
|
|
100
111
|
failures.push(cssFailure);
|
|
101
112
|
printStylesheets(stylesheets, cssMaxGzip, cssFailure !== null, width);
|
|
102
|
-
printSsrNote(expected);
|
|
113
|
+
printSsrNote(expected, ssr);
|
|
103
114
|
if (failures.length > 0) {
|
|
104
115
|
console.error(`\n✗ Budget di bundle:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
|
|
105
116
|
return 1;
|
|
106
117
|
}
|
|
107
|
-
console.log(
|
|
118
|
+
console.log(ssr.comparesExpectedRoutes
|
|
119
|
+
? '\n✓ Budget di bundle rispettato su ogni rotta attesa.\n'
|
|
120
|
+
: '\n✓ Budget del CSS rispettato; le rotte non sono state misurate.\n');
|
|
108
121
|
return 0;
|
|
109
122
|
}
|
|
110
123
|
if (import.meta.main)
|
|
@@ -7,7 +7,7 @@ import process from 'node:process';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { missingInput } from '../lib/cli.js';
|
|
9
9
|
import { loadConfig } from '../lib/config.js';
|
|
10
|
-
import { lighthouseConfig } from '../lib/lighthouse.js';
|
|
10
|
+
import { baseUrlArg, lighthouseConfig } from '../lib/lighthouse.js';
|
|
11
11
|
import { auditRoutes, expectedRoutes, readPageFiles } from '../lib/routes.js';
|
|
12
12
|
const PAGES_DIR = 'src/pages';
|
|
13
13
|
const RC_FILE = '.lighthouserc.json';
|
|
@@ -18,21 +18,24 @@ function runLocal() {
|
|
|
18
18
|
const env = { ...process.env, OFFICINA_BIN: process.argv[1] };
|
|
19
19
|
return spawnSync('bash', [script], { stdio: 'inherit', env }).status ?? 1;
|
|
20
20
|
}
|
|
21
|
-
|
|
22
|
-
if (args.includes('--local'))
|
|
23
|
-
return runLocal();
|
|
21
|
+
async function resolveConfig(url) {
|
|
24
22
|
const { routes: configured = {} } = await loadConfig(process.cwd());
|
|
25
23
|
if (missingInput(RC_FILE, 'è la configurazione di Lighthouse CI'))
|
|
26
|
-
return
|
|
24
|
+
return null;
|
|
27
25
|
if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
|
|
28
|
-
return
|
|
26
|
+
return null;
|
|
29
27
|
const rc = JSON.parse(readFileSync(RC_FILE, 'utf8'));
|
|
30
28
|
const routes = auditRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR), configured.representatives);
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
const env = url === undefined ? process.env : { ...process.env, LH_BASE_URL: url };
|
|
30
|
+
const resolved = lighthouseConfig(rc, routes, env);
|
|
31
|
+
const where = url === undefined ? '' : ` su ${url}`;
|
|
32
|
+
console.log(`\nLighthouse CI — ${routes.length} rotta/e derivate da ${PAGES_DIR}${where}\n`);
|
|
33
|
+
for (const page of resolved.ci.collect.url ?? [])
|
|
34
|
+
console.log(` ${page}`);
|
|
35
35
|
console.log();
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
function autorun(resolved) {
|
|
36
39
|
const dir = mkdtempSync(join(tmpdir(), 'lhci-'));
|
|
37
40
|
const config = join(dir, 'lighthouserc.json');
|
|
38
41
|
writeFileSync(config, JSON.stringify(resolved, null, 2));
|
|
@@ -42,5 +45,16 @@ export async function main(args = []) {
|
|
|
42
45
|
rmSync(dir, { recursive: true, force: true });
|
|
43
46
|
return status ?? 1;
|
|
44
47
|
}
|
|
48
|
+
export async function main(args = []) {
|
|
49
|
+
if (args.includes('--local'))
|
|
50
|
+
return runLocal();
|
|
51
|
+
const { url, error } = baseUrlArg(args);
|
|
52
|
+
if (error !== undefined) {
|
|
53
|
+
console.error(`\n✗ ${error}\n`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
const resolved = await resolveConfig(url);
|
|
57
|
+
return resolved === null ? 1 : autorun(resolved);
|
|
58
|
+
}
|
|
45
59
|
if (import.meta.main)
|
|
46
60
|
process.exit(await main(process.argv.slice(2)));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LineCounter, parseDocument } from 'yaml';
|
|
1
2
|
import { readLines } from './cli.js';
|
|
2
3
|
import { isLive } from './workflows.js';
|
|
3
4
|
const EXPRESSION = /\$\{\{[^}]*\}\}/g;
|
|
@@ -16,10 +17,20 @@ function lineFindings(text) {
|
|
|
16
17
|
return context === undefined ? [] : [`\`${context}\` non è un contesto di una composite action: passalo come input`];
|
|
17
18
|
});
|
|
18
19
|
}
|
|
20
|
+
function syntaxFindings(source) {
|
|
21
|
+
const lineCounter = new LineCounter();
|
|
22
|
+
return parseDocument(source, { lineCounter, prettyErrors: false }).errors.map(({ message, pos }) => ({
|
|
23
|
+
line: lineCounter.linePos(pos[0]).line,
|
|
24
|
+
message: `non è YAML valido: ${message}`,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
19
27
|
// Il runner valuta le espressioni del manifesto prima di eseguire qualunque passo: un contesto che una
|
|
20
28
|
// composite action non conosce non è un valore vuoto, è un'action che non si carica.
|
|
21
29
|
export function manifestFindings(manifests) {
|
|
22
|
-
return manifests.flatMap(({ path, source }) =>
|
|
23
|
-
.
|
|
24
|
-
|
|
30
|
+
return manifests.flatMap(({ path, source }) => [
|
|
31
|
+
...syntaxFindings(source).map((hit) => ({ path, ...hit })),
|
|
32
|
+
...readLines(source)
|
|
33
|
+
.filter(({ text }) => isLive(text))
|
|
34
|
+
.flatMap(({ n, text }) => lineFindings(text).map((message) => ({ path, line: n, message }))),
|
|
35
|
+
]);
|
|
25
36
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type PageEntries = {
|
|
2
|
+
route: string;
|
|
3
|
+
entries: string[];
|
|
4
|
+
};
|
|
5
|
+
export type SsrReading = {
|
|
6
|
+
pages: PageEntries[];
|
|
7
|
+
failures: string[];
|
|
8
|
+
notes: string[];
|
|
9
|
+
comparesExpectedRoutes: boolean;
|
|
10
|
+
measured: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** Le rotte renderizzate a richiesta non emettono HTML: le dà il manifest che Astro scrive nella build Vercel. */
|
|
13
|
+
export declare function readSsr(hasHtml: boolean): SsrReading;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ssrRoutes } from './vercel-build.js';
|
|
4
|
+
const FUNCTIONS = '.vercel/output/_functions';
|
|
5
|
+
const SERVER_OUTPUT = 'dist/server';
|
|
6
|
+
function readServerFiles() {
|
|
7
|
+
const read = (path) => ({ path, source: readFileSync(join(FUNCTIONS, path), 'utf8') });
|
|
8
|
+
const chunks = existsSync(join(FUNCTIONS, 'chunks')) ? readdirSync(join(FUNCTIONS, 'chunks')) : [];
|
|
9
|
+
const entry = existsSync(join(FUNCTIONS, 'entry.mjs')) ? ['entry.mjs'] : [];
|
|
10
|
+
return [...entry, ...chunks.filter((name) => name.endsWith('.mjs')).map((name) => `chunks/${name}`)].map(read);
|
|
11
|
+
}
|
|
12
|
+
/** Le rotte renderizzate a richiesta non emettono HTML: le dà il manifest che Astro scrive nella build Vercel. */
|
|
13
|
+
export function readSsr(hasHtml) {
|
|
14
|
+
const none = { pages: [], failures: [], notes: [], comparesExpectedRoutes: true, measured: false };
|
|
15
|
+
if (!existsSync(FUNCTIONS)) {
|
|
16
|
+
if (hasHtml || !existsSync(SERVER_OUTPUT))
|
|
17
|
+
return none;
|
|
18
|
+
const note = `build SSR senza l'adapter Vercel: le rotte non si misurano, resta il budget del CSS`;
|
|
19
|
+
return { ...none, notes: [note], comparesExpectedRoutes: false };
|
|
20
|
+
}
|
|
21
|
+
const routes = ssrRoutes(readServerFiles());
|
|
22
|
+
if (routes === null) {
|
|
23
|
+
const problem = `${FUNCTIONS}: il manifest di Astro non è in una forma riconosciuta`;
|
|
24
|
+
return hasHtml
|
|
25
|
+
? { ...none, notes: [`${problem}, le rotte renderizzate a richiesta restano fuori dal budget`] }
|
|
26
|
+
: { ...none, failures: [`${problem}, quindi non c'è nessuna rotta da misurare`], comparesExpectedRoutes: false };
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
...none,
|
|
30
|
+
pages: routes.map(({ route, entries }) => ({ route, entries })),
|
|
31
|
+
failures: routes.flatMap(({ route, unmapped, unknownIslands }) => [
|
|
32
|
+
...(unmapped ? [`${route}: nessun chunk server in ${FUNCTIONS}, la misura non coprirebbe le isole`] : []),
|
|
33
|
+
...unknownIslands.map((island) => `${route}: l'isola ${island} non ha un chunk in dist/client/_astro fra quelli del manifest`),
|
|
34
|
+
]),
|
|
35
|
+
measured: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/lib/lighthouse.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface LighthouseRc {
|
|
|
12
12
|
[section: string]: unknown;
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
-
export type LighthouseEnv = Partial<Record<'LH_PORT' | 'LH_RUNS' | 'LH_EXTERNAL_SERVER' | 'LH_CHROME_FLAGS' | 'LH_OUT', string>>;
|
|
15
|
+
export type LighthouseEnv = Partial<Record<'LH_PORT' | 'LH_RUNS' | 'LH_EXTERNAL_SERVER' | 'LH_BASE_URL' | 'LH_CHROME_FLAGS' | 'LH_OUT', string>>;
|
|
16
|
+
export type BaseUrlArg = {
|
|
17
|
+
url?: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function baseUrlArg(args: readonly string[]): BaseUrlArg;
|
|
16
21
|
/** Le rotte arrivano da src/pages, il resto dal `.lighthouserc.json` del progetto; l'ambiente lo piega al giro locale. */
|
|
17
22
|
export declare function lighthouseConfig(rc: LighthouseRc, routes: readonly string[], env: LighthouseEnv): LighthouseRc;
|
package/dist/lib/lighthouse.js
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
const DEFAULT_PORT = '4321';
|
|
2
|
+
const SERVER_PORT = /(--listen|--port)([ =])\d+/;
|
|
3
|
+
export function baseUrlArg(args) {
|
|
4
|
+
const url = args.find((arg) => !arg.startsWith('--'));
|
|
5
|
+
if (url === undefined)
|
|
6
|
+
return {};
|
|
7
|
+
const protocol = URL.parse(url)?.protocol;
|
|
8
|
+
if (protocol === 'http:' || protocol === 'https:')
|
|
9
|
+
return { url: url.replace(/\/+$/, '') };
|
|
10
|
+
return { error: `${url} non è un URL http(s): passa l'indirizzo di un sito già servito, per esempio https://…` };
|
|
11
|
+
}
|
|
2
12
|
/** Le rotte arrivano da src/pages, il resto dal `.lighthouserc.json` del progetto; l'ambiente lo piega al giro locale. */
|
|
3
13
|
export function lighthouseConfig(rc, routes, env) {
|
|
4
14
|
const port = env.LH_PORT ?? DEFAULT_PORT;
|
|
5
|
-
const
|
|
15
|
+
const base = env.LH_BASE_URL ?? `http://localhost:${port}`;
|
|
16
|
+
const collect = { ...rc.ci.collect, url: routes.map((route) => `${base}${route}`) };
|
|
6
17
|
if (env.LH_RUNS)
|
|
7
18
|
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) {
|
|
19
|
+
// Il server è già in ascolto: lo avvia lhci-local.sh, per servire una directory diversa, oppure è il sito dell'URL.
|
|
20
|
+
if (env.LH_EXTERNAL_SERVER || env.LH_BASE_URL) {
|
|
10
21
|
delete collect.startServerCommand;
|
|
11
22
|
delete collect.startServerReadyPattern;
|
|
12
23
|
delete collect.startServerReadyTimeout;
|
|
13
24
|
}
|
|
14
25
|
else if (collect.startServerCommand !== undefined) {
|
|
15
|
-
collect.startServerCommand = collect.startServerCommand.replace(
|
|
26
|
+
collect.startServerCommand = collect.startServerCommand.replace(SERVER_PORT, `$1$2${port}`);
|
|
16
27
|
}
|
|
17
28
|
if (env.LH_CHROME_FLAGS)
|
|
18
29
|
collect.settings = { ...collect.settings, chromeFlags: env.LH_CHROME_FLAGS };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ServerFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
source: string;
|
|
4
|
+
};
|
|
5
|
+
export type SsrRoute = {
|
|
6
|
+
route: string;
|
|
7
|
+
entries: string[];
|
|
8
|
+
unmapped: boolean;
|
|
9
|
+
unknownIslands: string[];
|
|
10
|
+
};
|
|
11
|
+
export declare function ssrRoutes(files: readonly ServerFile[]): SsrRoute[] | null;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
const MANIFEST_CALL = 'deserializeManifest({';
|
|
3
|
+
const ASSET = /^_astro\/([^/]+\.js)$/;
|
|
4
|
+
const RENDERER_RUNTIME = /^@astrojs\/[\w-]+\/client\.js$/;
|
|
5
|
+
const COMPONENT_SCRIPT = '?astro&type=script';
|
|
6
|
+
const PAGE_ROUTE = /\["(src\/pages\/[^"]+)", (_page\d+)\]/g;
|
|
7
|
+
const PAGE_IMPORT = /(?:var|const|let) (_page\d+) = \(\) => import\("(\.{1,2}\/[^"]+\.mjs)"\)/g;
|
|
8
|
+
const ISLAND_PATH = /"client:component-path":\s*"([^"]+)"/g;
|
|
9
|
+
const SERVER_IMPORT = /(?<![\w$.])(?:from|import)\s*"(\.{1,2}\/[^"]+\.mjs)"/g;
|
|
10
|
+
const resolve = (from, specifier) => posix.join(posix.dirname(from), specifier);
|
|
11
|
+
function objectLiteralAt(source, start) {
|
|
12
|
+
let depth = 0;
|
|
13
|
+
let quoted = false;
|
|
14
|
+
for (let i = start; i < source.length; i++) {
|
|
15
|
+
const char = source[i];
|
|
16
|
+
if (quoted) {
|
|
17
|
+
if (char === '\\')
|
|
18
|
+
i++;
|
|
19
|
+
else if (char === '"')
|
|
20
|
+
quoted = false;
|
|
21
|
+
}
|
|
22
|
+
else if (char === '"')
|
|
23
|
+
quoted = true;
|
|
24
|
+
else if (char === '{')
|
|
25
|
+
depth++;
|
|
26
|
+
else if (char === '}' && --depth === 0)
|
|
27
|
+
return source.slice(start, i + 1);
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function parseManifest(source) {
|
|
32
|
+
const call = source.indexOf(MANIFEST_CALL);
|
|
33
|
+
const literal = call === -1 ? null : objectLiteralAt(source, call + MANIFEST_CALL.length - 1);
|
|
34
|
+
if (literal === null)
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(literal);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function pageChunks(file) {
|
|
44
|
+
const chunkOf = new Map([...file.source.matchAll(PAGE_IMPORT)].map(([, name, path]) => [name, resolve(file.path, path)]));
|
|
45
|
+
const pages = new Map();
|
|
46
|
+
for (const [, component, name] of file.source.matchAll(PAGE_ROUTE)) {
|
|
47
|
+
const chunk = chunkOf.get(name);
|
|
48
|
+
if (chunk !== undefined)
|
|
49
|
+
pages.set(component, chunk);
|
|
50
|
+
}
|
|
51
|
+
return pages;
|
|
52
|
+
}
|
|
53
|
+
// Il file del manifest nomina ogni modulo del sito: seguirlo attribuirebbe a ogni pagina tutte le isole.
|
|
54
|
+
function serverClosure(start, files, manifestFile) {
|
|
55
|
+
const reached = new Map();
|
|
56
|
+
const queue = [start];
|
|
57
|
+
while (queue.length > 0) {
|
|
58
|
+
const path = queue.pop();
|
|
59
|
+
const source = files.get(path);
|
|
60
|
+
if (path === manifestFile || reached.has(path) || source === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
reached.set(path, source);
|
|
63
|
+
for (const [, specifier] of source.matchAll(SERVER_IMPORT))
|
|
64
|
+
queue.push(resolve(path, specifier));
|
|
65
|
+
}
|
|
66
|
+
return [...reached.values()];
|
|
67
|
+
}
|
|
68
|
+
const assetOf = (value) => value?.match(ASSET)?.[1];
|
|
69
|
+
function clientModules(manifest) {
|
|
70
|
+
const inlined = new Set((manifest.inlinedScripts ?? []).map(([specifier]) => specifier));
|
|
71
|
+
const modules = new Map();
|
|
72
|
+
for (const [specifier, value] of Object.entries(manifest.entryModules ?? {})) {
|
|
73
|
+
const asset = assetOf(value);
|
|
74
|
+
if (asset !== undefined && !inlined.has(specifier))
|
|
75
|
+
modules.set(specifier, asset);
|
|
76
|
+
}
|
|
77
|
+
return modules;
|
|
78
|
+
}
|
|
79
|
+
function routeEntries(page, texts, modules) {
|
|
80
|
+
const used = [...modules].filter(([specifier]) => !RENDERER_RUNTIME.test(specifier) && texts.some((text) => text.includes(`"${specifier}"`)));
|
|
81
|
+
const hasIsland = used.some(([specifier]) => !specifier.includes(COMPONENT_SCRIPT));
|
|
82
|
+
const runtimes = hasIsland ? [...modules].filter(([specifier]) => RENDERER_RUNTIME.test(specifier)) : [];
|
|
83
|
+
const scripts = page.scripts.filter((script) => script.type === 'external').map((script) => assetOf(script.value));
|
|
84
|
+
const names = [...scripts, ...[...used, ...runtimes].map(([, asset]) => asset)];
|
|
85
|
+
return [...new Set(names.filter((name) => name !== undefined))].sort();
|
|
86
|
+
}
|
|
87
|
+
const unknownIslands = (texts, modules) => [...new Set(texts.flatMap((text) => [...text.matchAll(ISLAND_PATH)].map(([, path]) => path)))]
|
|
88
|
+
.filter((path) => !modules.has(path))
|
|
89
|
+
.sort();
|
|
90
|
+
function ssrPage({ scripts = [], routeData }) {
|
|
91
|
+
if (routeData?.type !== 'page' || routeData.origin !== 'project' || routeData.prerender !== false)
|
|
92
|
+
return [];
|
|
93
|
+
const { route, component } = routeData;
|
|
94
|
+
return typeof route === 'string' && typeof component === 'string' ? [{ scripts, route, component }] : [];
|
|
95
|
+
}
|
|
96
|
+
export function ssrRoutes(files) {
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const manifest = parseManifest(file.source);
|
|
99
|
+
if (manifest !== null)
|
|
100
|
+
return routesOf(file, manifest, files);
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function routesOf(host, manifest, files) {
|
|
105
|
+
const sources = new Map(files.map((file) => [file.path, file.source]));
|
|
106
|
+
const chunks = pageChunks(host);
|
|
107
|
+
const modules = clientModules(manifest);
|
|
108
|
+
return (manifest.routes ?? []).flatMap(ssrPage).map((page) => {
|
|
109
|
+
const chunk = chunks.get(page.component);
|
|
110
|
+
const texts = chunk === undefined ? [] : serverClosure(chunk, sources, host.path);
|
|
111
|
+
return {
|
|
112
|
+
route: page.route,
|
|
113
|
+
entries: routeEntries(page, texts, modules),
|
|
114
|
+
unmapped: chunk === undefined,
|
|
115
|
+
unknownIslands: unknownIslands(texts, modules),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
package/dist/sh/lhci-local.sh
CHANGED
|
@@ -47,6 +47,13 @@ trap cleanup EXIT
|
|
|
47
47
|
echo "→ build equivalente alla produzione in ${OUT_DIR}"
|
|
48
48
|
VERCEL_ENV=production pnpm build --outDir "${OUT_DIR}"
|
|
49
49
|
|
|
50
|
+
if [[ -z "$(find "${OUT_DIR}/client" -name '*.html' -print -quit 2>/dev/null)" ]]; then
|
|
51
|
+
echo "✗ ${OUT_DIR}/client non contiene HTML: il sito è renderizzato a richiesta (output: 'server')," >&2
|
|
52
|
+
echo " e un server statico risponderebbe 404 su ogni rotta. Misura un sito già servito:" >&2
|
|
53
|
+
echo " pnpm run lhci https://… (produzione o anteprima)" >&2
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
50
57
|
if ! grep -qx 'Allow: /' "${OUT_DIR}/client/robots.txt"; then
|
|
51
58
|
echo "✗ ${OUT_DIR}/client/robots.txt non ha 'Allow: /': la build non equivale alla produzione." >&2
|
|
52
59
|
head -3 "${OUT_DIR}/client/robots.txt" >&2
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elia97/officina",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
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",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"test:pack": "bash scripts/pack-test.sh"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@biomejs/biome": "2.5.
|
|
57
|
+
"@biomejs/biome": "2.5.14",
|
|
58
58
|
"@types/node": "^26.5.1",
|
|
59
59
|
"@vitest/coverage-v8": "^4.1.11",
|
|
60
60
|
"fallow": "^3.26.0",
|