@elia97/officina 0.4.0 → 0.6.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.
Files changed (60) hide show
  1. package/README.md +59 -15
  2. package/dist/bin.js +0 -0
  3. package/dist/bundle.d.ts +1 -0
  4. package/dist/bundle.js +1 -0
  5. package/dist/checks/analytics.js +10 -4
  6. package/dist/checks/bundle.js +10 -2
  7. package/dist/checks/doctor.js +33 -6
  8. package/dist/checks/lighthouse.d.ts +1 -1
  9. package/dist/checks/lighthouse.js +8 -4
  10. package/dist/checks/placeholders.d.ts +1 -1
  11. package/dist/checks/placeholders.js +16 -10
  12. package/dist/checks/roadmap.d.ts +1 -1
  13. package/dist/checks/roadmap.js +11 -5
  14. package/dist/checks/smoke.js +3 -2
  15. package/dist/checks/vercel-cli.d.ts +1 -1
  16. package/dist/checks/vercel-cli.js +35 -21
  17. package/dist/gen/hook-points.d.mts +6 -0
  18. package/dist/gen/hook-points.mjs +13 -0
  19. package/dist/gen/inject-section.d.mts +25 -2
  20. package/dist/gen/inject-section.mjs +13 -13
  21. package/dist/gen/inject-strings.d.mts +3 -0
  22. package/dist/gen/inject-strings.mjs +7 -3
  23. package/dist/gen/section-targets.d.mts +5 -0
  24. package/dist/gen/section-targets.mjs +23 -0
  25. package/dist/icons.d.ts +1 -0
  26. package/dist/icons.js +2 -0
  27. package/dist/index.d.ts +0 -4
  28. package/dist/index.js +0 -4
  29. package/dist/lib/alignment.d.ts +2 -6
  30. package/dist/lib/alignment.js +25 -52
  31. package/dist/lib/anchors.d.ts +2 -0
  32. package/dist/lib/anchors.js +35 -0
  33. package/dist/lib/bundle-budget.d.ts +6 -1
  34. package/dist/lib/bundle-budget.js +9 -6
  35. package/dist/lib/check-placeholders.d.ts +4 -2
  36. package/dist/lib/check-placeholders.js +14 -7
  37. package/dist/lib/config-gaps.d.ts +7 -0
  38. package/dist/lib/config-gaps.js +29 -0
  39. package/dist/lib/config.d.ts +20 -1
  40. package/dist/lib/config.js +37 -4
  41. package/dist/lib/contract.d.ts +8 -0
  42. package/dist/lib/contract.js +51 -6
  43. package/dist/lib/pins.d.ts +10 -0
  44. package/dist/lib/pins.js +48 -0
  45. package/dist/lib/routes.d.ts +6 -2
  46. package/dist/lib/routes.js +16 -7
  47. package/dist/lib/tooling.d.ts +3 -0
  48. package/dist/lib/tooling.js +24 -0
  49. package/dist/lib/validate.d.ts +11 -0
  50. package/dist/lib/validate.js +40 -0
  51. package/dist/lib/versions.d.ts +5 -0
  52. package/dist/lib/versions.js +38 -0
  53. package/dist/lib/workflows.d.ts +6 -0
  54. package/dist/lib/workflows.js +70 -0
  55. package/dist/sh/lhci-local.sh +2 -2
  56. package/dist/smoke.d.ts +2 -0
  57. package/dist/smoke.js +4 -0
  58. package/package.json +28 -12
  59. package/dist/lib/vercel-cli-pin.d.ts +0 -2
  60. package/dist/lib/vercel-cli-pin.js +0 -29
