@elia97/officina 0.3.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 +103 -10
- package/dist/bin.js +0 -0
- package/dist/bundle.d.ts +1 -0
- package/dist/bundle.js +1 -0
- package/dist/checks/analytics.js +14 -8
- package/dist/checks/bundle.js +18 -10
- package/dist/checks/doctor.d.ts +1 -1
- package/dist/checks/doctor.js +72 -8
- package/dist/checks/lighthouse.d.ts +1 -1
- package/dist/checks/lighthouse.js +9 -5
- package/dist/checks/roadmap.d.ts +1 -1
- package/dist/checks/roadmap.js +11 -5
- package/dist/checks/smoke.js +11 -10
- package/dist/checks/vercel-cli.js +33 -17
- 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 +19 -0
- package/dist/lib/alignment.js +139 -0
- 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 +17 -1
- package/dist/lib/config.js +36 -3
- 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 +19 -10
- package/dist/lib/smoke-production.js +17 -17
- 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 +12 -12
- package/dist/smoke.d.ts +2 -0
- package/dist/smoke.js +4 -0
- package/package.json +31 -13
- package/presets/biome.base.json +100 -0
- package/presets/lefthook.yml +11 -0
- package/dist/lib/vercel-cli-pin.d.ts +0 -2
- package/dist/lib/vercel-cli-pin.js +0 -29
|
@@ -1,7 +1,30 @@
|
|
|
1
|
-
|
|
1
|
+
import { Project } from 'ts-morph';
|
|
2
|
+
export declare function assertSectionAnchors({ root, collection, project }: {
|
|
3
|
+
collection: any;
|
|
4
|
+
project?: Project | undefined;
|
|
5
|
+
root: any;
|
|
6
|
+
}): {
|
|
7
|
+
targets: {
|
|
8
|
+
barrel: string;
|
|
9
|
+
schemaFunction: string;
|
|
10
|
+
dataLayer: string;
|
|
11
|
+
dataFunction: string;
|
|
12
|
+
schemaDir: string;
|
|
13
|
+
contentDir: string;
|
|
14
|
+
componentDir: string;
|
|
15
|
+
importsMarker: string;
|
|
16
|
+
sectionsMarker: string;
|
|
17
|
+
};
|
|
18
|
+
barrel: any;
|
|
19
|
+
fn: any;
|
|
20
|
+
union: any;
|
|
21
|
+
obj: any;
|
|
22
|
+
page: string | undefined;
|
|
23
|
+
src: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function assertSectionInjectable({ root, collection, camel, kebab, pascal }: {
|
|
2
26
|
camel: any;
|
|
3
27
|
collection: any;
|
|
4
|
-
image?: boolean | undefined;
|
|
5
28
|
kebab: any;
|
|
6
29
|
pascal: any;
|
|
7
30
|
root: any;
|
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
// trivia iniziale del nuovo import e lo sposta nel blocco ordinato. (.astro non è leggibile da ts-morph.)
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { Node, Project, SyntaxKind } from 'ts-morph';
|
|
5
|
+
import { failing } from './hook-points.mjs';
|
|
5
6
|
import { findSectionedPages, sectionFiles, sectionTargets } from './section-targets.mjs';
|
|
6
7
|
import { isNameTaken } from './ts-morph-utils.mjs';
|
|
7
8
|
const GUIDE = 'docs/guides/content-collections.md';
|
|
8
|
-
|
|
9
|
-
throw new Error(`gen:section injection failed in ${where}: ${problem} (contract: ${GUIDE})`);
|
|
10
|
-
}
|
|
9
|
+
const fail = failing('gen:section injection failed', GUIDE);
|
|
11
10
|
function addSource(project, root, path, missing) {
|
|
12
11
|
if (!existsSync(`${root}/${path}`))
|
|
13
12
|
fail(path, missing);
|
|
@@ -71,23 +70,27 @@ function assertContextForwardable(fn, targets) {
|
|
|
71
70
|
fail(targets.barrel, `${targets.schemaFunction} destructures its parameter — name it \`context: SchemaContext\`, so an image section can receive it`);
|
|
72
71
|
}
|
|
73
72
|
}
|
|
74
|
-
|
|
73
|
+
// Gli ancoraggi di una collection a sezioni, senza sapere che sezione stia per nascere: il pre-volo
|
|
74
|
+
// ci aggiunge i controlli sul nome nuovo, `doctor` li guarda e basta.
|
|
75
|
+
export function assertSectionAnchors({ root, collection, project = new Project() }) {
|
|
75
76
|
const targets = sectionTargets(collection);
|
|
76
|
-
const project = new Project();
|
|
77
77
|
const { barrel, fn, union } = locateUnionArray(project, root, targets, collection.kebab);
|
|
78
|
+
assertContextForwardable(fn, targets);
|
|
79
|
+
const obj = locateReturnObject(project, root, targets, collection.kebab);
|
|
80
|
+
const { page, src } = readSectionedPage(root, targets);
|
|
81
|
+
return { targets, barrel, fn, union, obj, page, src };
|
|
82
|
+
}
|
|
83
|
+
export function assertSectionInjectable({ root, collection, camel, kebab, pascal }) {
|
|
84
|
+
const { targets, barrel, union, obj, page, src } = assertSectionAnchors({ root, collection });
|
|
78
85
|
if (inUnion(union, camel)) {
|
|
79
86
|
fail(targets.barrel, `section "${camel}" is already in the union — pick another name`);
|
|
80
87
|
}
|
|
81
|
-
if (image)
|
|
82
|
-
assertContextForwardable(fn, targets);
|
|
83
88
|
if (isNameTaken(barrel, `${camel}SectionSchema`)) {
|
|
84
89
|
fail(targets.barrel, `the identifier \`${camel}SectionSchema\` is already taken — the injected import would collide. Pick another name`);
|
|
85
90
|
}
|
|
86
|
-
const obj = locateReturnObject(project, root, targets, collection.kebab);
|
|
87
91
|
if (obj.getProperty(camel)) {
|
|
88
92
|
fail(targets.dataLayer, `section "${camel}" is already picked in ${targets.dataFunction}`);
|
|
89
93
|
}
|
|
90
|
-
const { page, src } = readSectionedPage(root, targets);
|
|
91
94
|
const frontmatter = src.split('---')[1] ?? '';
|
|
92
95
|
if (new RegExp(`\\b${pascal}\\b`).test(frontmatter)) {
|
|
93
96
|
fail(page, `the identifier \`${pascal}\` is already used in the frontmatter — the component import would collide. Pick another name`);
|
|
@@ -109,9 +112,8 @@ function contextArgument(barrel, fn) {
|
|
|
109
112
|
return 'context';
|
|
110
113
|
}
|
|
111
114
|
export function injectSection({ root, collection, camel, kebab, pascal, image = false }) {
|
|
112
|
-
const targets = sectionTargets(collection);
|
|
113
115
|
const project = new Project();
|
|
114
|
-
const { barrel, fn, union } =
|
|
116
|
+
const { targets, barrel, fn, union, obj, page, src: original } = assertSectionAnchors({ root, collection, project });
|
|
115
117
|
if (!barrel.getImportDeclaration((d) => d.getModuleSpecifierValue() === `./${kebab}`)) {
|
|
116
118
|
barrel.addImportDeclaration({
|
|
117
119
|
moduleSpecifier: `./${kebab}`,
|
|
@@ -121,12 +123,10 @@ export function injectSection({ root, collection, camel, kebab, pascal, image =
|
|
|
121
123
|
if (!inUnion(union, camel)) {
|
|
122
124
|
union.addElement(`${camel}SectionSchema(${image ? contextArgument(barrel, fn) : ''})`);
|
|
123
125
|
}
|
|
124
|
-
const obj = locateReturnObject(project, root, targets, collection.kebab);
|
|
125
126
|
if (!obj.getProperty(camel)) {
|
|
126
127
|
obj.addPropertyAssignment({ name: camel, initializer: `pick('${camel}')` });
|
|
127
128
|
}
|
|
128
129
|
project.saveSync();
|
|
129
|
-
const { page, src: original } = readSectionedPage(root, targets);
|
|
130
130
|
let src = original;
|
|
131
131
|
const imp = `import ${pascal} from '@/components/${collection.kebab}/${kebab}.astro'`;
|
|
132
132
|
if (!src.includes(imp)) {
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { Project, SyntaxKind } from 'ts-morph';
|
|
4
|
+
import { failing } from './hook-points.mjs';
|
|
4
5
|
const DICTIONARIES = 'src/i18n/strings';
|
|
5
6
|
const GUIDE = 'docs/guides/content-collections.md';
|
|
6
|
-
|
|
7
|
-
throw new Error(`gen:page injection failed in ${where}: ${problem} (contract: ${GUIDE})`);
|
|
8
|
-
}
|
|
7
|
+
const fail = failing('gen:page injection failed', GUIDE);
|
|
9
8
|
function locateObject(file, path, locale) {
|
|
10
9
|
const declaration = file.getVariableDeclaration(locale);
|
|
11
10
|
if (!declaration) {
|
|
@@ -37,6 +36,11 @@ function loadDictionaries(root) {
|
|
|
37
36
|
});
|
|
38
37
|
return { project, dictionaries };
|
|
39
38
|
}
|
|
39
|
+
// La forma dei dizionari, senza sapere quali chiavi stiano per arrivare: il pre-volo ci aggiunge
|
|
40
|
+
// il controllo sulle chiavi nuove, `doctor` la guarda e basta.
|
|
41
|
+
export function assertDictionaries({ root }) {
|
|
42
|
+
loadDictionaries(root);
|
|
43
|
+
}
|
|
40
44
|
export function assertStringsInjectable({ root, keys }) {
|
|
41
45
|
for (const { path, object } of loadDictionaries(root).dictionaries) {
|
|
42
46
|
for (const key of keys) {
|
|
@@ -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';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ContractGap } from './contract.ts';
|
|
2
|
+
export interface ProjectFiles {
|
|
3
|
+
/** Tracciati e non tracciati, senza gli ignorati: un file locale fuori da git non è un residuo. */
|
|
4
|
+
paths: readonly string[];
|
|
5
|
+
read: (path: string) => string | undefined;
|
|
6
|
+
}
|
|
7
|
+
export interface Manifest {
|
|
8
|
+
scripts?: Record<string, string>;
|
|
9
|
+
dependencies?: Record<string, string>;
|
|
10
|
+
devDependencies?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
export declare const EXPECTED_SCRIPTS: Readonly<Record<string, string>>;
|
|
13
|
+
export declare const CI_STEPS: readonly string[];
|
|
14
|
+
export declare function scriptGaps({ scripts }: Manifest): ContractGap[];
|
|
15
|
+
export declare function dependencyGaps({ dependencies, devDependencies }: Manifest): ContractGap[];
|
|
16
|
+
export declare function leftoverGaps(files: ProjectFiles): ContractGap[];
|
|
17
|
+
export declare const BIOME_PRESET = "@elia97/officina/biome";
|
|
18
|
+
export declare const LEFTHOOK_PRESET = "node_modules/@elia97/officina/presets/lefthook.yml";
|
|
19
|
+
export declare function presetGaps(files: ProjectFiles): ContractGap[];
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { mentions } from './workflows.js';
|
|
2
|
+
export const EXPECTED_SCRIPTS = {
|
|
3
|
+
'check:comments': 'officina check comments',
|
|
4
|
+
'check:language': 'officina check language',
|
|
5
|
+
'check:routes': 'officina check routes',
|
|
6
|
+
'check:roadmap': 'officina check roadmap',
|
|
7
|
+
'check:placeholders': 'officina check placeholders',
|
|
8
|
+
'perf:bundle': 'officina check bundle',
|
|
9
|
+
'smoke:prod': 'officina check smoke',
|
|
10
|
+
'analytics:verify': 'officina check analytics',
|
|
11
|
+
lhci: 'officina check lighthouse',
|
|
12
|
+
'lhci:local': 'officina check lighthouse --local',
|
|
13
|
+
doctor: 'officina doctor',
|
|
14
|
+
gen: 'officina gen',
|
|
15
|
+
'gen:section': 'officina gen section',
|
|
16
|
+
'gen:page': 'officina gen page',
|
|
17
|
+
'gen:component': 'officina gen component',
|
|
18
|
+
'gen:collection': 'officina gen collection',
|
|
19
|
+
'gen:icons': 'officina gen icons',
|
|
20
|
+
};
|
|
21
|
+
export const CI_STEPS = [
|
|
22
|
+
'check:language',
|
|
23
|
+
'check:routes',
|
|
24
|
+
'check:roadmap',
|
|
25
|
+
'check:comments',
|
|
26
|
+
'doctor',
|
|
27
|
+
];
|
|
28
|
+
const MANIFEST = 'package.json';
|
|
29
|
+
export function scriptGaps({ scripts = {} }) {
|
|
30
|
+
const gaps = [];
|
|
31
|
+
for (const [name, expected] of Object.entries(EXPECTED_SCRIPTS)) {
|
|
32
|
+
const actual = scripts[name];
|
|
33
|
+
if (actual === undefined)
|
|
34
|
+
gaps.push({ path: MANIFEST, message: `script \`${name}\` assente: atteso \`${expected}\`` });
|
|
35
|
+
else if (!actual.startsWith(expected))
|
|
36
|
+
gaps.push({ path: MANIFEST, message: `script \`${name}\` è \`${actual}\`: atteso \`${expected}\`` });
|
|
37
|
+
}
|
|
38
|
+
const ci = scripts.ci ?? '';
|
|
39
|
+
for (const step of CI_STEPS) {
|
|
40
|
+
if (!ci.includes(`pnpm run ${step}`))
|
|
41
|
+
gaps.push({ path: MANIFEST, message: `script \`ci\` non lancia \`${step}\`` });
|
|
42
|
+
}
|
|
43
|
+
return gaps;
|
|
44
|
+
}
|
|
45
|
+
const RETIRED_DEPENDENCIES = ['plop', 'ts-morph'];
|
|
46
|
+
export function dependencyGaps({ dependencies = {}, devDependencies = {} }) {
|
|
47
|
+
const declared = { ...dependencies, ...devDependencies };
|
|
48
|
+
const gaps = RETIRED_DEPENDENCIES.filter((name) => name in declared).map((name) => ({
|
|
49
|
+
path: MANIFEST,
|
|
50
|
+
message: `\`${name}\` non serve più al progetto: è una dipendenza di officina`,
|
|
51
|
+
}));
|
|
52
|
+
if (!('@elia97/officina' in devDependencies)) {
|
|
53
|
+
gaps.push({ path: MANIFEST, message: '`@elia97/officina` non è fra le devDependencies' });
|
|
54
|
+
}
|
|
55
|
+
return gaps;
|
|
56
|
+
}
|
|
57
|
+
const FROM_PLUGIN = 'arriva dal plugin `metodo`';
|
|
58
|
+
const FROM_PACKAGE = 'arriva da officina';
|
|
59
|
+
const OUT_OF_REPOSITORY = 'sta nella cartella del progetto, fuori dal repository';
|
|
60
|
+
// Una cartella si nomina con la barra finale: corrisponde a qualunque file sotto di lei.
|
|
61
|
+
const LEFTOVERS = [
|
|
62
|
+
{ path: '.claude/agents/', reason: FROM_PLUGIN },
|
|
63
|
+
{ path: '.claude/commands/', reason: FROM_PLUGIN },
|
|
64
|
+
{ path: '.claude/scripts/', reason: FROM_PLUGIN },
|
|
65
|
+
{ path: '.claude/hooks/', reason: FROM_PLUGIN },
|
|
66
|
+
{ path: 'HOW_TO_USE.md', reason: FROM_PLUGIN },
|
|
67
|
+
{ path: 'docs/milestone-templates/', reason: FROM_PLUGIN },
|
|
68
|
+
{ path: 'docs/proposal-templates/', reason: 'i modelli commerciali stanno nel sistema, non nel repository' },
|
|
69
|
+
{ path: 'plopfile.mjs', reason: FROM_PACKAGE },
|
|
70
|
+
{ path: 'scripts/gen/', reason: FROM_PACKAGE },
|
|
71
|
+
{ path: 'scripts/templates/', reason: FROM_PACKAGE },
|
|
72
|
+
{ path: 'scripts/check-comments.mjs', reason: FROM_PACKAGE },
|
|
73
|
+
{ path: 'scripts/check-language.mjs', reason: FROM_PACKAGE },
|
|
74
|
+
{ path: 'scripts/bundle-budget.mjs', reason: FROM_PACKAGE },
|
|
75
|
+
{ path: 'scripts/smoke-production.mjs', reason: FROM_PACKAGE },
|
|
76
|
+
{ path: 'scripts/lighthouse.mjs', reason: FROM_PACKAGE },
|
|
77
|
+
{ path: 'scripts/verify-analytics-coverage.mjs', reason: FROM_PACKAGE },
|
|
78
|
+
{ path: 'scripts/gen-icons.mjs', reason: FROM_PACKAGE },
|
|
79
|
+
{ path: 'scripts/gen-icons.ts', reason: FROM_PACKAGE },
|
|
80
|
+
{ path: 'scripts/lhci-local.sh', reason: FROM_PACKAGE },
|
|
81
|
+
{ path: 'scripts/lib/bundle-budget.ts', reason: FROM_PACKAGE },
|
|
82
|
+
{ path: 'scripts/lib/smoke-production.ts', reason: FROM_PACKAGE },
|
|
83
|
+
{ path: 'scripts/lib/routes.ts', reason: FROM_PACKAGE },
|
|
84
|
+
{ path: 'scripts/lib/check-comments.ts', reason: FROM_PACKAGE },
|
|
85
|
+
{ path: 'scripts/lib/check-language.ts', reason: FROM_PACKAGE },
|
|
86
|
+
{ path: 'scripts/lib/gtm-container.ts', reason: FROM_PACKAGE },
|
|
87
|
+
{ path: 'scripts/lib/analytics-coverage.ts', reason: FROM_PACKAGE },
|
|
88
|
+
{ path: 'scripts/lib/icons.ts', reason: FROM_PACKAGE },
|
|
89
|
+
{ path: '.github/workflows/vercel-cli.yml', reason: "il pin di Vercel sta nell'action di deploy di officina" },
|
|
90
|
+
{ path: 'docs/PROJECT.md', reason: OUT_OF_REPOSITORY },
|
|
91
|
+
{ path: 'docs/DECISIONS.md', reason: OUT_OF_REPOSITORY },
|
|
92
|
+
{ path: 'docs/RISKS.md', reason: OUT_OF_REPOSITORY },
|
|
93
|
+
{ path: 'docs/INPUTS.md', reason: OUT_OF_REPOSITORY },
|
|
94
|
+
{ path: 'docs/TASK-CONTEXT.md', reason: OUT_OF_REPOSITORY },
|
|
95
|
+
{ path: 'docs/ESTIMATE.md', reason: OUT_OF_REPOSITORY },
|
|
96
|
+
];
|
|
97
|
+
const holds = (paths, target) => target.endsWith('/') ? paths.some((path) => path.startsWith(target)) : paths.includes(target);
|
|
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 }) => ({
|
|
115
|
+
path,
|
|
116
|
+
message: `residuo: ${reason}`,
|
|
117
|
+
}));
|
|
118
|
+
if (readJson(files, '.claude/settings.json')?.hooks !== undefined) {
|
|
119
|
+
gaps.push({ path: '.claude/settings.json', message: `blocco \`hooks\` residuo: il guard ${FROM_PLUGIN}` });
|
|
120
|
+
}
|
|
121
|
+
const servers = readJson(files, '.mcp.json')?.mcpServers;
|
|
122
|
+
if (isRecord(servers) && servers['astro-docs'] !== undefined) {
|
|
123
|
+
gaps.push({ path: '.mcp.json', message: `server \`astro-docs\` residuo: ${FROM_PLUGIN}` });
|
|
124
|
+
}
|
|
125
|
+
return gaps;
|
|
126
|
+
}
|
|
127
|
+
export const BIOME_PRESET = '@elia97/officina/biome';
|
|
128
|
+
export const LEFTHOOK_PRESET = 'node_modules/@elia97/officina/presets/lefthook.yml';
|
|
129
|
+
export function presetGaps(files) {
|
|
130
|
+
const gaps = [];
|
|
131
|
+
const extended = readJson(files, 'biome.json')?.extends;
|
|
132
|
+
if (!(Array.isArray(extended) && extended.includes(BIOME_PRESET))) {
|
|
133
|
+
gaps.push({ path: 'biome.json', message: `non estende \`${BIOME_PRESET}\`` });
|
|
134
|
+
}
|
|
135
|
+
if (!mentions(files.read('lefthook.yml'), LEFTHOOK_PRESET)) {
|
|
136
|
+
gaps.push({ path: 'lefthook.yml', message: `non estende \`${LEFTHOOK_PRESET}\`` });
|
|
137
|
+
}
|
|
138
|
+
return gaps;
|
|
139
|
+
}
|
|
@@ -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,6 +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;
|
|
39
|
+
export declare const findConfigFile: (root: string) => string | undefined;
|
|
24
40
|
export declare function loadConfig(root: string): Promise<OfficinaConfig>;
|
package/dist/lib/config.js
CHANGED
|
@@ -7,15 +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.
|
|
35
|
+
const findConfigFiles = (root) => CONFIG_FILES.map((name) => join(root, name)).filter((path) => existsSync(path));
|
|
36
|
+
export const findConfigFile = (root) => findConfigFiles(root)[0];
|
|
15
37
|
export async function loadConfig(root) {
|
|
16
|
-
const file =
|
|
38
|
+
const [file, ...rest] = findConfigFiles(root);
|
|
17
39
|
if (file === undefined)
|
|
18
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);
|
|
19
46
|
const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
|
|
20
|
-
|
|
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;
|
|
21
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 {
|