popeye-cli 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +50 -8
- package/dist/cli/commands/create.d.ts.map +1 -1
- package/dist/cli/commands/create.js +54 -4
- package/dist/cli/commands/create.js.map +1 -1
- package/dist/cli/interactive.d.ts +29 -0
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +90 -7
- package/dist/cli/interactive.js.map +1 -1
- package/dist/generators/all.d.ts +4 -1
- package/dist/generators/all.d.ts.map +1 -1
- package/dist/generators/all.js +36 -316
- package/dist/generators/all.js.map +1 -1
- package/dist/generators/doc-parser.d.ts +18 -3
- package/dist/generators/doc-parser.d.ts.map +1 -1
- package/dist/generators/doc-parser.js +81 -10
- package/dist/generators/doc-parser.js.map +1 -1
- package/dist/generators/frontend-design-analyzer.d.ts +30 -0
- package/dist/generators/frontend-design-analyzer.d.ts.map +1 -0
- package/dist/generators/frontend-design-analyzer.js +208 -0
- package/dist/generators/frontend-design-analyzer.js.map +1 -0
- package/dist/generators/shared-packages.d.ts +45 -0
- package/dist/generators/shared-packages.d.ts.map +1 -0
- package/dist/generators/shared-packages.js +456 -0
- package/dist/generators/shared-packages.js.map +1 -0
- package/dist/generators/templates/index.d.ts +4 -0
- package/dist/generators/templates/index.d.ts.map +1 -1
- package/dist/generators/templates/index.js +4 -0
- package/dist/generators/templates/index.js.map +1 -1
- package/dist/generators/templates/website-components.d.ts.map +1 -1
- package/dist/generators/templates/website-components.js +36 -11
- package/dist/generators/templates/website-components.js.map +1 -1
- package/dist/generators/templates/website-config.d.ts +15 -1
- package/dist/generators/templates/website-config.d.ts.map +1 -1
- package/dist/generators/templates/website-config.js +155 -13
- package/dist/generators/templates/website-config.js.map +1 -1
- package/dist/generators/templates/website-landing.d.ts +24 -0
- package/dist/generators/templates/website-landing.d.ts.map +1 -0
- package/dist/generators/templates/website-landing.js +276 -0
- package/dist/generators/templates/website-landing.js.map +1 -0
- package/dist/generators/templates/website-layout.d.ts +42 -0
- package/dist/generators/templates/website-layout.d.ts.map +1 -0
- package/dist/generators/templates/website-layout.js +408 -0
- package/dist/generators/templates/website-layout.js.map +1 -0
- package/dist/generators/templates/website-pricing.d.ts +11 -0
- package/dist/generators/templates/website-pricing.d.ts.map +1 -0
- package/dist/generators/templates/website-pricing.js +313 -0
- package/dist/generators/templates/website-pricing.js.map +1 -0
- package/dist/generators/templates/website-sections.d.ts +102 -0
- package/dist/generators/templates/website-sections.d.ts.map +1 -0
- package/dist/generators/templates/website-sections.js +444 -0
- package/dist/generators/templates/website-sections.js.map +1 -0
- package/dist/generators/templates/website.d.ts +10 -50
- package/dist/generators/templates/website.d.ts.map +1 -1
- package/dist/generators/templates/website.js +12 -788
- package/dist/generators/templates/website.js.map +1 -1
- package/dist/generators/website-content-scanner.d.ts +37 -0
- package/dist/generators/website-content-scanner.d.ts.map +1 -0
- package/dist/generators/website-content-scanner.js +165 -0
- package/dist/generators/website-content-scanner.js.map +1 -0
- package/dist/generators/website-context.d.ts +38 -2
- package/dist/generators/website-context.d.ts.map +1 -1
- package/dist/generators/website-context.js +179 -19
- package/dist/generators/website-context.js.map +1 -1
- package/dist/generators/website-debug.d.ts +68 -0
- package/dist/generators/website-debug.d.ts.map +1 -0
- package/dist/generators/website-debug.js +93 -0
- package/dist/generators/website-debug.js.map +1 -0
- package/dist/generators/website.d.ts +2 -0
- package/dist/generators/website.d.ts.map +1 -1
- package/dist/generators/website.js +66 -4
- package/dist/generators/website.js.map +1 -1
- package/dist/generators/workspace-root.d.ts +27 -0
- package/dist/generators/workspace-root.d.ts.map +1 -0
- package/dist/generators/workspace-root.js +100 -0
- package/dist/generators/workspace-root.js.map +1 -0
- package/dist/state/index.d.ts +8 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/state/index.js +10 -0
- package/dist/state/index.js.map +1 -1
- package/dist/types/workflow.d.ts +6 -0
- package/dist/types/workflow.d.ts.map +1 -1
- package/dist/types/workflow.js +2 -0
- package/dist/types/workflow.js.map +1 -1
- package/dist/upgrade/handlers.d.ts +15 -0
- package/dist/upgrade/handlers.d.ts.map +1 -1
- package/dist/upgrade/handlers.js +52 -0
- package/dist/upgrade/handlers.js.map +1 -1
- package/dist/workflow/auto-fix-bundler.d.ts +37 -0
- package/dist/workflow/auto-fix-bundler.d.ts.map +1 -0
- package/dist/workflow/auto-fix-bundler.js +320 -0
- package/dist/workflow/auto-fix-bundler.js.map +1 -0
- package/dist/workflow/auto-fix.d.ts.map +1 -1
- package/dist/workflow/auto-fix.js +10 -3
- package/dist/workflow/auto-fix.js.map +1 -1
- package/dist/workflow/index.d.ts +1 -0
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +12 -0
- package/dist/workflow/index.js.map +1 -1
- package/dist/workflow/overview.d.ts.map +1 -1
- package/dist/workflow/overview.js +4 -0
- package/dist/workflow/overview.js.map +1 -1
- package/dist/workflow/plan-mode.d.ts +4 -3
- package/dist/workflow/plan-mode.d.ts.map +1 -1
- package/dist/workflow/plan-mode.js +69 -5
- package/dist/workflow/plan-mode.js.map +1 -1
- package/dist/workflow/website-strategy.d.ts +9 -0
- package/dist/workflow/website-strategy.d.ts.map +1 -1
- package/dist/workflow/website-strategy.js +73 -1
- package/dist/workflow/website-strategy.js.map +1 -1
- package/dist/workflow/website-updater.d.ts.map +1 -1
- package/dist/workflow/website-updater.js +15 -4
- package/dist/workflow/website-updater.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/create.ts +58 -4
- package/src/cli/interactive.ts +96 -7
- package/src/generators/all.ts +44 -332
- package/src/generators/doc-parser.ts +87 -10
- package/src/generators/frontend-design-analyzer.ts +261 -0
- package/src/generators/shared-packages.ts +500 -0
- package/src/generators/templates/index.ts +4 -0
- package/src/generators/templates/website-components.ts +36 -11
- package/src/generators/templates/website-config.ts +166 -13
- package/src/generators/templates/website-landing.ts +331 -0
- package/src/generators/templates/website-layout.ts +443 -0
- package/src/generators/templates/website-pricing.ts +330 -0
- package/src/generators/templates/website-sections.ts +541 -0
- package/src/generators/templates/website.ts +38 -851
- package/src/generators/website-content-scanner.ts +208 -0
- package/src/generators/website-context.ts +248 -20
- package/src/generators/website-debug.ts +130 -0
- package/src/generators/website.ts +71 -3
- package/src/generators/workspace-root.ts +113 -0
- package/src/state/index.ts +14 -0
- package/src/types/workflow.ts +6 -0
- package/src/upgrade/handlers.ts +65 -0
- package/src/workflow/auto-fix-bundler.ts +392 -0
- package/src/workflow/auto-fix.ts +11 -3
- package/src/workflow/index.ts +12 -0
- package/src/workflow/overview.ts +6 -0
- package/src/workflow/plan-mode.ts +81 -7
- package/src/workflow/website-strategy.ts +75 -1
- package/src/workflow/website-updater.ts +17 -6
- package/tests/cli/project-naming.test.ts +136 -0
- package/tests/generators/doc-parser.test.ts +121 -0
- package/tests/generators/frontend-design-analyzer.test.ts +90 -0
- package/tests/generators/quality-gate.test.ts +183 -0
- package/tests/generators/shared-packages.test.ts +83 -0
- package/tests/generators/website-components.test.ts +1 -1
- package/tests/generators/website-config.test.ts +84 -0
- package/tests/generators/website-content-scanner.test.ts +181 -0
- package/tests/generators/website-context.test.ts +109 -0
- package/tests/generators/website-debug.test.ts +77 -0
- package/tests/generators/website-landing.test.ts +188 -0
- package/tests/generators/website-pricing.test.ts +98 -0
- package/tests/generators/website-sections.test.ts +245 -0
- package/tests/generators/workspace-root.test.ts +105 -0
- package/tests/upgrade/handlers.test.ts +162 -0
- package/tests/workflow/auto-fix-bundler.test.ts +242 -0
- package/tests/workflow/plan-mode.test.ts +111 -1
- package/tests/workflow/website-strategy.test.ts +55 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-generation content scanner
|
|
3
|
+
* Scans generated website files for known placeholder fingerprints
|
|
4
|
+
* and reports quality issues as warnings
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A single scan issue found in a generated file
|
|
12
|
+
*/
|
|
13
|
+
export interface ScanIssue {
|
|
14
|
+
/** Relative file path within the website directory */
|
|
15
|
+
file: string;
|
|
16
|
+
/** Human-readable description of the issue */
|
|
17
|
+
message: string;
|
|
18
|
+
/** Severity: error = likely broken, warning = looks generic */
|
|
19
|
+
severity: 'error' | 'warning';
|
|
20
|
+
/** Line number where the issue was found (approximate) */
|
|
21
|
+
line?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Result of scanning generated website content
|
|
26
|
+
*/
|
|
27
|
+
export interface ScanResult {
|
|
28
|
+
/** All issues found across scanned files */
|
|
29
|
+
issues: ScanIssue[];
|
|
30
|
+
/** Number of files scanned */
|
|
31
|
+
filesScanned: number;
|
|
32
|
+
/** Content quality score 0-100 based on issues found */
|
|
33
|
+
score: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Known placeholder patterns to detect in generated files
|
|
38
|
+
*/
|
|
39
|
+
const PLACEHOLDER_PATTERNS: Array<{
|
|
40
|
+
pattern: RegExp;
|
|
41
|
+
message: string;
|
|
42
|
+
severity: 'error' | 'warning';
|
|
43
|
+
}> = [
|
|
44
|
+
{
|
|
45
|
+
pattern: /\/\*\s*TODO[^*]*\*\//,
|
|
46
|
+
message: 'Contains TODO block comment',
|
|
47
|
+
severity: 'error',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
pattern: /\{\/\*\s*TODO[^*]*\*\/\}/,
|
|
51
|
+
message: 'Contains JSX TODO comment',
|
|
52
|
+
severity: 'error',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
pattern: /\/\/\s*TODO\b/,
|
|
56
|
+
message: 'Contains TODO line comment',
|
|
57
|
+
severity: 'error',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
pattern: /Build something amazing/,
|
|
61
|
+
message: 'Default tagline "Build something amazing"',
|
|
62
|
+
severity: 'warning',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
pattern: /Your modern web application/,
|
|
66
|
+
message: 'Generic description "Your modern web application"',
|
|
67
|
+
severity: 'warning',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
pattern: /Lorem ipsum/i,
|
|
71
|
+
message: 'Contains lorem ipsum placeholder text',
|
|
72
|
+
severity: 'error',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
pattern: /\$29(?:\/mo)?/,
|
|
76
|
+
message: 'Default pricing amount ($29/mo)',
|
|
77
|
+
severity: 'warning',
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Multi-line patterns checked against the full file content
|
|
83
|
+
* These detect combinations that indicate default/template content
|
|
84
|
+
*/
|
|
85
|
+
const COMPOSITE_PATTERNS: Array<{
|
|
86
|
+
pattern: RegExp;
|
|
87
|
+
message: string;
|
|
88
|
+
severity: 'error' | 'warning';
|
|
89
|
+
}> = [
|
|
90
|
+
{
|
|
91
|
+
pattern: /name:\s*['"]Starter['"][\s\S]{0,500}name:\s*['"]Pro['"][\s\S]{0,500}name:\s*['"]Enterprise['"]/,
|
|
92
|
+
message: 'Default pricing tiers (Starter/Pro/Enterprise)',
|
|
93
|
+
severity: 'warning',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
pattern: /title:\s*['"]Sign Up['"][\s\S]{0,500}title:\s*['"]Configure['"][\s\S]{0,500}title:\s*['"]Deploy['"]/,
|
|
97
|
+
message: 'Default "How It Works" steps (Sign Up/Configure/Deploy)',
|
|
98
|
+
severity: 'warning',
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* File extensions to scan within the website directory
|
|
104
|
+
*/
|
|
105
|
+
const SCANNABLE_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css']);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Directories to skip during scanning
|
|
109
|
+
*/
|
|
110
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', 'coverage']);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Recursively collect scannable files from a directory
|
|
114
|
+
*
|
|
115
|
+
* @param dir - Directory to scan
|
|
116
|
+
* @param baseDir - Base directory for relative path calculation
|
|
117
|
+
* @returns Array of absolute file paths
|
|
118
|
+
*/
|
|
119
|
+
async function collectFiles(dir: string, baseDir: string): Promise<string[]> {
|
|
120
|
+
const results: string[] = [];
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
124
|
+
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
if (entry.isDirectory()) {
|
|
127
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
128
|
+
const subFiles = await collectFiles(path.join(dir, entry.name), baseDir);
|
|
129
|
+
results.push(...subFiles);
|
|
130
|
+
} else if (entry.isFile()) {
|
|
131
|
+
const ext = path.extname(entry.name);
|
|
132
|
+
if (SCANNABLE_EXTENSIONS.has(ext)) {
|
|
133
|
+
results.push(path.join(dir, entry.name));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// Directory not accessible, skip
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return results;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Find the approximate line number for a regex match in content
|
|
146
|
+
*
|
|
147
|
+
* @param content - File content
|
|
148
|
+
* @param pattern - Pattern to search for
|
|
149
|
+
* @returns Line number (1-based) or undefined
|
|
150
|
+
*/
|
|
151
|
+
function findLineNumber(content: string, pattern: RegExp): number | undefined {
|
|
152
|
+
const match = content.match(pattern);
|
|
153
|
+
if (!match || match.index === undefined) return undefined;
|
|
154
|
+
const beforeMatch = content.slice(0, match.index);
|
|
155
|
+
return beforeMatch.split('\n').length;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Scan generated website files for placeholder fingerprints
|
|
160
|
+
*
|
|
161
|
+
* @param websiteDir - The website project directory to scan
|
|
162
|
+
* @returns Scan result with issues and quality score
|
|
163
|
+
*/
|
|
164
|
+
export async function scanGeneratedContent(websiteDir: string): Promise<ScanResult> {
|
|
165
|
+
const issues: ScanIssue[] = [];
|
|
166
|
+
const files = await collectFiles(path.join(websiteDir, 'src'), websiteDir);
|
|
167
|
+
let score = 100;
|
|
168
|
+
|
|
169
|
+
for (const filePath of files) {
|
|
170
|
+
try {
|
|
171
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
172
|
+
const relativePath = path.relative(websiteDir, filePath);
|
|
173
|
+
|
|
174
|
+
// Check per-line patterns
|
|
175
|
+
for (const { pattern, message, severity } of PLACEHOLDER_PATTERNS) {
|
|
176
|
+
if (pattern.test(content)) {
|
|
177
|
+
issues.push({
|
|
178
|
+
file: relativePath,
|
|
179
|
+
message,
|
|
180
|
+
severity,
|
|
181
|
+
line: findLineNumber(content, pattern),
|
|
182
|
+
});
|
|
183
|
+
score -= severity === 'error' ? 15 : 5;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check composite (multi-line) patterns
|
|
188
|
+
for (const { pattern, message, severity } of COMPOSITE_PATTERNS) {
|
|
189
|
+
if (pattern.test(content)) {
|
|
190
|
+
issues.push({
|
|
191
|
+
file: relativePath,
|
|
192
|
+
message,
|
|
193
|
+
severity,
|
|
194
|
+
});
|
|
195
|
+
score -= severity === 'error' ? 15 : 5;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// Skip unreadable files
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
issues,
|
|
205
|
+
filesScanned: files.length,
|
|
206
|
+
score: Math.max(0, Math.min(100, score)),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -15,8 +15,12 @@ import {
|
|
|
15
15
|
extractPricing,
|
|
16
16
|
extractPrimaryColor,
|
|
17
17
|
} from './doc-parser.js';
|
|
18
|
+
import { getScanDirectories } from './workspace-root.js';
|
|
18
19
|
import type { WebsiteStrategyDocument, BrandAssetsContract } from '../types/website-strategy.js';
|
|
19
20
|
|
|
21
|
+
/** Per-file character cap to prevent a single large doc from consuming the budget */
|
|
22
|
+
const PER_FILE_CAP = 8000;
|
|
23
|
+
|
|
20
24
|
/**
|
|
21
25
|
* Structured content context for website generation
|
|
22
26
|
*/
|
|
@@ -48,7 +52,8 @@ export interface WebsiteContentContext {
|
|
|
48
52
|
|
|
49
53
|
/**
|
|
50
54
|
* Resolve brand assets into a deterministic contract
|
|
51
|
-
* Searches for logo/favicon in project directory
|
|
55
|
+
* Searches for logo/favicon in project directory and workspace root,
|
|
56
|
+
* sets canonical output paths
|
|
52
57
|
*
|
|
53
58
|
* @param cwd - Directory to scan for brand assets
|
|
54
59
|
* @param brandContext - Optional existing brand context from state
|
|
@@ -103,10 +108,53 @@ const EXCLUDED_DIRS = [
|
|
|
103
108
|
* @returns Array of absolute paths to discovered doc files
|
|
104
109
|
*/
|
|
105
110
|
export async function discoverProjectDocs(cwd: string): Promise<string[]> {
|
|
111
|
+
const scanDirs = await getScanDirectories(cwd);
|
|
112
|
+
const allDocs: string[] = [];
|
|
113
|
+
const seenPaths = new Set<string>();
|
|
114
|
+
|
|
115
|
+
for (const dir of scanDirs) {
|
|
116
|
+
const found = await scanDirectoryForDocs(dir);
|
|
117
|
+
for (const docPath of found) {
|
|
118
|
+
const resolved = path.resolve(docPath);
|
|
119
|
+
if (!seenPaths.has(resolved)) {
|
|
120
|
+
seenPaths.add(resolved);
|
|
121
|
+
allDocs.push(resolved);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Sort: brand/color docs first, then spec, then pricing, then others
|
|
127
|
+
// Reason: ensures critical brand context isn't truncated by the maxLength cap
|
|
128
|
+
allDocs.sort((a, b) => {
|
|
129
|
+
const nameA = path.basename(a).toLowerCase();
|
|
130
|
+
const nameB = path.basename(b).toLowerCase();
|
|
131
|
+
const priorityA = getDocPriority(nameA);
|
|
132
|
+
const priorityB = getDocPriority(nameB);
|
|
133
|
+
return priorityA - priorityB;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return allDocs;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get sort priority for a doc file (lower = higher priority)
|
|
141
|
+
*/
|
|
142
|
+
function getDocPriority(fileName: string): number {
|
|
143
|
+
if (/color|brand/.test(fileName)) return 0;
|
|
144
|
+
if (/spec/.test(fileName)) return 1;
|
|
145
|
+
if (/pricing/.test(fileName)) return 2;
|
|
146
|
+
if (/feature/.test(fileName)) return 3;
|
|
147
|
+
return 4;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Scan a single directory for doc files (flat + docs/ subdirectory)
|
|
152
|
+
*/
|
|
153
|
+
async function scanDirectoryForDocs(dir: string): Promise<string[]> {
|
|
106
154
|
const docs: string[] = [];
|
|
107
155
|
|
|
108
156
|
try {
|
|
109
|
-
const entries = await fs.readdir(
|
|
157
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
110
158
|
|
|
111
159
|
for (const entry of entries) {
|
|
112
160
|
if (entry.isDirectory() && EXCLUDED_DIRS.includes(entry.name)) {
|
|
@@ -116,19 +164,19 @@ export async function discoverProjectDocs(cwd: string): Promise<string[]> {
|
|
|
116
164
|
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
117
165
|
const matches = DOC_PATTERNS.some((pattern) => pattern.test(entry.name));
|
|
118
166
|
if (matches) {
|
|
119
|
-
docs.push(path.join(
|
|
167
|
+
docs.push(path.join(dir, entry.name));
|
|
120
168
|
}
|
|
121
169
|
}
|
|
122
170
|
|
|
123
171
|
// Check one level of subdirectories (e.g., docs/)
|
|
124
172
|
if (entry.isDirectory() && entry.name === 'docs') {
|
|
125
173
|
try {
|
|
126
|
-
const subEntries = await fs.readdir(path.join(
|
|
174
|
+
const subEntries = await fs.readdir(path.join(dir, entry.name), {
|
|
127
175
|
withFileTypes: true,
|
|
128
176
|
});
|
|
129
177
|
for (const subEntry of subEntries) {
|
|
130
178
|
if (subEntry.isFile() && subEntry.name.endsWith('.md')) {
|
|
131
|
-
docs.push(path.join(
|
|
179
|
+
docs.push(path.join(dir, entry.name, subEntry.name));
|
|
132
180
|
}
|
|
133
181
|
}
|
|
134
182
|
} catch {
|
|
@@ -140,21 +188,12 @@ export async function discoverProjectDocs(cwd: string): Promise<string[]> {
|
|
|
140
188
|
// Directory not accessible
|
|
141
189
|
}
|
|
142
190
|
|
|
143
|
-
// Sort: color/brand docs first (small but critical), then others
|
|
144
|
-
// This ensures brand context isn't truncated by the maxLength cap
|
|
145
|
-
docs.sort((a, b) => {
|
|
146
|
-
const nameA = path.basename(a).toLowerCase();
|
|
147
|
-
const nameB = path.basename(b).toLowerCase();
|
|
148
|
-
const priorityA = /color|brand/.test(nameA) ? 0 : 1;
|
|
149
|
-
const priorityB = /color|brand/.test(nameB) ? 0 : 1;
|
|
150
|
-
return priorityA - priorityB;
|
|
151
|
-
});
|
|
152
|
-
|
|
153
191
|
return docs;
|
|
154
192
|
}
|
|
155
193
|
|
|
156
194
|
/**
|
|
157
195
|
* Find brand assets (logo files) in a directory
|
|
196
|
+
* Also searches workspace root and parent directories
|
|
158
197
|
*
|
|
159
198
|
* @param cwd - Directory to scan for brand assets
|
|
160
199
|
* @returns Object with optional logoPath
|
|
@@ -162,10 +201,24 @@ export async function discoverProjectDocs(cwd: string): Promise<string[]> {
|
|
|
162
201
|
export async function findBrandAssets(
|
|
163
202
|
cwd: string
|
|
164
203
|
): Promise<{ logoPath?: string }> {
|
|
204
|
+
const scanDirs = await getScanDirectories(cwd);
|
|
205
|
+
|
|
206
|
+
for (const dir of scanDirs) {
|
|
207
|
+
const result = await scanDirectoryForLogo(dir);
|
|
208
|
+
if (result.logoPath) return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Scan a single directory for logo files
|
|
216
|
+
*/
|
|
217
|
+
async function scanDirectoryForLogo(dir: string): Promise<{ logoPath?: string }> {
|
|
165
218
|
const logoExtensions = ['.png', '.svg', '.jpg', '.jpeg', '.webp'];
|
|
166
219
|
|
|
167
220
|
try {
|
|
168
|
-
const entries = await fs.readdir(
|
|
221
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
169
222
|
|
|
170
223
|
for (const entry of entries) {
|
|
171
224
|
if (!entry.isFile()) continue;
|
|
@@ -175,7 +228,7 @@ export async function findBrandAssets(
|
|
|
175
228
|
const hasValidExt = logoExtensions.some((ext) => lowerName.endsWith(ext));
|
|
176
229
|
|
|
177
230
|
if (hasLogoInName && hasValidExt) {
|
|
178
|
-
return { logoPath: path.join(
|
|
231
|
+
return { logoPath: path.join(dir, entry.name) };
|
|
179
232
|
}
|
|
180
233
|
}
|
|
181
234
|
} catch {
|
|
@@ -189,12 +242,12 @@ export async function findBrandAssets(
|
|
|
189
242
|
* Read and concatenate project documentation files
|
|
190
243
|
*
|
|
191
244
|
* @param docPaths - Array of absolute paths to doc files
|
|
192
|
-
* @param maxLength - Maximum combined length (default
|
|
245
|
+
* @param maxLength - Maximum combined length (default 25000 chars)
|
|
193
246
|
* @returns Combined documentation content with file headers
|
|
194
247
|
*/
|
|
195
248
|
export async function readProjectDocs(
|
|
196
249
|
docPaths: string[],
|
|
197
|
-
maxLength: number =
|
|
250
|
+
maxLength: number = 25000
|
|
198
251
|
): Promise<string> {
|
|
199
252
|
const sections: string[] = [];
|
|
200
253
|
let totalLength = 0;
|
|
@@ -203,9 +256,15 @@ export async function readProjectDocs(
|
|
|
203
256
|
if (totalLength >= maxLength) break;
|
|
204
257
|
|
|
205
258
|
try {
|
|
206
|
-
|
|
259
|
+
let content = await fs.readFile(docPath, 'utf-8');
|
|
207
260
|
const fileName = path.basename(docPath);
|
|
208
261
|
const header = `--- ${fileName} ---`;
|
|
262
|
+
|
|
263
|
+
// Per-file cap to prevent a single large doc from consuming the budget
|
|
264
|
+
if (content.length > PER_FILE_CAP) {
|
|
265
|
+
content = content.slice(0, PER_FILE_CAP) + '...';
|
|
266
|
+
}
|
|
267
|
+
|
|
209
268
|
const remaining = maxLength - totalLength;
|
|
210
269
|
const trimmedContent =
|
|
211
270
|
content.length > remaining ? content.slice(0, remaining) + '...' : content;
|
|
@@ -263,3 +322,172 @@ export async function buildWebsiteContext(
|
|
|
263
322
|
return context;
|
|
264
323
|
}
|
|
265
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Result of soft (non-throwing) website context validation
|
|
327
|
+
*/
|
|
328
|
+
export interface ValidationResult {
|
|
329
|
+
/** Whether the context is sufficient for generation (no blocking issues) */
|
|
330
|
+
passed: boolean;
|
|
331
|
+
/** Blocking problems that prevent quality generation */
|
|
332
|
+
issues: string[];
|
|
333
|
+
/** Non-blocking concerns about content quality */
|
|
334
|
+
warnings: string[];
|
|
335
|
+
/** Content quality score 0-100, deducted for each default/missing piece */
|
|
336
|
+
contentScore: number;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Default pricing tier names that indicate placeholder content */
|
|
340
|
+
const DEFAULT_PRICING_NAMES = ['starter', 'free', 'pro', 'enterprise'];
|
|
341
|
+
|
|
342
|
+
/** Default pricing amounts that indicate placeholder content */
|
|
343
|
+
const DEFAULT_PRICING_AMOUNTS = ['$0', '$29', '$99', 'custom', 'free'];
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Validate website content context without throwing
|
|
347
|
+
* Returns structured result with issues, warnings, and a content quality score
|
|
348
|
+
*
|
|
349
|
+
* @param context - The built website content context
|
|
350
|
+
* @param projectName - The project name for validation
|
|
351
|
+
* @returns Validation result with issues, warnings, and score
|
|
352
|
+
*/
|
|
353
|
+
export function validateWebsiteContext(
|
|
354
|
+
context: WebsiteContentContext,
|
|
355
|
+
_projectName: string
|
|
356
|
+
): ValidationResult {
|
|
357
|
+
const issues: string[] = [];
|
|
358
|
+
const warnings: string[] = [];
|
|
359
|
+
let contentScore = 100;
|
|
360
|
+
|
|
361
|
+
// Suspicious product name (looks like a directory name with hyphens/underscores)
|
|
362
|
+
const suspiciousNames = ['my-app', 'my-project', 'project', 'app', 'website', 'frontend'];
|
|
363
|
+
if (
|
|
364
|
+
/^[a-z]+-[a-z]+(-[a-z]+)*$/.test(context.productName) ||
|
|
365
|
+
suspiciousNames.includes(context.productName.toLowerCase())
|
|
366
|
+
) {
|
|
367
|
+
issues.push(
|
|
368
|
+
`Product name "${context.productName}" looks like a directory name, not a product. ` +
|
|
369
|
+
`Provide .md docs with "# ProductName -- tagline" heading.`
|
|
370
|
+
);
|
|
371
|
+
contentScore -= 25;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// No docs found at all
|
|
375
|
+
if (!context.rawDocs || context.rawDocs.length < 100) {
|
|
376
|
+
issues.push(
|
|
377
|
+
'No project documentation found. Place .md files (spec, pricing, brand) ' +
|
|
378
|
+
'in the project directory or its parent.'
|
|
379
|
+
);
|
|
380
|
+
contentScore -= 30;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// No features extracted
|
|
384
|
+
if (context.features.length === 0) {
|
|
385
|
+
issues.push(
|
|
386
|
+
'No product features extracted from docs. Add a "## Features" section to your spec.'
|
|
387
|
+
);
|
|
388
|
+
contentScore -= 20;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Strategy validation
|
|
392
|
+
if (!context.strategy) {
|
|
393
|
+
issues.push(
|
|
394
|
+
'Website strategy missing. Strategy generation may have failed or been skipped.'
|
|
395
|
+
);
|
|
396
|
+
contentScore -= 15;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Brand color validation: brand/color docs exist but no color extracted
|
|
400
|
+
if (!context.brand?.primaryColor && context.rawDocs && /color|brand/i.test(context.rawDocs)) {
|
|
401
|
+
issues.push(
|
|
402
|
+
'Brand/color docs found but no primary color extracted. Check color doc format.'
|
|
403
|
+
);
|
|
404
|
+
contentScore -= 5;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Logo found but output path not resolved
|
|
408
|
+
if (context.brand?.logoPath && !context.brandAssets?.logoOutputPath) {
|
|
409
|
+
issues.push(
|
|
410
|
+
'Logo found but output path not resolved. Call resolveBrandAssets().'
|
|
411
|
+
);
|
|
412
|
+
contentScore -= 5;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// --- Non-blocking warnings ---
|
|
416
|
+
|
|
417
|
+
// Default pricing tiers detection
|
|
418
|
+
if (context.pricing && context.pricing.length > 0) {
|
|
419
|
+
const tierNames = context.pricing.map((t) => t.name.toLowerCase());
|
|
420
|
+
const tierPrices = context.pricing.map((t) => t.price.toLowerCase().replace(/\/mo.*/, ''));
|
|
421
|
+
const nameMatches = tierNames.filter((n) => DEFAULT_PRICING_NAMES.includes(n));
|
|
422
|
+
const priceMatches = tierPrices.filter((p) => DEFAULT_PRICING_AMOUNTS.includes(p));
|
|
423
|
+
|
|
424
|
+
// Reason: if most tier names AND prices match defaults, it's likely placeholder content
|
|
425
|
+
if (nameMatches.length >= 2 && priceMatches.length >= 2) {
|
|
426
|
+
warnings.push(
|
|
427
|
+
'Pricing tiers appear to use default values (Starter/Pro/Enterprise at $0/$29/Custom). ' +
|
|
428
|
+
'Add a pricing section to your docs for accurate tiers.'
|
|
429
|
+
);
|
|
430
|
+
contentScore -= 10;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Missing tagline
|
|
435
|
+
if (!context.tagline) {
|
|
436
|
+
warnings.push(
|
|
437
|
+
'No tagline extracted from docs. Footer and meta tags will use generic defaults. ' +
|
|
438
|
+
'Add "# ProductName -- Your tagline here" to your spec.'
|
|
439
|
+
);
|
|
440
|
+
contentScore -= 5;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Missing description
|
|
444
|
+
if (!context.description) {
|
|
445
|
+
warnings.push(
|
|
446
|
+
'No product description extracted. Meta description will use generic text.'
|
|
447
|
+
);
|
|
448
|
+
contentScore -= 5;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// No brand color at all (not just missing from color docs)
|
|
452
|
+
if (!context.brand?.primaryColor && !(context.rawDocs && /color|brand/i.test(context.rawDocs))) {
|
|
453
|
+
warnings.push(
|
|
454
|
+
'No brand color found. Website will use default color scheme.'
|
|
455
|
+
);
|
|
456
|
+
contentScore -= 5;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Clamp score to 0-100
|
|
460
|
+
contentScore = Math.max(0, Math.min(100, contentScore));
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
passed: issues.length === 0,
|
|
464
|
+
issues,
|
|
465
|
+
warnings,
|
|
466
|
+
contentScore,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Validate website content context and throw if insufficient
|
|
472
|
+
* Prevents generation of generic websites with placeholder content
|
|
473
|
+
*
|
|
474
|
+
* @param context - The built website content context
|
|
475
|
+
* @param projectName - The project name for validation
|
|
476
|
+
* @throws Error with actionable message if context is insufficient
|
|
477
|
+
*/
|
|
478
|
+
export function validateWebsiteContextOrThrow(
|
|
479
|
+
context: WebsiteContentContext,
|
|
480
|
+
projectName: string
|
|
481
|
+
): { passed: boolean; issues: string[] } {
|
|
482
|
+
const result = validateWebsiteContext(context, projectName);
|
|
483
|
+
|
|
484
|
+
if (!result.passed) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`Website generation blocked - insufficient project context:\n` +
|
|
487
|
+
result.issues.map((i) => ` - ${i}`).join('\n') +
|
|
488
|
+
`\n\nFix these issues and re-run. Use POPEYE_DEBUG_WEBSITE=1 to see discovery details.`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return { passed: true, issues: [] };
|
|
493
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug tracing for website generation pipeline
|
|
3
|
+
* Enabled via POPEYE_DEBUG_WEBSITE=1 environment variable
|
|
4
|
+
* Shows exactly which value came from where during website generation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Structured trace of website generation pipeline decisions
|
|
9
|
+
*/
|
|
10
|
+
export interface WebsiteDebugTrace {
|
|
11
|
+
workspaceRoot: string;
|
|
12
|
+
docsFound: Array<{ path: string; size: number }>;
|
|
13
|
+
brandAssets: { logoPath?: string; logoOutputPath: string };
|
|
14
|
+
productName: { value: string; source: 'docs' | 'spec' | 'package.json' | 'directory' };
|
|
15
|
+
primaryColor: { value?: string; source: 'brand-docs' | 'frontend' | 'defaults' };
|
|
16
|
+
strategyStatus: 'success' | 'failed' | 'skipped';
|
|
17
|
+
strategyError?: string;
|
|
18
|
+
feDesignAnalysis?: { componentLib?: string; darkMode: boolean; primaryColor?: string };
|
|
19
|
+
templateValues: { headline?: string; features: number; pricingTiers: number };
|
|
20
|
+
/** Sections rendered with their data sources */
|
|
21
|
+
sectionsRendered: Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
dataSource: 'strategy' | 'docs' | 'defaults' | 'skipped';
|
|
24
|
+
itemCount: number;
|
|
25
|
+
}>;
|
|
26
|
+
/** Validation result from quality gate */
|
|
27
|
+
validationPassed: boolean;
|
|
28
|
+
validationIssues: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if debug tracing is enabled
|
|
33
|
+
*
|
|
34
|
+
* @returns True if POPEYE_DEBUG_WEBSITE=1 is set
|
|
35
|
+
*/
|
|
36
|
+
export function isDebugEnabled(): boolean {
|
|
37
|
+
return process.env.POPEYE_DEBUG_WEBSITE === '1';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Format a debug trace for terminal output
|
|
42
|
+
*
|
|
43
|
+
* @param trace - The debug trace to format
|
|
44
|
+
* @returns Formatted string for terminal output
|
|
45
|
+
*/
|
|
46
|
+
export function formatDebugTrace(trace: WebsiteDebugTrace): string {
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push('=== WEBSITE GENERATION DEBUG TRACE ===');
|
|
51
|
+
lines.push('');
|
|
52
|
+
|
|
53
|
+
lines.push(`Workspace Root: ${trace.workspaceRoot}`);
|
|
54
|
+
lines.push('');
|
|
55
|
+
|
|
56
|
+
lines.push(`Docs Found (${trace.docsFound.length}):`);
|
|
57
|
+
if (trace.docsFound.length === 0) {
|
|
58
|
+
lines.push(' (none)');
|
|
59
|
+
} else {
|
|
60
|
+
for (const doc of trace.docsFound) {
|
|
61
|
+
lines.push(` - ${doc.path} (${doc.size} chars)`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
lines.push('');
|
|
65
|
+
|
|
66
|
+
lines.push(`Brand Assets:`);
|
|
67
|
+
lines.push(` Logo Source: ${trace.brandAssets.logoPath || '(none)'}`);
|
|
68
|
+
lines.push(` Logo Output: ${trace.brandAssets.logoOutputPath}`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
|
|
71
|
+
lines.push(`Product Name: "${trace.productName.value}" (from: ${trace.productName.source})`);
|
|
72
|
+
lines.push('');
|
|
73
|
+
|
|
74
|
+
lines.push(`Primary Color: ${trace.primaryColor.value || '(default)'} (from: ${trace.primaryColor.source})`);
|
|
75
|
+
lines.push('');
|
|
76
|
+
|
|
77
|
+
lines.push(`Strategy: ${trace.strategyStatus}`);
|
|
78
|
+
if (trace.strategyError) {
|
|
79
|
+
lines.push(` Error: ${trace.strategyError}`);
|
|
80
|
+
}
|
|
81
|
+
lines.push('');
|
|
82
|
+
|
|
83
|
+
if (trace.feDesignAnalysis) {
|
|
84
|
+
lines.push('Frontend Design Analysis:');
|
|
85
|
+
lines.push(` Component Library: ${trace.feDesignAnalysis.componentLib || '(unknown)'}`);
|
|
86
|
+
lines.push(` Dark Mode: ${trace.feDesignAnalysis.darkMode}`);
|
|
87
|
+
lines.push(` Primary Color: ${trace.feDesignAnalysis.primaryColor || '(none)'}`);
|
|
88
|
+
lines.push('');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
lines.push('Template Values:');
|
|
92
|
+
lines.push(` Headline: ${trace.templateValues.headline || '(default)'}`);
|
|
93
|
+
lines.push(` Features: ${trace.templateValues.features}`);
|
|
94
|
+
lines.push(` Pricing Tiers: ${trace.templateValues.pricingTiers}`);
|
|
95
|
+
lines.push('');
|
|
96
|
+
|
|
97
|
+
lines.push(`Sections Rendered (${trace.sectionsRendered.length}):`);
|
|
98
|
+
if (trace.sectionsRendered.length === 0) {
|
|
99
|
+
lines.push(' (none)');
|
|
100
|
+
} else {
|
|
101
|
+
for (const section of trace.sectionsRendered) {
|
|
102
|
+
lines.push(` - ${section.name}: ${section.dataSource} (${section.itemCount} items)`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
lines.push('');
|
|
106
|
+
|
|
107
|
+
lines.push(`Validation: ${trace.validationPassed ? 'PASSED' : 'FAILED'}`);
|
|
108
|
+
if (trace.validationIssues.length > 0) {
|
|
109
|
+
for (const issue of trace.validationIssues) {
|
|
110
|
+
lines.push(` - ${issue}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push('=== END DEBUG TRACE ===');
|
|
116
|
+
lines.push('');
|
|
117
|
+
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Print debug trace to console if debug mode is enabled
|
|
123
|
+
*
|
|
124
|
+
* @param trace - The debug trace to print
|
|
125
|
+
*/
|
|
126
|
+
export function printDebugTrace(trace: WebsiteDebugTrace): void {
|
|
127
|
+
if (isDebugEnabled()) {
|
|
128
|
+
console.log(formatDebugTrace(trace));
|
|
129
|
+
}
|
|
130
|
+
}
|