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
|
@@ -70,7 +70,7 @@ Analyze the following product documentation and generate a complete website mark
|
|
|
70
70
|
PRODUCT NAME: ${input.projectName}
|
|
71
71
|
|
|
72
72
|
PRODUCT DOCUMENTATION:
|
|
73
|
-
${input.productContext
|
|
73
|
+
${packProductContext(input.productContext)}
|
|
74
74
|
${competitorsBlock}${keywordsBlock}${marketNotesBlock}
|
|
75
75
|
|
|
76
76
|
Generate a JSON response matching this exact structure:
|
|
@@ -290,6 +290,80 @@ export async function isStrategyStale(
|
|
|
290
290
|
return stored.metadata.inputHash !== currentHash;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Pack product context into a budget with priority-based ordering
|
|
295
|
+
* Ensures high-priority docs (spec, pricing, brand) are included first
|
|
296
|
+
*
|
|
297
|
+
* @param productContext - Raw concatenated docs with "--- filename ---" headers
|
|
298
|
+
* @param budget - Maximum character budget (default 16000)
|
|
299
|
+
* @returns Packed context string within budget
|
|
300
|
+
*/
|
|
301
|
+
export function packProductContext(productContext: string, budget: number = 16000): string {
|
|
302
|
+
// Split by doc headers (--- filename ---)
|
|
303
|
+
const headerPattern = /^---\s+(.+?)\s+---$/gm;
|
|
304
|
+
const sections: Array<{ header: string; content: string; priority: number }> = [];
|
|
305
|
+
let lastIndex = 0;
|
|
306
|
+
let lastHeader = '';
|
|
307
|
+
let match;
|
|
308
|
+
|
|
309
|
+
while ((match = headerPattern.exec(productContext)) !== null) {
|
|
310
|
+
if (lastIndex > 0) {
|
|
311
|
+
sections.push({
|
|
312
|
+
header: lastHeader,
|
|
313
|
+
content: productContext.slice(lastIndex, match.index).trim(),
|
|
314
|
+
priority: getDocSortPriority(lastHeader),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
lastHeader = match[0];
|
|
318
|
+
lastIndex = match.index + match[0].length;
|
|
319
|
+
}
|
|
320
|
+
// Push the last section
|
|
321
|
+
if (lastIndex > 0 && lastIndex < productContext.length) {
|
|
322
|
+
sections.push({
|
|
323
|
+
header: lastHeader,
|
|
324
|
+
content: productContext.slice(lastIndex).trim(),
|
|
325
|
+
priority: getDocSortPriority(lastHeader),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// If no headers found, return the raw context trimmed to budget
|
|
330
|
+
if (sections.length === 0) {
|
|
331
|
+
return productContext.slice(0, budget);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Sort by priority (lower = more important)
|
|
335
|
+
sections.sort((a, b) => a.priority - b.priority);
|
|
336
|
+
|
|
337
|
+
// Pack into budget
|
|
338
|
+
let packed = '';
|
|
339
|
+
for (const section of sections) {
|
|
340
|
+
const block = `${section.header}\n${section.content}\n\n`;
|
|
341
|
+
if (packed.length + block.length <= budget) {
|
|
342
|
+
packed += block;
|
|
343
|
+
} else {
|
|
344
|
+
const remaining = budget - packed.length;
|
|
345
|
+
if (remaining > 200) {
|
|
346
|
+
packed += `${section.header}\n${section.content.slice(0, remaining - section.header.length - 10)}...\n`;
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return packed.trim();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Get sort priority for a doc section header (lower = higher priority)
|
|
357
|
+
*/
|
|
358
|
+
function getDocSortPriority(header: string): number {
|
|
359
|
+
const lower = header.toLowerCase();
|
|
360
|
+
if (/spec/i.test(lower)) return 1;
|
|
361
|
+
if (/pricing/i.test(lower)) return 2;
|
|
362
|
+
if (/color|brand/i.test(lower)) return 3;
|
|
363
|
+
if (/feature/i.test(lower)) return 4;
|
|
364
|
+
return 5;
|
|
365
|
+
}
|
|
366
|
+
|
|
293
367
|
/**
|
|
294
368
|
* Compute SHA-256 hash of strategy inputs for staleness detection
|
|
295
369
|
*/
|
|
@@ -9,13 +9,14 @@ import path from 'node:path';
|
|
|
9
9
|
import type { ProjectState } from '../types/workflow.js';
|
|
10
10
|
import type { OutputLanguage } from '../types/project.js';
|
|
11
11
|
import { isWorkspace } from '../types/project.js';
|
|
12
|
-
import { buildWebsiteContext } from '../generators/website-context.js';
|
|
12
|
+
import { buildWebsiteContext, resolveBrandAssets, validateWebsiteContext } from '../generators/website-context.js';
|
|
13
|
+
import { resolveWorkspaceRoot } from '../generators/workspace-root.js';
|
|
14
|
+
import { generateWebsiteLandingPage } from '../generators/templates/website-landing.js';
|
|
15
|
+
import { generateWebsitePricingPage } from '../generators/templates/website-pricing.js';
|
|
13
16
|
import {
|
|
14
|
-
generateWebsiteLandingPage,
|
|
15
|
-
generateWebsitePricingPage,
|
|
16
17
|
generateWebsiteLayout,
|
|
17
18
|
generateWebsiteGlobalsCss,
|
|
18
|
-
} from '../generators/templates/website.js';
|
|
19
|
+
} from '../generators/templates/website-layout.js';
|
|
19
20
|
import {
|
|
20
21
|
generateWebsiteHeader,
|
|
21
22
|
generateWebsiteFooter,
|
|
@@ -71,6 +72,10 @@ export async function updateWebsiteContent(
|
|
|
71
72
|
};
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
// Resolve brand assets using workspace root for proper logo resolution
|
|
76
|
+
const workspaceRoot = await resolveWorkspaceRoot(parentDir);
|
|
77
|
+
context.brandAssets = await resolveBrandAssets(workspaceRoot, context.brand);
|
|
78
|
+
|
|
74
79
|
// Load website strategy if available
|
|
75
80
|
const strategyData = await loadWebsiteStrategy(projectDir);
|
|
76
81
|
if (strategyData) {
|
|
@@ -78,6 +83,12 @@ export async function updateWebsiteContent(
|
|
|
78
83
|
onProgress?.('Loaded website strategy for content update');
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
// Soft validation: report quality issues via progress callback
|
|
87
|
+
const validation = validateWebsiteContext(context, state.name);
|
|
88
|
+
for (const msg of [...validation.issues, ...validation.warnings]) {
|
|
89
|
+
onProgress?.(`[quality-gate] ${msg}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
81
92
|
onProgress?.('Updating website content with project context...');
|
|
82
93
|
|
|
83
94
|
// Re-generate content files
|
|
@@ -116,11 +127,11 @@ export async function updateWebsiteContent(
|
|
|
116
127
|
}
|
|
117
128
|
}
|
|
118
129
|
|
|
119
|
-
// Copy logo to public/ if brand context has one
|
|
130
|
+
// Copy logo to public/brand/ if brand context has one
|
|
120
131
|
if (context.brand?.logoPath) {
|
|
121
132
|
try {
|
|
122
133
|
const logoExt = path.extname(context.brand.logoPath);
|
|
123
|
-
const destPath = path.join(websiteDir, 'public', `logo${logoExt}`);
|
|
134
|
+
const destPath = path.join(websiteDir, 'public', 'brand', `logo${logoExt}`);
|
|
124
135
|
await fs.copyFile(context.brand.logoPath, destPath);
|
|
125
136
|
} catch {
|
|
126
137
|
// Non-blocking
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for project naming logic
|
|
3
|
+
* Verifies CWD-aware naming, doc-derived names, and fallback behavior
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import {
|
|
11
|
+
generateProjectName,
|
|
12
|
+
generateProjectNameFromIdea,
|
|
13
|
+
extractNameFromDocs,
|
|
14
|
+
} from '../../src/cli/interactive.js';
|
|
15
|
+
|
|
16
|
+
let tmpDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-naming-'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('generateProjectNameFromIdea', () => {
|
|
27
|
+
it('should extract explicit project name patterns', () => {
|
|
28
|
+
// "named" pattern extracts the word after it
|
|
29
|
+
expect(generateProjectNameFromIdea("Create an app named 'Gateco'")).toBe('gateco');
|
|
30
|
+
// "called" pattern extracts the word after it
|
|
31
|
+
expect(generateProjectNameFromIdea('Build something called TodoApp')).toBe('todo-app');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should extract CamelCase project names', () => {
|
|
35
|
+
// Without "for" keyword that triggers the explicit pattern, CamelCase is detected
|
|
36
|
+
expect(generateProjectNameFromIdea('Build TodoMaster with style')).toBe('todo-master');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should filter out action words from prompt text', () => {
|
|
40
|
+
// "read all files" should NOT produce "read-all-files" since those are stop words
|
|
41
|
+
const result = generateProjectNameFromIdea('read all files');
|
|
42
|
+
expect(result).not.toBe('read-all-files');
|
|
43
|
+
// Meaningful word extraction filters all three, but the fallback takes first 2 raw words
|
|
44
|
+
expect(result).toBe('read-all');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should extract meaningful words when no explicit name found', () => {
|
|
48
|
+
const result = generateProjectNameFromIdea('secure enterprise authentication system');
|
|
49
|
+
expect(result).toBe('secure-enterprise-authentication');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should return my-project as last resort', () => {
|
|
53
|
+
expect(generateProjectNameFromIdea('')).toBe('my-project');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('extractNameFromDocs', () => {
|
|
58
|
+
it('should extract product name from markdown heading', async () => {
|
|
59
|
+
const docPath = path.join(tmpDir, 'Gateco-spec.md');
|
|
60
|
+
await fs.writeFile(docPath, '# Gateco\n\nSome description here.');
|
|
61
|
+
|
|
62
|
+
const name = await extractNameFromDocs(tmpDir);
|
|
63
|
+
expect(name).toBe('Gateco');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should skip README files', async () => {
|
|
67
|
+
await fs.writeFile(path.join(tmpDir, 'README.md'), '# MyProject\n\nDescription.');
|
|
68
|
+
|
|
69
|
+
const name = await extractNameFromDocs(tmpDir);
|
|
70
|
+
expect(name).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should return null when no docs found', async () => {
|
|
74
|
+
const name = await extractNameFromDocs(tmpDir);
|
|
75
|
+
expect(name).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should return null for non-existent directory', async () => {
|
|
79
|
+
const name = await extractNameFromDocs('/nonexistent/path/123456');
|
|
80
|
+
expect(name).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should ignore generic headings', async () => {
|
|
84
|
+
// "Home" is too short, but length >= 3 so it would pass... let's test with a generic dir name
|
|
85
|
+
await fs.writeFile(path.join(tmpDir, 'spec.md'), '# Src\n\nDescription.');
|
|
86
|
+
|
|
87
|
+
const name = await extractNameFromDocs(tmpDir);
|
|
88
|
+
// "Src" is in GENERIC_DIR_NAMES
|
|
89
|
+
expect(name).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('generateProjectName (CWD-aware)', () => {
|
|
94
|
+
it('should prefer doc-derived name over CWD basename', async () => {
|
|
95
|
+
const projectDir = path.join(tmpDir, 'SomeDir');
|
|
96
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
97
|
+
await fs.writeFile(
|
|
98
|
+
path.join(projectDir, 'product-spec.md'),
|
|
99
|
+
'# Gateco\n\nA security layer for AI.'
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const name = await generateProjectName('read all files', projectDir);
|
|
103
|
+
expect(name).toBe('gateco');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should use CWD basename when no docs found', async () => {
|
|
107
|
+
const projectDir = path.join(tmpDir, 'MyAwesomeApp');
|
|
108
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
109
|
+
|
|
110
|
+
const name = await generateProjectName('read all files', projectDir);
|
|
111
|
+
expect(name).toBe('my-awesome-app');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should skip generic CWD names and fall back to idea', async () => {
|
|
115
|
+
const projectDir = path.join(tmpDir, 'Projects');
|
|
116
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
// "Projects" is a generic dir name, so falls back to idea extraction
|
|
119
|
+
// The idea contains "named SuperApp" pattern -> extracts "SuperApp"
|
|
120
|
+
const name = await generateProjectName('Build an app named SuperApp', projectDir);
|
|
121
|
+
expect(name).toBe('super-app');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should fall back to idea extraction when no CWD provided', async () => {
|
|
125
|
+
const name = await generateProjectName('Build an app called Gateco');
|
|
126
|
+
expect(name).toBe('gateco');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should handle CamelCase CWD basenames', async () => {
|
|
130
|
+
const projectDir = path.join(tmpDir, 'MyGateco');
|
|
131
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
132
|
+
|
|
133
|
+
const name = await generateProjectName('some idea', projectDir);
|
|
134
|
+
expect(name).toBe('my-gateco');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for doc-parser module
|
|
3
|
+
* Verifies feature extraction, dev-task filtering, docs-first priority
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
extractFeatures,
|
|
9
|
+
extractProductName,
|
|
10
|
+
extractTagline,
|
|
11
|
+
extractDescription,
|
|
12
|
+
extractPrimaryColor,
|
|
13
|
+
extractPricing,
|
|
14
|
+
isSuspiciousProductName,
|
|
15
|
+
} from '../../src/generators/doc-parser.js';
|
|
16
|
+
|
|
17
|
+
describe('extractFeatures', () => {
|
|
18
|
+
it('extracts features from docs bullet list', () => {
|
|
19
|
+
const docs = `# Product\n## Features\n- **Fast Search** - Lightning fast search\n- **Auth** - Built-in authentication`;
|
|
20
|
+
const features = extractFeatures(docs);
|
|
21
|
+
expect(features.length).toBeGreaterThanOrEqual(2);
|
|
22
|
+
expect(features[0].title).toBe('Fast Search');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('extracts from docs before specification', () => {
|
|
26
|
+
const docs = `# Product\n## Features\n- **Real Feature** - From docs`;
|
|
27
|
+
const spec = `## Features\n- Implement login page\n- Fix CSS bug`;
|
|
28
|
+
const features = extractFeatures(docs, spec);
|
|
29
|
+
// Should use docs features, not spec dev tasks
|
|
30
|
+
expect(features[0].title).toBe('Real Feature');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('falls back to specification when docs have no features', () => {
|
|
34
|
+
const docs = `# Product\n## About\nJust a description.`;
|
|
35
|
+
const spec = `## Core Design Principles\n1. **Vector DB agnostic**\n2. **Embedding agnostic**`;
|
|
36
|
+
const features = extractFeatures(docs, spec);
|
|
37
|
+
expect(features.length).toBeGreaterThanOrEqual(2);
|
|
38
|
+
expect(features[0].title).toContain('Vector DB');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('filters out dev-task verbs', () => {
|
|
42
|
+
const docs = `# Tasks\n## Features\n- Implement user auth\n- Fix broken login\n- **Real Feature** - Does real stuff\n- Refactor database layer`;
|
|
43
|
+
const features = extractFeatures(docs);
|
|
44
|
+
// Dev tasks should be filtered out
|
|
45
|
+
expect(features.some(f => f.title.startsWith('Implement'))).toBe(false);
|
|
46
|
+
expect(features.some(f => f.title.startsWith('Fix'))).toBe(false);
|
|
47
|
+
expect(features.some(f => f.title.startsWith('Refactor'))).toBe(false);
|
|
48
|
+
// Real feature should remain
|
|
49
|
+
expect(features.some(f => f.title === 'Real Feature')).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns empty when no feature sections exist', () => {
|
|
53
|
+
const docs = `# README\nJust a readme file.`;
|
|
54
|
+
const features = extractFeatures(docs);
|
|
55
|
+
expect(features).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('limits features to 6', () => {
|
|
59
|
+
const items = Array.from({ length: 10 }, (_, i) => `- **Feature ${i + 1}** - Description ${i + 1}`).join('\n');
|
|
60
|
+
const docs = `## Features\n${items}`;
|
|
61
|
+
const features = extractFeatures(docs);
|
|
62
|
+
expect(features.length).toBeLessThanOrEqual(6);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('extractProductName', () => {
|
|
67
|
+
it('extracts from "# Name -- tagline" heading', () => {
|
|
68
|
+
const docs = '# Gateco -- Permission-Aware Retrieval';
|
|
69
|
+
expect(extractProductName(docs)).toBe('Gateco');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('picks shortest name when multiple headings', () => {
|
|
73
|
+
const docs = '# Gateco -- main product\n# Gateco UI Color System -- colors';
|
|
74
|
+
expect(extractProductName(docs)).toBe('Gateco');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns undefined when no match', () => {
|
|
78
|
+
expect(extractProductName('Just some text')).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('extractPrimaryColor', () => {
|
|
83
|
+
it('extracts accent-primary token', () => {
|
|
84
|
+
const docs = '| `accent-primary` | `#2563EB` | Primary CTA |';
|
|
85
|
+
expect(extractPrimaryColor(docs)).toBe('#2563EB');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('skips dark background colors', () => {
|
|
89
|
+
const docs = '| bg | #0F172A |\n| accent-primary | #2563EB |';
|
|
90
|
+
expect(extractPrimaryColor(docs)).toBe('#2563EB');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('isSuspiciousProductName', () => {
|
|
95
|
+
it('flags directory-like names', () => {
|
|
96
|
+
expect(isSuspiciousProductName('my-cool-project')).toBe(true);
|
|
97
|
+
expect(isSuspiciousProductName('my-app')).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('allows real product names', () => {
|
|
101
|
+
expect(isSuspiciousProductName('Gateco')).toBe(false);
|
|
102
|
+
expect(isSuspiciousProductName('SuperApp')).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('extractPricing', () => {
|
|
107
|
+
it('extracts pricing tiers from markdown table', () => {
|
|
108
|
+
const docs = `## Pricing\n| Plan | Price |\n|---|---|\n| Free | Free |\n| Pro | $99/month minimum |\n| Enterprise | Custom pricing |`;
|
|
109
|
+
const tiers = extractPricing(docs);
|
|
110
|
+
expect(tiers).toBeDefined();
|
|
111
|
+
expect(tiers!.length).toBe(3);
|
|
112
|
+
expect(tiers![0].name).toContain('Free');
|
|
113
|
+
expect(tiers![1].price).toBe('$99');
|
|
114
|
+
expect(tiers![2].price).toBe('Custom');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns undefined when no pricing found', () => {
|
|
118
|
+
const docs = '# Product\nJust a product.';
|
|
119
|
+
expect(extractPricing(docs)).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for frontend design language analyzer
|
|
3
|
+
* Verifies CSS variable extraction, component library detection, and tailwind parsing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import { analyzeFrontendDesign } from '../../src/generators/frontend-design-analyzer.js';
|
|
11
|
+
|
|
12
|
+
describe('analyzeFrontendDesign', () => {
|
|
13
|
+
let tmpDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-fe-design-'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns null when no frontend app directory exists', async () => {
|
|
24
|
+
const result = await analyzeFrontendDesign(tmpDir);
|
|
25
|
+
|
|
26
|
+
expect(result).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('extracts CSS custom properties from index.css', async () => {
|
|
30
|
+
const frontendDir = path.join(tmpDir, 'apps', 'frontend', 'src');
|
|
31
|
+
await fs.mkdir(frontendDir, { recursive: true });
|
|
32
|
+
await fs.writeFile(
|
|
33
|
+
path.join(frontendDir, 'index.css'),
|
|
34
|
+
`:root {
|
|
35
|
+
--primary: 222.2 47.4% 11.2%;
|
|
36
|
+
--radius: 0.5rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.dark {
|
|
40
|
+
--primary: 210 40% 98%;
|
|
41
|
+
}
|
|
42
|
+
`
|
|
43
|
+
);
|
|
44
|
+
// Need package.json for component lib detection
|
|
45
|
+
await fs.writeFile(
|
|
46
|
+
path.join(tmpDir, 'apps', 'frontend', 'package.json'),
|
|
47
|
+
JSON.stringify({ dependencies: { '@shadcn/ui': '^1.0.0' } })
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const result = await analyzeFrontendDesign(tmpDir);
|
|
51
|
+
|
|
52
|
+
expect(result).not.toBeNull();
|
|
53
|
+
expect(result!.primaryColor).toBeDefined();
|
|
54
|
+
expect(result!.borderRadius).toBe('0.5rem');
|
|
55
|
+
expect(result!.darkMode).toBe(true);
|
|
56
|
+
expect(result!.source).toBe('css-variables');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('detects shadcn component library from package.json', async () => {
|
|
60
|
+
const frontendDir = path.join(tmpDir, 'apps', 'frontend');
|
|
61
|
+
await fs.mkdir(frontendDir, { recursive: true });
|
|
62
|
+
await fs.writeFile(
|
|
63
|
+
path.join(frontendDir, 'package.json'),
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
dependencies: { '@shadcn/ui': '^1.0.0', react: '^18.0.0' },
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const result = await analyzeFrontendDesign(tmpDir);
|
|
70
|
+
|
|
71
|
+
expect(result).not.toBeNull();
|
|
72
|
+
expect(result!.componentLibrary).toBe('shadcn');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('detects MUI component library', async () => {
|
|
76
|
+
const frontendDir = path.join(tmpDir, 'apps', 'frontend');
|
|
77
|
+
await fs.mkdir(frontendDir, { recursive: true });
|
|
78
|
+
await fs.writeFile(
|
|
79
|
+
path.join(frontendDir, 'package.json'),
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
dependencies: { '@mui/material': '^5.0.0', react: '^18.0.0' },
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const result = await analyzeFrontendDesign(tmpDir);
|
|
86
|
+
|
|
87
|
+
expect(result).not.toBeNull();
|
|
88
|
+
expect(result!.componentLibrary).toBe('mui');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for website content quality gate
|
|
3
|
+
* Verifies that validateWebsiteContextOrThrow blocks generic websites
|
|
4
|
+
* and validateWebsiteContext returns structured soft validation results
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import {
|
|
9
|
+
validateWebsiteContextOrThrow,
|
|
10
|
+
validateWebsiteContext,
|
|
11
|
+
} from '../../src/generators/website-context.js';
|
|
12
|
+
import type { WebsiteContentContext } from '../../src/generators/website-context.js';
|
|
13
|
+
|
|
14
|
+
function makeContext(overrides: Partial<WebsiteContentContext> = {}): WebsiteContentContext {
|
|
15
|
+
return {
|
|
16
|
+
productName: 'Gateco',
|
|
17
|
+
features: [
|
|
18
|
+
{ title: 'Access Control', description: 'Fine-grained permissions' },
|
|
19
|
+
{ title: 'Vector DB Agnostic', description: 'Works with any vector database' },
|
|
20
|
+
],
|
|
21
|
+
rawDocs: '--- spec.md ---\n# Gateco\nLong enough content to pass validation threshold.\n' + 'x'.repeat(100),
|
|
22
|
+
strategy: {
|
|
23
|
+
icp: { primaryPersona: 'devs', painPoints: [], goals: [], objections: [] },
|
|
24
|
+
positioning: { category: 'Security', differentiators: [], valueProposition: 'Secure AI', proofPoints: [] },
|
|
25
|
+
messaging: { headline: 'h', subheadline: 's', elevatorPitch: 'e', longDescription: 'l' },
|
|
26
|
+
seoStrategy: { primaryKeywords: [], secondaryKeywords: [], longTailKeywords: [], titleTemplates: {}, metaDescriptions: {} },
|
|
27
|
+
siteArchitecture: { pages: [], navigation: [], footerSections: [] },
|
|
28
|
+
conversionStrategy: { primaryCta: { text: 'Go', href: '/' }, secondaryCta: { text: 'More', href: '/' }, trustSignals: [], socialProof: [], leadCapture: 'none' },
|
|
29
|
+
competitiveContext: { category: 'sec', competitors: [], differentiators: [] },
|
|
30
|
+
},
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('validateWebsiteContextOrThrow', () => {
|
|
36
|
+
it('passes validation with valid product context', () => {
|
|
37
|
+
const context = makeContext();
|
|
38
|
+
|
|
39
|
+
expect(() => validateWebsiteContextOrThrow(context, 'gateco')).not.toThrow();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('throws on suspicious product name (directory-like)', () => {
|
|
43
|
+
const context = makeContext({ productName: 'read-all-files' });
|
|
44
|
+
|
|
45
|
+
expect(() => validateWebsiteContextOrThrow(context, 'read-all-files')).toThrow(
|
|
46
|
+
/looks like a directory name/
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('throws when no docs found', () => {
|
|
51
|
+
const context = makeContext({ rawDocs: '' });
|
|
52
|
+
|
|
53
|
+
expect(() => validateWebsiteContextOrThrow(context, 'test')).toThrow(
|
|
54
|
+
/No project documentation found/
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('throws when no features extracted', () => {
|
|
59
|
+
const context = makeContext({ features: [] });
|
|
60
|
+
|
|
61
|
+
expect(() => validateWebsiteContextOrThrow(context, 'test')).toThrow(
|
|
62
|
+
/No product features extracted/
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('includes POPEYE_DEBUG_WEBSITE hint in error message', () => {
|
|
67
|
+
const context = makeContext({ rawDocs: '' });
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
validateWebsiteContextOrThrow(context, 'test');
|
|
71
|
+
expect.fail('Should have thrown');
|
|
72
|
+
} catch (error) {
|
|
73
|
+
expect((error as Error).message).toContain('POPEYE_DEBUG_WEBSITE=1');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('still throws when passed is false from validateWebsiteContext', () => {
|
|
78
|
+
// Context with multiple blocking issues
|
|
79
|
+
const context = makeContext({
|
|
80
|
+
productName: 'my-app',
|
|
81
|
+
rawDocs: '',
|
|
82
|
+
features: [],
|
|
83
|
+
strategy: undefined,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(() => validateWebsiteContextOrThrow(context, 'my-app')).toThrow(
|
|
87
|
+
/Website generation blocked/
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('validateWebsiteContext (soft mode)', () => {
|
|
93
|
+
it('returns passed=true with no issues for valid context', () => {
|
|
94
|
+
const context = makeContext({ tagline: 'Secure your AI' });
|
|
95
|
+
const result = validateWebsiteContext(context, 'gateco');
|
|
96
|
+
|
|
97
|
+
expect(result.passed).toBe(true);
|
|
98
|
+
expect(result.issues).toHaveLength(0);
|
|
99
|
+
expect(result.contentScore).toBeGreaterThanOrEqual(90);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns warnings without marking passed=false', () => {
|
|
103
|
+
// Valid context but missing tagline triggers a warning, not an issue
|
|
104
|
+
const context = makeContext({ tagline: undefined });
|
|
105
|
+
const result = validateWebsiteContext(context, 'gateco');
|
|
106
|
+
|
|
107
|
+
expect(result.passed).toBe(true);
|
|
108
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
109
|
+
expect(result.warnings.some((w) => /tagline/i.test(w))).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('detects default pricing fingerprint as a warning', () => {
|
|
113
|
+
const context = makeContext({
|
|
114
|
+
pricing: [
|
|
115
|
+
{ name: 'Free', price: '$0', description: 'Basic', features: ['1 user'], cta: 'Start' },
|
|
116
|
+
{ name: 'Pro', price: '$29', description: 'Team', features: ['10 users'], cta: 'Go', featured: true },
|
|
117
|
+
{ name: 'Enterprise', price: 'Custom', description: 'Scale', features: ['Unlimited'], cta: 'Contact' },
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
const result = validateWebsiteContext(context, 'gateco');
|
|
121
|
+
|
|
122
|
+
expect(result.passed).toBe(true);
|
|
123
|
+
expect(result.warnings.some((w) => /default values/i.test(w))).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('does not flag custom pricing as default', () => {
|
|
127
|
+
const context = makeContext({
|
|
128
|
+
pricing: [
|
|
129
|
+
{ name: 'Hobby', price: '$9', description: 'For side projects', features: ['5 APIs'], cta: 'Start' },
|
|
130
|
+
{ name: 'Team', price: '$49', description: 'For teams', features: ['50 APIs'], cta: 'Go' },
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
const result = validateWebsiteContext(context, 'gateco');
|
|
134
|
+
|
|
135
|
+
expect(result.warnings.some((w) => /default values/i.test(w))).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('decreases contentScore with more defaults', () => {
|
|
139
|
+
const minimal = makeContext({
|
|
140
|
+
tagline: undefined,
|
|
141
|
+
description: undefined,
|
|
142
|
+
brand: undefined,
|
|
143
|
+
});
|
|
144
|
+
const rich = makeContext({
|
|
145
|
+
tagline: 'Secure your AI',
|
|
146
|
+
description: 'The best AI security platform',
|
|
147
|
+
brand: { primaryColor: '#3B82F6' },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const minResult = validateWebsiteContext(minimal, 'gateco');
|
|
151
|
+
const richResult = validateWebsiteContext(rich, 'gateco');
|
|
152
|
+
|
|
153
|
+
expect(richResult.contentScore).toBeGreaterThan(minResult.contentScore);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns blocking issues for missing docs', () => {
|
|
157
|
+
const context = makeContext({ rawDocs: '' });
|
|
158
|
+
const result = validateWebsiteContext(context, 'test');
|
|
159
|
+
|
|
160
|
+
expect(result.passed).toBe(false);
|
|
161
|
+
expect(result.issues.some((i) => /documentation/i.test(i))).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('warns about missing description', () => {
|
|
165
|
+
const context = makeContext({ description: undefined });
|
|
166
|
+
const result = validateWebsiteContext(context, 'gateco');
|
|
167
|
+
|
|
168
|
+
expect(result.warnings.some((w) => /description/i.test(w))).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('clamps score to 0 when everything is wrong', () => {
|
|
172
|
+
const context: WebsiteContentContext = {
|
|
173
|
+
productName: 'my-app',
|
|
174
|
+
features: [],
|
|
175
|
+
rawDocs: '',
|
|
176
|
+
strategy: undefined,
|
|
177
|
+
};
|
|
178
|
+
const result = validateWebsiteContext(context, 'my-app');
|
|
179
|
+
|
|
180
|
+
expect(result.contentScore).toBe(0);
|
|
181
|
+
expect(result.passed).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
});
|