@elia97/officina 0.1.0 → 0.3.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 (96) hide show
  1. package/README.md +57 -3
  2. package/dist/bin.d.ts +8 -0
  3. package/dist/bin.js +28 -6
  4. package/dist/checks/analytics.d.ts +2 -0
  5. package/dist/checks/analytics.js +48 -0
  6. package/dist/checks/bundle.d.ts +2 -0
  7. package/dist/checks/bundle.js +98 -0
  8. package/dist/checks/comments.d.ts +2 -0
  9. package/dist/checks/doctor.d.ts +2 -0
  10. package/dist/checks/doctor.js +15 -0
  11. package/dist/checks/language.d.ts +2 -0
  12. package/dist/checks/lighthouse.d.ts +2 -0
  13. package/dist/checks/lighthouse.js +37 -0
  14. package/dist/checks/placeholders.d.ts +2 -0
  15. package/dist/checks/roadmap.d.ts +2 -0
  16. package/dist/checks/routes.d.ts +2 -0
  17. package/dist/checks/smoke.d.ts +2 -0
  18. package/dist/checks/smoke.js +57 -0
  19. package/dist/checks/vercel-cli.d.ts +2 -0
  20. package/dist/gen/collection.d.mts +1 -0
  21. package/dist/gen/collection.mjs +76 -0
  22. package/dist/gen/component.d.mts +1 -0
  23. package/dist/gen/component.mjs +41 -0
  24. package/dist/gen/icons.d.ts +2 -0
  25. package/dist/gen/icons.js +27 -0
  26. package/dist/gen/identifier.d.mts +1 -0
  27. package/dist/gen/identifier.mjs +52 -0
  28. package/dist/gen/inject-config.d.mts +12 -0
  29. package/dist/gen/inject-config.mjs +73 -0
  30. package/dist/gen/inject-section.d.mts +16 -0
  31. package/dist/gen/inject-section.mjs +140 -0
  32. package/dist/gen/inject-strings.d.mts +8 -0
  33. package/dist/gen/inject-strings.mjs +56 -0
  34. package/dist/gen/page.d.mts +10 -0
  35. package/dist/gen/page.mjs +68 -0
  36. package/dist/gen/plopfile.d.mts +2 -0
  37. package/dist/gen/plopfile.mjs +30 -0
  38. package/dist/gen/post-gen.d.mts +1 -0
  39. package/dist/gen/post-gen.mjs +16 -0
  40. package/dist/gen/run.d.ts +2 -0
  41. package/dist/gen/run.js +17 -0
  42. package/dist/gen/section-targets.d.mts +23 -0
  43. package/dist/gen/section-targets.mjs +46 -0
  44. package/dist/gen/section.d.mts +1 -0
  45. package/dist/gen/section.mjs +68 -0
  46. package/dist/gen/sectioned-collection.d.mts +26 -0
  47. package/dist/gen/sectioned-collection.mjs +81 -0
  48. package/dist/gen/templates/collection/data.yml.hbs +2 -0
  49. package/dist/gen/templates/collection/doc.md.hbs +8 -0
  50. package/dist/gen/templates/collection/schema.ts.hbs +11 -0
  51. package/dist/gen/templates/component/component.astro.hbs +21 -0
  52. package/dist/gen/templates/page/dynamic.astro.hbs +24 -0
  53. package/dist/gen/templates/page/static.astro.hbs +20 -0
  54. package/dist/gen/templates/section/component.astro.hbs +45 -0
  55. package/dist/gen/templates/section/content.yml.hbs +10 -0
  56. package/dist/gen/templates/section/schema.ts.hbs +20 -0
  57. package/dist/gen/templates/sections/data-layer.ts.hbs +13 -0
  58. package/dist/gen/templates/sections/index.ts.hbs +12 -0
  59. package/dist/gen/templates/sections/page.astro.hbs +30 -0
  60. package/dist/gen/ts-morph-utils.d.mts +1 -0
  61. package/dist/gen/ts-morph-utils.mjs +8 -0
  62. package/dist/index.d.ts +10 -0
  63. package/dist/index.js +5 -0
  64. package/dist/lib/analytics-coverage.d.ts +12 -0
  65. package/dist/lib/analytics-coverage.js +15 -0
  66. package/dist/lib/bundle-budget.d.ts +24 -0
  67. package/dist/lib/bundle-budget.js +50 -0
  68. package/dist/lib/check-comments.d.ts +10 -0
  69. package/dist/lib/check-language.d.ts +11 -0
  70. package/dist/lib/check-placeholders.d.ts +6 -0
  71. package/dist/lib/check-roadmap.d.ts +30 -0
  72. package/dist/lib/check-routes.d.ts +6 -0
  73. package/dist/lib/cli.d.ts +26 -0
  74. package/dist/lib/comment-syntax.d.ts +11 -0
  75. package/dist/lib/config.d.ts +24 -0
  76. package/dist/lib/config.js +21 -0
  77. package/dist/lib/contract.d.ts +22 -0
  78. package/dist/lib/contract.js +59 -0
  79. package/dist/lib/git.d.ts +6 -0
  80. package/dist/lib/gtm-container.d.ts +41 -0
  81. package/dist/lib/gtm-container.js +145 -0
  82. package/dist/lib/icon-specs.d.ts +6 -0
  83. package/dist/lib/icon-specs.js +6 -0
  84. package/dist/lib/icons.d.ts +6 -0
  85. package/dist/lib/icons.js +32 -0
  86. package/dist/lib/language-words.d.ts +2 -0
  87. package/dist/lib/lighthouse.d.ts +17 -0
  88. package/dist/lib/lighthouse.js +23 -0
  89. package/dist/lib/past-tense.d.ts +1 -0
  90. package/dist/lib/routes.d.ts +31 -0
  91. package/dist/lib/routes.js +97 -0
  92. package/dist/lib/smoke-production.d.ts +35 -0
  93. package/dist/lib/smoke-production.js +132 -0
  94. package/dist/lib/vercel-cli-pin.d.ts +2 -0
  95. package/dist/sh/lhci-local.sh +106 -0
  96. package/package.json +21 -3
