@siteable/core 0.1.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +4 -0
  3. package/README.md +82 -0
  4. package/dist/index.d.ts +401 -0
  5. package/dist/index.js +5389 -0
  6. package/package.json +66 -0
  7. package/src/blocks/BlockWrapper.tsx +149 -0
  8. package/src/blocks/banner/BannerBlock.tsx +38 -0
  9. package/src/blocks/contact/ContactBlock.tsx +63 -0
  10. package/src/blocks/content/ContentBlock.tsx +38 -0
  11. package/src/blocks/cta/CtaBlock.tsx +67 -0
  12. package/src/blocks/divider/DividerBlock.tsx +30 -0
  13. package/src/blocks/faq/FaqBlock.tsx +82 -0
  14. package/src/blocks/features/FeaturesBlock.tsx +170 -0
  15. package/src/blocks/footer/FooterBlock.tsx +136 -0
  16. package/src/blocks/gallery/GalleryBlock.tsx +67 -0
  17. package/src/blocks/hero/HeroBlock.tsx +163 -0
  18. package/src/blocks/image/ImageBlock.tsx +78 -0
  19. package/src/blocks/logocloud/LogoCloudBlock.tsx +70 -0
  20. package/src/blocks/navbar/NavbarBlock.tsx +102 -0
  21. package/src/blocks/newsletter/NewsletterBlock.tsx +51 -0
  22. package/src/blocks/pricing/PricingBlock.tsx +173 -0
  23. package/src/blocks/registry.tsx +90 -0
  24. package/src/blocks/stats/StatsBlock.tsx +96 -0
  25. package/src/blocks/team/TeamBlock.tsx +50 -0
  26. package/src/blocks/testimonials/TestimonialsBlock.tsx +203 -0
  27. package/src/blocks/types.ts +72 -0
  28. package/src/blocks/video/VideoBlock.tsx +58 -0
  29. package/src/editor/AgentPanel.tsx +318 -0
  30. package/src/editor/Canvas.tsx +95 -0
  31. package/src/editor/CanvasEmpty.tsx +40 -0
  32. package/src/editor/CanvasToolbar.tsx +366 -0
  33. package/src/editor/DesignPanel.tsx +224 -0
  34. package/src/editor/EditorLayout.tsx +196 -0
  35. package/src/editor/GenerationOverlay.tsx +201 -0
  36. package/src/editor/JsonDrawer.tsx +139 -0
  37. package/src/editor/LayersPanel.tsx +259 -0
  38. package/src/editor/LeftSidebar.tsx +136 -0
  39. package/src/editor/PropertiesPanel.tsx +564 -0
  40. package/src/editor/RightSidebar.tsx +85 -0
  41. package/src/editor/ShortcutsModal.tsx +126 -0
  42. package/src/editor/VersionHistory.tsx +110 -0
  43. package/src/index.ts +133 -0
  44. package/src/lib/block-metadata.ts +167 -0
  45. package/src/lib/export-html.ts +1424 -0
  46. package/src/lib/generate-site.ts +206 -0
  47. package/src/lib/generation-prompt.ts +64 -0
  48. package/src/lib/markdown.ts +88 -0
  49. package/src/lib/templates.ts +139 -0
  50. package/src/lib/theme-presets.ts +330 -0
  51. package/src/lib/useGoogleFonts.ts +49 -0
  52. package/src/lib/useScrollReveal.ts +28 -0
  53. package/src/settings/GeminiKeyInputField.tsx +82 -0
  54. package/src/store/configStore.ts +344 -0
  55. package/src/store/editorStore.ts +50 -0
  56. package/src/styles.css +210 -0
