popeye-cli 1.4.7 → 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.
Files changed (214) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +264 -63
  3. package/dist/adapters/gemini.d.ts +1 -0
  4. package/dist/adapters/gemini.d.ts.map +1 -1
  5. package/dist/adapters/gemini.js +9 -4
  6. package/dist/adapters/gemini.js.map +1 -1
  7. package/dist/adapters/grok.d.ts +1 -0
  8. package/dist/adapters/grok.d.ts.map +1 -1
  9. package/dist/adapters/grok.js +9 -4
  10. package/dist/adapters/grok.js.map +1 -1
  11. package/dist/adapters/openai.d.ts +1 -1
  12. package/dist/adapters/openai.d.ts.map +1 -1
  13. package/dist/adapters/openai.js +35 -9
  14. package/dist/adapters/openai.js.map +1 -1
  15. package/dist/cli/commands/create.d.ts.map +1 -1
  16. package/dist/cli/commands/create.js +54 -4
  17. package/dist/cli/commands/create.js.map +1 -1
  18. package/dist/cli/interactive.d.ts +29 -0
  19. package/dist/cli/interactive.d.ts.map +1 -1
  20. package/dist/cli/interactive.js +132 -7
  21. package/dist/cli/interactive.js.map +1 -1
  22. package/dist/generators/all.d.ts +8 -2
  23. package/dist/generators/all.d.ts.map +1 -1
  24. package/dist/generators/all.js +37 -316
  25. package/dist/generators/all.js.map +1 -1
  26. package/dist/generators/doc-parser.d.ts +64 -0
  27. package/dist/generators/doc-parser.d.ts.map +1 -0
  28. package/dist/generators/doc-parser.js +407 -0
  29. package/dist/generators/doc-parser.js.map +1 -0
  30. package/dist/generators/frontend-design-analyzer.d.ts +30 -0
  31. package/dist/generators/frontend-design-analyzer.d.ts.map +1 -0
  32. package/dist/generators/frontend-design-analyzer.js +208 -0
  33. package/dist/generators/frontend-design-analyzer.js.map +1 -0
  34. package/dist/generators/shared-packages.d.ts +45 -0
  35. package/dist/generators/shared-packages.d.ts.map +1 -0
  36. package/dist/generators/shared-packages.js +456 -0
  37. package/dist/generators/shared-packages.js.map +1 -0
  38. package/dist/generators/templates/index.d.ts +8 -0
  39. package/dist/generators/templates/index.d.ts.map +1 -1
  40. package/dist/generators/templates/index.js +8 -0
  41. package/dist/generators/templates/index.js.map +1 -1
  42. package/dist/generators/templates/website-components.d.ts +33 -0
  43. package/dist/generators/templates/website-components.d.ts.map +1 -0
  44. package/dist/generators/templates/website-components.js +303 -0
  45. package/dist/generators/templates/website-components.js.map +1 -0
  46. package/dist/generators/templates/website-config.d.ts +55 -0
  47. package/dist/generators/templates/website-config.d.ts.map +1 -0
  48. package/dist/generators/templates/website-config.js +425 -0
  49. package/dist/generators/templates/website-config.js.map +1 -0
  50. package/dist/generators/templates/website-conversion.d.ts +27 -0
  51. package/dist/generators/templates/website-conversion.d.ts.map +1 -0
  52. package/dist/generators/templates/website-conversion.js +326 -0
  53. package/dist/generators/templates/website-conversion.js.map +1 -0
  54. package/dist/generators/templates/website-landing.d.ts +24 -0
  55. package/dist/generators/templates/website-landing.d.ts.map +1 -0
  56. package/dist/generators/templates/website-landing.js +276 -0
  57. package/dist/generators/templates/website-landing.js.map +1 -0
  58. package/dist/generators/templates/website-layout.d.ts +42 -0
  59. package/dist/generators/templates/website-layout.d.ts.map +1 -0
  60. package/dist/generators/templates/website-layout.js +408 -0
  61. package/dist/generators/templates/website-layout.js.map +1 -0
  62. package/dist/generators/templates/website-pricing.d.ts +11 -0
  63. package/dist/generators/templates/website-pricing.d.ts.map +1 -0
  64. package/dist/generators/templates/website-pricing.js +313 -0
  65. package/dist/generators/templates/website-pricing.js.map +1 -0
  66. package/dist/generators/templates/website-sections.d.ts +102 -0
  67. package/dist/generators/templates/website-sections.d.ts.map +1 -0
  68. package/dist/generators/templates/website-sections.js +444 -0
  69. package/dist/generators/templates/website-sections.js.map +1 -0
  70. package/dist/generators/templates/website-seo.d.ts +76 -0
  71. package/dist/generators/templates/website-seo.d.ts.map +1 -0
  72. package/dist/generators/templates/website-seo.js +326 -0
  73. package/dist/generators/templates/website-seo.js.map +1 -0
  74. package/dist/generators/templates/website.d.ts +10 -83
  75. package/dist/generators/templates/website.d.ts.map +1 -1
  76. package/dist/generators/templates/website.js +12 -875
  77. package/dist/generators/templates/website.js.map +1 -1
  78. package/dist/generators/website-content-scanner.d.ts +37 -0
  79. package/dist/generators/website-content-scanner.d.ts.map +1 -0
  80. package/dist/generators/website-content-scanner.js +165 -0
  81. package/dist/generators/website-content-scanner.js.map +1 -0
  82. package/dist/generators/website-context.d.ts +119 -0
  83. package/dist/generators/website-context.d.ts.map +1 -0
  84. package/dist/generators/website-context.js +350 -0
  85. package/dist/generators/website-context.js.map +1 -0
  86. package/dist/generators/website-debug.d.ts +68 -0
  87. package/dist/generators/website-debug.d.ts.map +1 -0
  88. package/dist/generators/website-debug.js +93 -0
  89. package/dist/generators/website-debug.js.map +1 -0
  90. package/dist/generators/website.d.ts +5 -0
  91. package/dist/generators/website.d.ts.map +1 -1
  92. package/dist/generators/website.js +136 -11
  93. package/dist/generators/website.js.map +1 -1
  94. package/dist/generators/workspace-root.d.ts +27 -0
  95. package/dist/generators/workspace-root.d.ts.map +1 -0
  96. package/dist/generators/workspace-root.js +100 -0
  97. package/dist/generators/workspace-root.js.map +1 -0
  98. package/dist/state/index.d.ts +35 -0
  99. package/dist/state/index.d.ts.map +1 -1
  100. package/dist/state/index.js +40 -0
  101. package/dist/state/index.js.map +1 -1
  102. package/dist/types/consensus.d.ts +3 -0
  103. package/dist/types/consensus.d.ts.map +1 -1
  104. package/dist/types/consensus.js +1 -0
  105. package/dist/types/consensus.js.map +1 -1
  106. package/dist/types/website-strategy.d.ts +263 -0
  107. package/dist/types/website-strategy.d.ts.map +1 -0
  108. package/dist/types/website-strategy.js +105 -0
  109. package/dist/types/website-strategy.js.map +1 -0
  110. package/dist/types/workflow.d.ts +21 -0
  111. package/dist/types/workflow.d.ts.map +1 -1
  112. package/dist/types/workflow.js +8 -0
  113. package/dist/types/workflow.js.map +1 -1
  114. package/dist/upgrade/handlers.d.ts +15 -0
  115. package/dist/upgrade/handlers.d.ts.map +1 -1
  116. package/dist/upgrade/handlers.js +52 -0
  117. package/dist/upgrade/handlers.js.map +1 -1
  118. package/dist/workflow/auto-fix-bundler.d.ts +37 -0
  119. package/dist/workflow/auto-fix-bundler.d.ts.map +1 -0
  120. package/dist/workflow/auto-fix-bundler.js +320 -0
  121. package/dist/workflow/auto-fix-bundler.js.map +1 -0
  122. package/dist/workflow/auto-fix.d.ts.map +1 -1
  123. package/dist/workflow/auto-fix.js +10 -3
  124. package/dist/workflow/auto-fix.js.map +1 -1
  125. package/dist/workflow/consensus.d.ts.map +1 -1
  126. package/dist/workflow/consensus.js +2 -0
  127. package/dist/workflow/consensus.js.map +1 -1
  128. package/dist/workflow/execution-mode.d.ts.map +1 -1
  129. package/dist/workflow/execution-mode.js +18 -0
  130. package/dist/workflow/execution-mode.js.map +1 -1
  131. package/dist/workflow/index.d.ts +4 -0
  132. package/dist/workflow/index.d.ts.map +1 -1
  133. package/dist/workflow/index.js +37 -0
  134. package/dist/workflow/index.js.map +1 -1
  135. package/dist/workflow/overview.d.ts +89 -0
  136. package/dist/workflow/overview.d.ts.map +1 -0
  137. package/dist/workflow/overview.js +358 -0
  138. package/dist/workflow/overview.js.map +1 -0
  139. package/dist/workflow/plan-mode.d.ts +6 -4
  140. package/dist/workflow/plan-mode.d.ts.map +1 -1
  141. package/dist/workflow/plan-mode.js +148 -6
  142. package/dist/workflow/plan-mode.js.map +1 -1
  143. package/dist/workflow/website-strategy.d.ts +79 -0
  144. package/dist/workflow/website-strategy.d.ts.map +1 -0
  145. package/dist/workflow/website-strategy.js +310 -0
  146. package/dist/workflow/website-strategy.js.map +1 -0
  147. package/dist/workflow/website-updater.d.ts +17 -0
  148. package/dist/workflow/website-updater.d.ts.map +1 -0
  149. package/dist/workflow/website-updater.js +116 -0
  150. package/dist/workflow/website-updater.js.map +1 -0
  151. package/dist/workflow/workflow-logger.d.ts +1 -1
  152. package/dist/workflow/workflow-logger.d.ts.map +1 -1
  153. package/dist/workflow/workflow-logger.js.map +1 -1
  154. package/package.json +1 -1
  155. package/src/adapters/gemini.ts +10 -4
  156. package/src/adapters/grok.ts +10 -4
  157. package/src/adapters/openai.ts +38 -6
  158. package/src/cli/commands/create.ts +58 -4
  159. package/src/cli/interactive.ts +143 -7
  160. package/src/generators/all.ts +49 -332
  161. package/src/generators/doc-parser.ts +449 -0
  162. package/src/generators/frontend-design-analyzer.ts +261 -0
  163. package/src/generators/shared-packages.ts +500 -0
  164. package/src/generators/templates/index.ts +8 -0
  165. package/src/generators/templates/website-components.ts +330 -0
  166. package/src/generators/templates/website-config.ts +444 -0
  167. package/src/generators/templates/website-conversion.ts +341 -0
  168. package/src/generators/templates/website-landing.ts +331 -0
  169. package/src/generators/templates/website-layout.ts +443 -0
  170. package/src/generators/templates/website-pricing.ts +330 -0
  171. package/src/generators/templates/website-sections.ts +541 -0
  172. package/src/generators/templates/website-seo.ts +370 -0
  173. package/src/generators/templates/website.ts +38 -905
  174. package/src/generators/website-content-scanner.ts +208 -0
  175. package/src/generators/website-context.ts +493 -0
  176. package/src/generators/website-debug.ts +130 -0
  177. package/src/generators/website.ts +178 -20
  178. package/src/generators/workspace-root.ts +113 -0
  179. package/src/state/index.ts +56 -0
  180. package/src/types/consensus.ts +3 -0
  181. package/src/types/website-strategy.ts +243 -0
  182. package/src/types/workflow.ts +21 -0
  183. package/src/upgrade/handlers.ts +65 -0
  184. package/src/workflow/auto-fix-bundler.ts +392 -0
  185. package/src/workflow/auto-fix.ts +11 -3
  186. package/src/workflow/consensus.ts +2 -0
  187. package/src/workflow/execution-mode.ts +21 -0
  188. package/src/workflow/index.ts +37 -0
  189. package/src/workflow/overview.ts +475 -0
  190. package/src/workflow/plan-mode.ts +193 -8
  191. package/src/workflow/website-strategy.ts +379 -0
  192. package/src/workflow/website-updater.ts +142 -0
  193. package/src/workflow/workflow-logger.ts +1 -0
  194. package/tests/adapters/persona-switching.test.ts +63 -0
  195. package/tests/cli/project-naming.test.ts +136 -0
  196. package/tests/generators/doc-parser.test.ts +121 -0
  197. package/tests/generators/frontend-design-analyzer.test.ts +90 -0
  198. package/tests/generators/quality-gate.test.ts +183 -0
  199. package/tests/generators/shared-packages.test.ts +83 -0
  200. package/tests/generators/website-components.test.ts +159 -0
  201. package/tests/generators/website-config.test.ts +84 -0
  202. package/tests/generators/website-content-scanner.test.ts +181 -0
  203. package/tests/generators/website-context.test.ts +331 -0
  204. package/tests/generators/website-debug.test.ts +77 -0
  205. package/tests/generators/website-landing.test.ts +188 -0
  206. package/tests/generators/website-pricing.test.ts +98 -0
  207. package/tests/generators/website-sections.test.ts +245 -0
  208. package/tests/generators/website-seo-quality.test.ts +246 -0
  209. package/tests/generators/workspace-root.test.ts +105 -0
  210. package/tests/upgrade/handlers.test.ts +162 -0
  211. package/tests/workflow/auto-fix-bundler.test.ts +242 -0
  212. package/tests/workflow/overview.test.ts +392 -0
  213. package/tests/workflow/plan-mode.test.ts +111 -1
  214. package/tests/workflow/website-strategy.test.ts +246 -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
