277 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* MCPEngage Landing Page Generator
* Takes the gold-standard Zendesk v2 template and generates per-platform pages
* from config objects.
*
* Usage: node generate.js [platform-slug] or node generate.js --all
*/
const fs = require('fs');
const path = require('path');
const CONFIGS_DIR = path.join(__dirname, 'configs');
const OUTPUT_DIR = path.join(__dirname, '..', 'mcpengage-deploy');
const TEMPLATE_PATH = path.join(__dirname, 'template.html');
function loadTemplate() {
return fs.readFileSync(TEMPLATE_PATH, 'utf8');
}
function loadConfig(slug) {
const p = path.join(CONFIGS_DIR, `${slug}.json`);
if (!fs.existsSync(p)) throw new Error(`Config not found: ${slug}`);
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
function getAllConfigs() {
return fs.readdirSync(CONFIGS_DIR)
.filter(f => f.endsWith('.json'))
.map(f => f.replace('.json', ''));
}
/**
* Replace all {{PLACEHOLDER}} tokens in the template with config values.
* Supports nested: {{chat.userMsg1}}, arrays via {{#features}}...{{/features}}
*/
function renderTemplate(template, config) {
let html = template;
// Simple replacements: {{key}}
const simpleKeys = [
'name', 'slug', 'tagline', 'metaDescription', 'heroHeadline',
'heroSubtitle', 'badgeText', 'toolCount', 'urlBarPath',
'installPlatformName', 'installToolCount',
'ctaHeadline', 'ctaSubtext',
'painPointsHeadline', 'painPointsSubHeadline',
'howItWorksHeadline',
'featuresHeadline', 'featuresSubtext',
];
for (const key of simpleKeys) {
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
html = html.replace(regex, config[key] || '');
}
// Sidebar icons (array of {icon, label})
if (config.sidebarIcons) {
let sidebarHtml = '';
config.sidebarIcons.forEach((item, i) => {
const active = i === 0;
sidebarHtml += `
<div class="w-9 h-9 lg:w-10 lg:h-10 rounded-xl ${active ? 'bg-brand-500/20' : 'hover:bg-white/5'} flex items-center justify-center ${active ? 'text-brand-400' : 'text-zinc-600'} transition-colors">
<i data-lucide="${item.icon}" class="w-4 h-4 lg:w-5 lg:h-5"></i>
</div>`;
});
html = html.replace('{{sidebarIcons}}', sidebarHtml);
}
// Right panel tabs
if (config.rightPanel) {
const rp = config.rightPanel;
html = html.replace(/\{\{rightPanel\.tab1Label\}\}/g, rp.tab1Label || 'Items');
html = html.replace(/\{\{rightPanel\.tab1Icon\}\}/g, rp.tab1Icon || 'list');
html = html.replace(/\{\{rightPanel\.tab1Count\}\}/g, rp.tab1Count || '12');
html = html.replace(/\{\{rightPanel\.tab2Label\}\}/g, rp.tab2Label || 'Details');
html = html.replace(/\{\{rightPanel\.tab2Icon\}\}/g, rp.tab2Icon || 'user');
// Right panel cards
let cardsHtml = '';
(rp.cards || []).forEach(card => {
cardsHtml += `
<div class="glass rounded-xl xl:rounded-2xl p-4 xl:p-5 hover:bg-white/5 transition-all cursor-pointer group border border-transparent hover:border-brand-500/30">
<div class="flex items-start justify-between mb-2 xl:mb-3">
<div>
<h4 class="font-bold text-sm xl:text-base group-hover:text-brand-400 transition-colors">${card.title}</h4>
<p class="text-[10px] xl:text-xs text-zinc-500">${card.subtitle}</p>
</div>
<span class="px-2 py-0.5 rounded-md bg-${card.badgeColor}-500/20 text-${card.badgeColor}-400 text-[10px] xl:text-xs font-bold">${card.badge}</span>
</div>
<div class="flex gap-1.5 xl:gap-2">
${card.tags.map(t => `<span class="px-2 xl:px-2.5 py-0.5 xl:py-1 rounded-md xl:rounded-lg ${t.bg || 'bg-brand-500/15'} ${t.color || 'text-brand-400'} text-[10px] xl:text-xs font-medium">${t.text}</span>`).join('\n ')}
</div>
</div>`;
});
html = html.replace('{{rightPanel.cards}}', cardsHtml);
}
// Chat input placeholder
html = html.replace(/\{\{chatPlaceholder\}\}/g, config.chatPlaceholder || `Ask about your ${config.name}...`);
// Stats
if (config.stats) {
let statsHtml = '';
const colors = ['brand', 'cyan', 'violet', 'emerald'];
config.stats.forEach((stat, i) => {
const c = colors[i % colors.length];
const isCounter = stat.value !== undefined && !isNaN(stat.value);
statsHtml += `
<div class="stat-card text-center">
<div class="text-2xl sm:text-3xl lg:text-4xl xl:text-5xl font-black text-${c}-400 mb-1 sm:mb-2">${stat.prefix || ''}${isCounter ? `<span class="counter" data-target="${stat.value}">0</span>` : stat.display}${stat.suffix || ''}</div>
<div class="text-xs sm:text-sm text-zinc-500 font-medium">${stat.label}</div>
</div>`;
});
html = html.replace('{{statsGrid}}', statsHtml);
}
// Before items
if (config.beforeItems) {
let bHtml = '';
const icons = ['clock', 'copy', 'layout-grid', 'hourglass'];
config.beforeItems.forEach((item, i) => {
bHtml += `
<li class="flex items-start gap-3"><div class="w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0 mt-0.5"><i data-lucide="${icons[i % icons.length]}" class="w-3.5 h-3.5 text-red-400"></i></div><div><p class="text-sm sm:text-base text-zinc-300 font-medium">${item.title}</p><p class="text-xs sm:text-sm text-zinc-500">${item.desc}</p></div></li>`;
});
html = html.replace('{{beforeItems}}', bHtml);
}
// After items
if (config.afterItems) {
let aHtml = '';
const icons = ['zap', 'sparkles', 'message-square', 'rocket'];
config.afterItems.forEach((item, i) => {
aHtml += `
<li class="flex items-start gap-3"><div class="w-6 h-6 rounded-full bg-brand-500/10 flex items-center justify-center flex-shrink-0 mt-0.5"><i data-lucide="${icons[i % icons.length]}" class="w-3.5 h-3.5 text-brand-400"></i></div><div><p class="text-sm sm:text-base text-zinc-300 font-medium">${item.title}</p><p class="text-xs sm:text-sm text-zinc-500">${item.desc}</p></div></li>`;
});
html = html.replace('{{afterItems}}', aHtml);
}
// Pain points
if (config.painPoints) {
let ppHtml = '';
const ppIcons = ['layers', 'search-x', 'timer-off'];
config.painPoints.forEach((pp, i) => {
const colSpan = i === 2 ? 'sm:col-span-2 lg:col-span-1' : '';
ppHtml += `
<div class="glass rounded-2xl lg:rounded-3xl p-5 sm:p-6 lg:p-8 border border-red-500/10 hover:border-red-500/30 transition-all group feature-card ${colSpan} stagger-item">
<div class="w-12 h-12 sm:w-14 sm:h-14 lg:w-16 lg:h-16 rounded-xl lg:rounded-2xl bg-red-500/10 flex items-center justify-center mb-4 sm:mb-5 lg:mb-6 feature-icon">
<i data-lucide="${pp.icon || ppIcons[i]}" class="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8 text-red-400"></i>
</div>
<h3 class="font-bold text-lg sm:text-xl lg:text-2xl mb-2 sm:mb-3 lg:mb-4">${pp.title}</h3>
<p class="text-zinc-400 text-sm sm:text-base lg:text-lg leading-relaxed">${pp.desc}</p>
</div>`;
});
html = html.replace('{{painPointCards}}', ppHtml);
}
// How it works steps
if (config.howItWorks) {
let hiwHtml = '';
const gradients = [
'from-brand-500 to-teal-400', 'from-cyan-500 to-blue-400', 'from-violet-500 to-purple-400'
];
const shadows = ['brand', 'cyan', 'violet'];
config.howItWorks.forEach((step, i) => {
hiwHtml += `
<div class="flex flex-col lg:flex-row items-center gap-4 sm:gap-6 lg:gap-10 glass rounded-2xl lg:rounded-3xl p-5 sm:p-6 lg:p-10 feature-card stagger-item">
<div class="w-16 h-16 sm:w-20 sm:h-20 lg:w-24 lg:h-24 rounded-2xl lg:rounded-3xl bg-gradient-to-br ${gradients[i]} flex items-center justify-center flex-shrink-0 shadow-2xl shadow-${shadows[i]}-500/30">
<span class="text-3xl sm:text-4xl lg:text-5xl font-black text-black">${i + 1}</span>
</div>
<div class="text-center lg:text-left">
<h3 class="text-xl sm:text-2xl lg:text-3xl font-black mb-2 sm:mb-3 lg:mb-4">${step.title}</h3>
<p class="text-zinc-400 text-sm sm:text-base lg:text-xl leading-relaxed">${step.desc}</p>
</div>
</div>`;
});
html = html.replace('{{howItWorksSteps}}', hiwHtml);
}
// Features grid
if (config.features) {
let fHtml = '';
const fColors = ['brand', 'cyan', 'violet', 'orange', 'pink', 'emerald'];
const fGradients = [
'from-brand-500/20 to-teal-500/20',
'from-cyan-500/20 to-blue-500/20',
'from-violet-500/20 to-purple-500/20',
'from-orange-500/20 to-red-500/20',
'from-pink-500/20 to-rose-500/20',
'from-emerald-500/20 to-teal-500/20',
];
config.features.forEach((feat, i) => {
const c = fColors[i % fColors.length];
const g = fGradients[i % fGradients.length];
fHtml += `
<div class="glass rounded-2xl lg:rounded-3xl p-5 sm:p-6 lg:p-8 feature-card stagger-item group">
<div class="w-11 h-11 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl lg:rounded-2xl bg-gradient-to-br ${g} flex items-center justify-center mb-4 sm:mb-5 lg:mb-6 feature-icon">
<i data-lucide="${feat.icon}" class="w-5 h-5 sm:w-6 sm:h-6 lg:w-7 lg:h-7 text-${c}-400"></i>
</div>
<h3 class="font-bold text-base sm:text-lg lg:text-xl mb-2 sm:mb-3 group-hover:text-${c}-400 transition-colors">${feat.title}</h3>
<p class="text-zinc-400 text-sm sm:text-base">${feat.desc}</p>
</div>`;
});
html = html.replace('{{featuresGrid}}', fHtml);
}
// FAQ items
if (config.faq) {
let faqHtml = '';
config.faq.forEach(item => {
faqHtml += `
<div class="faq-item glass rounded-2xl overflow-hidden feature-card stagger-item">
<button class="faq-toggle w-full flex items-center justify-between p-5 sm:p-6 text-left" onclick="toggleFaq(this)">
<span class="font-bold text-sm sm:text-base lg:text-lg pr-4">${item.q}</span>
<i data-lucide="chevron-down" class="w-5 h-5 text-zinc-400 faq-chevron flex-shrink-0"></i>
</button>
<div class="faq-answer px-5 sm:px-6 text-zinc-400 text-sm sm:text-base leading-relaxed">
${item.a}
</div>
</div>`;
});
html = html.replace('{{faqItems}}', faqHtml);
}
// Chat messages (the animated conversation)
if (config.chatMessages) {
// Build the embeds and messages JS
let embedsJs = '';
let messagesJs = 'const messages = [\n';
config.chatMessages.forEach((msg, i) => {
if (msg.embed) {
embedsJs += ` const embed_${i} = \`${msg.embed}\`;\n`;
}
messagesJs += ` { type:'${msg.type}', text:'${msg.text.replace(/'/g, "\\'")}'${msg.embed ? `, embed: embed_${i}` : ''} },\n`;
});
messagesJs += ' ];';
html = html.replace('{{chatEmbedsJs}}', embedsJs);
html = html.replace('{{chatMessagesJs}}', messagesJs);
}
// Terminal lines
if (config.terminalLines) {
let termJs = 'const termLines = [\n';
config.terminalLines.forEach(line => {
termJs += ` { text:'${line.text.replace(/'/g, "\\'")}', color:'${line.color || 'text-white'}', delay:${line.delay || 0} },\n`;
});
termJs += ' ];';
html = html.replace('{{terminalLinesJs}}', termJs);
}
return html;
}
function generate(slug) {
const template = loadTemplate();
const config = loadConfig(slug);
const html = renderTemplate(template, config);
// Write to output directory
const outDir = path.join(OUTPUT_DIR, slug);
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'index.html'), html);
console.log(`✓ Generated: ${slug}/index.html`);
return outDir;
}
// CLI
const args = process.argv.slice(2);
if (args.includes('--all')) {
const slugs = getAllConfigs();
console.log(`Generating ${slugs.length} landing pages...`);
slugs.forEach(slug => generate(slug));
console.log(`\n✓ Done! ${slugs.length} pages generated in ${OUTPUT_DIR}`);
} else if (args[0]) {
generate(args[0]);
} else {
console.log('Usage: node generate.js [slug] or node generate.js --all');
console.log('Available configs:', getAllConfigs().join(', '));
}