@@ -0,0 +1,206 @@
1
+ import type { SiteConfig, BlockConfig, ThemeConfig } from '@/blocks/types'
2
+ import { blockMetadata } from '@/lib/block-metadata'
3
+ import { GENERATION_PROMPT } from '@/lib/generation-prompt'
4
+ import { getTemplateForPrompt } from '@/lib/templates'
5
+
6
+ const VALID_BLOCK_TYPES = new Set<string>(blockMetadata.map((b) => b.type))
7
+ const VARIANT_MAP = Object.fromEntries(blockMetadata.map((b) => [b.type, new Set(b.variants)]))
8
+ const DEFAULT_PROPS_MAP = Object.fromEntries(blockMetadata.map((b) => [b.type, b.defaultProps]))
9
+
10
+ const GEMINI_MODEL = 'gemini-3-flash-preview'
11
+ const STORAGE_KEY = 'openpage-gemini-key'
12
+
13
+ export interface GenerationResult {
14
+ config: SiteConfig
15
+ source: 'ai' | 'template'
16
+ }
17
+
18
+ export async function generateSiteConfig(
19
+ prompt: string,
20
+ signal?: AbortSignal,
21
+ onServerFallback?: (prompt: string, signal?: AbortSignal) => Promise<SiteConfig>,
22
+ ): Promise<GenerationResult> {
23
+ // 1. Try client-side Gemini if key exists
24
+ const apiKey = localStorage.getItem(STORAGE_KEY)
25
+ if (apiKey) {
26
+ try {
27
+ const config = await callGeminiDirect(prompt, apiKey, signal)
28
+ return { config, source: 'ai' }
29
+ } catch (err) {
30
+ if (err instanceof Error && err.name === 'AbortError') throw err
31
+ // Fall through to server
32
+ }
33
+ }
34
+
35
+ // 2. Injected server fallback (caller-supplied; the engine is route-agnostic)
36
+ if (onServerFallback) {
37
+ try {
38
+ const config = await onServerFallback(prompt, signal)
39
+ return { config, source: 'ai' }
40
+ } catch (err) {
41
+ if (err instanceof Error && err.name === 'AbortError') throw err
42
+ // Fall through to template
43
+ }
44
+ }
45
+
46
+ // 3. Smart fallback template (instant, no fake progress)
47
+ if (onServerFallback && import.meta.env?.DEV) {
48
+ // Dev-only diagnostic (not a correctness assertion): onServerFallback was supplied but
49
+ // generation still fell through to the template — surfaces A09/A10/A13 transitional degradation.
50
+ console.warn('[generateSiteConfig] onServerFallback was supplied but generation fell through to the template fallback.')
51
+ }
52
+ return { config: getTemplateForPrompt(prompt), source: 'template' }
53
+ }
54
+
55
+ async function callGeminiDirect(prompt: string, apiKey: string, signal?: AbortSignal): Promise<SiteConfig> {
56
+ const res = await fetch(
57
+ `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${apiKey}`,
58
+ {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ signal,
62
+ body: JSON.stringify({
63
+ contents: [{ role: 'user', parts: [{ text: `Generate a website configuration for: ${prompt}` }] }],
64
+ systemInstruction: { parts: [{ text: GENERATION_PROMPT }] },
65
+ generationConfig: { responseMimeType: 'application/json', temperature: 0.8 },
66
+ }),
67
+ },
68
+ )
69
+
70
+ if (!res.ok) throw new Error(`Gemini API error: ${res.status}`)
71
+
72
+ const data = await res.json()
73
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text
74
+ if (!text) throw new Error('Empty Gemini response')
75
+
76
+ return validateSiteConfig(JSON.parse(text), prompt)
77
+ }
78
+
79
+ function isValidHex(s: unknown): s is string {
80
+ return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s)
81
+ }
82
+
83
+ function validateTheme(raw: Record<string, unknown>): Partial<ThemeConfig> {
84
+ const theme: Partial<ThemeConfig> = {}
85
+ const colorKeys: (keyof ThemeConfig)[] = [
86
+ 'bg0', 'bg1', 'bg2', 'bg3', 'bg4', 'bg5',
87
+ 'text0', 'text1', 'text2', 'text3',
88
+ 'accent', 'accentDim',
89
+ 'borderDefault', 'borderSubtle', 'borderHover',
90
+ ]
91
+ const fontKeys: (keyof ThemeConfig)[] = ['fontSans', 'fontDisplay', 'fontMono']
92
+
93
+ for (const key of colorKeys) {
94
+ if (isValidHex(raw[key])) {
95
+ (theme as Record<string, unknown>)[key] = raw[key]
96
+ }
97
+ }
98
+
99
+ for (const key of fontKeys) {
100
+ if (typeof raw[key] === 'string' && (raw[key] as string).length > 0) {
101
+ (theme as Record<string, unknown>)[key] = raw[key]
102
+ }
103
+ }
104
+
105
+ if (typeof raw.radius === 'number' && raw.radius >= 0 && raw.radius <= 24) {
106
+ theme.radius = raw.radius
107
+ }
108
+ if (typeof raw.radiusLg === 'number' && raw.radiusLg >= 0 && raw.radiusLg <= 32) {
109
+ theme.radiusLg = raw.radiusLg
110
+ }
111
+
112
+ return theme
113
+ }
114
+
115
+ function validateBlock(raw: Record<string, unknown>, index: number): BlockConfig | null {
116
+ const type = raw.type as string
117
+ if (!type || !VALID_BLOCK_TYPES.has(type)) return null
118
+
119
+ const variants = VARIANT_MAP[type]
120
+ let variant = raw.variant as string
121
+ if (!variant || !variants?.has(variant)) {
122
+ variant = blockMetadata.find((b) => b.type === type)?.variants[0] || 'default'
123
+ }
124
+
125
+ const defaultProps = DEFAULT_PROPS_MAP[type] || {}
126
+ const props = typeof raw.props === 'object' && raw.props ? { ...defaultProps, ...raw.props } : defaultProps
127
+
128
+ return {
129
+ id: typeof raw.id === 'string' && raw.id ? raw.id : `block-${type}-${index}-${Date.now()}`,
130
+ type: type as BlockConfig['type'],
131
+ variant,
132
+ props: props as Record<string, unknown>,
133
+ }
134
+ }
135
+
136
+ function validatePageBlocks(rawBlocks: unknown[]): BlockConfig[] {
137
+ const blocks: BlockConfig[] = []
138
+ for (let i = 0; i < rawBlocks.length; i++) {
139
+ const block = validateBlock(rawBlocks[i] as Record<string, unknown>, i)
140
+ if (block) blocks.push(block)
141
+ }
142
+ return blocks
143
+ }
144
+
145
+ export function validateSiteConfig(raw: unknown, prompt?: string): SiteConfig {
146
+ if (!raw || typeof raw !== 'object') {
147
+ return getTemplateForPrompt(prompt || '')
148
+ }
149
+
150
+ const obj = raw as Record<string, unknown>
151
+ const name = typeof obj.name === 'string' ? obj.name : extractNameFromPrompt(prompt)
152
+
153
+ // Try pages first
154
+ let pages: { id: string; name: string; path: string; blocks: BlockConfig[] }[] | undefined
155
+ if (Array.isArray(obj.pages) && obj.pages.length > 0) {
156
+ pages = []
157
+ for (const rawPage of obj.pages) {
158
+ if (!rawPage || typeof rawPage !== 'object') continue
159
+ const p = rawPage as Record<string, unknown>
160
+ const pageBlocks = Array.isArray(p.blocks) ? validatePageBlocks(p.blocks) : []
161
+ if (pageBlocks.length > 0) {
162
+ pages.push({
163
+ id: typeof p.id === 'string' ? p.id : `page-${Date.now()}-${pages.length}`,
164
+ name: typeof p.name === 'string' ? p.name : `Page ${pages.length + 1}`,
165
+ path: typeof p.path === 'string' ? p.path : `/${pages.length === 0 ? '' : `page-${pages.length}`}`,
166
+ blocks: pageBlocks,
167
+ })
168
+ }
169
+ }
170
+ if (pages.length === 0) pages = undefined
171
+ }
172
+
173
+ // Fall back to top-level blocks
174
+ let blocks: BlockConfig[] = []
175
+ if (Array.isArray(obj.blocks)) {
176
+ blocks = validatePageBlocks(obj.blocks)
177
+ }
178
+
179
+ // If we have pages but no top-level blocks, use first page's blocks for compat
180
+ if (pages && pages.length > 0 && blocks.length === 0) {
181
+ blocks = pages[0].blocks
182
+ }
183
+
184
+ if (!pages && blocks.length === 0) {
185
+ return getTemplateForPrompt(prompt || '')
186
+ }
187
+
188
+ // If no pages but have blocks, wrap into single Home page
189
+ if (!pages && blocks.length > 0) {
190
+ pages = [{ id: 'page-home', name: 'Home', path: '/', blocks }]
191
+ }
192
+
193
+ let theme: Partial<ThemeConfig> | undefined
194
+ if (obj.theme && typeof obj.theme === 'object') {
195
+ theme = validateTheme(obj.theme as Record<string, unknown>)
196
+ if (Object.keys(theme).length === 0) theme = undefined
197
+ }
198
+
199
+ return { name, pages, blocks, theme }
200
+ }
201
+
202
+ function extractNameFromPrompt(prompt?: string): string {
203
+ if (!prompt) return 'My Website'
204
+ const words = prompt.split(/\s+/).slice(0, 4).join(' ')
205
+ return words.charAt(0).toUpperCase() + words.slice(1)
206
+ }
@@ -0,0 +1,64 @@
1
+ export const GENERATION_PROMPT = `You are a website configuration generator for OpenPage, a visual website builder.
2
+
3
+ Given a user's description, generate a complete JSON site configuration.
4
+
5
+ ## Output Schema
6
+
7
+ Return a JSON object matching this exact schema:
8
+
9
+ {
10
+ "name": "Site Name",
11
+ "theme": {
12
+ "bg0": "#hex", "bg1": "#hex", "bg2": "#hex", "bg3": "#hex", "bg4": "#hex", "bg5": "#hex",
13
+ "text0": "#hex", "text1": "#hex", "text2": "#hex", "text3": "#hex",
14
+ "accent": "#hex", "accentDim": "#hex",
15
+ "borderDefault": "#hex", "borderSubtle": "#hex", "borderHover": "#hex",
16
+ "fontSans": "Font Name", "fontDisplay": "Font Name", "fontMono": "Font Name",
17
+ "radius": 8, "radiusLg": 12
18
+ },
19
+ "pages": [
20
+ { "id": "page-home", "name": "Home", "path": "/", "blocks": [...] },
21
+ { "id": "page-about", "name": "About", "path": "/about", "blocks": [...] }
22
+ ],
23
+ "blocks": []
24
+ }
25
+
26
+ Each page has its own blocks array. Generate at least 2 pages: Home and one additional page (About, Pricing, or Features depending on the site type). The top-level "blocks" array should be empty (blocks live inside pages).
27
+
28
+ ## Available Block Types
29
+
30
+ 1. navbar (variants: default, centered) - Props: { logo, links[], ctaText }
31
+ 2. hero (variants: centered, split, gradient, minimal) - Props: { badge?, headline, subheadline, primaryCta, secondaryCta? }
32
+ 3. features (variants: grid, list, alternating) - Props: { label?, title, subtitle?, items: [{ icon?, title, description }] }
33
+ 4. pricing (variants: simple, comparison) - Props: { title, subtitle?, tiers?: [{ name, price, period?, description?, features[], cta, featured? }] }
34
+ 5. cta (variants: simple, split) - Props: { headline, subheadline?, buttonText }
35
+ 6. footer (variants: simple, multi-column, minimal) - Props: { logo, copyright, links[] }
36
+ 7. testimonials (variants: cards, carousel, spotlight) - Props: { title?, items?: [{ name, role?, quote, rating? }] }
37
+ 8. stats (variants: grid, bar, counter) - Props: { title?, items?: [{ value, label }] }
38
+ 9. faq (variants: accordion) - Props: { title?, items?: [{ question, answer }] }
39
+ 10. team (variants: grid) - Props: { title?, subtitle?, members?: [{ name, role }] }
40
+ 11. contact (variants: form) - Props: { title?, subtitle? }
41
+ 12. newsletter (variants: simple) - Props: { title?, subtitle?, buttonText? }
42
+ 13. logocloud (variants: default) - Props: { title? }
43
+ 14. content (variants: prose, columns, highlight) - Props: { body } (markdown: **bold**, *italic*, ## headers, - lists)
44
+ 15. image (variants: hero-image, side-by-side, grid) - Props: { src?, alt?, title?, subtitle?, images?: [{ src, alt }], imageSide? }
45
+ 16. video (variants: youtube, vimeo) - Props: { url, title? }
46
+ 17. gallery (variants: grid, masonry) - Props: { title?, images?: [{ src?, alt?, caption? }] }
47
+ 18. divider (variants: line, space, dots) - Props: { height?, width? }
48
+ 19. banner (variants: ribbon, bar) - Props: { text, linkText?, linkUrl? }
49
+
50
+ Icons: Blocks, Code, Bot, Zap, Shield, Globe, Layers, Palette, Rocket, Star, Lock, Settings
51
+ Fonts: DM Sans, Inter, Space Grotesk, Poppins, Manrope, Outfit, Plus Jakarta Sans, Sora, Nunito Sans, Work Sans, Rubik, Raleway
52
+
53
+ ## Rules
54
+
55
+ 1. ALWAYS generate at least 2 pages. The Home page MUST include: navbar, hero, at least 2 content sections, a CTA, and a footer
56
+ 2. Generate 6-10 blocks per page
57
+ 3. Write specific, realistic copy matching the user's description
58
+ 4. Pick a theme that fits the vibe (dark for tech, warm for food, clean for agencies)
59
+ 5. Use unique block IDs (format: block-type-1, block-hero-1, etc.)
60
+ 6. Do NOT use placeholder text like "Lorem ipsum"
61
+ 7. Make copy compelling and specific to the described business
62
+ 8. Each page needs a unique id (page-home, page-about, etc.), a name, and a path (/, /about, etc.)
63
+
64
+ Return ONLY valid JSON. No markdown, no code fences, no explanation.`
@@ -0,0 +1,88 @@
1
+ import { createElement, type ReactNode } from 'react'
2
+
3
+ /**
4
+ * Simple markdown-to-React renderer. No dangerouslySetInnerHTML, no XSS surface.
5
+ * Supports: ## headers, **bold**, *italic*, - lists, line breaks.
6
+ */
7
+ export function renderMarkdown(text: string): ReactNode[] {
8
+ const lines = text.split('\n')
9
+ const nodes: ReactNode[] = []
10
+ let listItems: ReactNode[] = []
11
+ let key = 0
12
+
13
+ function flushList() {
14
+ if (listItems.length > 0) {
15
+ nodes.push(createElement('ul', { key: key++, className: 'list-disc list-inside space-y-1 mb-4 text-text-1' }, ...listItems))
16
+ listItems = []
17
+ }
18
+ }
19
+
20
+ for (const line of lines) {
21
+ const trimmed = line.trim()
22
+
23
+ // Empty line
24
+ if (!trimmed) {
25
+ flushList()
26
+ continue
27
+ }
28
+
29
+ // Headers
30
+ if (trimmed.startsWith('### ')) {
31
+ flushList()
32
+ nodes.push(createElement('h3', { key: key++, className: 'text-lg font-semibold font-display mb-2 mt-4' }, inlineFormat(trimmed.slice(4))))
33
+ continue
34
+ }
35
+ if (trimmed.startsWith('## ')) {
36
+ flushList()
37
+ nodes.push(createElement('h2', { key: key++, className: 'text-xl font-semibold font-display mb-3 mt-5' }, inlineFormat(trimmed.slice(3))))
38
+ continue
39
+ }
40
+
41
+ // List item
42
+ if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
43
+ listItems.push(createElement('li', { key: key++ }, inlineFormat(trimmed.slice(2))))
44
+ continue
45
+ }
46
+
47
+ // Paragraph
48
+ flushList()
49
+ nodes.push(createElement('p', { key: key++, className: 'text-text-1 leading-relaxed mb-3' }, inlineFormat(trimmed)))
50
+ }
51
+
52
+ flushList()
53
+ return nodes
54
+ }
55
+
56
+ function inlineFormat(text: string): ReactNode[] {
57
+ const parts: ReactNode[] = []
58
+ let remaining = text
59
+ let key = 0
60
+
61
+ while (remaining.length > 0) {
62
+ // Bold
63
+ const boldMatch = remaining.match(/\*\*(.+?)\*\*/)
64
+ // Italic
65
+ const italicMatch = remaining.match(/\*(.+?)\*/)
66
+
67
+ // Find earliest match
68
+ const boldIdx = boldMatch?.index ?? Infinity
69
+ const italicIdx = italicMatch?.index ?? Infinity
70
+
71
+ if (boldIdx === Infinity && italicIdx === Infinity) {
72
+ parts.push(remaining)
73
+ break
74
+ }
75
+
76
+ if (boldIdx <= italicIdx && boldMatch) {
77
+ if (boldIdx > 0) parts.push(remaining.slice(0, boldIdx))
78
+ parts.push(createElement('strong', { key: key++, className: 'font-semibold text-text-0' }, boldMatch[1]))
79
+ remaining = remaining.slice(boldIdx + boldMatch[0].length)
80
+ } else if (italicMatch) {
81
+ if (italicIdx > 0) parts.push(remaining.slice(0, italicIdx))
82
+ parts.push(createElement('em', { key: key++, className: 'italic' }, italicMatch[1]))
83
+ remaining = remaining.slice(italicIdx + italicMatch[0].length)
84
+ }
85
+ }
86
+
87
+ return parts
88
+ }
@@ -0,0 +1,139 @@
1
+ import type { SiteConfig } from '@/blocks/types'
2
+ import { themePresets } from './theme-presets'
3
+
4
+ interface Template {
5
+ keywords: string[]
6
+ themePresetId: string
7
+ build: (name: string) => SiteConfig
8
+ }
9
+
10
+ function getTheme(id: string) {
11
+ return themePresets.find((p) => p.id === id)?.theme
12
+ }
13
+
14
+ const templates: Template[] = [
15
+ {
16
+ keywords: ['portfolio', 'personal', 'resume', 'freelance', 'designer', 'developer'],
17
+ themePresetId: 'slate',
18
+ build: (name) => ({
19
+ name,
20
+ theme: getTheme('slate'),
21
+ blocks: [
22
+ { id: 'block-navbar-1', type: 'navbar', variant: 'default', props: { logo: name, links: ['Work', 'About', 'Contact'], ctaText: 'Hire Me' } },
23
+ { id: 'block-hero-1', type: 'hero', variant: 'minimal', props: { headline: `Hi, I'm ${name}`, subheadline: 'I design and build digital experiences that make a difference.', primaryCta: 'View My Work' } },
24
+ { id: 'block-gallery-1', type: 'gallery', variant: 'grid', props: { title: 'Selected Work' } },
25
+ { id: 'block-stats-1', type: 'stats', variant: 'counter', props: { title: 'By the Numbers', items: [{ value: '50+', label: 'Projects completed' }, { value: '8', label: 'Years experience' }, { value: '30+', label: 'Happy clients' }, { value: '5', label: 'Awards won' }] } },
26
+ { id: 'block-testimonials-1', type: 'testimonials', variant: 'spotlight', props: { title: 'Client Feedback', items: [{ name: 'Alex Rivera', role: 'CEO at Startup', quote: 'Exceptional work. Delivered on time with incredible attention to detail.', rating: 5 }] } },
27
+ { id: 'block-contact-1', type: 'contact', variant: 'form', props: { title: 'Get in Touch', subtitle: "Have a project in mind? Let's talk." } },
28
+ { id: 'block-footer-1', type: 'footer', variant: 'minimal', props: { logo: name, copyright: `2026 ${name}. All rights reserved.`, links: ['LinkedIn', 'GitHub', 'Twitter'] } },
29
+ ],
30
+ }),
31
+ },
32
+ {
33
+ keywords: ['restaurant', 'food', 'cafe', 'bakery', 'bar', 'bistro', 'pizza', 'sushi', 'kitchen'],
34
+ themePresetId: 'amber',
35
+ build: (name) => ({
36
+ name,
37
+ theme: getTheme('amber'),
38
+ blocks: [
39
+ { id: 'block-navbar-1', type: 'navbar', variant: 'centered', props: { logo: name, links: ['Menu', 'About', 'Reservations', 'Gallery'], ctaText: 'Book a Table' } },
40
+ { id: 'block-hero-1', type: 'hero', variant: 'gradient', props: { headline: `Welcome to ${name}`, subheadline: 'Fresh ingredients, bold flavors, unforgettable dining experiences.', primaryCta: 'View Menu', secondaryCta: 'Make a Reservation' } },
41
+ { id: 'block-features-1', type: 'features', variant: 'list', props: { title: 'Why Choose Us', items: [{ icon: 'Star', title: 'Farm to Table', description: 'We source locally from sustainable farms.' }, { icon: 'Globe', title: 'World Cuisine', description: 'Inspired by flavors from around the globe.' }, { icon: 'Zap', title: 'Fresh Daily', description: 'Our menu changes with the seasons.' }] } },
42
+ { id: 'block-gallery-1', type: 'gallery', variant: 'masonry', props: { title: 'From Our Kitchen' } },
43
+ { id: 'block-testimonials-1', type: 'testimonials', variant: 'spotlight', props: { items: [{ name: 'Maria Garcia', role: 'Food Critic', quote: 'A culinary gem. Every dish is a masterpiece of flavor and presentation.', rating: 5 }] } },
44
+ { id: 'block-cta-1', type: 'cta', variant: 'simple', props: { headline: 'Reserve Your Table', subheadline: 'Open Tuesday through Sunday, 5pm to 11pm.', buttonText: 'Book Now' } },
45
+ { id: 'block-footer-1', type: 'footer', variant: 'multi-column', props: { logo: name, copyright: `2026 ${name}. All rights reserved.`, links: ['Menu', 'Reservations', 'Privacy'] } },
46
+ ],
47
+ }),
48
+ },
49
+ {
50
+ keywords: ['agency', 'studio', 'consulting', 'firm', 'digital', 'creative', 'marketing'],
51
+ themePresetId: 'clean',
52
+ build: (name) => ({
53
+ name,
54
+ theme: getTheme('clean'),
55
+ blocks: [
56
+ { id: 'block-navbar-1', type: 'navbar', variant: 'centered', props: { logo: name, links: ['Services', 'Work', 'About', 'Contact'], ctaText: 'Get a Quote' } },
57
+ { id: 'block-hero-1', type: 'hero', variant: 'split', props: { badge: 'Award-Winning Agency', headline: 'We build brands that matter', subheadline: 'Strategy, design, and technology working together to drive real results.', primaryCta: 'Start a Project', secondaryCta: 'Our Work' } },
58
+ { id: 'block-logocloud-1', type: 'logocloud', variant: 'default', props: { title: 'Trusted by Industry Leaders' } },
59
+ { id: 'block-features-1', type: 'features', variant: 'alternating', props: { label: 'Services', title: 'What We Do', subtitle: 'End-to-end digital solutions', items: [{ icon: 'Palette', title: 'Brand Strategy', description: 'We craft brand identities that resonate with your audience and stand the test of time.' }, { icon: 'Code', title: 'Web Development', description: 'Modern, performant websites built with the latest technologies.' }, { icon: 'Rocket', title: 'Growth Marketing', description: 'Data-driven campaigns that deliver measurable results.' }] } },
60
+ { id: 'block-stats-1', type: 'stats', variant: 'counter', props: { items: [{ value: '200+', label: 'Projects delivered' }, { value: '95%', label: 'Client retention' }, { value: '12', label: 'Team members' }, { value: '8', label: 'Years in business' }] } },
61
+ { id: 'block-testimonials-1', type: 'testimonials', variant: 'cards', props: { title: 'What Clients Say', items: [{ name: 'James Park', role: 'VP Marketing, TechCo', quote: 'They transformed our entire digital presence. ROI exceeded expectations by 3x.', rating: 5 }, { name: 'Lisa Chen', role: 'Founder, StartupXYZ', quote: 'Professional, creative, and incredibly responsive. Our go-to agency.', rating: 5 }, { name: 'David Kim', role: 'CMO, Enterprise Inc', quote: 'The strategic thinking behind their work sets them apart from other agencies.', rating: 5 }] } },
62
+ { id: 'block-cta-1', type: 'cta', variant: 'split', props: { headline: "Let's build something great together", subheadline: 'Schedule a free consultation to discuss your next project.', buttonText: 'Get Started' } },
63
+ { id: 'block-footer-1', type: 'footer', variant: 'multi-column', props: { logo: name, copyright: `2026 ${name}. All rights reserved.`, links: ['Services', 'Work', 'Blog', 'Careers', 'Privacy', 'Terms'] } },
64
+ ],
65
+ }),
66
+ },
67
+ {
68
+ keywords: ['blog', 'newsletter', 'magazine', 'journal', 'publication', 'writer', 'author'],
69
+ themePresetId: 'ivory',
70
+ build: (name) => ({
71
+ name,
72
+ theme: getTheme('ivory'),
73
+ blocks: [
74
+ { id: 'block-navbar-1', type: 'navbar', variant: 'default', props: { logo: name, links: ['Articles', 'Topics', 'About'], ctaText: 'Subscribe' } },
75
+ { id: 'block-hero-1', type: 'hero', variant: 'minimal', props: { headline: name, subheadline: 'Thoughtful writing on technology, design, and the future of work.', primaryCta: 'Start Reading' } },
76
+ { id: 'block-content-1', type: 'content', variant: 'columns', props: { body: '## Latest Thinking\n\nExploring ideas at the intersection of technology and humanity. From AI ethics to sustainable design, we cover what matters.\n\n## Featured Topics\n\n- **Technology** - The tools shaping our future\n- **Design** - Making things beautiful and useful\n- **Culture** - How work and life are evolving' } },
77
+ { id: 'block-divider-1', type: 'divider', variant: 'dots', props: { height: 40 } },
78
+ { id: 'block-newsletter-1', type: 'newsletter', variant: 'simple', props: { title: 'Join 5,000+ readers', subtitle: 'Get weekly insights delivered to your inbox. No spam, ever.', buttonText: 'Subscribe Free' } },
79
+ { id: 'block-faq-1', type: 'faq', variant: 'accordion', props: { title: 'Frequently Asked Questions', items: [{ question: 'How often do you publish?', answer: 'We publish 2-3 articles per week, plus a weekly newsletter digest.' }, { question: 'Can I contribute?', answer: 'Yes! We welcome guest contributions from thoughtful writers.' }, { question: 'Is it free?', answer: 'All articles are free. We offer a premium newsletter with deeper analysis.' }] } },
80
+ { id: 'block-footer-1', type: 'footer', variant: 'simple', props: { logo: name, copyright: `2026 ${name}. All rights reserved.`, links: ['RSS', 'Twitter', 'Privacy'] } },
81
+ ],
82
+ }),
83
+ },
84
+ {
85
+ // Default: SaaS landing page
86
+ keywords: [],
87
+ themePresetId: 'default',
88
+ build: (name) => ({
89
+ name,
90
+ theme: getTheme('default'),
91
+ blocks: [
92
+ { id: 'block-navbar-1', type: 'navbar', variant: 'default', props: { logo: name, links: ['Features', 'Pricing', 'About'], ctaText: 'Get Started' } },
93
+ { id: 'block-hero-1', type: 'hero', variant: 'centered', props: { badge: 'Now in Beta', headline: `${name} - Build Better, Ship Faster`, subheadline: 'The all-in-one platform that helps teams move from idea to production in record time.', primaryCta: 'Start Free Trial', secondaryCta: 'Watch Demo' } },
94
+ { id: 'block-logocloud-1', type: 'logocloud', variant: 'default', props: { title: 'Trusted by innovative teams' } },
95
+ { id: 'block-features-1', type: 'features', variant: 'grid', props: { label: 'Features', title: 'Everything you need', subtitle: 'Powerful tools that grow with your team', items: [{ icon: 'Zap', title: 'Lightning Fast', description: 'Sub-100ms response times. Your team never waits.' }, { icon: 'Shield', title: 'Enterprise Security', description: 'SOC 2 compliant with end-to-end encryption.' }, { icon: 'Globe', title: 'Global Scale', description: 'Deploy to 30+ regions worldwide.' }, { icon: 'Bot', title: 'AI-Powered', description: 'Smart automation that learns your workflow.' }, { icon: 'Layers', title: 'Integrations', description: '200+ integrations with your favorite tools.' }, { icon: 'Rocket', title: 'Fast Setup', description: 'Go from signup to production in under 5 minutes.' }] } },
96
+ { id: 'block-pricing-1', type: 'pricing', variant: 'simple', props: { title: 'Simple, transparent pricing', subtitle: 'No hidden fees. Cancel anytime.' } },
97
+ { id: 'block-testimonials-1', type: 'testimonials', variant: 'cards', props: { title: 'Loved by developers', items: [{ name: 'Sarah Chen', role: 'CTO at TechCorp', quote: 'Cut our deployment time by 80%. The team productivity gains are incredible.', rating: 5 }, { name: 'Marcus Johnson', role: 'Lead Developer', quote: 'Best developer experience I have ever used. Period.', rating: 5 }, { name: 'Emma Wilson', role: 'Product Manager', quote: 'Finally, a tool the whole team can align on. Worth every penny.', rating: 5 }] } },
98
+ { id: 'block-cta-1', type: 'cta', variant: 'simple', props: { headline: 'Ready to get started?', subheadline: 'Join thousands of teams shipping faster.', buttonText: 'Start Free Trial' } },
99
+ { id: 'block-footer-1', type: 'footer', variant: 'multi-column', props: { logo: name, copyright: `2026 ${name}. All rights reserved.`, links: ['Features', 'Pricing', 'Docs', 'Blog', 'Privacy', 'Terms'] } },
100
+ ],
101
+ }),
102
+ },
103
+ ]
104
+
105
+ // Metadata for the 4 featured template cards on Dashboard
106
+ // icon: lucide-react icon name, mapped in rendering components
107
+ export const templateMeta = [
108
+ { id: 'portfolio', name: 'Portfolio', description: 'Showcase your work and skills', accent: '#06b6d4', blockCount: 7, templateIndex: 0, icon: 'Briefcase' },
109
+ { id: 'restaurant', name: 'Restaurant', description: 'Menu, reservations, and ambiance', accent: '#e8a838', blockCount: 7, templateIndex: 1, icon: 'UtensilsCrossed' },
110
+ { id: 'agency', name: 'Agency', description: 'Services, case studies, and team', accent: '#228be6', blockCount: 8, templateIndex: 2, icon: 'Building2' },
111
+ { id: 'blog', name: 'Blog', description: 'Articles, topics, and subscribers', accent: '#4f46e5', blockCount: 7, templateIndex: 3, icon: 'BookOpen' },
112
+ ] as const
113
+
114
+ export function buildTemplate(id: string, name: string): SiteConfig {
115
+ const meta = templateMeta.find((m) => m.id === id)
116
+ if (!meta) return templates[templates.length - 1].build(name)
117
+ return templates[meta.templateIndex].build(name)
118
+ }
119
+
120
+ function extractName(prompt: string): string {
121
+ const words = prompt.split(/\s+/).slice(0, 4).join(' ')
122
+ return words.charAt(0).toUpperCase() + words.slice(1)
123
+ }
124
+
125
+ export function getTemplateForPrompt(prompt: string): SiteConfig {
126
+ const lower = prompt.toLowerCase()
127
+ const name = extractName(prompt)
128
+
129
+ // Find first template with a keyword match
130
+ for (const template of templates) {
131
+ if (template.keywords.length === 0) continue
132
+ if (template.keywords.some((kw) => lower.includes(kw))) {
133
+ return template.build(name)
134
+ }
135
+ }
136
+
137
+ // Default: SaaS template (last in array)
138
+ return templates[templates.length - 1].build(name)
139
+ }