@elia97/officina 0.4.0 → 0.5.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 -15
- package/dist/bin.js +0 -0
- package/dist/bundle.d.ts +1 -0
- package/dist/bundle.js +1 -0
- package/dist/checks/analytics.js +10 -4
- package/dist/checks/bundle.js +10 -2
- package/dist/checks/doctor.js +33 -6
- package/dist/checks/lighthouse.d.ts +1 -1
- package/dist/checks/lighthouse.js +8 -4
- package/dist/checks/roadmap.d.ts +1 -1
- package/dist/checks/roadmap.js +11 -5
- package/dist/checks/smoke.js +3 -2
- package/dist/checks/vercel-cli.d.ts +1 -1
- package/dist/checks/vercel-cli.js +35 -21
- package/dist/gen/hook-points.d.mts +6 -0
- package/dist/gen/hook-points.mjs +13 -0
- package/dist/gen/inject-section.d.mts +25 -2
- package/dist/gen/inject-section.mjs +13 -13
- package/dist/gen/inject-strings.d.mts +3 -0
- package/dist/gen/inject-strings.mjs +7 -3
- package/dist/gen/section-targets.d.mts +5 -0
- package/dist/gen/section-targets.mjs +23 -0
- package/dist/icons.d.ts +1 -0
- package/dist/icons.js +2 -0
- package/dist/index.d.ts +0 -4
- package/dist/index.js +0 -4
- package/dist/lib/alignment.d.ts +2 -6
- package/dist/lib/alignment.js +25 -52
- package/dist/lib/anchors.d.ts +2 -0
- package/dist/lib/anchors.js +35 -0
- package/dist/lib/bundle-budget.d.ts +6 -1
- package/dist/lib/bundle-budget.js +9 -6
- package/dist/lib/config-gaps.d.ts +7 -0
- package/dist/lib/config-gaps.js +29 -0
- package/dist/lib/config.d.ts +16 -1
- package/dist/lib/config.js +36 -4
- package/dist/lib/contract.d.ts +8 -0
- package/dist/lib/contract.js +51 -6
- package/dist/lib/pins.d.ts +10 -0
- package/dist/lib/pins.js +48 -0
- package/dist/lib/routes.d.ts +6 -2
- package/dist/lib/routes.js +16 -7
- package/dist/lib/tooling.d.ts +3 -0
- package/dist/lib/tooling.js +24 -0
- package/dist/lib/validate.d.ts +11 -0
- package/dist/lib/validate.js +40 -0
- package/dist/lib/versions.d.ts +5 -0
- package/dist/lib/versions.js +38 -0
- package/dist/lib/workflows.d.ts +6 -0
- package/dist/lib/workflows.js +70 -0
- package/dist/sh/lhci-local.sh +2 -2
- package/dist/smoke.d.ts +2 -0
- package/dist/smoke.js +4 -0
- package/package.json +28 -12
- package/dist/lib/vercel-cli-pin.d.ts +0 -2
- package/dist/lib/vercel-cli-pin.js +0 -29
|
@@ -19,5 +19,10 @@ export declare function sectionTargets({ camel, kebab, pascal }: {
|
|
|
19
19
|
sectionsMarker: string;
|
|
20
20
|
};
|
|
21
21
|
export declare const sectionFiles: (targets: any, kebab: any) => string[];
|
|
22
|
+
export declare function sectionedCollections(root: any): {
|
|
23
|
+
camel: string;
|
|
24
|
+
kebab: string;
|
|
25
|
+
pascal: string;
|
|
26
|
+
}[];
|
|
22
27
|
export declare function findSectionedPages(root: any, marker: any): string[];
|
|
23
28
|
export declare function validateSectionName(plop: any): (value: any) => string | true;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
|
+
import { Project } from 'ts-morph';
|
|
3
4
|
import { isValidIdentifier } from './identifier.mjs';
|
|
5
|
+
const CONFIG_PATH = 'src/content.config.ts';
|
|
6
|
+
const SCHEMAS = /^@\/lib\/schemas\/(.+)$/;
|
|
7
|
+
const COLLECTION_SCHEMA = 'CollectionSchema';
|
|
4
8
|
export function casings(plop, value) {
|
|
5
9
|
return {
|
|
6
10
|
camel: plop.getHelper('camelCase')(value),
|
|
@@ -26,6 +30,25 @@ export const sectionFiles = (targets, kebab) => [
|
|
|
26
30
|
`${targets.contentDir}/${kebab}.yml`,
|
|
27
31
|
`${targets.componentDir}/${kebab}.astro`,
|
|
28
32
|
];
|
|
33
|
+
// Le collection a sezioni si riconoscono dall'import che `gen:collection` scrive in
|
|
34
|
+
// content.config.ts, non dai marcatori nelle pagine: quelli sono proprio ciò che può sparire.
|
|
35
|
+
export function sectionedCollections(root) {
|
|
36
|
+
const config = new Project().addSourceFileAtPath(join(root, CONFIG_PATH));
|
|
37
|
+
return config.getImportDeclarations().flatMap((declaration) => {
|
|
38
|
+
const kebab = SCHEMAS.exec(declaration.getModuleSpecifierValue())?.[1];
|
|
39
|
+
if (kebab === undefined)
|
|
40
|
+
return [];
|
|
41
|
+
return declaration.getNamedImports().flatMap((named) => {
|
|
42
|
+
const name = named.getName();
|
|
43
|
+
if (!name.endsWith(COLLECTION_SCHEMA))
|
|
44
|
+
return [];
|
|
45
|
+
const camel = name.slice(0, -COLLECTION_SCHEMA.length);
|
|
46
|
+
if (camel === '')
|
|
47
|
+
return [];
|
|
48
|
+
return [{ camel, kebab, pascal: `${camel[0].toUpperCase()}${camel.slice(1)}` }];
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
29
52
|
export function findSectionedPages(root, marker) {
|
|
30
53
|
return readdirSync(join(root, 'src/pages'), { recursive: true, withFileTypes: true })
|
|
31
54
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.astro'))
|
package/dist/icons.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ICON_SPECS } from './lib/icon-specs.ts';
|
package/dist/icons.js
ADDED
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
export type { Budget } from './lib/bundle-budget.ts';
|
|
2
|
-
export { CSS_BUDGET_GZIP } from './lib/bundle-budget.ts';
|
|
3
2
|
export type { OfficinaConfig } from './lib/config.ts';
|
|
4
3
|
export { defineConfig } from './lib/config.ts';
|
|
5
4
|
export type { IconSpec } from './lib/icon-specs.ts';
|
|
6
|
-
export { ICON_SPECS } from './lib/icon-specs.ts';
|
|
7
5
|
export type { VerifiedRoute } from './lib/routes.ts';
|
|
8
|
-
export { NON_HTML_ROUTES } from './lib/routes.ts';
|
|
9
6
|
export type { CheckResult, SmokeCheck, SmokeContext } from './lib/smoke-production.ts';
|
|
10
|
-
export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1 @@
|
|
|
1
|
-
export { CSS_BUDGET_GZIP } from './lib/bundle-budget.js';
|
|
2
1
|
export { defineConfig } from './lib/config.js';
|
|
3
|
-
export { ICON_SPECS } from './lib/icon-specs.js';
|
|
4
|
-
export { NON_HTML_ROUTES } from './lib/routes.js';
|
|
5
|
-
export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.js';
|
package/dist/lib/alignment.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { OfficinaConfig } from './config.ts';
|
|
2
1
|
import type { ContractGap } from './contract.ts';
|
|
3
2
|
export interface ProjectFiles {
|
|
4
3
|
/** Tracciati e non tracciati, senza gli ignorati: un file locale fuori da git non è un residuo. */
|
|
@@ -14,10 +13,7 @@ export declare const EXPECTED_SCRIPTS: Readonly<Record<string, string>>;
|
|
|
14
13
|
export declare const CI_STEPS: readonly string[];
|
|
15
14
|
export declare function scriptGaps({ scripts }: Manifest): ContractGap[];
|
|
16
15
|
export declare function dependencyGaps({ dependencies, devDependencies }: Manifest): ContractGap[];
|
|
17
|
-
export declare function leftoverGaps(
|
|
16
|
+
export declare function leftoverGaps(files: ProjectFiles): ContractGap[];
|
|
18
17
|
export declare const BIOME_PRESET = "@elia97/officina/biome";
|
|
19
18
|
export declare const LEFTHOOK_PRESET = "node_modules/@elia97/officina/presets/lefthook.yml";
|
|
20
|
-
export declare function presetGaps(
|
|
21
|
-
export declare function workflowGaps({ read }: ProjectFiles): ContractGap[];
|
|
22
|
-
/** `loaded` è la configurazione letta, l'errore che il suo caricamento ha sollevato, o `undefined` se il file non c'è. */
|
|
23
|
-
export declare function configGaps(loaded: OfficinaConfig | Error | undefined): ContractGap[];
|
|
19
|
+
export declare function presetGaps(files: ProjectFiles): ContractGap[];
|
package/dist/lib/alignment.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mentions } from './workflows.js';
|
|
1
2
|
export const EXPECTED_SCRIPTS = {
|
|
2
3
|
'check:comments': 'officina check comments',
|
|
3
4
|
'check:language': 'officina check language',
|
|
@@ -94,73 +95,45 @@ const LEFTOVERS = [
|
|
|
94
95
|
{ path: 'docs/ESTIMATE.md', reason: OUT_OF_REPOSITORY },
|
|
95
96
|
];
|
|
96
97
|
const holds = (paths, target) => target.endsWith('/') ? paths.some((path) => path.startsWith(target)) : paths.includes(target);
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
99
|
+
// I file JSON si leggono come JSON: `"hooks"` dentro un valore qualunque non è un blocco `hooks`,
|
|
100
|
+
// e un file illeggibile non è un file che dichiara quello che si cerca.
|
|
101
|
+
function readJson({ read }, path) {
|
|
102
|
+
const source = read(path);
|
|
103
|
+
if (source === undefined)
|
|
104
|
+
return undefined;
|
|
105
|
+
try {
|
|
106
|
+
const value = JSON.parse(source);
|
|
107
|
+
return isRecord(value) ? value : undefined;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function leftoverGaps(files) {
|
|
114
|
+
const gaps = LEFTOVERS.filter(({ path }) => holds(files.paths, path)).map(({ path, reason }) => ({
|
|
99
115
|
path,
|
|
100
116
|
message: `residuo: ${reason}`,
|
|
101
117
|
}));
|
|
102
|
-
if (
|
|
118
|
+
if (readJson(files, '.claude/settings.json')?.hooks !== undefined) {
|
|
103
119
|
gaps.push({ path: '.claude/settings.json', message: `blocco \`hooks\` residuo: il guard ${FROM_PLUGIN}` });
|
|
104
120
|
}
|
|
105
|
-
|
|
121
|
+
const servers = readJson(files, '.mcp.json')?.mcpServers;
|
|
122
|
+
if (isRecord(servers) && servers['astro-docs'] !== undefined) {
|
|
106
123
|
gaps.push({ path: '.mcp.json', message: `server \`astro-docs\` residuo: ${FROM_PLUGIN}` });
|
|
107
124
|
}
|
|
108
125
|
return gaps;
|
|
109
126
|
}
|
|
110
127
|
export const BIOME_PRESET = '@elia97/officina/biome';
|
|
111
128
|
export const LEFTHOOK_PRESET = 'node_modules/@elia97/officina/presets/lefthook.yml';
|
|
112
|
-
export function presetGaps(
|
|
129
|
+
export function presetGaps(files) {
|
|
113
130
|
const gaps = [];
|
|
114
|
-
|
|
131
|
+
const extended = readJson(files, 'biome.json')?.extends;
|
|
132
|
+
if (!(Array.isArray(extended) && extended.includes(BIOME_PRESET))) {
|
|
115
133
|
gaps.push({ path: 'biome.json', message: `non estende \`${BIOME_PRESET}\`` });
|
|
116
134
|
}
|
|
117
|
-
if (!read('lefthook.yml')
|
|
135
|
+
if (!mentions(files.read('lefthook.yml'), LEFTHOOK_PRESET)) {
|
|
118
136
|
gaps.push({ path: 'lefthook.yml', message: `non estende \`${LEFTHOOK_PRESET}\`` });
|
|
119
137
|
}
|
|
120
138
|
return gaps;
|
|
121
139
|
}
|
|
122
|
-
const ACTIONS = [
|
|
123
|
-
{ workflow: '.github/workflows/ci.yml', action: 'ci' },
|
|
124
|
-
{ workflow: '.github/workflows/ci.yml', action: 'review' },
|
|
125
|
-
{ workflow: '.github/workflows/deploy.yml', action: 'deploy' },
|
|
126
|
-
{ workflow: '.github/workflows/lighthouse.yml', action: 'lighthouse' },
|
|
127
|
-
];
|
|
128
|
-
// Un tag di versione o uno SHA intero: `@main` farebbe girare in produzione passi mai collaudati.
|
|
129
|
-
const PINNED_REF = /^(v\d+\.\d+\.\d+|[0-9a-f]{40})$/;
|
|
130
|
-
function actionGap(source, action) {
|
|
131
|
-
const name = `Elia97/officina/actions/${action}`;
|
|
132
|
-
if (source === undefined)
|
|
133
|
-
return `manca: i suoi passi arrivano da \`${name}\``;
|
|
134
|
-
const ref = new RegExp(`${name}@(\\S+)`).exec(source)?.[1];
|
|
135
|
-
if (ref === undefined)
|
|
136
|
-
return `non usa \`${name}\``;
|
|
137
|
-
return PINNED_REF.test(ref)
|
|
138
|
-
? undefined
|
|
139
|
-
: `\`${name}@${ref}\`: il riferimento va fissato a un tag di versione o a uno SHA`;
|
|
140
|
-
}
|
|
141
|
-
export function workflowGaps({ read }) {
|
|
142
|
-
return ACTIONS.flatMap(({ workflow, action }) => {
|
|
143
|
-
const message = actionGap(read(workflow), action);
|
|
144
|
-
return message === undefined ? [] : [{ path: workflow, message }];
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
const CONFIG = 'officina.config.ts';
|
|
148
|
-
function siteUrlGap(siteUrl) {
|
|
149
|
-
if (siteUrl === undefined)
|
|
150
|
-
return '`siteUrl` assente: `check smoke` non ha un host canonico';
|
|
151
|
-
if (!URL.canParse(siteUrl))
|
|
152
|
-
return `\`siteUrl\` non è un URL: \`${siteUrl}\``;
|
|
153
|
-
return siteUrl.endsWith('/') ? `\`siteUrl\` finisce con una barra: \`${siteUrl}\`` : undefined;
|
|
154
|
-
}
|
|
155
|
-
/** `loaded` è la configurazione letta, l'errore che il suo caricamento ha sollevato, o `undefined` se il file non c'è. */
|
|
156
|
-
export function configGaps(loaded) {
|
|
157
|
-
if (loaded === undefined)
|
|
158
|
-
return [{ path: CONFIG, message: 'manca: i valori del progetto per officina stanno qui' }];
|
|
159
|
-
if (loaded instanceof Error)
|
|
160
|
-
return [{ path: CONFIG, message: `non si carica: ${loaded.message}` }];
|
|
161
|
-
const messages = [
|
|
162
|
-
siteUrlGap(loaded.siteUrl),
|
|
163
|
-
loaded.icons === undefined ? '`icons.background` assente: `gen icons` non ha un colore di fondo' : undefined,
|
|
164
|
-
];
|
|
165
|
-
return messages.filter((message) => message !== undefined).map((message) => ({ path: CONFIG, message }));
|
|
166
|
-
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { HookPointError } from '../gen/hook-points.mjs';
|
|
4
|
+
import { assertSectionAnchors } from '../gen/inject-section.mjs';
|
|
5
|
+
import { assertDictionaries } from '../gen/inject-strings.mjs';
|
|
6
|
+
import { sectionedCollections } from '../gen/section-targets.mjs';
|
|
7
|
+
// Gli ancoraggi che i generatori trovano al lancio, guardati senza generare niente: sono gli
|
|
8
|
+
// stessi controlli del pre-volo, tolto ciò che dipende dal nome della cosa nuova.
|
|
9
|
+
const CONFIG = 'src/content.config.ts';
|
|
10
|
+
const DICTIONARIES = 'src/i18n/strings';
|
|
11
|
+
function gapsFrom(fallback, check) {
|
|
12
|
+
try {
|
|
13
|
+
check();
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error instanceof HookPointError)
|
|
18
|
+
return [{ path: String(error.path), message: String(error.problem) }];
|
|
19
|
+
return [{ path: fallback, message: `non si lascia leggere: ${String(error)}` }];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function sectionGaps(root) {
|
|
23
|
+
if (!existsSync(join(root, CONFIG)))
|
|
24
|
+
return [];
|
|
25
|
+
const collections = sectionedCollections(root);
|
|
26
|
+
return collections.flatMap((collection) => gapsFrom(CONFIG, () => assertSectionAnchors({ root, collection })));
|
|
27
|
+
}
|
|
28
|
+
function dictionaryGaps(root) {
|
|
29
|
+
if (!existsSync(join(root, DICTIONARIES)))
|
|
30
|
+
return [];
|
|
31
|
+
return gapsFrom(DICTIONARIES, () => assertDictionaries({ root }));
|
|
32
|
+
}
|
|
33
|
+
export function anchorGaps(root) {
|
|
34
|
+
return [...sectionGaps(root), ...dictionaryGaps(root)];
|
|
35
|
+
}
|
|
@@ -13,7 +13,12 @@ export declare function budgetFor(route: string, budgets?: readonly Budget[]): B
|
|
|
13
13
|
/** Rollup racchiude gli specificatori statici con `"` e i dinamici in un template literal. */
|
|
14
14
|
export declare function parseEdges(source: string): Pick<Chunk, 'static' | 'dynamic'>;
|
|
15
15
|
export declare function htmlEntries(html: string): string[];
|
|
16
|
-
|
|
16
|
+
/** `unknown`: i nomi citati che in `dist/client/_astro` non ci sono — saltarli vorrebbe dire misurare meno del vero. */
|
|
17
|
+
export type Closure = {
|
|
18
|
+
reached: Set<string>;
|
|
19
|
+
unknown: Set<string>;
|
|
20
|
+
};
|
|
21
|
+
export declare function staticClosure(entries: Iterable<string>, chunks: Map<string, Chunk>): Closure;
|
|
17
22
|
export declare function deferredClosure(reached: Set<string>, chunks: Map<string, Chunk>): Set<string>;
|
|
18
23
|
export declare const CSS_BUDGET_GZIP: number;
|
|
19
24
|
export type Stylesheet = {
|
|
@@ -17,23 +17,26 @@ export function htmlEntries(html) {
|
|
|
17
17
|
return [...captured(html, /(?:src|href)="\/_astro\/([^"]+\.js)"/g, 1)];
|
|
18
18
|
}
|
|
19
19
|
export function staticClosure(entries, chunks) {
|
|
20
|
-
const
|
|
20
|
+
const reached = new Set();
|
|
21
|
+
const unknown = new Set();
|
|
21
22
|
const queue = [...entries];
|
|
22
23
|
while (queue.length > 0) {
|
|
23
24
|
const name = queue.pop();
|
|
24
|
-
if (name === undefined ||
|
|
25
|
+
if (name === undefined || reached.has(name))
|
|
25
26
|
continue;
|
|
26
27
|
const chunk = chunks.get(name);
|
|
27
|
-
if (chunk === undefined)
|
|
28
|
+
if (chunk === undefined) {
|
|
29
|
+
unknown.add(name);
|
|
28
30
|
continue;
|
|
29
|
-
|
|
31
|
+
}
|
|
32
|
+
reached.add(name);
|
|
30
33
|
queue.push(...chunk.static);
|
|
31
34
|
}
|
|
32
|
-
return
|
|
35
|
+
return { reached, unknown };
|
|
33
36
|
}
|
|
34
37
|
export function deferredClosure(reached, chunks) {
|
|
35
38
|
const entries = [...reached].flatMap((name) => [...(chunks.get(name)?.dynamic ?? [])]);
|
|
36
|
-
return new Set([...staticClosure(entries, chunks)].filter((name) => !reached.has(name)));
|
|
39
|
+
return new Set([...staticClosure(entries, chunks).reached].filter((name) => !reached.has(name)));
|
|
37
40
|
}
|
|
38
41
|
export const CSS_BUDGET_GZIP = 12 * 1024;
|
|
39
42
|
export function heaviestStylesheet(sheets) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type OfficinaConfig } from './config.ts';
|
|
2
|
+
import type { ContractGap } from './contract.ts';
|
|
3
|
+
/**
|
|
4
|
+
* `loaded` è la configurazione letta, l'errore che il suo caricamento ha sollevato, o `undefined` se
|
|
5
|
+
* il file non c'è; `patterns` i pattern dinamici di `src/pages` rimasti senza rappresentante.
|
|
6
|
+
*/
|
|
7
|
+
export declare function configGaps(loaded: OfficinaConfig | Error | undefined, patterns?: readonly string[]): ContractGap[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FEATURES } from './config.js';
|
|
2
|
+
const CONFIG = 'officina.config.ts';
|
|
3
|
+
function siteUrlGap(siteUrl) {
|
|
4
|
+
if (siteUrl === undefined)
|
|
5
|
+
return '`siteUrl` assente: `check smoke` non ha un host canonico';
|
|
6
|
+
if (!URL.canParse(siteUrl))
|
|
7
|
+
return `\`siteUrl\` non è un URL: \`${siteUrl}\``;
|
|
8
|
+
return siteUrl.endsWith('/') ? `\`siteUrl\` finisce con una barra: \`${siteUrl}\`` : undefined;
|
|
9
|
+
}
|
|
10
|
+
// Il silenzio vale `'required'`: un controllo si spegne dichiarandolo, non dimenticandolo.
|
|
11
|
+
const featureGaps = (config) => FEATURES.filter((name) => config.features?.[name] === undefined).map((name) => `\`features.${name}\` non dichiarata: vale \`'required'\`, e il gate fallisce se non trova cosa controllare`);
|
|
12
|
+
const representativeGaps = (patterns) => patterns.map((label) => `\`routes.representatives\` non ha un percorso per \`${label}\`: smoke e Lighthouse non guardano quella pagina`);
|
|
13
|
+
/**
|
|
14
|
+
* `loaded` è la configurazione letta, l'errore che il suo caricamento ha sollevato, o `undefined` se
|
|
15
|
+
* il file non c'è; `patterns` i pattern dinamici di `src/pages` rimasti senza rappresentante.
|
|
16
|
+
*/
|
|
17
|
+
export function configGaps(loaded, patterns = []) {
|
|
18
|
+
if (loaded === undefined)
|
|
19
|
+
return [{ path: CONFIG, message: 'manca: i valori del progetto per officina stanno qui' }];
|
|
20
|
+
if (loaded instanceof Error)
|
|
21
|
+
return [{ path: CONFIG, message: `non si carica: ${loaded.message}` }];
|
|
22
|
+
const messages = [
|
|
23
|
+
siteUrlGap(loaded.siteUrl),
|
|
24
|
+
loaded.icons === undefined ? '`icons.background` assente: `gen icons` non ha un colore di fondo' : undefined,
|
|
25
|
+
...featureGaps(loaded),
|
|
26
|
+
...representativeGaps(patterns),
|
|
27
|
+
];
|
|
28
|
+
return messages.filter((message) => message !== undefined).map((message) => ({ path: CONFIG, message }));
|
|
29
|
+
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Budget } from './bundle-budget.ts';
|
|
2
|
-
import type { VerifiedRoute } from './routes.ts';
|
|
2
|
+
import type { Representatives, VerifiedRoute } from './routes.ts';
|
|
3
3
|
import type { SmokeCheck } from './smoke-production.ts';
|
|
4
|
+
/** `'required'` fa fallire il gate quando ciò che deve controllare non c'è; `false` lo spegne. */
|
|
5
|
+
export type FeatureSetting = 'required' | false;
|
|
4
6
|
export interface OfficinaConfig {
|
|
5
7
|
/** L'apice di produzione, senza barra finale: lo usa `check smoke` quando non riceve un URL. */
|
|
6
8
|
siteUrl?: string;
|
|
@@ -19,7 +21,20 @@ export interface OfficinaConfig {
|
|
|
19
21
|
analytics?: {
|
|
20
22
|
linkTracking?: string;
|
|
21
23
|
};
|
|
24
|
+
/** Quali controlli il progetto pretende. Una voce non dichiarata vale `'required'`. */
|
|
25
|
+
features?: {
|
|
26
|
+
analytics?: FeatureSetting;
|
|
27
|
+
roadmap?: FeatureSetting;
|
|
28
|
+
};
|
|
29
|
+
/** Per ogni pattern dinamico di `src/pages`, un percorso vero che smoke e Lighthouse visitano. */
|
|
30
|
+
routes?: {
|
|
31
|
+
representatives?: Representatives;
|
|
32
|
+
};
|
|
22
33
|
}
|
|
34
|
+
export declare const FEATURES: readonly ['analytics', 'roadmap'];
|
|
35
|
+
export type FeatureName = (typeof FEATURES)[number];
|
|
36
|
+
/** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
|
|
37
|
+
export declare const isRequired: (config: OfficinaConfig, name: FeatureName) => boolean;
|
|
23
38
|
export declare const defineConfig: (config: OfficinaConfig) => OfficinaConfig;
|
|
24
39
|
export declare const findConfigFile: (root: string) => string | undefined;
|
|
25
40
|
export declare function loadConfig(root: string): Promise<OfficinaConfig>;
|
package/dist/lib/config.js
CHANGED
|
@@ -7,16 +7,48 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
10
|
+
import { basename, join } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { aFunction, aNumber, anArrayOf, aRecordOf, aShape, aString, aStringOrNull, oneOf } from './validate.js';
|
|
13
|
+
export const FEATURES = ['analytics', 'roadmap'];
|
|
14
|
+
/** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
|
|
15
|
+
export const isRequired = (config, name) => (config.features?.[name] ?? 'required') === 'required';
|
|
16
|
+
const budget = aShape({ label: aString, matches: aFunction, maxGzip: aNumber });
|
|
17
|
+
const verifiedRoute = aShape({ path: aString, type: aString });
|
|
18
|
+
const feature = oneOf("'required' oppure false", ['required', false]);
|
|
19
|
+
const SHAPE = aShape({
|
|
20
|
+
siteUrl: aString,
|
|
21
|
+
icons: aShape({ background: aString }),
|
|
22
|
+
bundle: aShape({ budgets: anArrayOf(budget), cssMaxGzip: aNumber }),
|
|
23
|
+
smoke: aShape({
|
|
24
|
+
securityHeaders: aRecordOf(aStringOrNull),
|
|
25
|
+
nonHtmlRoutes: anArrayOf(verifiedRoute),
|
|
26
|
+
checks: anArrayOf(aFunction),
|
|
27
|
+
}),
|
|
28
|
+
analytics: aShape({ linkTracking: aString }),
|
|
29
|
+
features: aShape({ analytics: feature, roadmap: feature }),
|
|
30
|
+
routes: aShape({ representatives: aRecordOf(aString) }),
|
|
31
|
+
});
|
|
12
32
|
const CONFIG_FILES = ['officina.config.ts', 'officina.config.mjs', 'officina.config.js'];
|
|
13
33
|
export const defineConfig = (config) => config;
|
|
14
34
|
// Fuori da node_modules Node toglie i tipi da solo: il file del progetto può essere TypeScript.
|
|
15
|
-
|
|
35
|
+
const findConfigFiles = (root) => CONFIG_FILES.map((name) => join(root, name)).filter((path) => existsSync(path));
|
|
36
|
+
export const findConfigFile = (root) => findConfigFiles(root)[0];
|
|
16
37
|
export async function loadConfig(root) {
|
|
17
|
-
const file =
|
|
38
|
+
const [file, ...rest] = findConfigFiles(root);
|
|
18
39
|
if (file === undefined)
|
|
19
40
|
return {};
|
|
41
|
+
if (rest.length > 0) {
|
|
42
|
+
const names = [file, ...rest].map((path) => basename(path)).join(' e ');
|
|
43
|
+
throw new Error(`${names} stanno insieme nella radice: quale valga non lo decide officina, tienine uno`);
|
|
44
|
+
}
|
|
45
|
+
const name = basename(file);
|
|
20
46
|
const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
|
|
21
|
-
|
|
47
|
+
if (module.default === undefined) {
|
|
48
|
+
throw new Error(`${name} non ha un default export: la configurazione si scrive \`export default defineConfig({ … })\``);
|
|
49
|
+
}
|
|
50
|
+
const problems = SHAPE(module.default, '');
|
|
51
|
+
if (problems.length > 0)
|
|
52
|
+
throw new Error(problems.join('; '));
|
|
53
|
+
return module.default;
|
|
22
54
|
}
|
package/dist/lib/contract.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ type Requirement = {
|
|
|
12
12
|
path: string;
|
|
13
13
|
text: string;
|
|
14
14
|
reason: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'dependency';
|
|
17
|
+
name: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'script';
|
|
21
|
+
name: string;
|
|
22
|
+
reason: string;
|
|
15
23
|
};
|
|
16
24
|
export declare const CONTRACT: Requirement[];
|
|
17
25
|
export interface ContractGap {
|
package/dist/lib/contract.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
const MANIFEST = 'package.json';
|
|
3
4
|
export const CONTRACT = [
|
|
4
5
|
{ kind: 'file', path: 'src/components/ui/section.astro', reason: 'le sezioni generate la importano' },
|
|
5
6
|
{ kind: 'file', path: 'src/components/ui/container.astro', reason: 'le sezioni e le pagine generate lo importano' },
|
|
@@ -27,33 +28,77 @@ export const CONTRACT = [
|
|
|
27
28
|
extension: '.ts',
|
|
28
29
|
reason: 'gen:collection e gen:section scrivono lì gli schemi',
|
|
29
30
|
},
|
|
31
|
+
{
|
|
32
|
+
kind: 'anchor',
|
|
33
|
+
path: 'src/lib/schemas/common.ts',
|
|
34
|
+
text: 'export const ctaSchema',
|
|
35
|
+
reason: 'lo schema di ogni sezione generata lo importa da ../common',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
kind: 'anchor',
|
|
39
|
+
path: 'src/lib/schemas/common.ts',
|
|
40
|
+
text: 'export function imageSchema',
|
|
41
|
+
reason: "lo schema di una sezione con immagine lo importa da ../common, e lo chiama con l'helper di Astro",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
kind: 'file',
|
|
45
|
+
path: 'src/assets/placeholder.jpg',
|
|
46
|
+
reason: 'il contenuto di una sezione con immagine ci punta appena generato',
|
|
47
|
+
},
|
|
30
48
|
{
|
|
31
49
|
kind: 'anchor',
|
|
32
50
|
path: 'src/content.config.ts',
|
|
33
51
|
text: 'export const collections',
|
|
34
52
|
reason: 'gen:collection e gen:section registrano lì la collection',
|
|
35
53
|
},
|
|
54
|
+
{
|
|
55
|
+
kind: 'dependency',
|
|
56
|
+
name: 'class-variance-authority',
|
|
57
|
+
reason: 'il componente che scrive gen:component lo importa',
|
|
58
|
+
},
|
|
59
|
+
{ kind: 'script', name: 'check', reason: 'il post-gen di ogni generatore lo lancia con `pnpm run check`' },
|
|
36
60
|
];
|
|
37
61
|
function hasFileWith(directory, extension) {
|
|
38
62
|
if (!existsSync(directory) || !statSync(directory).isDirectory())
|
|
39
63
|
return false;
|
|
40
64
|
return readdirSync(directory, { recursive: true }).some((entry) => String(entry).endsWith(extension));
|
|
41
65
|
}
|
|
42
|
-
function
|
|
66
|
+
function manifestGap(manifest, requirement) {
|
|
67
|
+
if (requirement.kind === 'script') {
|
|
68
|
+
return manifest.scripts?.[requirement.name] === undefined
|
|
69
|
+
? { path: MANIFEST, message: `manca lo script \`${requirement.name}\`: ${requirement.reason}` }
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
if (requirement.kind !== 'dependency')
|
|
73
|
+
return undefined;
|
|
74
|
+
const declared = { ...manifest.dependencies, ...manifest.devDependencies };
|
|
75
|
+
return declared[requirement.name] === undefined
|
|
76
|
+
? { path: MANIFEST, message: `manca la dipendenza \`${requirement.name}\`: ${requirement.reason}` }
|
|
77
|
+
: undefined;
|
|
78
|
+
}
|
|
79
|
+
function pathGap(root, requirement) {
|
|
80
|
+
if (requirement.kind === 'dependency' || requirement.kind === 'script')
|
|
81
|
+
return undefined;
|
|
82
|
+
const gap = (message) => ({ path: requirement.path, message });
|
|
43
83
|
const target = join(root, requirement.path);
|
|
44
84
|
if (requirement.kind === 'directory') {
|
|
45
|
-
return hasFileWith(target, requirement.extension) ? undefined : `manca, o è vuota: ${requirement.reason}
|
|
85
|
+
return hasFileWith(target, requirement.extension) ? undefined : gap(`manca, o è vuota: ${requirement.reason}`);
|
|
46
86
|
}
|
|
47
87
|
if (!existsSync(target))
|
|
48
|
-
return `manca: ${requirement.reason}
|
|
88
|
+
return gap(`manca: ${requirement.reason}`);
|
|
49
89
|
if (requirement.kind === 'anchor' && !readFileSync(target, 'utf8').includes(requirement.text)) {
|
|
50
|
-
return `manca l'ancoraggio \`${requirement.text}\`: ${requirement.reason}
|
|
90
|
+
return gap(`manca l'ancoraggio \`${requirement.text}\`: ${requirement.reason}`);
|
|
51
91
|
}
|
|
52
92
|
return undefined;
|
|
53
93
|
}
|
|
94
|
+
function readManifest(root) {
|
|
95
|
+
const target = join(root, MANIFEST);
|
|
96
|
+
return existsSync(target) ? JSON.parse(readFileSync(target, 'utf8')) : {};
|
|
97
|
+
}
|
|
54
98
|
export function contractGaps(root) {
|
|
99
|
+
const manifest = readManifest(root);
|
|
55
100
|
return CONTRACT.flatMap((requirement) => {
|
|
56
|
-
const
|
|
57
|
-
return
|
|
101
|
+
const gap = pathGap(root, requirement) ?? manifestGap(manifest, requirement);
|
|
102
|
+
return gap === undefined ? [] : [gap];
|
|
58
103
|
});
|
|
59
104
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Hit } from './cli.ts';
|
|
2
|
+
/** Un file che porta il pin di un pacchetto scaricato al volo, e il suo contenuto. */
|
|
3
|
+
export interface PinSite {
|
|
4
|
+
path: string;
|
|
5
|
+
source: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PinHit extends Hit {
|
|
8
|
+
path: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function pinFindings(pkg: string, latest: string, sites: readonly [PinSite, ...PinSite[]]): PinHit[];
|
package/dist/lib/pins.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readLines } from './cli.js';
|
|
2
|
+
const EXACT = /^\d+\.\d+\.\d+$/;
|
|
3
|
+
const escaped = (name) => name.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
4
|
+
function pinsOf(pkg, sites) {
|
|
5
|
+
const pattern = new RegExp(`(?<![\\w@/.-])${escaped(pkg)}@([\\w.-]+)`);
|
|
6
|
+
return sites.flatMap(({ path, source }) => readLines(source).flatMap(({ n, text }) => {
|
|
7
|
+
const version = pattern.exec(text)?.[1];
|
|
8
|
+
return version === undefined ? [] : [{ path, line: n, version }];
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
const major = (version) => Number(/^(\d+)\./.exec(version)?.[1]);
|
|
12
|
+
// Il pin esatto è il contratto: `vercel@59` ridà una CLI diversa ogni settimana, e un deploy che
|
|
13
|
+
// fallisce non si riproduce. La major più nuova su npm resta una segnalazione, non un obbligo.
|
|
14
|
+
export function pinFindings(pkg, latest, sites) {
|
|
15
|
+
const pins = pinsOf(pkg, sites);
|
|
16
|
+
const home = sites[0].path;
|
|
17
|
+
if (pins.length === 0) {
|
|
18
|
+
return [{ path: home, line: 1, message: `nessun \`${pkg}@<x.y.z>\`: il pin è sparito` }];
|
|
19
|
+
}
|
|
20
|
+
const loose = pins.filter(({ version }) => !EXACT.test(version));
|
|
21
|
+
if (loose.length > 0) {
|
|
22
|
+
return loose.map(({ path, line, version }) => ({
|
|
23
|
+
path,
|
|
24
|
+
line,
|
|
25
|
+
message: `${pkg}@${version}: il pin va scritto per intero, \`x.y.z\``,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
const versions = [...new Set(pins.map(({ version }) => version))].sort();
|
|
29
|
+
const [pinned = ''] = versions;
|
|
30
|
+
if (versions.length > 1) {
|
|
31
|
+
return pins.map(({ path, line, version }) => ({
|
|
32
|
+
path,
|
|
33
|
+
line,
|
|
34
|
+
message: `${pkg}@${version}: il pin non è lo stesso ovunque (${versions.join(', ')})`,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
const published = major(latest);
|
|
38
|
+
if (Number.isNaN(published)) {
|
|
39
|
+
return [{ path: home, line: 1, message: `versione pubblicata illeggibile: ${JSON.stringify(latest)}` }];
|
|
40
|
+
}
|
|
41
|
+
if (major(pinned) >= published)
|
|
42
|
+
return [];
|
|
43
|
+
return pins.map(({ path, line }) => ({
|
|
44
|
+
path,
|
|
45
|
+
line,
|
|
46
|
+
message: `${pkg}@${pinned}: su npm è pubblicata la major ${published}`,
|
|
47
|
+
}));
|
|
48
|
+
}
|
package/dist/lib/routes.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type VerifiedRoute = {
|
|
|
18
18
|
path: string;
|
|
19
19
|
type: string;
|
|
20
20
|
};
|
|
21
|
+
/** `/blog/[slug]` → `/blog/hello-world`: il percorso vero che sta per il pattern. */
|
|
22
|
+
export type Representatives = Readonly<Record<string, string>>;
|
|
21
23
|
export declare const ERROR_PAGES: readonly string[];
|
|
22
24
|
export declare const NON_HTML_ROUTES: readonly VerifiedRoute[];
|
|
23
25
|
export declare function filesWithExtension(dir: string, extension: string): string[];
|
|
@@ -26,6 +28,8 @@ export declare function routeOf(htmlPath: string, dist: string): string;
|
|
|
26
28
|
export declare function expectedRoutes(pages: readonly PageFile[], pagesDir: string): Expectations;
|
|
27
29
|
/** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
|
|
28
30
|
export declare function missingRouteFailures(expected: Expectations, emitted: readonly string[], dist: string): string[];
|
|
29
|
-
|
|
30
|
-
export declare function
|
|
31
|
+
/** I pattern dinamici che `officina.config.ts` non ha ancora mandato nessuno a visitare. */
|
|
32
|
+
export declare function missingRepresentatives(expected: Expectations, representatives?: Representatives): string[];
|
|
33
|
+
export declare function auditRoutes(expected: Expectations, representatives?: Representatives): string[];
|
|
34
|
+
export declare function smokeRoutes(expected: Expectations, nonHtmlRoutes?: readonly VerifiedRoute[], representatives?: Representatives): VerifiedRoute[];
|
|
31
35
|
export declare function orphanExceptions(expected: Expectations): string[];
|
package/dist/lib/routes.js
CHANGED
|
@@ -81,15 +81,24 @@ export function missingRouteFailures(expected, emitted, dist) {
|
|
|
81
81
|
}
|
|
82
82
|
return failures;
|
|
83
83
|
}
|
|
84
|
-
|
|
85
|
-
export function
|
|
86
|
-
return expected.
|
|
87
|
-
.map(({
|
|
88
|
-
.filter((
|
|
84
|
+
/** I pattern dinamici che `officina.config.ts` non ha ancora mandato nessuno a visitare. */
|
|
85
|
+
export function missingRepresentatives(expected, representatives = {}) {
|
|
86
|
+
return expected.patterns
|
|
87
|
+
.map(({ label }) => label)
|
|
88
|
+
.filter((label) => representatives[label] === undefined)
|
|
89
89
|
.sort();
|
|
90
90
|
}
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// Ordinate: l'ordine nativo di readdirSync cambia da un filesystem all'altro. Un pattern dinamico
|
|
92
|
+
// entra col percorso vero che il progetto gli ha dato: senza, resta una pagina che nessuno guarda.
|
|
93
|
+
export function auditRoutes(expected, representatives = {}) {
|
|
94
|
+
const exact = expected.exact.map(({ route }) => route).filter((route) => !ERROR_PAGES.includes(route));
|
|
95
|
+
const stand = expected.patterns
|
|
96
|
+
.map(({ label }) => representatives[label])
|
|
97
|
+
.filter((path) => path !== undefined);
|
|
98
|
+
return [...exact, ...stand].sort();
|
|
99
|
+
}
|
|
100
|
+
export function smokeRoutes(expected, nonHtmlRoutes = NON_HTML_ROUTES, representatives = {}) {
|
|
101
|
+
return [...auditRoutes(expected, representatives).map((path) => ({ path, type: 'text/html' })), ...nonHtmlRoutes];
|
|
93
102
|
}
|
|
94
103
|
export function orphanExceptions(expected) {
|
|
95
104
|
const routes = new Set(expected.exact.map(({ route }) => route));
|