@elia97/officina 0.2.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 (68) hide show
  1. package/README.md +40 -2
  2. package/dist/bin.d.ts +8 -0
  3. package/dist/bin.js +12 -3
  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/language.d.ts +2 -0
  11. package/dist/checks/lighthouse.d.ts +2 -0
  12. package/dist/checks/lighthouse.js +37 -0
  13. package/dist/checks/placeholders.d.ts +2 -0
  14. package/dist/checks/roadmap.d.ts +2 -0
  15. package/dist/checks/routes.d.ts +2 -0
  16. package/dist/checks/smoke.d.ts +2 -0
  17. package/dist/checks/smoke.js +57 -0
  18. package/dist/checks/vercel-cli.d.ts +2 -0
  19. package/dist/gen/collection.d.mts +1 -0
  20. package/dist/gen/component.d.mts +1 -0
  21. package/dist/gen/icons.d.ts +2 -0
  22. package/dist/gen/icons.js +27 -0
  23. package/dist/gen/identifier.d.mts +1 -0
  24. package/dist/gen/inject-config.d.mts +12 -0
  25. package/dist/gen/inject-section.d.mts +16 -0
  26. package/dist/gen/inject-strings.d.mts +8 -0
  27. package/dist/gen/page.d.mts +10 -0
  28. package/dist/gen/plopfile.d.mts +2 -0
  29. package/dist/gen/post-gen.d.mts +1 -0
  30. package/dist/gen/run.d.ts +2 -0
  31. package/dist/gen/section-targets.d.mts +23 -0
  32. package/dist/gen/section.d.mts +1 -0
  33. package/dist/gen/sectioned-collection.d.mts +26 -0
  34. package/dist/gen/ts-morph-utils.d.mts +1 -0
  35. package/dist/index.d.ts +10 -0
  36. package/dist/index.js +5 -0
  37. package/dist/lib/analytics-coverage.d.ts +12 -0
  38. package/dist/lib/analytics-coverage.js +15 -0
  39. package/dist/lib/bundle-budget.d.ts +24 -0
  40. package/dist/lib/bundle-budget.js +50 -0
  41. package/dist/lib/check-comments.d.ts +10 -0
  42. package/dist/lib/check-language.d.ts +11 -0
  43. package/dist/lib/check-placeholders.d.ts +6 -0
  44. package/dist/lib/check-roadmap.d.ts +30 -0
  45. package/dist/lib/check-routes.d.ts +6 -0
  46. package/dist/lib/cli.d.ts +26 -0
  47. package/dist/lib/comment-syntax.d.ts +11 -0
  48. package/dist/lib/config.d.ts +24 -0
  49. package/dist/lib/config.js +21 -0
  50. package/dist/lib/contract.d.ts +22 -0
  51. package/dist/lib/git.d.ts +6 -0
  52. package/dist/lib/gtm-container.d.ts +41 -0
  53. package/dist/lib/gtm-container.js +145 -0
  54. package/dist/lib/icon-specs.d.ts +6 -0
  55. package/dist/lib/icon-specs.js +6 -0
  56. package/dist/lib/icons.d.ts +6 -0
  57. package/dist/lib/icons.js +32 -0
  58. package/dist/lib/language-words.d.ts +2 -0
  59. package/dist/lib/lighthouse.d.ts +17 -0
  60. package/dist/lib/lighthouse.js +23 -0
  61. package/dist/lib/past-tense.d.ts +1 -0
  62. package/dist/lib/routes.d.ts +31 -0
  63. package/dist/lib/routes.js +97 -0
  64. package/dist/lib/smoke-production.d.ts +35 -0
  65. package/dist/lib/smoke-production.js +132 -0
  66. package/dist/lib/vercel-cli-pin.d.ts +2 -0
  67. package/dist/sh/lhci-local.sh +106 -0
  68. package/package.json +17 -3