+ }
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Website content context builder
3
+ * Discovers user documentation and brand assets to populate website templates
4
+ * with project-specific content instead of generic placeholders
5
+ */
6
+
7
+ import { promises as fs } from 'node:fs';
8
+ import path from 'node:path';
9
+ import {
10
+ stripCodeFences,
11
+ extractProductName,
12
+ extractTagline,
13
+ extractDescription,
14
+ extractFeatures,
15
+ extractPricing,
16
+ extractPrimaryColor,
17
+ } from './doc-parser.js';
18
+ import { getScanDirectories } from './workspace-root.js';
19
+ import type { WebsiteStrategyDocument, BrandAssetsContract } from '../types/website-strategy.js';
20
+
21
+ /** Per-file character cap to prevent a single large doc from consuming the budget */
22
+ const PER_FILE_CAP = 8000;
23
+
24
+ /**
25
+ * Structured content context for website generation
26
+ */
27
+ export interface WebsiteContentContext {
28
+ productName: string;
29
+ tagline?: string;
30
+ description?: string;
31
+ features: Array<{ title: string; description: string }>;
32
+ pricing?: Array<{
33
+ name: string;
34
+ price: string;
35
+ period?: string;
36
+ description: string;
37
+ features: string[];
38
+ cta: string;
39
+ featured?: boolean;
40
+ }>;
41
+ brand?: {
42
+ primaryColor?: string;
43
+ colorScheme?: Record<string, string>;
44
+ logoPath?: string;
45
+ };
46
+ rawDocs: string;
47
+ /** Website marketing strategy (generated by AI from product context) */
48
+ strategy?: WebsiteStrategyDocument;
49
+ /** Resolved brand assets contract for deterministic logo/favicon placement */
50
+ brandAssets?: BrandAssetsContract;
51
+ }
52
+
53
+ /**
54
+ * Resolve brand assets into a deterministic contract
55
+ * Searches for logo/favicon in project directory and workspace root,
56
+ * sets canonical output paths
57
+ *
58
+ * @param cwd - Directory to scan for brand assets
59
+ * @param brandContext - Optional existing brand context from state
60
+ * @returns Resolved brand assets contract
61
+ */
62
+ export async function resolveBrandAssets(
63
+ cwd: string,
64
+ brandContext?: { logoPath?: string; primaryColor?: string }
65
+ ): Promise<BrandAssetsContract> {
66
+ const assets = await findBrandAssets(cwd);
67
+ const logoPath = brandContext?.logoPath || assets.logoPath;
68
+ const ext = logoPath ? path.extname(logoPath) : '.svg';
69
+
70
+ return {
71
+ logoPath,
72
+ logoOutputPath: `public/brand/logo${ext}`,
73
+ primaryColor: brandContext?.primaryColor,
74
+ };
75
+ }
76
+
77
+ /** Patterns to match documentation files */
78
+ const DOC_PATTERNS = [
79
+ /spec/i,
80
+ /pricing/i,
81
+ /color/i,
82
+ /brand/i,
83
+ /ui[\s_-]?spec/i,
84
+ /^readme\.md$/i,
85
+ /overview/i,
86
+ /features/i,
87
+ /product/i,
88
+ ];
89
+
90
+ /** Directories to exclude from scanning */
91
+ const EXCLUDED_DIRS = [
92
+ 'node_modules',
93
+ '.popeye',
94
+ '.git',
95
+ 'dist',
96
+ 'build',
97
+ '.next',
98
+ '__pycache__',
99
+ 'venv',
100
+ '.venv',
101
+ 'coverage',
102
+ ];
103
+
104
+ /**
105
+ * Discover project documentation files in a directory
106
+ *
107
+ * @param cwd - Directory to scan for documentation
108
+ * @returns Array of absolute paths to discovered doc files
109
+ */
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[]> {
154
+ const docs: string[] = [];
155
+
156
+ try {
157
+ const entries = await fs.readdir(dir, { withFileTypes: true });
158
+
159
+ for (const entry of entries) {
160
+ if (entry.isDirectory() && EXCLUDED_DIRS.includes(entry.name)) {
161
+ continue;
162
+ }
163
+
164
+ if (entry.isFile() && entry.name.endsWith('.md')) {
165
+ const matches = DOC_PATTERNS.some((pattern) => pattern.test(entry.name));
166
+ if (matches) {
167
+ docs.push(path.join(dir, entry.name));
168
+ }
169
+ }
170
+
171
+ // Check one level of subdirectories (e.g., docs/)
172
+ if (entry.isDirectory() && entry.name === 'docs') {
173
+ try {
174
+ const subEntries = await fs.readdir(path.join(dir, entry.name), {
175
+ withFileTypes: true,
176
+ });
177
+ for (const subEntry of subEntries) {
178
+ if (subEntry.isFile() && subEntry.name.endsWith('.md')) {
179
+ docs.push(path.join(dir, entry.name, subEntry.name));
180
+ }
181
+ }
182
+ } catch {
183
+ // Skip unreadable directories
184
+ }
185
+ }
186
+ }
187
+ } catch {
188
+ // Directory not accessible
189
+ }
190
+
191
+ return docs;
192
+ }
193
+
194
+ /**
195
+ * Find brand assets (logo files) in a directory
196
+ * Also searches workspace root and parent directories
197
+ *
198
+ * @param cwd - Directory to scan for brand assets
199
+ * @returns Object with optional logoPath
200
+ */
201
+ export async function findBrandAssets(
202
+ cwd: string
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 }> {
218
+ const logoExtensions = ['.png', '.svg', '.jpg', '.jpeg', '.webp'];
219
+
220
+ try {
221
+ const entries = await fs.readdir(dir, { withFileTypes: true });
222
+
223
+ for (const entry of entries) {
224
+ if (!entry.isFile()) continue;
225
+
226
+ const lowerName = entry.name.toLowerCase();
227
+ const hasLogoInName = lowerName.includes('logo');
228
+ const hasValidExt = logoExtensions.some((ext) => lowerName.endsWith(ext));
229
+
230
+ if (hasLogoInName && hasValidExt) {
231
+ return { logoPath: path.join(dir, entry.name) };
232
+ }
233
+ }
234
+ } catch {
235
+ // Directory not accessible
236
+ }
237
+
238
+ return {};
239
+ }
240
+
241
+ /**
242
+ * Read and concatenate project documentation files
243
+ *
244
+ * @param docPaths - Array of absolute paths to doc files
245
+ * @param maxLength - Maximum combined length (default 25000 chars)
246
+ * @returns Combined documentation content with file headers
247
+ */
248
+ export async function readProjectDocs(
249
+ docPaths: string[],
250
+ maxLength: number = 25000
251
+ ): Promise<string> {
252
+ const sections: string[] = [];
253
+ let totalLength = 0;
254
+
255
+ for (const docPath of docPaths) {
256
+ if (totalLength >= maxLength) break;
257
+
258
+ try {
259
+ let content = await fs.readFile(docPath, 'utf-8');
260
+ const fileName = path.basename(docPath);
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
+
268
+ const remaining = maxLength - totalLength;
269
+ const trimmedContent =
270
+ content.length > remaining ? content.slice(0, remaining) + '...' : content;
271
+
272
+ sections.push(`${header}\n${trimmedContent}`);
273
+ totalLength += header.length + 1 + trimmedContent.length;
274
+ } catch {
275
+ // Skip unreadable files
276
+ }
277
+ }
278
+
279
+ return sections.join('\n\n');
280
+ }
281
+
282
+ /**
283
+ * Build a structured website content context from discovered docs
284
+ *
285
+ * @param cwd - Working directory to scan for docs
286
+ * @param projectName - The project name (folder name fallback)
287
+ * @param specification - Optional expanded specification text
288
+ * @returns Structured content context for website templates
289
+ */
290
+ export async function buildWebsiteContext(
291
+ cwd: string,
292
+ projectName: string,
293
+ specification?: string
294
+ ): Promise<WebsiteContentContext> {
295
+ const docPaths = await discoverProjectDocs(cwd);
296
+ const rawDocs = docPaths.length > 0 ? await readProjectDocs(docPaths) : '';
297
+ const brandAssets = await findBrandAssets(cwd);
298
+
299
+ // Strip markdown code fences that wrap entire doc files
300
+ const cleanDocs = stripCodeFences(rawDocs);
301
+
302
+ const context: WebsiteContentContext = {
303
+ productName: extractProductName(cleanDocs, specification) || projectName,
304
+ features: extractFeatures(cleanDocs, specification),
305
+ rawDocs,
306
+ };
307
+
308
+ context.tagline = extractTagline(cleanDocs, context.productName);
309
+ context.description = extractDescription(cleanDocs, specification);
310
+ context.pricing = extractPricing(cleanDocs);
311
+
312
+ // Extract brand info
313
+ if (brandAssets.logoPath) {
314
+ context.brand = { ...context.brand, logoPath: brandAssets.logoPath };
315
+ }
316
+
317
+ const primaryColor = extractPrimaryColor(cleanDocs);
318
+ if (primaryColor) {
319
+ context.brand = { ...context.brand, primaryColor };
320
+ }
321
+
322
+ return context;
323
+ }
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
+ }