@elia97/officina 0.9.1 → 0.11.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 +23 -9
- package/dist/bin.js +2 -1
- package/dist/checks/bundle.js +42 -26
- package/dist/checks/doctor.js +20 -7
- package/dist/checks/lighthouse.js +4 -1
- package/dist/checks/links.d.ts +2 -0
- package/dist/checks/links.js +69 -0
- package/dist/checks/placeholders.js +2 -2
- package/dist/checks/smoke.js +3 -2
- package/dist/gen/hook-points.mjs +0 -3
- package/dist/lib/alignment.d.ts +1 -0
- package/dist/lib/alignment.js +19 -3
- package/dist/lib/bundle-budget.d.ts +1 -2
- package/dist/lib/bundle-budget.js +8 -13
- package/dist/lib/bundle-css.d.ts +16 -0
- package/dist/lib/bundle-css.js +33 -0
- package/dist/lib/bundle-ssr.d.ts +3 -0
- package/dist/lib/bundle-ssr.js +4 -3
- package/dist/lib/check-placeholders.js +4 -3
- package/dist/lib/check-routes.js +87 -18
- package/dist/lib/config-gaps.d.ts +5 -5
- package/dist/lib/config-gaps.js +14 -8
- package/dist/lib/config.d.ts +5 -2
- package/dist/lib/config.js +3 -3
- package/dist/lib/contract.d.ts +1 -0
- package/dist/lib/dependabot.js +19 -2
- package/dist/lib/links.d.ts +30 -0
- package/dist/lib/links.js +101 -0
- package/dist/lib/markdown.d.ts +7 -0
- package/dist/lib/markdown.js +41 -0
- package/dist/lib/past-tense.js +1 -1
- package/dist/lib/routes.d.ts +19 -7
- package/dist/lib/routes.js +34 -10
- package/dist/lib/validate.js +0 -2
- package/dist/lib/vercel-build.d.ts +2 -0
- package/dist/lib/vercel-build.js +41 -8
- package/dist/smoke.js +0 -2
- package/package.json +3 -2
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
2
|
+
export const routeCount = (count) => (count === 1 ? '1 rotta' : `${count} rotte`);
|
|
3
|
+
function overBudget({ stylesheets, gzip, routes }, maxGzip) {
|
|
4
|
+
const weight = `CSS ${kb(gzip)} > ${kb(maxGzip)} (+${kb(gzip - maxGzip)})`;
|
|
5
|
+
if (routes.length === 0)
|
|
6
|
+
return `${stylesheets.join(' + ')}: ${weight}, pesato da solo: nessuna rotta misurata lo collega`;
|
|
7
|
+
const [first] = [...routes].sort();
|
|
8
|
+
return `${first}: ${weight} — ${stylesheets.join(' + ')}, su ${routeCount(routes.length)}`;
|
|
9
|
+
}
|
|
10
|
+
export function measureCss(routes, sheets, maxGzip, assets) {
|
|
11
|
+
const weights = new Map(sheets.map(({ file, gzip }) => [file, gzip]));
|
|
12
|
+
const groups = new Map();
|
|
13
|
+
const failures = [];
|
|
14
|
+
for (const { route, stylesheets } of routes) {
|
|
15
|
+
const linked = [...new Set(stylesheets)].sort();
|
|
16
|
+
for (const name of linked.filter((sheet) => !weights.has(sheet)))
|
|
17
|
+
failures.push(`${route}: il foglio ${name} non è in ${assets}, e il CSS della rotta non si misura per intero`);
|
|
18
|
+
if (linked.length === 0)
|
|
19
|
+
continue;
|
|
20
|
+
const key = linked.join(' + ');
|
|
21
|
+
const gzip = linked.reduce((sum, sheet) => sum + (weights.get(sheet) ?? 0), 0);
|
|
22
|
+
const group = groups.get(key) ?? { stylesheets: linked, gzip, routes: [] };
|
|
23
|
+
group.routes.push(route);
|
|
24
|
+
groups.set(key, group);
|
|
25
|
+
}
|
|
26
|
+
const linkedAnywhere = new Set(routes.flatMap(({ stylesheets }) => stylesheets));
|
|
27
|
+
for (const { file, gzip } of sheets)
|
|
28
|
+
if (!linkedAnywhere.has(file))
|
|
29
|
+
groups.set(file, { stylesheets: [file], gzip, routes: [] });
|
|
30
|
+
const measured = [...groups.values()].sort((a, b) => b.gzip - a.gzip);
|
|
31
|
+
failures.push(...measured.filter((group) => group.gzip > maxGzip).map((group) => overBudget(group, maxGzip)));
|
|
32
|
+
return { groups: measured, failures };
|
|
33
|
+
}
|
package/dist/lib/bundle-ssr.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { type ServerFile } from './vercel-build.ts';
|
|
1
2
|
export type PageEntries = {
|
|
2
3
|
route: string;
|
|
3
4
|
entries: string[];
|
|
5
|
+
stylesheets: string[];
|
|
4
6
|
};
|
|
5
7
|
export type SsrReading = {
|
|
6
8
|
pages: PageEntries[];
|
|
@@ -9,5 +11,6 @@ export type SsrReading = {
|
|
|
9
11
|
comparesExpectedRoutes: boolean;
|
|
10
12
|
measured: boolean;
|
|
11
13
|
};
|
|
14
|
+
export declare function readServerFiles(): ServerFile[];
|
|
12
15
|
/** Le rotte renderizzate a richiesta non emettono HTML: le dà il manifest che Astro scrive nella build Vercel. */
|
|
13
16
|
export declare function readSsr(hasHtml: boolean): SsrReading;
|
package/dist/lib/bundle-ssr.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { ssrRoutes } from './vercel-build.js';
|
|
4
4
|
const FUNCTIONS = '.vercel/output/_functions';
|
|
5
5
|
const SERVER_OUTPUT = 'dist/server';
|
|
6
|
-
function readServerFiles() {
|
|
6
|
+
export function readServerFiles() {
|
|
7
7
|
const read = (path) => ({ path, source: readFileSync(join(FUNCTIONS, path), 'utf8') });
|
|
8
8
|
const chunks = existsSync(join(FUNCTIONS, 'chunks')) ? readdirSync(join(FUNCTIONS, 'chunks')) : [];
|
|
9
9
|
const entry = existsSync(join(FUNCTIONS, 'entry.mjs')) ? ['entry.mjs'] : [];
|
|
@@ -27,9 +27,10 @@ export function readSsr(hasHtml) {
|
|
|
27
27
|
}
|
|
28
28
|
return {
|
|
29
29
|
...none,
|
|
30
|
-
pages: routes.map(({ route, entries }) => ({ route, entries })),
|
|
31
|
-
failures: routes.flatMap(({ route, unmapped, unknownIslands }) => [
|
|
30
|
+
pages: routes.map(({ route, entries, stylesheets }) => ({ route, entries, stylesheets: stylesheets ?? [] })),
|
|
31
|
+
failures: routes.flatMap(({ route, unmapped, unknownIslands, stylesheets }) => [
|
|
32
32
|
...(unmapped ? [`${route}: nessun chunk server in ${FUNCTIONS}, la misura non coprirebbe le isole`] : []),
|
|
33
|
+
...(stylesheets === null ? [`${route}: il manifest non dice quali fogli di stile collega la rotta`] : []),
|
|
33
34
|
...unknownIslands.map((island) => `${route}: l'isola ${island} non ha un chunk in dist/client/_astro fra quelli del manifest`),
|
|
34
35
|
]),
|
|
35
36
|
measured: true,
|
|
@@ -11,8 +11,9 @@ const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
|
|
|
11
11
|
const COMMENT_ONLY = /^\s*(?:\/\/|\/\*|\*)/;
|
|
12
12
|
const ZERO_POSTAL_CODE = /\bpostalCode:\s*(['"])00000\1/;
|
|
13
13
|
const EMPTY_HREF = /\bhref:\s*(['"])#\1/;
|
|
14
|
-
// `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@
|
|
14
|
+
// `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@62.2.0: `vercel pull` lo scrive al posto del valore di una Sensitive.
|
|
15
15
|
const SENSITIVE = '[SENSITIVE]';
|
|
16
|
+
const WITHOUT_VALUE = 'il deploy andrebbe online senza il suo valore';
|
|
16
17
|
const dictionaries = () => existsSync(DICTIONARIES)
|
|
17
18
|
? readdirSync(DICTIONARIES)
|
|
18
19
|
.filter((name) => name.endsWith('.ts'))
|
|
@@ -56,7 +57,7 @@ export function envFindings(content, keys) {
|
|
|
56
57
|
const value = env[key]?.trim();
|
|
57
58
|
const declaration = lines.find(({ text }) => new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`).test(text));
|
|
58
59
|
if (value === undefined || declaration === undefined) {
|
|
59
|
-
const message = `${key} non c'è fra le variabili di produzione su Vercel:
|
|
60
|
+
const message = `${key} non c'è fra le variabili di produzione su Vercel: ${WITHOUT_VALUE}`;
|
|
60
61
|
return [{ severity: 'error', message }];
|
|
61
62
|
}
|
|
62
63
|
const hit = (severity, message) => [
|
|
@@ -65,7 +66,7 @@ export function envFindings(content, keys) {
|
|
|
65
66
|
if (value === SENSITIVE)
|
|
66
67
|
return hit('warning', 'è Sensitive su Vercel: vercel pull non ne scarica il valore, e il deploy non lo può verificare');
|
|
67
68
|
if (value === '')
|
|
68
|
-
return hit('error',
|
|
69
|
+
return hit('error', `è vuota: ${WITHOUT_VALUE}`);
|
|
69
70
|
if (TOKEN.test(value) || DOMAIN.test(value))
|
|
70
71
|
return hit('error', 'porta un segnaposto del template');
|
|
71
72
|
return [];
|
package/dist/lib/check-routes.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { blankFences, blankSpans, linkDestinations } from './markdown.js';
|
|
2
4
|
const SCANNED = /\.md$/;
|
|
5
|
+
const ROADMAP = 'docs/ROADMAP.md';
|
|
3
6
|
const IGNORED = [
|
|
4
7
|
// Scritti a runtime dai comandi, gitignored per costruzione.
|
|
5
8
|
/^\.claude\/plans\//,
|
|
@@ -7,9 +10,39 @@ const IGNORED = [
|
|
|
7
10
|
/\.pdf$/,
|
|
8
11
|
/^docs\/sources\//,
|
|
9
12
|
];
|
|
10
|
-
const PATH_REFERENCE = /`((?:docs|src|scripts|test|public|\.claude|\.github)\/[A-Za-z0-9._/-]+\.[a-z]+)`/g;
|
|
13
|
+
const PATH_REFERENCE = /`((?:docs|src|scripts|test|e2e|public|\.claude|\.github)\/[A-Za-z0-9._/-]+\.[a-z]+)`/g;
|
|
14
|
+
const NAME_REFERENCE = /`([A-Za-z0-9._-]+)`/g;
|
|
11
15
|
// La virgola non delimita: i titoli la contengono.
|
|
12
16
|
const SECTION_REFERENCE = /`([A-Za-z0-9._/-]+\.md)`\s*§\s*([^.·|)§]{1,80})/g;
|
|
17
|
+
const LINK_TEXT = /\[[^[\]]*\]\(/g;
|
|
18
|
+
const ROOT_DOCUMENT = /^[A-Z][A-Z0-9_-]*\.md$/;
|
|
19
|
+
// I file tracciati alla radice di template/vetrina (git ls-files), Markdown esclusi.
|
|
20
|
+
const ROOT_CONFIG = new Set([
|
|
21
|
+
'.editorconfig',
|
|
22
|
+
'.env.example',
|
|
23
|
+
'.fallowrc.jsonc',
|
|
24
|
+
'.gitignore',
|
|
25
|
+
'.lighthouserc.json',
|
|
26
|
+
'.npmrc',
|
|
27
|
+
'.nvmrc',
|
|
28
|
+
'.release-please-manifest.json',
|
|
29
|
+
'astro.config.mjs',
|
|
30
|
+
'biome.json',
|
|
31
|
+
'commitlint.config.js',
|
|
32
|
+
'lefthook.yml',
|
|
33
|
+
'officina.config.ts',
|
|
34
|
+
'package.json',
|
|
35
|
+
'playwright.config.ts',
|
|
36
|
+
'pnpm-lock.yaml',
|
|
37
|
+
'pnpm-workspace.yaml',
|
|
38
|
+
'release-please-config.json',
|
|
39
|
+
'tsconfig.json',
|
|
40
|
+
'vercel.json',
|
|
41
|
+
'vitest.config.ts',
|
|
42
|
+
]);
|
|
43
|
+
const CHANGELOG = /(?:^|\/)CHANGELOG\.md$/;
|
|
44
|
+
const SITE_CONTENT = /^src\/content\//;
|
|
45
|
+
const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|#|\/)/i;
|
|
13
46
|
const isIgnored = (path) => IGNORED.some((re) => re.test(path));
|
|
14
47
|
export const isScanned = (path) => SCANNED.test(path) && !isIgnored(path);
|
|
15
48
|
export function normalizeTitle(raw) {
|
|
@@ -32,23 +65,37 @@ const resolves = (titles, cited) => titles.some((title) => cited === title || (c
|
|
|
32
65
|
export function resolveTarget(from, cited) {
|
|
33
66
|
if (cited.includes('/'))
|
|
34
67
|
return cited;
|
|
35
|
-
const
|
|
36
|
-
return
|
|
68
|
+
const candidates = [`${from.slice(0, from.lastIndexOf('/') + 1)}${cited}`, cited, `docs/${cited}`];
|
|
69
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? cited;
|
|
37
70
|
}
|
|
38
|
-
|
|
71
|
+
const references = (text, pattern) => [...text.matchAll(pattern)]
|
|
72
|
+
.map((match) => ({ index: match.index, target: match[1] }))
|
|
73
|
+
.filter((reference) => reference.target !== undefined);
|
|
74
|
+
const pathReferences = (flat) => references(flat, PATH_REFERENCE).filter(({ target }) => !target.includes('*'));
|
|
75
|
+
function nameReferences(path, flat) {
|
|
76
|
+
if (path === ROADMAP || CHANGELOG.test(path) || SITE_CONTENT.test(path))
|
|
77
|
+
return [];
|
|
78
|
+
const labels = [...flat.matchAll(LINK_TEXT)].map(({ index, 0: text }) => ({ start: index, end: index + text.length }));
|
|
79
|
+
const inLabel = (index) => labels.some(({ start, end }) => index > start && index < end);
|
|
80
|
+
return references(flat, NAME_REFERENCE)
|
|
81
|
+
.filter(({ index, target }) => (ROOT_DOCUMENT.test(target) || ROOT_CONFIG.has(target)) && !inLabel(index))
|
|
82
|
+
.map(({ index, target }) => ({ index, target: resolveTarget(path, target) }));
|
|
83
|
+
}
|
|
84
|
+
const linkTarget = (from, destination) => EXTERNAL.test(destination)
|
|
85
|
+
? undefined
|
|
86
|
+
: posix.normalize(posix.join(posix.dirname(from), destination.replace(/[#?].*$/, '')));
|
|
87
|
+
function linkReferences(path, code) {
|
|
88
|
+
if (SITE_CONTENT.test(path))
|
|
89
|
+
return [];
|
|
90
|
+
return linkDestinations(code).flatMap(({ index, destination }) => {
|
|
91
|
+
const target = linkTarget(path, destination);
|
|
92
|
+
return target === undefined ? [] : [{ index, target }];
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const escapes = (target) => target === '..' || target.startsWith('../');
|
|
96
|
+
const isMissing = (target) => !isIgnored(target) && (escapes(target) || !existsSync(target));
|
|
97
|
+
function sectionFindings(path, flat, lineOf, missing) {
|
|
39
98
|
const findings = [];
|
|
40
|
-
// Un titolo va a capo come qualunque prosa: si cerca su testo continuo. La sostituzione è uno a
|
|
41
|
-
// uno, quindi gli offset restano quelli del sorgente e la riga si conta da lì.
|
|
42
|
-
const flat = source.replace(/\n/g, ' ');
|
|
43
|
-
const lineOf = (index) => source.slice(0, index).split('\n').length;
|
|
44
|
-
const missingTargets = new Set();
|
|
45
|
-
for (const match of flat.matchAll(PATH_REFERENCE)) {
|
|
46
|
-
const target = match[1];
|
|
47
|
-
if (!target || isIgnored(target) || target.includes('*') || existsSync(target))
|
|
48
|
-
continue;
|
|
49
|
-
missingTargets.add(target);
|
|
50
|
-
findings.push({ line: lineOf(match.index), message: `percorso che non esiste: ${target}` });
|
|
51
|
-
}
|
|
52
99
|
for (const match of flat.matchAll(SECTION_REFERENCE)) {
|
|
53
100
|
const [, file, title] = match;
|
|
54
101
|
/* v8 ignore next -- i due gruppi sono obbligatori nella regex, ma noUncheckedIndexedAccess pretende la guardia */
|
|
@@ -56,7 +103,7 @@ export function findingsFor(path, source) {
|
|
|
56
103
|
continue;
|
|
57
104
|
const line = lineOf(match.index);
|
|
58
105
|
const target = resolveTarget(path, file);
|
|
59
|
-
if (isIgnored(target) ||
|
|
106
|
+
if (isIgnored(target) || missing.has(`${line}:${target}`))
|
|
60
107
|
continue;
|
|
61
108
|
if (!existsSync(target)) {
|
|
62
109
|
findings.push({ line, message: `sezione in un file che non esiste: ${target}` });
|
|
@@ -66,5 +113,27 @@ export function findingsFor(path, source) {
|
|
|
66
113
|
if (!resolves(headings(readFileSync(target, 'utf8')), cited))
|
|
67
114
|
findings.push({ line, message: `sezione che non esiste: ${target} § ${cited}` });
|
|
68
115
|
}
|
|
69
|
-
return findings
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
118
|
+
export function findingsFor(path, source) {
|
|
119
|
+
const fenced = blankFences(source);
|
|
120
|
+
// Un titolo va a capo come qualunque prosa: si cerca su testo continuo. La sostituzione è uno a
|
|
121
|
+
// uno, quindi gli offset restano quelli del sorgente e la riga si conta da lì.
|
|
122
|
+
const flat = fenced.replace(/\n/g, ' ');
|
|
123
|
+
const lineOf = (index) => source.slice(0, index).split('\n').length;
|
|
124
|
+
const missing = new Map();
|
|
125
|
+
for (const { index, target } of [
|
|
126
|
+
...pathReferences(flat),
|
|
127
|
+
...nameReferences(path, flat),
|
|
128
|
+
...linkReferences(path, blankSpans(fenced)),
|
|
129
|
+
]) {
|
|
130
|
+
const line = lineOf(index);
|
|
131
|
+
if (isMissing(target))
|
|
132
|
+
missing.set(`${line}:${target}`, { line, target });
|
|
133
|
+
}
|
|
134
|
+
const paths = [...missing.values()].map(({ line, target }) => ({
|
|
135
|
+
line,
|
|
136
|
+
message: `percorso che non esiste: ${target}`,
|
|
137
|
+
}));
|
|
138
|
+
return [...paths, ...sectionFindings(path, flat, lineOf, new Set(missing.keys()))].sort((a, b) => a.line - b.line);
|
|
70
139
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type OfficinaConfig } from './config.ts';
|
|
2
2
|
import type { ContractGap } from './contract.ts';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export declare function configGaps(loaded: OfficinaConfig | Error | undefined,
|
|
3
|
+
export type RouteGaps = {
|
|
4
|
+
missingRepresentatives?: readonly string[];
|
|
5
|
+
unknownDisabled?: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
export declare function configGaps(loaded: OfficinaConfig | Error | undefined, { missingRepresentatives, unknownDisabled }?: RouteGaps): ContractGap[];
|
package/dist/lib/config-gaps.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FEATURES } from './config.js';
|
|
2
2
|
const CONFIG = 'officina.config.ts';
|
|
3
|
+
const asGap = (message) => ({ path: CONFIG, message });
|
|
3
4
|
function siteUrlGap(siteUrl) {
|
|
4
5
|
if (siteUrl === undefined)
|
|
5
6
|
return '`siteUrl` assente: `check smoke` non ha un host canonico';
|
|
@@ -7,14 +8,16 @@ function siteUrlGap(siteUrl) {
|
|
|
7
8
|
return `\`siteUrl\` non è un URL: \`${siteUrl}\``;
|
|
8
9
|
return siteUrl.endsWith('/') ? `\`siteUrl\` finisce con una barra: \`${siteUrl}\`` : undefined;
|
|
9
10
|
}
|
|
11
|
+
// TODO: dalla 0.12.0 `links` è un errore di doctor: toglila da NEW_FEATURES.
|
|
12
|
+
const NEW_FEATURES = new Set(['links']);
|
|
10
13
|
// Il silenzio vale `'required'`: un controllo si spegne dichiarandolo, non dimenticandolo.
|
|
11
|
-
const featureGaps = (config) => FEATURES.filter((name) => config.features?.[name] === undefined).map((name) =>
|
|
14
|
+
const featureGaps = (config) => FEATURES.filter((name) => config.features?.[name] === undefined).map((name) => ({
|
|
15
|
+
...asGap(`\`features.${name}\` non dichiarata: vale \`'required'\`, e il gate fallisce se non trova cosa controllare`),
|
|
16
|
+
...(NEW_FEATURES.has(name) ? { severity: 'warning' } : {}),
|
|
17
|
+
}));
|
|
12
18
|
const representativeGaps = (patterns) => patterns.map((label) => `\`routes.representatives\` non ha un percorso per \`${label}\`: smoke e Lighthouse non guardano quella pagina`);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* il file non c'è; `patterns` i pattern dinamici di `src/pages` rimasti senza rappresentante.
|
|
16
|
-
*/
|
|
17
|
-
export function configGaps(loaded, patterns = []) {
|
|
19
|
+
const disabledGaps = (labels) => labels.map((label) => `\`routes.disabled\` nomina \`${label}\`, che non è una pagina dinamica prerenderizzata di \`src/pages\`: non spegne niente`);
|
|
20
|
+
export function configGaps(loaded, { missingRepresentatives = [], unknownDisabled = [] } = {}) {
|
|
18
21
|
if (loaded === undefined)
|
|
19
22
|
return [{ path: CONFIG, message: 'manca: i valori del progetto per officina stanno qui' }];
|
|
20
23
|
if (loaded instanceof Error)
|
|
@@ -22,8 +25,11 @@ export function configGaps(loaded, patterns = []) {
|
|
|
22
25
|
const messages = [
|
|
23
26
|
siteUrlGap(loaded.siteUrl),
|
|
24
27
|
loaded.icons === undefined ? '`icons.background` assente: `gen icons` non ha un colore di fondo' : undefined,
|
|
28
|
+
].filter((message) => message !== undefined);
|
|
29
|
+
return [
|
|
30
|
+
...messages.map(asGap),
|
|
25
31
|
...featureGaps(loaded),
|
|
26
|
-
...representativeGaps(
|
|
32
|
+
...representativeGaps(missingRepresentatives).map(asGap),
|
|
33
|
+
...disabledGaps(unknownDisabled).map(asGap),
|
|
27
34
|
];
|
|
28
|
-
return messages.filter((message) => message !== undefined).map((message) => ({ path: CONFIG, message }));
|
|
29
35
|
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -28,17 +28,20 @@ export interface OfficinaConfig {
|
|
|
28
28
|
roadmap?: FeatureSetting;
|
|
29
29
|
generators?: GeneratorsSetting;
|
|
30
30
|
botId?: FeatureSetting;
|
|
31
|
+
links?: FeatureSetting;
|
|
31
32
|
};
|
|
32
33
|
placeholders?: {
|
|
33
34
|
sources?: readonly string[];
|
|
34
35
|
contactEnvKeys?: readonly string[];
|
|
35
36
|
};
|
|
36
|
-
/** Per ogni pattern dinamico di `src/pages`, un percorso vero che smoke e Lighthouse visitano. */
|
|
37
37
|
routes?: {
|
|
38
|
+
/** Per ogni pattern dinamico di `src/pages`, un percorso vero che smoke e Lighthouse visitano. */
|
|
38
39
|
representatives?: Representatives;
|
|
40
|
+
/** I pattern dinamici di `src/pages` spenti per scelta: non emettono pagine, e nessun gate le pretende. */
|
|
41
|
+
disabled?: readonly string[];
|
|
39
42
|
};
|
|
40
43
|
}
|
|
41
|
-
export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators', 'botId'];
|
|
44
|
+
export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators', 'botId', 'links'];
|
|
42
45
|
export type FeatureName = (typeof FEATURES)[number];
|
|
43
46
|
/** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
|
|
44
47
|
export declare const isRequired: (config: OfficinaConfig, name: FeatureName) => boolean;
|
package/dist/lib/config.js
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync } from 'node:fs';
|
|
|
10
10
|
import { basename, join } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { aFunction, aNumber, anArrayOf, aRecordOf, aShape, aString, aStringOrNull, oneOf } from './validate.js';
|
|
13
|
-
export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId'];
|
|
13
|
+
export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId', 'links'];
|
|
14
14
|
/** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
|
|
15
15
|
export const isRequired = (config, name) => (config.features?.[name] ?? 'required') === 'required';
|
|
16
16
|
export const generatorsSetting = (config) => config.features?.generators ?? 'required';
|
|
@@ -28,9 +28,9 @@ const SHAPE = aShape({
|
|
|
28
28
|
checks: anArrayOf(aFunction),
|
|
29
29
|
}),
|
|
30
30
|
analytics: aShape({ linkTracking: aString }),
|
|
31
|
-
features: aShape({ analytics: feature, roadmap: feature, generators, botId: feature }),
|
|
31
|
+
features: aShape({ analytics: feature, roadmap: feature, generators, botId: feature, links: feature }),
|
|
32
32
|
placeholders: aShape({ sources: anArrayOf(aString), contactEnvKeys: anArrayOf(aString) }),
|
|
33
|
-
routes: aShape({ representatives: aRecordOf(aString) }),
|
|
33
|
+
routes: aShape({ representatives: aRecordOf(aString), disabled: anArrayOf(aString) }),
|
|
34
34
|
});
|
|
35
35
|
const CONFIG_FILES = ['officina.config.ts', 'officina.config.mjs', 'officina.config.js'];
|
|
36
36
|
export const defineConfig = (config) => config;
|
package/dist/lib/contract.d.ts
CHANGED
package/dist/lib/dependabot.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { parse } from 'yaml';
|
|
2
2
|
const DEPENDABOT = '.github/dependabot.yml';
|
|
3
3
|
const PACKAGE = '@elia97/officina';
|
|
4
|
-
const ACTIONS = 'Elia97/officina
|
|
4
|
+
const ACTIONS = 'Elia97/officina';
|
|
5
|
+
const PATH_FILTER = `${ACTIONS}/*`;
|
|
5
6
|
const GROUP_ONLY = ['commit-message', 'open-pull-requests-limit'];
|
|
6
7
|
const gap = (message) => ({ path: DEPENDABOT, message });
|
|
7
8
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -32,6 +33,16 @@ const coverageGaps = (updates) => [
|
|
|
32
33
|
]
|
|
33
34
|
.filter(({ ecosystem, needle }) => !inGroup(updates).some((u) => u['package-ecosystem'] === ecosystem && covers(u, needle)))
|
|
34
35
|
.map(({ needle, reason }) => gap(`nessuna voce del gruppo copre \`${needle}\`: ${reason}`));
|
|
36
|
+
const strings = (value) => {
|
|
37
|
+
if (typeof value === 'string')
|
|
38
|
+
return [value];
|
|
39
|
+
return typeof value === 'object' && value !== null ? Object.values(value).flatMap(strings) : [];
|
|
40
|
+
};
|
|
41
|
+
const pathFilterGaps = (document) => strings(document).includes(PATH_FILTER)
|
|
42
|
+
? [
|
|
43
|
+
gap(`\`${PATH_FILTER}\` non corrisponde a nessuna dipendenza: Dependabot chiama le action \`${ACTIONS}\`, senza la sottocartella, e il filtro va corretto ovunque compaia`),
|
|
44
|
+
]
|
|
45
|
+
: [];
|
|
35
46
|
// `patterns` restringe l'intera voce: il resto dell'ecosistema smette di aggiornarsi e nessun check diventa rosso.
|
|
36
47
|
function restGaps(updates) {
|
|
37
48
|
const restrained = inGroup(updates).filter((update) => (update.patterns ?? []).length > 0);
|
|
@@ -55,5 +66,11 @@ export function dependabotGaps({ read: readFile }) {
|
|
|
55
66
|
if (typeof document === 'string')
|
|
56
67
|
return [gap(document)];
|
|
57
68
|
const updates = document.updates ?? [];
|
|
58
|
-
return [
|
|
69
|
+
return [
|
|
70
|
+
...groupGaps(document, updates),
|
|
71
|
+
...misplacedGaps(updates),
|
|
72
|
+
...coverageGaps(updates),
|
|
73
|
+
...pathFilterGaps(document),
|
|
74
|
+
...restGaps(updates),
|
|
75
|
+
];
|
|
59
76
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type LinkTarget = {
|
|
2
|
+
kind: 'placeholder' | 'anchor' | 'scheme' | 'external';
|
|
3
|
+
} | {
|
|
4
|
+
kind: 'internal';
|
|
5
|
+
path: string;
|
|
6
|
+
};
|
|
7
|
+
export type PageLinks = {
|
|
8
|
+
route: string;
|
|
9
|
+
hrefs: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
export type LinkReport = {
|
|
12
|
+
broken: {
|
|
13
|
+
destination: string;
|
|
14
|
+
pages: string[];
|
|
15
|
+
}[];
|
|
16
|
+
internal: number;
|
|
17
|
+
onDemand: number;
|
|
18
|
+
outside: {
|
|
19
|
+
external: number;
|
|
20
|
+
scheme: number;
|
|
21
|
+
anchor: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export declare function htmlLinks(html: string): string[];
|
|
25
|
+
export declare const siteOrigin: (siteUrl: string | undefined) => string;
|
|
26
|
+
export declare function linkTarget(href: string, page: URL): LinkTarget;
|
|
27
|
+
export declare function servedPaths(files: readonly string[]): Set<string>;
|
|
28
|
+
export declare function servedBy(path: string, served: ReadonlySet<string>, onDemand: readonly RegExp[]): 'build' | 'on-demand' | undefined;
|
|
29
|
+
export declare function linkReport(pages: readonly PageLinks[], origin: string, served: ReadonlySet<string>, onDemand: readonly RegExp[]): LinkReport;
|
|
30
|
+
export declare const brokenLines: ({ broken }: LinkReport) => string[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { listed } from './routes.js';
|
|
2
|
+
const VALUE = String.raw `(?:"[^"]*"|'[^']*'|[^\s"'>]+)`;
|
|
3
|
+
const ATTRIBUTES = String.raw `(?:\s+[^\s"'>/=]+(?:\s*=\s*${VALUE})?)*`;
|
|
4
|
+
// Astro conserva i commenti HTML, e nei valori dinamici degli attributi scappa solo `&` e `"`.
|
|
5
|
+
const MARKUP = new RegExp(String.raw `<!--[\s\S]*?-->|<(script|style)\b${ATTRIBUTES}\s*\/?>[\s\S]*?<\/\1\s*>|<a(${ATTRIBUTES})\s*\/?>|<[a-z][^\s/>]*${ATTRIBUTES}\s*\/?>`, 'gi');
|
|
6
|
+
const ATTRIBUTE = /\s+([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
7
|
+
const ENTITY = /&(?:#(\d{1,6})|#x([\da-f]{1,5})|(amp|lt|gt|quot|apos));/gi;
|
|
8
|
+
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
9
|
+
const SCHEME = /^[a-z][a-z\d+.-]*:/i;
|
|
10
|
+
const WEB = /^https?:/i;
|
|
11
|
+
const NO_SITE = 'https://officina.invalid';
|
|
12
|
+
const decodeEntities = (value) => value.replace(ENTITY, (_, decimal, hex, name) => {
|
|
13
|
+
if (decimal !== undefined)
|
|
14
|
+
return String.fromCodePoint(Number(decimal));
|
|
15
|
+
if (hex !== undefined)
|
|
16
|
+
return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
17
|
+
return NAMED[name.toLowerCase()];
|
|
18
|
+
});
|
|
19
|
+
function hrefOf(attributes) {
|
|
20
|
+
for (const [, name, double, single, bare] of attributes.matchAll(ATTRIBUTE)) {
|
|
21
|
+
if (name.toLowerCase() === 'href')
|
|
22
|
+
return decodeEntities(double ?? single ?? bare ?? '');
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
export function htmlLinks(html) {
|
|
27
|
+
return [...html.matchAll(MARKUP)]
|
|
28
|
+
.map(([, , attributes]) => (attributes === undefined ? undefined : hrefOf(attributes)))
|
|
29
|
+
.filter((href) => href !== undefined);
|
|
30
|
+
}
|
|
31
|
+
export const siteOrigin = (siteUrl) => siteUrl !== undefined && URL.canParse(siteUrl) ? new URL(siteUrl).origin : NO_SITE;
|
|
32
|
+
function decoded(path) {
|
|
33
|
+
try {
|
|
34
|
+
return decodeURIComponent(path);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function linkTarget(href, page) {
|
|
41
|
+
const value = href.trim();
|
|
42
|
+
if (value === '' || value === '#')
|
|
43
|
+
return { kind: 'placeholder' };
|
|
44
|
+
if (value.startsWith('#'))
|
|
45
|
+
return { kind: 'anchor' };
|
|
46
|
+
if (SCHEME.test(value) && !WEB.test(value))
|
|
47
|
+
return { kind: 'scheme' };
|
|
48
|
+
if (!URL.canParse(value, page))
|
|
49
|
+
return { kind: 'internal', path: value };
|
|
50
|
+
const url = new URL(value, page);
|
|
51
|
+
return url.origin === page.origin ? { kind: 'internal', path: decoded(url.pathname) } : { kind: 'external' };
|
|
52
|
+
}
|
|
53
|
+
export function servedPaths(files) {
|
|
54
|
+
const served = new Set();
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
served.add(file);
|
|
57
|
+
if (file.endsWith('/index.html'))
|
|
58
|
+
served.add(file.slice(0, -'/index.html'.length) || '/');
|
|
59
|
+
else if (file.endsWith('.html'))
|
|
60
|
+
served.add(file.slice(0, -'.html'.length));
|
|
61
|
+
}
|
|
62
|
+
return served;
|
|
63
|
+
}
|
|
64
|
+
const withoutTrailingSlash = (path) => (path.length > 1 ? path.replace(/\/+$/, '') : path);
|
|
65
|
+
export function servedBy(path, served, onDemand) {
|
|
66
|
+
const bare = withoutTrailingSlash(path);
|
|
67
|
+
if (served.has(path) || served.has(bare))
|
|
68
|
+
return 'build';
|
|
69
|
+
return onDemand.some((pattern) => pattern.test(bare)) ? 'on-demand' : undefined;
|
|
70
|
+
}
|
|
71
|
+
const placeholderLabel = (href) => `segnaposto href="${href.trim()}"`;
|
|
72
|
+
export function linkReport(pages, origin, served, onDemand) {
|
|
73
|
+
const citing = new Map();
|
|
74
|
+
const report = { broken: [], internal: 0, onDemand: 0, outside: { external: 0, scheme: 0, anchor: 0 } };
|
|
75
|
+
const cite = (destination, route) => citing.set(destination, (citing.get(destination) ?? new Set()).add(route));
|
|
76
|
+
const checkInternal = (path, route) => {
|
|
77
|
+
report.internal++;
|
|
78
|
+
const by = servedBy(path, served, onDemand);
|
|
79
|
+
if (by === undefined)
|
|
80
|
+
cite(withoutTrailingSlash(path), route);
|
|
81
|
+
if (by === 'on-demand')
|
|
82
|
+
report.onDemand++;
|
|
83
|
+
};
|
|
84
|
+
for (const { route, hrefs } of pages) {
|
|
85
|
+
const page = new URL(route, origin);
|
|
86
|
+
for (const href of hrefs) {
|
|
87
|
+
const target = linkTarget(href, page);
|
|
88
|
+
if (target.kind === 'placeholder')
|
|
89
|
+
cite(placeholderLabel(href), route);
|
|
90
|
+
else if (target.kind === 'internal')
|
|
91
|
+
checkInternal(target.path, route);
|
|
92
|
+
else
|
|
93
|
+
report.outside[target.kind]++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
report.broken = [...citing]
|
|
97
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
98
|
+
.map(([destination, routes]) => ({ destination, pages: [...routes].sort() }));
|
|
99
|
+
return report;
|
|
100
|
+
}
|
|
101
|
+
export const brokenLines = ({ broken }) => broken.map(({ destination, pages }) => `${destination} ← ${listed(pages)}`);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface Destination {
|
|
2
|
+
index: number;
|
|
3
|
+
destination: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function blankFences(source: string): string;
|
|
6
|
+
export declare const blankSpans: (text: string) => string;
|
|
7
|
+
export declare const linkDestinations: (text: string) => Destination[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// CommonMark § 4.5: recinto di tre o più ` o ~ (rientro fino a 3 spazi), chiuso da una riga dello stesso carattere,
|
|
2
|
+
// lunga almeno quanto l'apertura.
|
|
3
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
4
|
+
// CommonMark § 6.1: lo span si chiude alla prima stringa di apici inversi di pari lunghezza; una riga vuota lo interrompe.
|
|
5
|
+
const SPAN = /(?<!`)(`+)(?!`)((?:(?!\n[ \t]*\n)[\s\S])*?)(?<!`)\1(?!`)/g;
|
|
6
|
+
// CommonMark § 6.3: destinazione fra <> o senza spazi (parentesi bilanciate, qui un solo livello); titolo fra "", '' o ().
|
|
7
|
+
const INLINE = /\]\(\s*(?:<([^>\n]*)>|((?:[^()\s\\]|\\.|\([^()\s]*\))*))(?:\s+(?:"[^"]*"|'[^']*'|\([^()]*\)))?\s*\)/g;
|
|
8
|
+
const DEFINITION = /^ {0,3}\[[^\]\n]+\]:[ \t]*(?:<([^>\n]*)>|(\S+))/gm;
|
|
9
|
+
const spaces = (text) => text.replace(/[^\n]/g, ' ');
|
|
10
|
+
export function blankFences(source) {
|
|
11
|
+
let fence;
|
|
12
|
+
return source
|
|
13
|
+
.split('\n')
|
|
14
|
+
.map((line) => {
|
|
15
|
+
const run = FENCE.exec(line)?.[1];
|
|
16
|
+
if (fence === undefined) {
|
|
17
|
+
if (run === undefined)
|
|
18
|
+
return line;
|
|
19
|
+
fence = run;
|
|
20
|
+
}
|
|
21
|
+
else if (run !== undefined && run[0] === fence[0] && run.length >= fence.length && line.trim() === run) {
|
|
22
|
+
fence = undefined;
|
|
23
|
+
}
|
|
24
|
+
return spaces(line);
|
|
25
|
+
})
|
|
26
|
+
.join('\n');
|
|
27
|
+
}
|
|
28
|
+
export const blankSpans = (text) => text.replace(SPAN, spaces);
|
|
29
|
+
const unescaped = (text) => text.replace(/\\(.)/g, '$1');
|
|
30
|
+
function decoded(text) {
|
|
31
|
+
try {
|
|
32
|
+
return decodeURIComponent(text);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export const linkDestinations = (text) => [...text.matchAll(INLINE), ...text.matchAll(DEFINITION)].map((match) => ({
|
|
39
|
+
index: match.index,
|
|
40
|
+
destination: decoded(unescaped((match[1] ?? match[2]))),
|
|
41
|
+
}));
|
package/dist/lib/past-tense.js
CHANGED
|
@@ -13,7 +13,7 @@ export const PAST_TENSE = [
|
|
|
13
13
|
// "non più di 3" è una quantità, non un cambiamento.
|
|
14
14
|
phrase(['non (?:è |viene |serve )?più(?! di)']),
|
|
15
15
|
phrase(["ora (?:è|invece|lo|la|il|si|c'è|non)", 'adesso', 'per ora', 'temporaneamente']),
|
|
16
|
-
phrase(['sostituisce', '
|
|
16
|
+
phrase(['sostituisce', 'vecchi[oa]']),
|
|
17
17
|
phrase(['used to', 'previously', 'no longer', 'replaces the', 'replaced by', 'removed in', 'for now', 'temporarily']),
|
|
18
18
|
/#\d{1,3}(?!\d)/,
|
|
19
19
|
];
|
package/dist/lib/routes.d.ts
CHANGED
|
@@ -2,16 +2,18 @@ export type PageFile = {
|
|
|
2
2
|
file: string;
|
|
3
3
|
source: string;
|
|
4
4
|
};
|
|
5
|
+
type DynamicRoute = {
|
|
6
|
+
pattern: RegExp;
|
|
7
|
+
label: string;
|
|
8
|
+
file: string;
|
|
9
|
+
};
|
|
5
10
|
export type Expectations = {
|
|
6
11
|
exact: {
|
|
7
12
|
route: string;
|
|
8
13
|
file: string;
|
|
9
14
|
}[];
|
|
10
|
-
patterns:
|
|
11
|
-
|
|
12
|
-
label: string;
|
|
13
|
-
file: string;
|
|
14
|
-
}[];
|
|
15
|
+
patterns: DynamicRoute[];
|
|
16
|
+
disabled: DynamicRoute[];
|
|
15
17
|
ssr: string[];
|
|
16
18
|
};
|
|
17
19
|
export type VerifiedRoute = {
|
|
@@ -25,11 +27,21 @@ export declare const NON_HTML_ROUTES: readonly VerifiedRoute[];
|
|
|
25
27
|
export declare function filesWithExtension(dir: string, extension: string): string[];
|
|
26
28
|
export declare function readPageFiles(pagesDir: string): PageFile[];
|
|
27
29
|
export declare function routeOf(htmlPath: string, dist: string): string;
|
|
28
|
-
export declare function
|
|
30
|
+
export declare function pageRouteOf(file: string, pagesDir: string): string;
|
|
31
|
+
export declare function routePattern(route: string): RegExp;
|
|
32
|
+
export declare function expectedRoutes(pages: readonly PageFile[], pagesDir: string, disabled?: readonly string[]): Expectations;
|
|
33
|
+
export type EmittedRoutes = {
|
|
34
|
+
html: readonly string[];
|
|
35
|
+
ssr: readonly string[];
|
|
36
|
+
};
|
|
29
37
|
/** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
|
|
30
|
-
export declare function missingRouteFailures(expected: Expectations, emitted:
|
|
38
|
+
export declare function missingRouteFailures(expected: Expectations, emitted: EmittedRoutes, dist: string): string[];
|
|
39
|
+
export declare const listed: (routes: readonly string[]) => string;
|
|
40
|
+
export declare function disabledRouteFailures(expected: Expectations, { html, ssr }: EmittedRoutes): string[];
|
|
41
|
+
export declare function unknownDisabled(expected: Expectations, disabled?: readonly string[]): string[];
|
|
31
42
|
/** I pattern dinamici che `officina.config.ts` non ha ancora mandato nessuno a visitare. */
|
|
32
43
|
export declare function missingRepresentatives(expected: Expectations, representatives?: Representatives): string[];
|
|
33
44
|
export declare function auditRoutes(expected: Expectations, representatives?: Representatives): string[];
|
|
34
45
|
export declare function smokeRoutes(expected: Expectations, nonHtmlRoutes?: readonly VerifiedRoute[], representatives?: Representatives): VerifiedRoute[];
|
|
35
46
|
export declare function orphanExceptions(expected: Expectations): string[];
|
|
47
|
+
export {};
|