popeye-cli 1.4.7 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +222 -63
- package/dist/adapters/gemini.d.ts +1 -0
- package/dist/adapters/gemini.d.ts.map +1 -1
- package/dist/adapters/gemini.js +9 -4
- package/dist/adapters/gemini.js.map +1 -1
- package/dist/adapters/grok.d.ts +1 -0
- package/dist/adapters/grok.d.ts.map +1 -1
- package/dist/adapters/grok.js +9 -4
- package/dist/adapters/grok.js.map +1 -1
- package/dist/adapters/openai.d.ts +1 -1
- package/dist/adapters/openai.d.ts.map +1 -1
- package/dist/adapters/openai.js +35 -9
- package/dist/adapters/openai.js.map +1 -1
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +42 -0
- 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 +2 -1
- package/dist/generators/all.js.map +1 -1
- package/dist/generators/doc-parser.d.ts +49 -0
- package/dist/generators/doc-parser.d.ts.map +1 -0
- package/dist/generators/doc-parser.js +336 -0
- package/dist/generators/doc-parser.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 +33 -0
- package/dist/generators/templates/website-components.d.ts.map +1 -0
- package/dist/generators/templates/website-components.js +278 -0
- package/dist/generators/templates/website-components.js.map +1 -0
- package/dist/generators/templates/website-config.d.ts +41 -0
- package/dist/generators/templates/website-config.d.ts.map +1 -0
- package/dist/generators/templates/website-config.js +283 -0
- package/dist/generators/templates/website-config.js.map +1 -0
- package/dist/generators/templates/website-conversion.d.ts +27 -0
- package/dist/generators/templates/website-conversion.d.ts.map +1 -0
- package/dist/generators/templates/website-conversion.js +326 -0
- package/dist/generators/templates/website-conversion.js.map +1 -0
- package/dist/generators/templates/website-seo.d.ts +76 -0
- package/dist/generators/templates/website-seo.d.ts.map +1 -0
- package/dist/generators/templates/website-seo.js +326 -0
- package/dist/generators/templates/website-seo.js.map +1 -0
- package/dist/generators/templates/website.d.ts +14 -47
- package/dist/generators/templates/website.d.ts.map +1 -1
- package/dist/generators/templates/website.js +412 -499
- package/dist/generators/templates/website.js.map +1 -1
- package/dist/generators/website-context.d.ts +83 -0
- package/dist/generators/website-context.d.ts.map +1 -0
- package/dist/generators/website-context.js +190 -0
- package/dist/generators/website-context.js.map +1 -0
- package/dist/generators/website.d.ts +3 -0
- package/dist/generators/website.d.ts.map +1 -1
- package/dist/generators/website.js +73 -10
- package/dist/generators/website.js.map +1 -1
- package/dist/state/index.d.ts +27 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/state/index.js +30 -0
- package/dist/state/index.js.map +1 -1
- package/dist/types/consensus.d.ts +3 -0
- package/dist/types/consensus.d.ts.map +1 -1
- package/dist/types/consensus.js +1 -0
- package/dist/types/consensus.js.map +1 -1
- package/dist/types/website-strategy.d.ts +263 -0
- package/dist/types/website-strategy.d.ts.map +1 -0
- package/dist/types/website-strategy.js +105 -0
- package/dist/types/website-strategy.js.map +1 -0
- package/dist/types/workflow.d.ts +15 -0
- package/dist/types/workflow.d.ts.map +1 -1
- package/dist/types/workflow.js +6 -0
- package/dist/types/workflow.js.map +1 -1
- package/dist/workflow/consensus.d.ts.map +1 -1
- package/dist/workflow/consensus.js +2 -0
- package/dist/workflow/consensus.js.map +1 -1
- package/dist/workflow/execution-mode.d.ts.map +1 -1
- package/dist/workflow/execution-mode.js +18 -0
- package/dist/workflow/execution-mode.js.map +1 -1
- package/dist/workflow/index.d.ts +3 -0
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +25 -0
- package/dist/workflow/index.js.map +1 -1
- package/dist/workflow/overview.d.ts +89 -0
- package/dist/workflow/overview.d.ts.map +1 -0
- package/dist/workflow/overview.js +354 -0
- package/dist/workflow/overview.js.map +1 -0
- package/dist/workflow/plan-mode.d.ts +2 -1
- package/dist/workflow/plan-mode.d.ts.map +1 -1
- package/dist/workflow/plan-mode.js +83 -5
- package/dist/workflow/plan-mode.js.map +1 -1
- package/dist/workflow/website-strategy.d.ts +70 -0
- package/dist/workflow/website-strategy.d.ts.map +1 -0
- package/dist/workflow/website-strategy.js +238 -0
- package/dist/workflow/website-strategy.js.map +1 -0
- package/dist/workflow/website-updater.d.ts +17 -0
- package/dist/workflow/website-updater.d.ts.map +1 -0
- package/dist/workflow/website-updater.js +105 -0
- package/dist/workflow/website-updater.js.map +1 -0
- package/dist/workflow/workflow-logger.d.ts +1 -1
- package/dist/workflow/workflow-logger.d.ts.map +1 -1
- package/dist/workflow/workflow-logger.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/gemini.ts +10 -4
- package/src/adapters/grok.ts +10 -4
- package/src/adapters/openai.ts +38 -6
- package/src/cli/interactive.ts +47 -0
- package/src/generators/all.ts +6 -1
- package/src/generators/doc-parser.ts +372 -0
- package/src/generators/templates/index.ts +4 -0
- package/src/generators/templates/website-components.ts +305 -0
- package/src/generators/templates/website-config.ts +291 -0
- package/src/generators/templates/website-conversion.ts +341 -0
- package/src/generators/templates/website-seo.ts +370 -0
- package/src/generators/templates/website.ts +451 -505
- package/src/generators/website-context.ts +265 -0
- package/src/generators/website.ts +109 -19
- package/src/state/index.ts +42 -0
- package/src/types/consensus.ts +3 -0
- package/src/types/website-strategy.ts +243 -0
- package/src/types/workflow.ts +15 -0
- package/src/workflow/consensus.ts +2 -0
- package/src/workflow/execution-mode.ts +21 -0
- package/src/workflow/index.ts +25 -0
- package/src/workflow/overview.ts +469 -0
- package/src/workflow/plan-mode.ts +115 -4
- package/src/workflow/website-strategy.ts +305 -0
- package/src/workflow/website-updater.ts +131 -0
- package/src/workflow/workflow-logger.ts +1 -0
- package/tests/adapters/persona-switching.test.ts +63 -0
- package/tests/generators/website-components.test.ts +159 -0
- package/tests/generators/website-context.test.ts +222 -0
- package/tests/generators/website-seo-quality.test.ts +246 -0
- package/tests/workflow/overview.test.ts +392 -0
- package/tests/workflow/website-strategy.test.ts +191 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document parsing helpers for extracting structured content from user docs
|
|
3
|
+
* Used by website-context.ts to populate website templates with project-specific content
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Generic AI preamble patterns to skip */
|
|
7
|
+
const GENERIC_PREAMBLES = [
|
|
8
|
+
"here's a comprehensive",
|
|
9
|
+
"here is a comprehensive",
|
|
10
|
+
"here's a detailed",
|
|
11
|
+
"here is a detailed",
|
|
12
|
+
"based on your idea",
|
|
13
|
+
"based on the idea",
|
|
14
|
+
"here's a software",
|
|
15
|
+
"here is a software",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Strip markdown code fences that wrap entire doc files (```md ... ```)
|
|
20
|
+
*/
|
|
21
|
+
export function stripCodeFences(text: string): string {
|
|
22
|
+
return text.replace(/```(?:md|markdown)?\s*\n/g, '').replace(/```\s*$/gm, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract the real product name from docs or specification
|
|
27
|
+
* Finds all "# Name — tagline" headings and picks the shortest (most likely the product name)
|
|
28
|
+
*/
|
|
29
|
+
export function extractProductName(docs: string, specification?: string): string | undefined {
|
|
30
|
+
// Collect all "# Name — tagline" headings, pick the shortest name
|
|
31
|
+
// Reason: sub-documents like "Gateco UI Color System" are longer than "Gateco"
|
|
32
|
+
const headingPattern = /^#\s+([A-Z][a-zA-Z0-9]+(?:[ \t]+[A-Z][a-zA-Z0-9]+)*)(?:[ \t]*[—\-–|:][ \t])/gm;
|
|
33
|
+
const candidates: string[] = [];
|
|
34
|
+
let match;
|
|
35
|
+
while ((match = headingPattern.exec(docs)) !== null) {
|
|
36
|
+
candidates.push(match[1].trim());
|
|
37
|
+
}
|
|
38
|
+
if (candidates.length > 0) {
|
|
39
|
+
// Prefer shortest name (product name is typically 1-2 words)
|
|
40
|
+
candidates.sort((a, b) => a.length - b.length);
|
|
41
|
+
return candidates[0];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Pattern: "**Project Name**: ProductName" in specification
|
|
45
|
+
if (specification) {
|
|
46
|
+
const nameMatch = specification.match(/\*\*Project\s+Name\*\*:\s*(.+)/i);
|
|
47
|
+
if (nameMatch) return nameMatch[1].trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Pattern: "# ProductName" in spec/product doc sections only
|
|
51
|
+
const sectionHeading = docs.match(/^---\s+\S*(?:spec|product)\S*\s+---\n#\s+([A-Z][a-zA-Z0-9]+)/im);
|
|
52
|
+
if (sectionHeading) return sectionHeading[1].trim();
|
|
53
|
+
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extract a tagline from docs (text after em-dash in first heading)
|
|
59
|
+
* When productName is provided, prefer tagline from the heading containing that name
|
|
60
|
+
*/
|
|
61
|
+
export function extractTagline(docs: string, productName?: string): string | undefined {
|
|
62
|
+
// Collect all "# Name — Tagline" matches
|
|
63
|
+
const taglineMatches = [...docs.matchAll(/^#\s+(.+?)[—\-–]\s*(.{10,80})$/gm)];
|
|
64
|
+
|
|
65
|
+
if (taglineMatches.length > 0) {
|
|
66
|
+
// Prefer tagline from the heading that best matches the product name
|
|
67
|
+
// Reason: "Gateco UI Color System — ..." also includes "Gateco", so prefer exact match
|
|
68
|
+
if (productName) {
|
|
69
|
+
const exactMatch = taglineMatches.find((m) => m[1].trim() === productName);
|
|
70
|
+
if (exactMatch) return exactMatch[2].trim();
|
|
71
|
+
// Fall back to shortest heading containing the name (closest to just the product name)
|
|
72
|
+
const nameMatches = taglineMatches.filter((m) => m[1].includes(productName));
|
|
73
|
+
if (nameMatches.length > 0) {
|
|
74
|
+
nameMatches.sort((a, b) => a[1].length - b[1].length);
|
|
75
|
+
return nameMatches[0][2].trim();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return taglineMatches[0][2].trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Bold tagline near the top: "**Secure AI Retrieval. Priced by Use.**"
|
|
82
|
+
const boldMatch = docs.match(/\*\*([A-Z].{15,80}?)\*\*/);
|
|
83
|
+
if (boldMatch && !boldMatch[1].includes('Project Name')) return boldMatch[1];
|
|
84
|
+
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract a meaningful description, skipping generic AI preambles
|
|
90
|
+
*/
|
|
91
|
+
export function extractDescription(docs: string, specification?: string): string | undefined {
|
|
92
|
+
// Look for "What is [Product]?" or "About [Product]" sections in docs
|
|
93
|
+
// Collect all matches and prefer the one that looks most like a product description
|
|
94
|
+
// Reason: "What is a Secured Retrieval?" in pricing doc should not beat "What Is Gateco?" in spec
|
|
95
|
+
const descPattern = /##\s+(?:\d+\.\s*)?(?:What\s+Is|About(?!\s+(?:This|the)))\b[^\n]*\n+([\s\S]*?)(?=\n##\s|\n---)/gi;
|
|
96
|
+
const descMatches = [...docs.matchAll(descPattern)];
|
|
97
|
+
// Sort: prefer matches whose heading has just a product name (no articles like "a/an/the")
|
|
98
|
+
descMatches.sort((a, b) => {
|
|
99
|
+
const headingA = a[0].split('\n')[0];
|
|
100
|
+
const headingB = b[0].split('\n')[0];
|
|
101
|
+
const hasArticleA = /what\s+is\s+(?:a|an|the)\s/i.test(headingA) ? 1 : 0;
|
|
102
|
+
const hasArticleB = /what\s+is\s+(?:a|an|the)\s/i.test(headingB) ? 1 : 0;
|
|
103
|
+
return hasArticleA - hasArticleB;
|
|
104
|
+
});
|
|
105
|
+
for (const whatIsMatch of descMatches) {
|
|
106
|
+
const paragraph = whatIsMatch[1].trim().split('\n\n')[0]
|
|
107
|
+
.replace(/\*\*/g, '').replace(/\n/g, ' ').trim();
|
|
108
|
+
if (paragraph.length > 30) return paragraph.slice(0, 300);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Look in specification, skipping generic lines
|
|
112
|
+
if (specification) {
|
|
113
|
+
for (const line of specification.split('\n')) {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.length < 30) continue;
|
|
116
|
+
const lower = trimmed.toLowerCase();
|
|
117
|
+
if (GENERIC_PREAMBLES.some((p) => lower.startsWith(p))) continue;
|
|
118
|
+
if (lower.startsWith('**project name')) continue;
|
|
119
|
+
return trimmed.replace(/^\*\*(.+?)\*\*:?\s*/, '$1: ').slice(0, 300);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Extract features from docs and specification
|
|
128
|
+
* Looks for bullet/numbered lists in sections about features, principles, capabilities
|
|
129
|
+
*/
|
|
130
|
+
export function extractFeatures(
|
|
131
|
+
docs: string,
|
|
132
|
+
specification?: string
|
|
133
|
+
): Array<{ title: string; description: string }> {
|
|
134
|
+
const features: Array<{ title: string; description: string }> = [];
|
|
135
|
+
const source = docs + '\n' + (specification || '');
|
|
136
|
+
|
|
137
|
+
// Split into sections by heading
|
|
138
|
+
const sectionPattern = /^#{1,3}\s+(.+)$/gm;
|
|
139
|
+
const sections: Array<{ heading: string; content: string }> = [];
|
|
140
|
+
let lastIndex = 0;
|
|
141
|
+
let lastHeading = '';
|
|
142
|
+
let match;
|
|
143
|
+
|
|
144
|
+
while ((match = sectionPattern.exec(source)) !== null) {
|
|
145
|
+
if (lastIndex > 0) {
|
|
146
|
+
sections.push({
|
|
147
|
+
heading: lastHeading,
|
|
148
|
+
content: source.slice(lastIndex, match.index),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
lastHeading = match[1];
|
|
152
|
+
lastIndex = match.index + match[0].length;
|
|
153
|
+
}
|
|
154
|
+
if (lastIndex > 0) {
|
|
155
|
+
sections.push({ heading: lastHeading, content: source.slice(lastIndex) });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Find sections about product features, principles, capabilities
|
|
159
|
+
// Reason: pattern must be specific to avoid matching design docs ("Feature Gating", "Enforcement Colors")
|
|
160
|
+
// "features" requires plural or "Key/Core Features" prefix to avoid "Feature Gating"
|
|
161
|
+
const featureKeywords = /(?:key|core|main)?\s*features\b|principle|capabilit|what\s+(?:it|we)\s+do|core\s+design/i;
|
|
162
|
+
|
|
163
|
+
for (const section of sections) {
|
|
164
|
+
if (!featureKeywords.test(section.heading)) continue;
|
|
165
|
+
|
|
166
|
+
// Collect bullet points (- or * or +) and numbered items (1. 2. 3.)
|
|
167
|
+
const items = section.content.match(/^(?:[-*+]|\d+\.)\s+.+/gm);
|
|
168
|
+
if (!items) continue;
|
|
169
|
+
|
|
170
|
+
for (const item of items) {
|
|
171
|
+
const text = item.replace(/^(?:[-*+]|\d+\.)\s+/, '');
|
|
172
|
+
|
|
173
|
+
// Try "**bold title** - description" pattern
|
|
174
|
+
const boldWithDesc = text.match(/^\*\*(.+?)\*\*\s*[-–:]\s*(.+)/);
|
|
175
|
+
if (boldWithDesc) {
|
|
176
|
+
features.push({
|
|
177
|
+
title: boldWithDesc[1].trim(),
|
|
178
|
+
description: boldWithDesc[2].trim().slice(0, 150),
|
|
179
|
+
});
|
|
180
|
+
} else if (/^\*\*.+\*\*/.test(text)) {
|
|
181
|
+
// Bold title with no trailing description: "**Vector DB agnostic**"
|
|
182
|
+
const title = text.replace(/\*\*/g, '').trim();
|
|
183
|
+
if (title.length > 3 && title.length < 80) {
|
|
184
|
+
features.push({ title, description: title });
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
const cleaned = text.replace(/\*\*/g, '');
|
|
188
|
+
// Split on sentence-level delimiters only; keep hyphens in compound words
|
|
189
|
+
const titlePart = cleaned.split(/[.,:;—–]/)[0].trim();
|
|
190
|
+
if (titlePart.length > 3 && titlePart.length < 60) {
|
|
191
|
+
features.push({
|
|
192
|
+
title: titlePart,
|
|
193
|
+
description: cleaned.slice(0, 150),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (features.length >= 6) break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (features.length > 0) break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return features;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Extract pricing tiers from docs
|
|
209
|
+
* Parses markdown tables and "Plan Positioning" sections
|
|
210
|
+
*/
|
|
211
|
+
export function extractPricing(
|
|
212
|
+
docs: string
|
|
213
|
+
): Array<{
|
|
214
|
+
name: string; price: string; period?: string;
|
|
215
|
+
description: string; features: string[];
|
|
216
|
+
cta: string; featured?: boolean;
|
|
217
|
+
}> | undefined {
|
|
218
|
+
const tiers: Array<{
|
|
219
|
+
name: string; price: string; period?: string;
|
|
220
|
+
description: string; features: string[];
|
|
221
|
+
cta: string; featured?: boolean;
|
|
222
|
+
}> = [];
|
|
223
|
+
|
|
224
|
+
// Find the pricing section to avoid matching design token tables
|
|
225
|
+
// Reason: "Plan-Based Color Usage" matches "Plans?" - require "Pricing" keyword
|
|
226
|
+
const pricingSection = docs.match(
|
|
227
|
+
/##\s+(?:[\d.]*\s*)?Pricing\b[^\n]*\n([\s\S]*?)(?=\n##\s(?!.*(?:Plan\s+Positioning|Feature|Comparison))|\n---(?:\s*\n##\s)|$)/i
|
|
228
|
+
);
|
|
229
|
+
const searchArea = pricingSection ? pricingSection[0] : docs;
|
|
230
|
+
|
|
231
|
+
// Look for pricing overview table rows with plan names and actual prices
|
|
232
|
+
const priceMap = new Map<string, string>();
|
|
233
|
+
const tableRows = searchArea.match(/^\|[^|]*(?:Free|Pro|Enterprise|Starter|Growth|Team|Business)[^|]*\|.+\|$/gm);
|
|
234
|
+
if (tableRows) {
|
|
235
|
+
for (const row of tableRows) {
|
|
236
|
+
const cells = row.split('|').map((c) => c.trim()).filter(Boolean);
|
|
237
|
+
if (cells.length >= 2) {
|
|
238
|
+
const planName = cells[0].replace(/[🟢🔵🟣⚪🟡🟠🔴]\s*/g, '').replace(/\*\*/g, '').trim();
|
|
239
|
+
const price = cells[1].replace(/<br>/g, ' / ').replace(/\*\*/g, '').trim();
|
|
240
|
+
// Require the price to look like an actual price (Free, $amount, Custom, Contact)
|
|
241
|
+
// Reason: avoids matching design tokens like `plan-free` from color-scheme docs
|
|
242
|
+
const looksLikePrice = /^free$/i.test(price) || /^\$/.test(price)
|
|
243
|
+
|| /^custom/i.test(price) || /^contact/i.test(price);
|
|
244
|
+
if (looksLikePrice && !priceMap.has(planName)) {
|
|
245
|
+
priceMap.set(planName, price);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (priceMap.size === 0) return undefined;
|
|
252
|
+
|
|
253
|
+
// Look for plan descriptions
|
|
254
|
+
const descMap = new Map<string, string>();
|
|
255
|
+
const positionMatch = docs.match(
|
|
256
|
+
/##\s+(?:Plan\s+Positioning|Plan\s+Descriptions?)[^\n]*\n([\s\S]*?)(?=\n##\s|\n---)/i
|
|
257
|
+
);
|
|
258
|
+
if (positionMatch) {
|
|
259
|
+
const descPattern = /[-*]\s+\*\*(.+?)\*\*\s*\n\s+\*(.+?)\*/g;
|
|
260
|
+
let descMatch;
|
|
261
|
+
while ((descMatch = descPattern.exec(positionMatch[1])) !== null) {
|
|
262
|
+
descMap.set(descMatch[1].trim(), descMatch[2].trim());
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Build tier objects
|
|
267
|
+
let index = 0;
|
|
268
|
+
for (const [name, price] of priceMap) {
|
|
269
|
+
let displayPrice = price;
|
|
270
|
+
let period: string | undefined;
|
|
271
|
+
if (/free/i.test(price)) {
|
|
272
|
+
displayPrice = 'Free';
|
|
273
|
+
} else if (/custom|contact/i.test(price)) {
|
|
274
|
+
displayPrice = 'Custom';
|
|
275
|
+
} else {
|
|
276
|
+
// If there's a "minimum" monthly amount (e.g. "$0.40/1K ... $99/month minimum"),
|
|
277
|
+
// prefer the minimum amount as the display price
|
|
278
|
+
const minMatch = price.match(/(\$[\d,.]+)\s*\/?\s*month\s*minimum/i);
|
|
279
|
+
if (minMatch) {
|
|
280
|
+
displayPrice = minMatch[1];
|
|
281
|
+
period = '/month';
|
|
282
|
+
} else {
|
|
283
|
+
const dollarMatch = price.match(/\$[\d,.]+/);
|
|
284
|
+
if (dollarMatch) {
|
|
285
|
+
displayPrice = dollarMatch[0];
|
|
286
|
+
if (/month/i.test(price)) period = '/month';
|
|
287
|
+
if (/year|annual/i.test(price)) period = '/year';
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const description = descMap.get(name) || '';
|
|
293
|
+
const cta = /enterprise|custom/i.test(name) ? 'Contact sales'
|
|
294
|
+
: /pro/i.test(name) ? 'Start free trial'
|
|
295
|
+
: 'Get started';
|
|
296
|
+
const featured = index === 1;
|
|
297
|
+
|
|
298
|
+
tiers.push({
|
|
299
|
+
name: name.replace(/\(.+?\)/, '').trim(),
|
|
300
|
+
price: displayPrice,
|
|
301
|
+
period,
|
|
302
|
+
description,
|
|
303
|
+
features: [],
|
|
304
|
+
cta,
|
|
305
|
+
featured,
|
|
306
|
+
});
|
|
307
|
+
index++;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Extract features per plan from comparison table
|
|
311
|
+
const compTable = docs.match(
|
|
312
|
+
/\|\s*Feature\s*\/\s*Plan\s*\|(.+)\|[\s\S]*?(?=\n\n|\n##\s|\n---)/i
|
|
313
|
+
);
|
|
314
|
+
if (compTable && tiers.length > 0) {
|
|
315
|
+
const rows = compTable[0].split('\n').filter((r) => r.startsWith('|'));
|
|
316
|
+
for (const row of rows.slice(2)) {
|
|
317
|
+
const cells = row.split('|').map((c) => c.trim()).filter(Boolean);
|
|
318
|
+
if (cells.length < 2) continue;
|
|
319
|
+
const featureName = cells[0].replace(/\*\*/g, '').trim();
|
|
320
|
+
if (!featureName || /^[-=]+$/.test(featureName)) continue;
|
|
321
|
+
|
|
322
|
+
for (let i = 0; i < tiers.length && i + 1 < cells.length; i++) {
|
|
323
|
+
const val = cells[i + 1].trim();
|
|
324
|
+
if (val && val !== '❌') {
|
|
325
|
+
const display = val === '✅' ? featureName : `${featureName}: ${val}`;
|
|
326
|
+
tiers[i].features.push(display);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const tier of tiers) {
|
|
333
|
+
tier.features = tier.features.slice(0, 6);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return tiers.length > 0 ? tiers : undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Extract the primary brand/accent color, not just any hex color
|
|
341
|
+
* Looks for accent/primary/CTA color tokens, avoids dark backgrounds
|
|
342
|
+
*/
|
|
343
|
+
export function extractPrimaryColor(docs: string): string | undefined {
|
|
344
|
+
// Look for "accent-primary" or "accent_primary" token with nearby hex
|
|
345
|
+
const accentMatch = docs.match(/accent[_-]?primary[^#]{0,40}(#[0-9a-fA-F]{6})/i);
|
|
346
|
+
if (accentMatch) return accentMatch[1];
|
|
347
|
+
|
|
348
|
+
// Look for "Primary Brand" or "Primary CTA" color label near a hex value
|
|
349
|
+
const primaryMatch = docs.match(
|
|
350
|
+
/(?:primary\s+(?:brand\s+)?(?:accent|color|CTA))[^#]{0,40}(#[0-9a-fA-F]{6})/i
|
|
351
|
+
);
|
|
352
|
+
if (primaryMatch) return primaryMatch[1];
|
|
353
|
+
|
|
354
|
+
// Look for CTA/link color
|
|
355
|
+
const ctaMatch = docs.match(/(?:CTA|primary\s+link)[^#]{0,40}(#[0-9a-fA-F]{6})/i);
|
|
356
|
+
if (ctaMatch) return ctaMatch[1];
|
|
357
|
+
|
|
358
|
+
// Fallback: first hex color that isn't very dark or very light
|
|
359
|
+
const allColors = [...docs.matchAll(/#([0-9a-fA-F]{6})/g)];
|
|
360
|
+
for (const colorMatch of allColors) {
|
|
361
|
+
const hex = colorMatch[1];
|
|
362
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
363
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
364
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
365
|
+
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
366
|
+
if (brightness > 60 && brightness < 210) {
|
|
367
|
+
return '#' + hex;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
@@ -6,3 +6,7 @@ export * as pythonTemplates from './python.js';
|
|
|
6
6
|
export * as typescriptTemplates from './typescript.js';
|
|
7
7
|
export * as fullstackTemplates from './fullstack.js';
|
|
8
8
|
export * as websiteTemplates from './website.js';
|
|
9
|
+
export * as websiteConfigTemplates from './website-config.js';
|
|
10
|
+
export * as websiteComponentTemplates from './website-components.js';
|
|
11
|
+
export * as websiteSeoTemplates from './website-seo.js';
|
|
12
|
+
export * as websiteConversionTemplates from './website-conversion.js';
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared website component templates
|
|
3
|
+
* Generates Header, Footer, and Navigation components with
|
|
4
|
+
* strategy-driven content, logo support, and mobile responsiveness
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { WebsiteContentContext } from '../website-context.js';
|
|
8
|
+
import type { WebsiteStrategyDocument, NavItem, FooterSection } from '../../types/website-strategy.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Escape a string for safe use inside JSX template literals
|
|
12
|
+
*/
|
|
13
|
+
function escapeJsx(str: string): string {
|
|
14
|
+
return str
|
|
15
|
+
.replace(/\\/g, '\\\\')
|
|
16
|
+
.replace(/'/g, "\\'")
|
|
17
|
+
.replace(/`/g, '\\`')
|
|
18
|
+
.replace(/\$/g, '\\$');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate website header component with logo, navigation, and CTA
|
|
23
|
+
*
|
|
24
|
+
* @param projectName - Project name for fallback display
|
|
25
|
+
* @param context - Optional content context
|
|
26
|
+
* @param strategy - Optional strategy for navigation and CTA
|
|
27
|
+
* @returns Header component source code
|
|
28
|
+
*/
|
|
29
|
+
export function generateWebsiteHeader(
|
|
30
|
+
projectName: string,
|
|
31
|
+
context?: WebsiteContentContext,
|
|
32
|
+
strategy?: WebsiteStrategyDocument
|
|
33
|
+
): string {
|
|
34
|
+
const displayName = context?.productName || projectName
|
|
35
|
+
.split('-')
|
|
36
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
37
|
+
.join(' ');
|
|
38
|
+
|
|
39
|
+
const hasLogo = !!(context?.brandAssets?.logoOutputPath || context?.brand?.logoPath);
|
|
40
|
+
const logoPath = context?.brandAssets?.logoOutputPath
|
|
41
|
+
? `/${context.brandAssets.logoOutputPath}`
|
|
42
|
+
: '/logo.svg';
|
|
43
|
+
|
|
44
|
+
// Build nav items from strategy or defaults
|
|
45
|
+
const navItems = strategy?.siteArchitecture.navigation || [
|
|
46
|
+
{ label: 'Features', href: '/#features' },
|
|
47
|
+
{ label: 'Pricing', href: '/pricing' },
|
|
48
|
+
{ label: 'Docs', href: '/docs' },
|
|
49
|
+
{ label: 'Blog', href: '/blog' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const navItemsStr = navItems
|
|
53
|
+
.map(item => ` { label: '${escapeJsx(item.label)}', href: '${escapeJsx(item.href)}' }`)
|
|
54
|
+
.join(',\n');
|
|
55
|
+
|
|
56
|
+
// CTA from strategy or default
|
|
57
|
+
const ctaText = strategy?.conversionStrategy.primaryCta.text || 'Get Started';
|
|
58
|
+
const ctaHref = strategy?.conversionStrategy.primaryCta.href || '/pricing';
|
|
59
|
+
|
|
60
|
+
// Logo rendering
|
|
61
|
+
const logoBlock = hasLogo
|
|
62
|
+
? `<Image src="${logoPath}" alt="${escapeJsx(displayName)}" width={32} height={32} className="h-8 w-auto" />`
|
|
63
|
+
: `<span className="text-xl font-bold text-primary-600">${escapeJsx(displayName)}</span>`;
|
|
64
|
+
|
|
65
|
+
return `'use client';
|
|
66
|
+
|
|
67
|
+
import { useState } from 'react';
|
|
68
|
+
import Link from 'next/link';
|
|
69
|
+
${hasLogo ? "import Image from 'next/image';" : ''}
|
|
70
|
+
|
|
71
|
+
const NAV_ITEMS = [
|
|
72
|
+
${navItemsStr}
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Site header with logo, navigation links, mobile menu, and CTA
|
|
77
|
+
*/
|
|
78
|
+
export default function Header() {
|
|
79
|
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<header className="sticky top-0 z-50 border-b border-gray-200 bg-white/80 backdrop-blur-sm">
|
|
83
|
+
<nav className="container flex h-16 items-center justify-between">
|
|
84
|
+
{/* Logo */}
|
|
85
|
+
<Link href="/" className="flex items-center gap-2">
|
|
86
|
+
${logoBlock}
|
|
87
|
+
</Link>
|
|
88
|
+
|
|
89
|
+
{/* Desktop Navigation */}
|
|
90
|
+
<div className="hidden items-center gap-8 md:flex">
|
|
91
|
+
{NAV_ITEMS.map((item) => (
|
|
92
|
+
<Link
|
|
93
|
+
key={item.href}
|
|
94
|
+
href={item.href}
|
|
95
|
+
className="text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors"
|
|
96
|
+
>
|
|
97
|
+
{item.label}
|
|
98
|
+
</Link>
|
|
99
|
+
))}
|
|
100
|
+
<Link
|
|
101
|
+
href="${escapeJsx(ctaHref)}"
|
|
102
|
+
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-primary-500 transition-colors"
|
|
103
|
+
>
|
|
104
|
+
${escapeJsx(ctaText)}
|
|
105
|
+
</Link>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Mobile Menu Button */}
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
className="md:hidden rounded-md p-2 text-gray-700"
|
|
112
|
+
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
|
113
|
+
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
|
|
114
|
+
aria-expanded={mobileMenuOpen}
|
|
115
|
+
>
|
|
116
|
+
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
117
|
+
{mobileMenuOpen ? (
|
|
118
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
119
|
+
) : (
|
|
120
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
|
121
|
+
)}
|
|
122
|
+
</svg>
|
|
123
|
+
</button>
|
|
124
|
+
</nav>
|
|
125
|
+
|
|
126
|
+
{/* Mobile Menu */}
|
|
127
|
+
{mobileMenuOpen && (
|
|
128
|
+
<div className="border-t border-gray-200 bg-white px-4 py-4 md:hidden">
|
|
129
|
+
<div className="flex flex-col gap-4">
|
|
130
|
+
{NAV_ITEMS.map((item) => (
|
|
131
|
+
<Link
|
|
132
|
+
key={item.href}
|
|
133
|
+
href={item.href}
|
|
134
|
+
className="text-sm font-medium text-gray-700 hover:text-primary-600"
|
|
135
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
136
|
+
>
|
|
137
|
+
{item.label}
|
|
138
|
+
</Link>
|
|
139
|
+
))}
|
|
140
|
+
<Link
|
|
141
|
+
href="${escapeJsx(ctaHref)}"
|
|
142
|
+
className="rounded-md bg-primary-600 px-4 py-2 text-center text-sm font-semibold text-white"
|
|
143
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
144
|
+
>
|
|
145
|
+
${escapeJsx(ctaText)}
|
|
146
|
+
</Link>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</header>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Generate website footer component with multi-column sections
|
|
158
|
+
*
|
|
159
|
+
* @param projectName - Project name for copyright
|
|
160
|
+
* @param context - Optional content context
|
|
161
|
+
* @param strategy - Optional strategy for footer sections
|
|
162
|
+
* @returns Footer component source code
|
|
163
|
+
*/
|
|
164
|
+
export function generateWebsiteFooter(
|
|
165
|
+
projectName: string,
|
|
166
|
+
context?: WebsiteContentContext,
|
|
167
|
+
strategy?: WebsiteStrategyDocument
|
|
168
|
+
): string {
|
|
169
|
+
const displayName = context?.productName || projectName
|
|
170
|
+
.split('-')
|
|
171
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
172
|
+
.join(' ');
|
|
173
|
+
|
|
174
|
+
// Build footer sections from strategy or defaults
|
|
175
|
+
const sections: FooterSection[] = strategy?.siteArchitecture.footerSections || [
|
|
176
|
+
{
|
|
177
|
+
title: 'Product',
|
|
178
|
+
links: [
|
|
179
|
+
{ label: 'Features', href: '/#features' },
|
|
180
|
+
{ label: 'Pricing', href: '/pricing' },
|
|
181
|
+
{ label: 'Documentation', href: '/docs' },
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
title: 'Resources',
|
|
186
|
+
links: [
|
|
187
|
+
{ label: 'Blog', href: '/blog' },
|
|
188
|
+
{ label: 'Support', href: '/contact' },
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
title: 'Legal',
|
|
193
|
+
links: [
|
|
194
|
+
{ label: 'Privacy Policy', href: '/privacy' },
|
|
195
|
+
{ label: 'Terms of Service', href: '/terms' },
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const sectionsStr = sections
|
|
201
|
+
.map(section => {
|
|
202
|
+
const linksStr = section.links
|
|
203
|
+
.map(link => ` { label: '${escapeJsx(link.label)}', href: '${escapeJsx(link.href)}' }`)
|
|
204
|
+
.join(',\n');
|
|
205
|
+
return ` {\n title: '${escapeJsx(section.title)}',\n links: [\n${linksStr}\n ],\n }`;
|
|
206
|
+
})
|
|
207
|
+
.join(',\n');
|
|
208
|
+
|
|
209
|
+
return `import Link from 'next/link';
|
|
210
|
+
|
|
211
|
+
const FOOTER_SECTIONS = [
|
|
212
|
+
${sectionsStr}
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Site footer with multi-column link sections and copyright
|
|
217
|
+
*/
|
|
218
|
+
export default function Footer() {
|
|
219
|
+
return (
|
|
220
|
+
<footer className="border-t border-gray-200 bg-gray-50">
|
|
221
|
+
<div className="container py-12">
|
|
222
|
+
<div className="grid grid-cols-2 gap-8 md:grid-cols-${Math.min(sections.length + 1, 4)}">
|
|
223
|
+
{/* Brand column */}
|
|
224
|
+
<div className="col-span-2 md:col-span-1">
|
|
225
|
+
<Link href="/" className="text-lg font-bold text-gray-900">
|
|
226
|
+
${escapeJsx(displayName)}
|
|
227
|
+
</Link>
|
|
228
|
+
<p className="mt-2 text-sm text-gray-600">
|
|
229
|
+
${context?.tagline ? escapeJsx(context.tagline) : 'Build something amazing.'}
|
|
230
|
+
</p>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{/* Link columns */}
|
|
234
|
+
{FOOTER_SECTIONS.map((section) => (
|
|
235
|
+
<div key={section.title}>
|
|
236
|
+
<h3 className="text-sm font-semibold text-gray-900">{section.title}</h3>
|
|
237
|
+
<ul className="mt-4 space-y-2">
|
|
238
|
+
{section.links.map((link) => (
|
|
239
|
+
<li key={link.href}>
|
|
240
|
+
<Link
|
|
241
|
+
href={link.href}
|
|
242
|
+
className="text-sm text-gray-600 hover:text-primary-600 transition-colors"
|
|
243
|
+
>
|
|
244
|
+
{link.label}
|
|
245
|
+
</Link>
|
|
246
|
+
</li>
|
|
247
|
+
))}
|
|
248
|
+
</ul>
|
|
249
|
+
</div>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div className="mt-12 border-t border-gray-200 pt-8">
|
|
254
|
+
<p className="text-center text-sm text-gray-500">
|
|
255
|
+
© {new Date().getFullYear()} ${escapeJsx(displayName)}. All rights reserved.
|
|
256
|
+
</p>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</footer>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Generate navigation config module
|
|
267
|
+
*
|
|
268
|
+
* @param strategy - Optional strategy for navigation items
|
|
269
|
+
* @returns Navigation config source code
|
|
270
|
+
*/
|
|
271
|
+
export function generateWebsiteNavigation(
|
|
272
|
+
strategy?: WebsiteStrategyDocument
|
|
273
|
+
): string {
|
|
274
|
+
const navItems: NavItem[] = strategy?.siteArchitecture.navigation || [
|
|
275
|
+
{ label: 'Features', href: '/#features' },
|
|
276
|
+
{ label: 'Pricing', href: '/pricing' },
|
|
277
|
+
{ label: 'Docs', href: '/docs' },
|
|
278
|
+
{ label: 'Blog', href: '/blog' },
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
const itemsStr = navItems
|
|
282
|
+
.map(item => {
|
|
283
|
+
const childrenStr = item.children && item.children.length > 0
|
|
284
|
+
? `,\n children: [\n${item.children.map(c => ` { label: '${escapeJsx(c.label)}', href: '${escapeJsx(c.href)}' }`).join(',\n')}\n ]`
|
|
285
|
+
: '';
|
|
286
|
+
return ` { label: '${escapeJsx(item.label)}', href: '${escapeJsx(item.href)}'${childrenStr} }`;
|
|
287
|
+
})
|
|
288
|
+
.join(',\n');
|
|
289
|
+
|
|
290
|
+
return `/**
|
|
291
|
+
* Navigation configuration
|
|
292
|
+
* Exported for use in Header and mobile navigation components
|
|
293
|
+
*/
|
|
294
|
+
|
|
295
|
+
export interface NavItem {
|
|
296
|
+
label: string;
|
|
297
|
+
href: string;
|
|
298
|
+
children?: NavItem[];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const NAV_ITEMS: NavItem[] = [
|
|
302
|
+
${itemsStr}
|
|
303
|
+
];
|
|
304
|
+
`;
|
|
305
|
+
}
|