@@ -0,0 +1,97 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export const ERROR_PAGES = ['/404', '/500'];
4
+ // Non escono da una pagina .astro: `robots.txt.ts` e `site.webmanifest.ts` sono endpoint,
5
+ // `sitemap-index.xml` lo emette @astrojs/sitemap e `/api/health` è `prerender = false`.
6
+ export const NON_HTML_ROUTES = [
7
+ { path: '/robots.txt', type: 'text/plain' },
8
+ { path: '/sitemap-index.xml', type: 'xml' },
9
+ // In produzione il MIME viene dall'estensione .webmanifest, non dall'intestazione che
10
+ // l'endpoint imposta: si verifica la famiglia json invece di fissarne una delle due forme.
11
+ { path: '/site.webmanifest', type: 'json' },
12
+ { path: '/api/health', type: 'application/json' },
13
+ ];
14
+ export function filesWithExtension(dir, extension) {
15
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
16
+ const path = join(dir, entry.name);
17
+ if (entry.isDirectory())
18
+ return filesWithExtension(path, extension);
19
+ return entry.name.endsWith(extension) ? [path] : [];
20
+ });
21
+ }
22
+ export function readPageFiles(pagesDir) {
23
+ return filesWithExtension(pagesDir, '.astro').map((file) => ({ file, source: readFileSync(file, 'utf8') }));
24
+ }
25
+ const toPosix = (path, prefix) => path.slice(prefix.length).split(/[\\/]/).join('/');
26
+ export function routeOf(htmlPath, dist) {
27
+ const route = toPosix(htmlPath, dist).replace(/\/index\.html$/, '');
28
+ return route.replace(/\.html$/, '') || '/';
29
+ }
30
+ const SSR_OPT_OUT = /^\s*export\s+const\s+prerender\s*=\s*false\b/m;
31
+ function pageRouteOf(file, pagesDir) {
32
+ return (toPosix(file, pagesDir)
33
+ .replace(/\.astro$/, '')
34
+ .replace(/\/index$/, '') || '/');
35
+ }
36
+ function segmentPattern(segment) {
37
+ if (segment.startsWith('['))
38
+ return '[^/]+';
39
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
+ }
41
+ const TRAILING_REST_SEGMENT = /\/\[\.\.\.[^/]*\]$/;
42
+ // Anche un segmento rest finale fa match col vuoto: `paginate()` emette la prima pagina come
43
+ // percorso nudo (`/news`, mai `/news/1`), quindi esigere un segmento boccia un archivio di una pagina.
44
+ function routePattern(route) {
45
+ const body = (path) => path.split('/').map(segmentPattern).join('/');
46
+ if (!TRAILING_REST_SEGMENT.test(route))
47
+ return new RegExp(`^${body(route)}$`);
48
+ return new RegExp(`^${body(route.replace(TRAILING_REST_SEGMENT, ''))}(?:/.*)?$`);
49
+ }
50
+ export function expectedRoutes(pages, pagesDir) {
51
+ const expectations = { exact: [], patterns: [], ssr: [] };
52
+ for (const { file, source } of pages) {
53
+ if (SSR_OPT_OUT.test(source)) {
54
+ expectations.ssr.push(file);
55
+ continue;
56
+ }
57
+ const route = pageRouteOf(file, pagesDir);
58
+ if (!route.includes('[')) {
59
+ expectations.exact.push({ route, file });
60
+ continue;
61
+ }
62
+ expectations.patterns.push({ pattern: routePattern(route), label: route, file });
63
+ }
64
+ return expectations;
65
+ }
66
+ /** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
67
+ export function missingRouteFailures(expected, emitted, dist) {
68
+ if (emitted.length === 0) {
69
+ return [`${dist} holds no .html file — no route was measured, so the per-route budgets assert nothing`];
70
+ }
71
+ const failures = [];
72
+ const routes = new Set(emitted);
73
+ for (const { route, file } of expected.exact) {
74
+ if (!routes.has(route))
75
+ failures.push(`missing route ${route} — ${file} is prerendered but emitted no HTML`);
76
+ }
77
+ for (const { pattern, label, file } of expected.patterns) {
78
+ if (emitted.some((route) => pattern.test(route)))
79
+ continue;
80
+ failures.push(`missing route ${label} — ${file} is prerendered but getStaticPaths emitted nothing`);
81
+ }
82
+ return failures;
83
+ }
84
+ // Ordinate: l'ordine nativo di readdirSync cambia da un filesystem all'altro.
85
+ export function auditRoutes(expected) {
86
+ return expected.exact
87
+ .map(({ route }) => route)
88
+ .filter((route) => !ERROR_PAGES.includes(route))
89
+ .sort();
90
+ }
91
+ export function smokeRoutes(expected, nonHtmlRoutes = NON_HTML_ROUTES) {
92
+ return [...auditRoutes(expected).map((path) => ({ path, type: 'text/html' })), ...nonHtmlRoutes];
93
+ }
94
+ export function orphanExceptions(expected) {
95
+ const routes = new Set(expected.exact.map(({ route }) => route));
96
+ return ERROR_PAGES.filter((page) => !routes.has(page));
97
+ }
@@ -0,0 +1,35 @@
1
+ import type { VerifiedRoute } from './routes.ts';
2
+ export interface SmokeResponse {
3
+ ok: boolean;
4
+ status: number;
5
+ headers: {
6
+ get: (name: string) => string | null;
7
+ };
8
+ }
9
+ export type Fetcher = (url: string) => Promise<SmokeResponse>;
10
+ export interface SmokeContext {
11
+ get: Fetcher;
12
+ baseUrl: string;
13
+ siteUrl: string;
14
+ securityHeaders: Record<string, string | null>;
15
+ }
16
+ export type SmokeCheck = (context: SmokeContext, pages: readonly VerifiedRoute[]) => Promise<CheckResult[]>;
17
+ export interface CheckResult {
18
+ check: string;
19
+ status: 'pass' | 'fail' | 'skip';
20
+ detail?: string;
21
+ }
22
+ export declare const SECURITY_HEADERS: Record<string, string | null>;
23
+ /** Deve corrispondere al `source` del rewrite in vercel.json. */
24
+ export declare const BOTID_CHALLENGE = "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js";
25
+ /** L'alias di produzione di Vercel impiega un attimo a puntare al deployment appena caricato. */
26
+ export declare function waitForAlias({ get, baseUrl }: SmokeContext, sleep: (ms: number) => Promise<void>, attempts?: number): Promise<void>;
27
+ export declare function checkPages({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
28
+ export declare function checkSecurityHeaders({ get, baseUrl, securityHeaders }: SmokeContext): Promise<CheckResult[]>;
29
+ export declare function checkBotIdChallenge({ get, baseUrl }: SmokeContext): Promise<CheckResult[]>;
30
+ /** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
31
+ export declare function checkCanonicalHost({ get, baseUrl, siteUrl }: SmokeContext): Promise<CheckResult[]>;
32
+ export declare function checkTrailingSlash({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
33
+ export declare const DEFAULT_CHECKS: readonly SmokeCheck[];
34
+ /** In sequenza, non in parallelo: l'ordine dei risultati è quello dei controlli. */
35
+ export declare function runChecks(context: SmokeContext, pages: readonly VerifiedRoute[], checks?: readonly SmokeCheck[]): Promise<CheckResult[]>;
@@ -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.2.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
  },
@@ -33,8 +39,16 @@
33
39
  "plop": "^4.0.5",
34
40
  "ts-morph": "^28.0.0"
35
41
  },
42
+ "peerDependencies": {
43
+ "sharp": ">=0.34.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "sharp": {
47
+ "optional": true
48
+ }
49
+ },
36
50
  "scripts": {
37
- "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/gen/templates dist/gen/templates",
51
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/gen/templates dist/gen/templates && cp -r src/sh dist/sh",
38
52
  "typecheck": "tsc --noEmit",
39
53
  "test": "vitest run --coverage",
40
54
  "format": "biome check --write .",