@drewpayment/mink 0.1.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 +347 -0
- package/package.json +32 -0
- package/src/cli.ts +176 -0
- package/src/commands/bug-search.ts +32 -0
- package/src/commands/config.ts +109 -0
- package/src/commands/cron.ts +295 -0
- package/src/commands/daemon.ts +46 -0
- package/src/commands/dashboard.ts +21 -0
- package/src/commands/designqc.ts +160 -0
- package/src/commands/detect-waste.ts +81 -0
- package/src/commands/framework-advisor.ts +52 -0
- package/src/commands/init.ts +159 -0
- package/src/commands/post-read.ts +123 -0
- package/src/commands/post-write.ts +157 -0
- package/src/commands/pre-read.ts +109 -0
- package/src/commands/pre-write.ts +136 -0
- package/src/commands/reflect.ts +39 -0
- package/src/commands/restore.ts +31 -0
- package/src/commands/scan.ts +101 -0
- package/src/commands/session-start.ts +21 -0
- package/src/commands/session-stop.ts +115 -0
- package/src/commands/status.ts +152 -0
- package/src/commands/update.ts +121 -0
- package/src/core/action-log.ts +341 -0
- package/src/core/backup.ts +122 -0
- package/src/core/bug-memory.ts +223 -0
- package/src/core/cron-parser.ts +94 -0
- package/src/core/daemon.ts +152 -0
- package/src/core/dashboard-api.ts +280 -0
- package/src/core/dashboard-server.ts +580 -0
- package/src/core/description.ts +232 -0
- package/src/core/design-eval/capture.ts +269 -0
- package/src/core/design-eval/route-detect.ts +165 -0
- package/src/core/design-eval/server-detect.ts +91 -0
- package/src/core/framework-advisor/catalog.ts +360 -0
- package/src/core/framework-advisor/decision-tree.ts +287 -0
- package/src/core/framework-advisor/generate.ts +132 -0
- package/src/core/framework-advisor/migration-prompts.ts +502 -0
- package/src/core/framework-advisor/validate.ts +137 -0
- package/src/core/fs-utils.ts +30 -0
- package/src/core/global-config.ts +74 -0
- package/src/core/index-store.ts +72 -0
- package/src/core/learning-memory.ts +120 -0
- package/src/core/paths.ts +86 -0
- package/src/core/pattern-engine.ts +108 -0
- package/src/core/project-id.ts +19 -0
- package/src/core/project-registry.ts +64 -0
- package/src/core/reflection.ts +256 -0
- package/src/core/scanner.ts +99 -0
- package/src/core/scheduler.ts +352 -0
- package/src/core/seed.ts +239 -0
- package/src/core/session.ts +128 -0
- package/src/core/stdin.ts +13 -0
- package/src/core/task-registry.ts +202 -0
- package/src/core/token-estimate.ts +36 -0
- package/src/core/token-ledger.ts +185 -0
- package/src/core/waste-detection.ts +214 -0
- package/src/core/write-exclusions.ts +24 -0
- package/src/types/action-log.ts +20 -0
- package/src/types/backup.ts +6 -0
- package/src/types/bug-memory.ts +24 -0
- package/src/types/config.ts +59 -0
- package/src/types/dashboard.ts +104 -0
- package/src/types/design-eval.ts +64 -0
- package/src/types/file-index.ts +38 -0
- package/src/types/framework-advisor.ts +97 -0
- package/src/types/hook-input.ts +27 -0
- package/src/types/learning-memory.ts +36 -0
- package/src/types/scheduler.ts +82 -0
- package/src/types/session.ts +50 -0
- package/src/types/token-ledger.ts +43 -0
- package/src/types/waste-detection.ts +21 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { basename, extname } from "path";
|
|
2
|
+
|
|
3
|
+
const MAX_DESCRIPTION_LENGTH = 100;
|
|
4
|
+
|
|
5
|
+
const CONFIG_DESCRIPTIONS: Record<string, string> = {
|
|
6
|
+
"package.json": "Node.js package manifest",
|
|
7
|
+
"tsconfig.json": "TypeScript configuration",
|
|
8
|
+
"tsconfig.node.json": "TypeScript configuration (Node)",
|
|
9
|
+
"tailwind.config.js": "Tailwind CSS configuration",
|
|
10
|
+
"tailwind.config.ts": "Tailwind CSS configuration",
|
|
11
|
+
"vite.config.js": "Vite build configuration",
|
|
12
|
+
"vite.config.ts": "Vite build configuration",
|
|
13
|
+
"next.config.js": "Next.js configuration",
|
|
14
|
+
"next.config.ts": "Next.js configuration",
|
|
15
|
+
"next.config.mjs": "Next.js configuration",
|
|
16
|
+
"eslint.config.js": "ESLint configuration",
|
|
17
|
+
"eslint.config.mjs": "ESLint configuration",
|
|
18
|
+
".eslintrc": "ESLint configuration",
|
|
19
|
+
".eslintrc.js": "ESLint configuration",
|
|
20
|
+
".eslintrc.json": "ESLint configuration",
|
|
21
|
+
".prettierrc": "Prettier configuration",
|
|
22
|
+
".prettierrc.json": "Prettier configuration",
|
|
23
|
+
"prettier.config.js": "Prettier configuration",
|
|
24
|
+
"Dockerfile": "Docker container definition",
|
|
25
|
+
"docker-compose.yml": "Docker Compose services",
|
|
26
|
+
"docker-compose.yaml": "Docker Compose services",
|
|
27
|
+
"Makefile": "Make build targets",
|
|
28
|
+
"CMakeLists.txt": "CMake build configuration",
|
|
29
|
+
"Cargo.toml": "Rust package manifest",
|
|
30
|
+
"go.mod": "Go module definition",
|
|
31
|
+
"pyproject.toml": "Python project configuration",
|
|
32
|
+
"setup.py": "Python package setup",
|
|
33
|
+
"Gemfile": "Ruby dependency manifest",
|
|
34
|
+
"composer.json": "PHP package manifest",
|
|
35
|
+
"build.gradle": "Gradle build configuration",
|
|
36
|
+
"pom.xml": "Maven build configuration",
|
|
37
|
+
"bunfig.toml": "Bun configuration",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function truncate(str: string): string {
|
|
41
|
+
if (str.length <= MAX_DESCRIPTION_LENGTH) return str;
|
|
42
|
+
return str.slice(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasBinaryContent(content: string): boolean {
|
|
46
|
+
return content.includes("\0");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractMarkdownHeading(content: string): string | null {
|
|
50
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
51
|
+
return match ? match[1].trim() : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractHtmlTitle(content: string): string | null {
|
|
55
|
+
const match = content.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
56
|
+
return match ? match[1].trim() : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractDocComment(content: string): string | null {
|
|
60
|
+
// JSDoc / JavaDoc style: /** ... */
|
|
61
|
+
const jsdoc = content.match(/^\/\*\*\s*\n?\s*\*?\s*(.+)/m);
|
|
62
|
+
if (jsdoc) return jsdoc[1].replace(/\*\/\s*$/, "").trim();
|
|
63
|
+
|
|
64
|
+
// Python docstring: """...""" or '''...'''
|
|
65
|
+
const pydoc = content.match(/^(?:def |class ).*\n\s*(?:"""|''')(.+)/m);
|
|
66
|
+
if (pydoc) return pydoc[1].trim();
|
|
67
|
+
|
|
68
|
+
// Shell/Ruby/Python top-of-file comment block
|
|
69
|
+
const lines = content.split("\n");
|
|
70
|
+
if (lines[0]?.startsWith("#!")) {
|
|
71
|
+
// Skip shebang, look at next comment
|
|
72
|
+
for (let i = 1; i < Math.min(lines.length, 5); i++) {
|
|
73
|
+
const line = lines[i].trim();
|
|
74
|
+
if (line.startsWith("# ") && line.length > 2) {
|
|
75
|
+
return line.slice(2).trim();
|
|
76
|
+
}
|
|
77
|
+
if (line && !line.startsWith("#")) break;
|
|
78
|
+
}
|
|
79
|
+
} else if (lines[0]?.startsWith("# ") && lines[0].length > 2) {
|
|
80
|
+
return lines[0].slice(2).trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extractExports(content: string): string | null {
|
|
87
|
+
const exports: string[] = [];
|
|
88
|
+
const re = /export\s+(?:function|const|class|interface|type|enum)\s+(\w+)/g;
|
|
89
|
+
let match: RegExpExecArray | null;
|
|
90
|
+
while ((match = re.exec(content)) !== null) {
|
|
91
|
+
exports.push(match[1]);
|
|
92
|
+
}
|
|
93
|
+
if (exports.length === 0) return null;
|
|
94
|
+
return `exports: ${exports.join(", ")}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractComponent(content: string, filePath: string): string | null {
|
|
98
|
+
const ext = extname(filePath).toLowerCase();
|
|
99
|
+
if (![".tsx", ".jsx", ".vue", ".svelte"].includes(ext)) return null;
|
|
100
|
+
|
|
101
|
+
// Look for a PascalCase named export (component convention)
|
|
102
|
+
const namedMatch = content.match(
|
|
103
|
+
/(?:export\s+(?:default\s+)?function|export\s+const)\s+([A-Z]\w+)/
|
|
104
|
+
);
|
|
105
|
+
const componentName = namedMatch
|
|
106
|
+
? namedMatch[1]
|
|
107
|
+
: basename(filePath, ext);
|
|
108
|
+
|
|
109
|
+
const elements: string[] = [];
|
|
110
|
+
if (/<form[\s>]/i.test(content)) elements.push("form");
|
|
111
|
+
if (/<table[\s>]/i.test(content)) elements.push("table");
|
|
112
|
+
if (/modal/i.test(content)) elements.push("modal");
|
|
113
|
+
if (/<ul[\s>]|<ol[\s>]|<li[\s>]/i.test(content)) elements.push("list");
|
|
114
|
+
if (/<input[\s>]|<textarea[\s>]|<select[\s>]/i.test(content))
|
|
115
|
+
elements.push("inputs");
|
|
116
|
+
|
|
117
|
+
if (elements.length === 0) return null;
|
|
118
|
+
return `${componentName} — renders ${elements.join(", ")}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function extractCiWorkflow(content: string, filePath: string): string | null {
|
|
122
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
123
|
+
const isCi =
|
|
124
|
+
normalized.includes(".github/workflows/") ||
|
|
125
|
+
normalized.includes(".gitlab-ci") ||
|
|
126
|
+
basename(filePath).toLowerCase() === "jenkinsfile";
|
|
127
|
+
if (!isCi) return null;
|
|
128
|
+
|
|
129
|
+
const nameMatch = content.match(/^name:\s*(.+)$/m);
|
|
130
|
+
if (nameMatch) return `CI: ${nameMatch[1].trim()}`;
|
|
131
|
+
return `CI: ${basename(filePath)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function extractMigration(content: string, filePath: string): string | null {
|
|
135
|
+
const normalized = filePath.toLowerCase();
|
|
136
|
+
const isMigration =
|
|
137
|
+
normalized.includes("migration") || normalized.includes("migrate");
|
|
138
|
+
if (!isMigration) return null;
|
|
139
|
+
|
|
140
|
+
const tableMatch = content.match(
|
|
141
|
+
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)/i
|
|
142
|
+
);
|
|
143
|
+
if (tableMatch) return `migration: create ${tableMatch[1]}`;
|
|
144
|
+
return `migration: ${basename(filePath)}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractFallback(content: string): string | null {
|
|
148
|
+
const lines = content.split("\n");
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
const trimmed = line.trim();
|
|
151
|
+
if (trimmed && !trimmed.startsWith("//") && !trimmed.startsWith("#")) {
|
|
152
|
+
return trimmed;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function extractDescription(filePath: string, content: string): string {
|
|
159
|
+
const name = basename(filePath);
|
|
160
|
+
const ext = extname(filePath).toLowerCase();
|
|
161
|
+
|
|
162
|
+
// Edge cases first
|
|
163
|
+
if (content.length === 0) return `${name} — empty file`;
|
|
164
|
+
if (hasBinaryContent(content)) return `${name} — binary file`;
|
|
165
|
+
|
|
166
|
+
let description: string | null = null;
|
|
167
|
+
const isLargeFile = content.length > 100 * 1024;
|
|
168
|
+
|
|
169
|
+
// Priority 1: Markdown heading
|
|
170
|
+
if ([".md", ".mdx"].includes(ext)) {
|
|
171
|
+
description = extractMarkdownHeading(content);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Priority 2: HTML title
|
|
175
|
+
if (!description && [".html", ".htm"].includes(ext)) {
|
|
176
|
+
description = extractHtmlTitle(content);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Priority 3: Doc comment
|
|
180
|
+
if (!description) {
|
|
181
|
+
description = extractDocComment(content);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Priority 4/5: For component files (tsx/jsx/vue/svelte), try component
|
|
185
|
+
// detection before generic exports so element-rich components get
|
|
186
|
+
// meaningful descriptions instead of bare export lists.
|
|
187
|
+
const componentExt = [".tsx", ".jsx", ".vue", ".svelte"];
|
|
188
|
+
if (!description && componentExt.includes(extname(filePath).toLowerCase())) {
|
|
189
|
+
description = extractComponent(content, filePath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Priority 4: Exports (for non-component files, or component files without elements)
|
|
193
|
+
if (!description) {
|
|
194
|
+
description = extractExports(content);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Priority 5 (non-component fallback): already handled above for component files
|
|
198
|
+
|
|
199
|
+
// Priority 6: Known config file
|
|
200
|
+
if (!description) {
|
|
201
|
+
const configDesc = CONFIG_DESCRIPTIONS[name];
|
|
202
|
+
if (configDesc) description = configDesc;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Priority 7: CI/CD
|
|
206
|
+
if (!description) {
|
|
207
|
+
description = extractCiWorkflow(content, filePath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Priority 8: Migration
|
|
211
|
+
if (!description) {
|
|
212
|
+
description = extractMigration(content, filePath);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Priority 9: Fallback
|
|
216
|
+
if (!description) {
|
|
217
|
+
description = extractFallback(content);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Final fallback
|
|
221
|
+
if (!description) {
|
|
222
|
+
description = name;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (isLargeFile) {
|
|
226
|
+
description = truncate(description + " (large file)");
|
|
227
|
+
} else {
|
|
228
|
+
description = truncate(description);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return description;
|
|
232
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { mkdirSync, statSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import puppeteer from "puppeteer-core";
|
|
4
|
+
import type { Browser, Page } from "puppeteer-core";
|
|
5
|
+
import { minkRoot } from "../paths";
|
|
6
|
+
import type {
|
|
7
|
+
Viewport,
|
|
8
|
+
CaptureResult,
|
|
9
|
+
DesignEvalReport,
|
|
10
|
+
DesignQcOptions,
|
|
11
|
+
} from "../../types/design-eval";
|
|
12
|
+
|
|
13
|
+
// ── Browser Detection ─────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const CHROME_PATHS: Record<string, string[]> = {
|
|
16
|
+
darwin: [
|
|
17
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
18
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
19
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
20
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
21
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
22
|
+
],
|
|
23
|
+
linux: [
|
|
24
|
+
"/usr/bin/google-chrome",
|
|
25
|
+
"/usr/bin/google-chrome-stable",
|
|
26
|
+
"/usr/bin/chromium",
|
|
27
|
+
"/usr/bin/chromium-browser",
|
|
28
|
+
"/snap/bin/chromium",
|
|
29
|
+
],
|
|
30
|
+
win32: [
|
|
31
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
32
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
33
|
+
`${process.env.LOCALAPPDATA ?? ""}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find a Chrome/Chromium executable on the system.
|
|
39
|
+
* Checks platform-specific paths, then ~/.mink/browsers/.
|
|
40
|
+
*/
|
|
41
|
+
export function findBrowser(): string {
|
|
42
|
+
const platform = process.platform;
|
|
43
|
+
|
|
44
|
+
// Check system paths
|
|
45
|
+
const paths = CHROME_PATHS[platform] ?? [];
|
|
46
|
+
for (const p of paths) {
|
|
47
|
+
if (existsSync(p)) return p;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check mink-managed browser installs
|
|
51
|
+
const minkBrowsers = join(minkRoot(), "browsers");
|
|
52
|
+
if (existsSync(minkBrowsers)) {
|
|
53
|
+
// Look for chrome/chromium executables recursively
|
|
54
|
+
const found = findChromeInDir(minkBrowsers);
|
|
55
|
+
if (found) return found;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(
|
|
59
|
+
[
|
|
60
|
+
"[mink] No Chrome/Chromium browser found.",
|
|
61
|
+
"",
|
|
62
|
+
"Install one of:",
|
|
63
|
+
" • Google Chrome: https://www.google.com/chrome/",
|
|
64
|
+
" • Or run: npx @puppeteer/browsers install chrome@stable --path ~/.mink/browsers",
|
|
65
|
+
"",
|
|
66
|
+
"Then retry: mink designqc",
|
|
67
|
+
].join("\n")
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findChromeInDir(dir: string): string | null {
|
|
72
|
+
const { readdirSync, statSync } = require("fs") as typeof import("fs");
|
|
73
|
+
try {
|
|
74
|
+
const entries = readdirSync(dir);
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const full = join(dir, entry);
|
|
77
|
+
try {
|
|
78
|
+
const stat = statSync(full);
|
|
79
|
+
if (stat.isDirectory()) {
|
|
80
|
+
const found = findChromeInDir(full);
|
|
81
|
+
if (found) return found;
|
|
82
|
+
} else if (/^(chrome|chromium|Google Chrome)$/i.test(entry)) {
|
|
83
|
+
return full;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// dir not readable
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Route Sanitization ────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Convert a route path to a safe filename prefix.
|
|
99
|
+
* "/" → "index", "/about" → "about", "/foo/bar" → "foo-bar"
|
|
100
|
+
*/
|
|
101
|
+
export function sanitizeRoute(route: string): string {
|
|
102
|
+
if (route === "/") return "index";
|
|
103
|
+
return route
|
|
104
|
+
.replace(/^\//, "")
|
|
105
|
+
.replace(/\/+/g, "-")
|
|
106
|
+
.replace(/[^a-zA-Z0-9-_]/g, "_");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Calculate how many viewport-height sections are needed.
|
|
111
|
+
*/
|
|
112
|
+
export function calculateSections(
|
|
113
|
+
pageHeight: number,
|
|
114
|
+
viewportHeight: number,
|
|
115
|
+
maxSections: number
|
|
116
|
+
): number {
|
|
117
|
+
if (pageHeight <= 0) return 0;
|
|
118
|
+
return Math.min(Math.ceil(pageHeight / viewportHeight), maxSections);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Screenshot Capture ────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Capture a single route at a single viewport, producing sectioned screenshots.
|
|
125
|
+
*/
|
|
126
|
+
export async function captureRoute(
|
|
127
|
+
page: Page,
|
|
128
|
+
route: string,
|
|
129
|
+
baseUrl: string,
|
|
130
|
+
viewport: Viewport,
|
|
131
|
+
options: { quality: number; maxSections: number; outputDir: string }
|
|
132
|
+
): Promise<CaptureResult[]> {
|
|
133
|
+
const results: CaptureResult[] = [];
|
|
134
|
+
const url = `${baseUrl.replace(/\/$/, "")}${route}`;
|
|
135
|
+
const timestamp = new Date().toISOString();
|
|
136
|
+
|
|
137
|
+
// Set viewport
|
|
138
|
+
await page.setViewport({ width: viewport.width, height: viewport.height });
|
|
139
|
+
|
|
140
|
+
// Navigate and wait for stability
|
|
141
|
+
const response = await page.goto(url, {
|
|
142
|
+
waitUntil: "networkidle0",
|
|
143
|
+
timeout: 30000,
|
|
144
|
+
});
|
|
145
|
+
const statusCode = response?.status() ?? 0;
|
|
146
|
+
|
|
147
|
+
// Get full page height
|
|
148
|
+
const pageHeight = await page.evaluate(() => {
|
|
149
|
+
return Math.max(
|
|
150
|
+
document.body.scrollHeight,
|
|
151
|
+
document.documentElement.scrollHeight
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const totalSections = calculateSections(
|
|
156
|
+
pageHeight,
|
|
157
|
+
viewport.height,
|
|
158
|
+
options.maxSections
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (totalSections === 0) return results;
|
|
162
|
+
|
|
163
|
+
const prefix = sanitizeRoute(route);
|
|
164
|
+
|
|
165
|
+
for (let section = 0; section < totalSections; section++) {
|
|
166
|
+
const y = section * viewport.height;
|
|
167
|
+
const clipHeight = Math.min(viewport.height, pageHeight - y);
|
|
168
|
+
|
|
169
|
+
const fileName = `${prefix}-${viewport.name}-${section}.jpg`;
|
|
170
|
+
const filePath = join(options.outputDir, fileName);
|
|
171
|
+
|
|
172
|
+
await page.screenshot({
|
|
173
|
+
path: filePath,
|
|
174
|
+
type: "jpeg",
|
|
175
|
+
quality: options.quality,
|
|
176
|
+
clip: {
|
|
177
|
+
x: 0,
|
|
178
|
+
y,
|
|
179
|
+
width: viewport.width,
|
|
180
|
+
height: clipHeight,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
let fileSize = 0;
|
|
185
|
+
try {
|
|
186
|
+
fileSize = statSync(filePath).size;
|
|
187
|
+
} catch {
|
|
188
|
+
// stat failed — leave as 0
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
results.push({
|
|
192
|
+
route,
|
|
193
|
+
viewport: viewport.name,
|
|
194
|
+
section,
|
|
195
|
+
totalSections,
|
|
196
|
+
filePath,
|
|
197
|
+
fileName,
|
|
198
|
+
fileSize,
|
|
199
|
+
pageHeight,
|
|
200
|
+
statusCode,
|
|
201
|
+
timestamp,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return results;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Capture all routes across all viewports. Launches the browser once.
|
|
210
|
+
*/
|
|
211
|
+
export async function captureAllRoutes(
|
|
212
|
+
routes: string[],
|
|
213
|
+
baseUrl: string,
|
|
214
|
+
viewports: Viewport[],
|
|
215
|
+
options: DesignQcOptions,
|
|
216
|
+
outputDir: string
|
|
217
|
+
): Promise<DesignEvalReport> {
|
|
218
|
+
mkdirSync(outputDir, { recursive: true });
|
|
219
|
+
|
|
220
|
+
const executablePath = findBrowser();
|
|
221
|
+
const browser: Browser = await puppeteer.launch({
|
|
222
|
+
executablePath,
|
|
223
|
+
headless: true,
|
|
224
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const report: DesignEvalReport = {
|
|
228
|
+
capturedAt: new Date().toISOString(),
|
|
229
|
+
serverUrl: baseUrl,
|
|
230
|
+
routes,
|
|
231
|
+
viewports,
|
|
232
|
+
quality: options.quality,
|
|
233
|
+
captures: [],
|
|
234
|
+
errors: [],
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const page = await browser.newPage();
|
|
239
|
+
|
|
240
|
+
for (const route of routes) {
|
|
241
|
+
for (const viewport of viewports) {
|
|
242
|
+
try {
|
|
243
|
+
const results = await captureRoute(page, route, baseUrl, viewport, {
|
|
244
|
+
quality: options.quality,
|
|
245
|
+
maxSections: options.maxSections,
|
|
246
|
+
outputDir,
|
|
247
|
+
});
|
|
248
|
+
report.captures.push(...results);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
251
|
+
report.errors.push({
|
|
252
|
+
route,
|
|
253
|
+
viewport: viewport.name,
|
|
254
|
+
error: message,
|
|
255
|
+
});
|
|
256
|
+
console.error(
|
|
257
|
+
`[mink] Error capturing ${route} (${viewport.name}): ${message}`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
await page.close();
|
|
264
|
+
} finally {
|
|
265
|
+
await browser.close();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return report;
|
|
269
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
2
|
+
import { join, relative, sep } from "path";
|
|
3
|
+
|
|
4
|
+
export type FrameworkType = "nextjs" | "sveltekit" | "nuxt" | "generic";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect the frontend framework by checking for config files.
|
|
8
|
+
*/
|
|
9
|
+
export function detectFramework(cwd: string): FrameworkType {
|
|
10
|
+
const has = (name: string) =>
|
|
11
|
+
["js", "mjs", "ts", "cjs"].some((ext) =>
|
|
12
|
+
existsSync(join(cwd, `${name}.${ext}`))
|
|
13
|
+
) || existsSync(join(cwd, name));
|
|
14
|
+
|
|
15
|
+
if (has("next.config")) return "nextjs";
|
|
16
|
+
if (has("svelte.config")) return "sveltekit";
|
|
17
|
+
if (has("nuxt.config")) return "nuxt";
|
|
18
|
+
return "generic";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detect capturable routes based on the project's framework conventions.
|
|
23
|
+
* Only returns static routes — dynamic segments like [param] are excluded.
|
|
24
|
+
*/
|
|
25
|
+
export function detectRoutes(cwd: string): string[] {
|
|
26
|
+
const framework = detectFramework(cwd);
|
|
27
|
+
|
|
28
|
+
switch (framework) {
|
|
29
|
+
case "nextjs":
|
|
30
|
+
return detectNextRoutes(cwd);
|
|
31
|
+
case "sveltekit":
|
|
32
|
+
return detectSvelteKitRoutes(cwd);
|
|
33
|
+
case "nuxt":
|
|
34
|
+
return detectNuxtRoutes(cwd);
|
|
35
|
+
case "generic":
|
|
36
|
+
default:
|
|
37
|
+
return ["/"];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Next.js ───────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function detectNextRoutes(cwd: string): string[] {
|
|
44
|
+
const routes: string[] = [];
|
|
45
|
+
|
|
46
|
+
// App Router: app/**/page.{tsx,jsx,js,ts}
|
|
47
|
+
const appDir = join(cwd, "app");
|
|
48
|
+
if (existsSync(appDir)) {
|
|
49
|
+
const pageFiles = findFiles(appDir, /^page\.(tsx?|jsx?)$/);
|
|
50
|
+
for (const file of pageFiles) {
|
|
51
|
+
const rel = relative(appDir, file);
|
|
52
|
+
const dir = rel.replace(/([/\\])?page\.(tsx?|jsx?)$/, "");
|
|
53
|
+
const route = dir === "" ? "/" : `/${dir.split(sep).join("/")}`;
|
|
54
|
+
|
|
55
|
+
// Skip dynamic segments, route groups, and parallel routes
|
|
56
|
+
if (/\[|@|\(/.test(route)) continue;
|
|
57
|
+
routes.push(route);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Pages Router: pages/**/*.{tsx,jsx,js,ts}
|
|
62
|
+
const pagesDir = join(cwd, "pages");
|
|
63
|
+
if (existsSync(pagesDir)) {
|
|
64
|
+
const pageFiles = findFiles(pagesDir, /\.(tsx?|jsx?)$/);
|
|
65
|
+
for (const file of pageFiles) {
|
|
66
|
+
const rel = relative(pagesDir, file);
|
|
67
|
+
const name = rel.replace(/\.(tsx?|jsx?)$/, "");
|
|
68
|
+
|
|
69
|
+
// Skip special Next.js files and API routes
|
|
70
|
+
if (/^_(app|document|error)/.test(name)) continue;
|
|
71
|
+
if (name.startsWith(`api${sep}`) || name === "api") continue;
|
|
72
|
+
// Skip dynamic segments
|
|
73
|
+
if (/\[/.test(name)) continue;
|
|
74
|
+
|
|
75
|
+
const route =
|
|
76
|
+
name === "index" ? "/" : `/${name.split(sep).join("/")}`;
|
|
77
|
+
routes.push(route);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Deduplicate (app router and pages router may both define /)
|
|
82
|
+
const unique = [...new Set(routes)];
|
|
83
|
+
return unique.length > 0 ? unique.sort() : ["/"];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── SvelteKit ─────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function detectSvelteKitRoutes(cwd: string): string[] {
|
|
89
|
+
const routesDir = join(cwd, "src", "routes");
|
|
90
|
+
if (!existsSync(routesDir)) return ["/"];
|
|
91
|
+
|
|
92
|
+
const routes: string[] = [];
|
|
93
|
+
const pageFiles = findFiles(routesDir, /^\+page\.svelte$/);
|
|
94
|
+
|
|
95
|
+
for (const file of pageFiles) {
|
|
96
|
+
const rel = relative(routesDir, file);
|
|
97
|
+
const dir = rel.replace(/([/\\])?\+page\.svelte$/, "");
|
|
98
|
+
const route = dir === "" ? "/" : `/${dir.split(sep).join("/")}`;
|
|
99
|
+
|
|
100
|
+
// Skip dynamic segments and groups
|
|
101
|
+
if (/\[|\(/.test(route)) continue;
|
|
102
|
+
routes.push(route);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return routes.length > 0 ? routes.sort() : ["/"];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Nuxt ──────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function detectNuxtRoutes(cwd: string): string[] {
|
|
111
|
+
const pagesDir = join(cwd, "pages");
|
|
112
|
+
if (!existsSync(pagesDir)) return ["/"];
|
|
113
|
+
|
|
114
|
+
const routes: string[] = [];
|
|
115
|
+
const vueFiles = findFiles(pagesDir, /\.vue$/);
|
|
116
|
+
|
|
117
|
+
for (const file of vueFiles) {
|
|
118
|
+
const rel = relative(pagesDir, file);
|
|
119
|
+
const name = rel.replace(/\.vue$/, "");
|
|
120
|
+
|
|
121
|
+
// Skip dynamic segments
|
|
122
|
+
if (/\[/.test(name)) continue;
|
|
123
|
+
|
|
124
|
+
const route =
|
|
125
|
+
name === "index" ? "/" : `/${name.split(sep).join("/")}`;
|
|
126
|
+
routes.push(route);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return routes.length > 0 ? routes.sort() : ["/"];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function findFiles(dir: string, pattern: RegExp): string[] {
|
|
135
|
+
const results: string[] = [];
|
|
136
|
+
|
|
137
|
+
function walk(current: string): void {
|
|
138
|
+
let entries: string[];
|
|
139
|
+
try {
|
|
140
|
+
entries = readdirSync(current);
|
|
141
|
+
} catch {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
// Skip hidden dirs and node_modules
|
|
147
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
148
|
+
|
|
149
|
+
const full = join(current, entry);
|
|
150
|
+
try {
|
|
151
|
+
const stat = statSync(full);
|
|
152
|
+
if (stat.isDirectory()) {
|
|
153
|
+
walk(full);
|
|
154
|
+
} else if (pattern.test(entry)) {
|
|
155
|
+
results.push(full);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Skip inaccessible entries
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
walk(dir);
|
|
164
|
+
return results;
|
|
165
|
+
}
|