@@ -0,0 +1,132 @@
1
+ const pass = (check) => ({ check, status: 'pass' });
2
+ const fail = (check, detail) => ({ check, status: 'fail', detail });
3
+ const skip = (check, detail) => ({ check, status: 'skip', detail });
4
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
5
+ // Dalla regola globale `/(.*)` di vercel.json. `null` = verifica solo la presenza; src/vercel-headers.test.ts fissa i valori.
6
+ export const SECURITY_HEADERS = {
7
+ 'content-security-policy': null,
8
+ 'strict-transport-security': null,
9
+ 'x-content-type-options': 'nosniff',
10
+ 'x-frame-options': 'DENY',
11
+ 'referrer-policy': null,
12
+ 'permissions-policy': null,
13
+ };
14
+ /** Deve corrispondere al `source` del rewrite in vercel.json. */
15
+ export const BOTID_CHALLENGE = '/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js';
16
+ /** L'alias di produzione di Vercel impiega un attimo a puntare al deployment appena caricato. */
17
+ export async function waitForAlias({ get, baseUrl }, sleep, attempts = 5) {
18
+ for (let attempt = 1; attempt <= attempts; attempt++) {
19
+ try {
20
+ if ((await get(baseUrl)).ok)
21
+ return;
22
+ }
23
+ catch { }
24
+ if (attempt < attempts)
25
+ await sleep(attempt * 2000);
26
+ }
27
+ }
28
+ export async function checkPages({ get, baseUrl }, pages) {
29
+ const results = [];
30
+ for (const { path, type } of pages) {
31
+ const check = `GET ${path}`;
32
+ try {
33
+ const response = await get(`${baseUrl}${path}`);
34
+ const contentType = response.headers.get('content-type') ?? '';
35
+ if (response.status !== 200)
36
+ results.push(fail(check, `expected 200, got ${response.status}`));
37
+ else if (!contentType.includes(type))
38
+ results.push(fail(check, `expected a ${type} content-type, got "${contentType}"`));
39
+ else
40
+ results.push(pass(check));
41
+ }
42
+ catch (error) {
43
+ results.push(fail(check, messageOf(error)));
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+ export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
49
+ let response;
50
+ try {
51
+ response = await get(baseUrl);
52
+ }
53
+ catch (error) {
54
+ return [fail('security headers', messageOf(error))];
55
+ }
56
+ const results = Object.entries(securityHeaders).map(([header, expected]) => {
57
+ const check = `header ${header}`;
58
+ const value = response.headers.get(header);
59
+ if (value === null)
60
+ return fail(check, 'missing');
61
+ if (expected !== null && value !== expected)
62
+ return fail(check, `expected "${expected}", got "${value}"`);
63
+ return pass(check);
64
+ });
65
+ // Il noindex `has: host = *.vercel.app` di vercel.json applicato al dominio vero farebbe sparire il sito da ogni indice.
66
+ const check = 'no x-robots-tag on the production host';
67
+ const robots = response.headers.get('x-robots-tag');
68
+ results.push(robots === null ? pass(check) : fail(check, `present on ${baseUrl}: "${robots}"`));
69
+ return results;
70
+ }
71
+ export async function checkBotIdChallenge({ get, baseUrl }) {
72
+ const check = 'BotID challenge proxied same-origin';
73
+ try {
74
+ const response = await get(`${baseUrl}${BOTID_CHALLENGE}`);
75
+ if (response.status !== 200)
76
+ return [fail(check, `expected 200 from the rewrite, got ${response.status}`)];
77
+ return [pass(check)];
78
+ }
79
+ catch (error) {
80
+ return [fail(check, messageOf(error))];
81
+ }
82
+ }
83
+ /** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
84
+ export async function checkCanonicalHost({ get, baseUrl, siteUrl }) {
85
+ const check = 'www → apex 308';
86
+ if (baseUrl !== siteUrl)
87
+ return [skip(check, `base URL is not ${siteUrl}`)];
88
+ try {
89
+ const response = await get(`https://www.${new URL(siteUrl).host}/`);
90
+ const location = response.headers.get('location') ?? '';
91
+ if (response.status !== 308)
92
+ return [fail(check, `expected 308, got ${response.status}`)];
93
+ if (!location.startsWith(siteUrl))
94
+ return [fail(check, `location "${location}" does not point at ${siteUrl}`)];
95
+ return [pass(check)];
96
+ }
97
+ catch (error) {
98
+ return [fail(check, messageOf(error))];
99
+ }
100
+ }
101
+ export async function checkTrailingSlash({ get, baseUrl }, pages) {
102
+ const page = pages.find(({ path, type }) => type === 'text/html' && path !== '/');
103
+ const check = 'trailing slash → 308';
104
+ if (page === undefined)
105
+ return [skip(check, 'no HTML page other than / to probe')];
106
+ try {
107
+ const response = await get(`${baseUrl}${page.path}/`);
108
+ if (response.status !== 308)
109
+ return [fail(check, `expected 308 on ${page.path}/, got ${response.status}`)];
110
+ const location = response.headers.get('location') ?? '';
111
+ if (!location.endsWith(page.path))
112
+ return [fail(check, `location "${location}" does not point at ${page.path}`)];
113
+ return [pass(check)];
114
+ }
115
+ catch (error) {
116
+ return [fail(check, messageOf(error))];
117
+ }
118
+ }
119
+ export const DEFAULT_CHECKS = [
120
+ checkPages,
121
+ checkSecurityHeaders,
122
+ checkBotIdChallenge,
123
+ checkCanonicalHost,
124
+ checkTrailingSlash,
125
+ ];
126
+ /** In sequenza, non in parallelo: l'ordine dei risultati è quello dei controlli. */
127
+ export async function runChecks(context, pages, checks = DEFAULT_CHECKS) {
128
+ const results = [];
129
+ for (const check of checks)
130
+ results.push(...(await check(context, pages)));
131
+ return results;
132
+ }
@@ -0,0 +1,2 @@
1
+ import { type Hit } from './cli.ts';
2
+ export declare function pinFindings(workflow: string, latest: string): Hit[];
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PORT="${LHCI_PORT:-4399}"
5
+ OUT_DIR='dist/lh-prod'
6
+ REPORT_DIR="${LHCI_REPORT_DIR:-.lighthouseci/local}"
7
+
8
+ export TMPDIR="${TMPDIR:-/tmp}"
9
+
10
+ detect_chrome() {
11
+ local candidate
12
+ for candidate in \
13
+ "${CHROME_PATH:-}" \
14
+ $(ls -d "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux/chrome 2>/dev/null | sort -V -r) \
15
+ $(ls -d "$HOME"/.cache/puppeteer/chrome/*/chrome-linux*/chrome 2>/dev/null | sort -V -r) \
16
+ /usr/bin/google-chrome /usr/bin/chromium /usr/bin/chromium-browser
17
+ do
18
+ [[ -n "${candidate}" && -x "${candidate}" && "${candidate}" != *.exe ]] || continue
19
+ if "${candidate}" --version >/dev/null 2>&1; then
20
+ echo "${candidate}"
21
+ return 0
22
+ fi
23
+ done
24
+ return 1
25
+ }
26
+
27
+ if ! CHROME_PATH="$(detect_chrome)"; then
28
+ echo "✗ no usable Linux Chrome/Chromium found." >&2
29
+ echo " Install one with: pnpm dlx playwright@latest install chromium" >&2
30
+ echo " Or point at the binary: CHROME_PATH=/path/to/chrome pnpm run lhci:local" >&2
31
+ exit 1
32
+ fi
33
+ export CHROME_PATH
34
+ echo "✓ chrome: ${CHROME_PATH} ($("${CHROME_PATH}" --version 2>/dev/null))"
35
+
36
+ SERVER_PID=''
37
+
38
+ cleanup() {
39
+ if [[ -n "${SERVER_PID}" ]]; then
40
+ kill -- "-${SERVER_PID}" 2>/dev/null || kill "${SERVER_PID}" 2>/dev/null || true
41
+ pkill -f "serve@14 .*--listen ${PORT}" 2>/dev/null || true
42
+ fi
43
+ find . -maxdepth 1 \( -name '*wsl.localhost*' -o -name 'undefined*' \) -exec rm -rf {} + 2>/dev/null || true
44
+ }
45
+ trap cleanup EXIT
46
+
47
+ echo "→ production-equivalent build in ${OUT_DIR}"
48
+ VERCEL_ENV=production pnpm build --outDir "${OUT_DIR}"
49
+
50
+ if ! grep -qx 'Allow: /' "${OUT_DIR}/client/robots.txt"; then
51
+ echo "✗ ${OUT_DIR}/client/robots.txt has no 'Allow: /' — the build is not production-equivalent." >&2
52
+ head -3 "${OUT_DIR}/client/robots.txt" >&2
53
+ exit 1
54
+ fi
55
+ echo "✓ robots.txt: Allow: /"
56
+
57
+ echo "→ starting the static server on :${PORT}"
58
+ setsid pnpm dlx serve@14 "${OUT_DIR}/client" --listen "${PORT}" --no-clipboard >/dev/null 2>&1 &
59
+ SERVER_PID=$!
60
+
61
+ for _ in $(seq 1 60); do
62
+ if curl -fs -o /dev/null "http://localhost:${PORT}/"; then break; fi
63
+ sleep 1
64
+ done
65
+ if ! curl -fs -o /dev/null "http://localhost:${PORT}/"; then
66
+ echo "✗ the server did not answer on :${PORT} within 60s." >&2
67
+ echo " Retry on another port: LHCI_PORT=4400 pnpm run lhci:local" >&2
68
+ exit 1
69
+ fi
70
+ echo "✓ server ready"
71
+
72
+ rm -rf "${REPORT_DIR}"
73
+
74
+ LH_PORT="${PORT}" \
75
+ LH_RUNS="${LHCI_RUNS:-1}" \
76
+ LH_OUT="${REPORT_DIR}" \
77
+ LH_EXTERNAL_SERVER=1 \
78
+ LH_CHROME_FLAGS='--no-sandbox --disable-dev-shm-usage' \
79
+ node "${OFFICINA_BIN}" check lighthouse
80
+
81
+ echo
82
+ echo "→ score per URL (median over ${LHCI_RUNS:-1} run(s), the way LHCI asserts)"
83
+ node -e '
84
+ const fs = require("fs")
85
+ const dir = process.argv[1]
86
+ const byUrl = new Map()
87
+ for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".report.json"))) {
88
+ const r = JSON.parse(fs.readFileSync(`${dir}/${f}`))
89
+ const url = String(r.finalDisplayedUrl)
90
+ if (!byUrl.has(url)) byUrl.set(url, [])
91
+ byUrl.get(url).push(r.categories)
92
+ }
93
+ const median = (xs) => {
94
+ const s = [...xs].sort((a, b) => a - b)
95
+ return s[Math.floor(s.length / 2)]
96
+ }
97
+ for (const [url, runs] of [...byUrl].sort()) {
98
+ const pick = (k) => median(runs.map((c) => c[k].score)).toFixed(2)
99
+ console.log(
100
+ url.padEnd(46),
101
+ "perf", pick("performance"),
102
+ "a11y", pick("accessibility"),
103
+ "bp", pick("best-practices"),
104
+ "seo", pick("seo"),
105
+ )
106
+ }' "${REPORT_DIR}"
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "@elia97/officina",
3
- "version": "0.1.0",
4
- "description": "Gli strumenti dei progetti nati dai template di Elia: gate di qualità e, più avanti, generatori.",
3
+ "version": "0.3.0",
4
+ "description": "Gli strumenti dei progetti nati dai template di Elia: gate di qualità, generatori e verifiche di build e di produzione.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/Elia97/officina.git"
10
10
  },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
11
17
  "bin": {
12
18
  "officina": "dist/bin.js"
13
19
  },
@@ -29,8 +35,20 @@
29
35
  "typescript": "^7.0.2",
30
36
  "vitest": "^4.1.11"
31
37
  },
38
+ "dependencies": {
39
+ "plop": "^4.0.5",
40
+ "ts-morph": "^28.0.0"
41
+ },
42
+ "peerDependencies": {
43
+ "sharp": ">=0.34.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "sharp": {
47
+ "optional": true
48
+ }
49
+ },
32
50
  "scripts": {
33
- "build": "tsc -p tsconfig.build.json",
51
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/gen/templates dist/gen/templates && cp -r src/sh dist/sh",
34
52
  "typecheck": "tsc --noEmit",
35
53
  "test": "vitest run --coverage",
36
54
  "format": "biome check --write .",