@@ -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
- function fail(where, problem) {
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
- export function assertSectionInjectable({ root, collection, camel, kebab, pascal, image = false }) {
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 } = locateUnionArray(project, root, targets, collection.kebab);
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,3 +1,6 @@
1
+ export declare function assertDictionaries({ root }: {
2
+ root: any;
3
+ }): void;
1
4
  export declare function assertStringsInjectable({ root, keys }: {
2
5
  keys: any;
3
6
  root: any;
@@ -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
- function fail(where, problem) {
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'))
@@ -0,0 +1 @@
1
+ export { ICON_SPECS } from './lib/icon-specs.ts';
package/dist/icons.js ADDED
@@ -0,0 +1,2 @@
1
+ // Il test che confronta le icone col manifest del sito importa questo, senza caricare sharp.
2
+ export { ICON_SPECS } from './lib/icon-specs.js';
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';
@@ -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({ paths, read }: ProjectFiles): ContractGap[];
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({ read }: ProjectFiles): ContractGap[];
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[];
@@ -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
- export function leftoverGaps({ paths, read }) {
98
- const gaps = LEFTOVERS.filter(({ path }) => holds(paths, path)).map(({ path, reason }) => ({
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 (read('.claude/settings.json')?.includes('"hooks"')) {
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
- if (read('.mcp.json')?.includes('astro-docs')) {
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({ read }) {
129
+ export function presetGaps(files) {
113
130
  const gaps = [];
114
- if (!read('biome.json')?.includes(BIOME_PRESET)) {
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')?.includes(LEFTHOOK_PRESET)) {
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,2 @@
1
+ import type { ContractGap } from './contract.ts';
2
+ export declare function anchorGaps(root: string): ContractGap[];
@@ -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
- export declare function staticClosure(entries: Iterable<string>, chunks: Map<string, Chunk>): Set<string>;
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 seen = new Set();
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 || seen.has(name))
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
- seen.add(name);
31
+ }
32
+ reached.add(name);
30
33
  queue.push(...chunk.static);
31
34
  }
32
- return seen;
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) {
@@ -1,6 +1,8 @@
1
1
  import { type Finding, type Hit } from './cli.ts';
2
+ import type { OfficinaConfig } from './config.ts';
2
3
  type EnvHit = Omit<Finding, 'path' | 'severity'>;
3
- export declare function placeholderSources(): string[];
4
+ export declare function placeholderSources(config?: OfficinaConfig): string[];
5
+ export declare const contactEnvKeys: (config?: OfficinaConfig) => readonly string[];
4
6
  export declare function sourceFindings(source: string): Hit[];
5
- export declare function envFindings(content: string): EnvHit[];
7
+ export declare function envFindings(content: string, keys: readonly string[]): EnvHit[];
6
8
  export {};
@@ -1,8 +1,9 @@
1
- import { readdirSync } from 'node:fs';
1
+ import { existsSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { parseEnv } from 'node:util';
4
4
  import { readLines } from './cli.js';
5
5
  const DICTIONARIES = 'src/i18n/strings';
6
+ const DEFAULT_SOURCES = ['src/lib/site.ts', 'src/lib/company.ts'];
6
7
  const CONTACT_ENV_KEYS = ['CONTACT_FROM_EMAIL', 'CONTACT_FROM_NAME', 'CONTACT_TO_EMAIL'];
7
8
  const TOKEN = /<[A-Z][A-Z_]{2,}>/;
8
9
  const DOMAIN = /\bexample\.com\b/;
@@ -10,13 +11,19 @@ const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
10
11
  const COMMENT_ONLY = /^\s*(?:\/\/|\/\*|\*)/;
11
12
  const ZERO_POSTAL_CODE = /\bpostalCode:\s*(['"])00000\1/;
12
13
  const EMPTY_HREF = /\bhref:\s*(['"])#\1/;
13
- export function placeholderSources() {
14
- const dictionaries = readdirSync(DICTIONARIES)
14
+ const dictionaries = () => existsSync(DICTIONARIES)
15
+ ? readdirSync(DICTIONARIES)
15
16
  .filter((name) => name.endsWith('.ts'))
16
17
  .sort()
17
- .map((name) => join(DICTIONARIES, name));
18
- return ['src/lib/site.ts', 'src/lib/company.ts', ...dictionaries];
18
+ .map((name) => join(DICTIONARIES, name))
19
+ : [];
20
+ export function placeholderSources(config = {}) {
21
+ const declared = config.placeholders?.sources;
22
+ if (declared !== undefined)
23
+ return [...declared];
24
+ return [...DEFAULT_SOURCES, ...dictionaries()];
19
25
  }
26
+ export const contactEnvKeys = (config = {}) => config.placeholders?.contactEnvKeys ?? CONTACT_ENV_KEYS;
20
27
  function literalFindings(value) {
21
28
  const messages = [];
22
29
  const token = value.match(TOKEN);
@@ -40,10 +47,10 @@ export function sourceFindings(source) {
40
47
  return messages.map((message) => ({ line: n, message }));
41
48
  });
42
49
  }
43
- export function envFindings(content) {
50
+ export function envFindings(content, keys) {
44
51
  const env = parseEnv(content);
45
52
  const lines = readLines(content);
46
- return CONTACT_ENV_KEYS.flatMap((key) => {
53
+ return keys.flatMap((key) => {
47
54
  const value = env[key]?.trim() ?? '';
48
55
  const declaration = lines.find(({ text }) => new RegExp(`^\\s*${key}\\s*=`).test(text));
49
56
  const at = declaration ? { line: declaration.n } : {};
@@ -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
+ }
@@ -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,24 @@ 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
+ placeholders?: {
30
+ sources?: readonly string[];
31
+ contactEnvKeys?: readonly string[];
32
+ };
33
+ /** Per ogni pattern dinamico di `src/pages`, un percorso vero che smoke e Lighthouse visitano. */
34
+ routes?: {
35
+ representatives?: Representatives;
36
+ };
22
37
  }
38
+ export declare const FEATURES: readonly ['analytics', 'roadmap'];
39
+ export type FeatureName = (typeof FEATURES)[number];
40
+ /** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
41
+ export declare const isRequired: (config: OfficinaConfig, name: FeatureName) => boolean;
23
42
  export declare const defineConfig: (config: OfficinaConfig) => OfficinaConfig;
24
43
  export declare const findConfigFile: (root: string) => string | undefined;
25
44
  export declare function loadConfig(root: string): Promise<OfficinaConfig>;
@@ -7,16 +7,49 @@ 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
+ placeholders: aShape({ sources: anArrayOf(aString), contactEnvKeys: anArrayOf(aString) }),
31
+ routes: aShape({ representatives: aRecordOf(aString) }),
32
+ });
12
33
  const CONFIG_FILES = ['officina.config.ts', 'officina.config.mjs', 'officina.config.js'];
13
34
  export const defineConfig = (config) => config;
14
35
  // Fuori da node_modules Node toglie i tipi da solo: il file del progetto può essere TypeScript.
15
- export const findConfigFile = (root) => CONFIG_FILES.map((name) => join(root, name)).find((path) => existsSync(path));
36
+ const findConfigFiles = (root) => CONFIG_FILES.map((name) => join(root, name)).filter((path) => existsSync(path));
37
+ export const findConfigFile = (root) => findConfigFiles(root)[0];
16
38
  export async function loadConfig(root) {
17
- const file = findConfigFile(root);
39
+ const [file, ...rest] = findConfigFiles(root);
18
40
  if (file === undefined)
19
41
  return {};
42
+ if (rest.length > 0) {
43
+ const names = [file, ...rest].map((path) => basename(path)).join(' e ');
44
+ throw new Error(`${names} stanno insieme nella radice: quale valga non lo decide officina, tienine uno`);
45
+ }
46
+ const name = basename(file);
20
47
  const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
21
- return module.default ?? {};
48
+ if (module.default === undefined) {
49
+ throw new Error(`${name} non ha un default export: la configurazione si scrive \`export default defineConfig({ … })\``);
50
+ }
51
+ const problems = SHAPE(module.default, '');
52
+ if (problems.length > 0)
53
+ throw new Error(problems.join('; '));
54
+ return module.default;
22
55
  }
@@ -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 {