@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.
- package/LICENSE +21 -0
- package/NOTICE +4 -0
- package/README.md +82 -0
- package/dist/index.d.ts +401 -0
- package/dist/index.js +5389 -0
- package/package.json +66 -0
- package/src/blocks/BlockWrapper.tsx +149 -0
- package/src/blocks/banner/BannerBlock.tsx +38 -0
- package/src/blocks/contact/ContactBlock.tsx +63 -0
- package/src/blocks/content/ContentBlock.tsx +38 -0
- package/src/blocks/cta/CtaBlock.tsx +67 -0
- package/src/blocks/divider/DividerBlock.tsx +30 -0
- package/src/blocks/faq/FaqBlock.tsx +82 -0
- package/src/blocks/features/FeaturesBlock.tsx +170 -0
- package/src/blocks/footer/FooterBlock.tsx +136 -0
- package/src/blocks/gallery/GalleryBlock.tsx +67 -0
- package/src/blocks/hero/HeroBlock.tsx +163 -0
- package/src/blocks/image/ImageBlock.tsx +78 -0
- package/src/blocks/logocloud/LogoCloudBlock.tsx +70 -0
- package/src/blocks/navbar/NavbarBlock.tsx +102 -0
- package/src/blocks/newsletter/NewsletterBlock.tsx +51 -0
- package/src/blocks/pricing/PricingBlock.tsx +173 -0
- package/src/blocks/registry.tsx +90 -0
- package/src/blocks/stats/StatsBlock.tsx +96 -0
- package/src/blocks/team/TeamBlock.tsx +50 -0
- package/src/blocks/testimonials/TestimonialsBlock.tsx +203 -0
- package/src/blocks/types.ts +72 -0
- package/src/blocks/video/VideoBlock.tsx +58 -0
- package/src/editor/AgentPanel.tsx +318 -0
- package/src/editor/Canvas.tsx +95 -0
- package/src/editor/CanvasEmpty.tsx +40 -0
- package/src/editor/CanvasToolbar.tsx +366 -0
- package/src/editor/DesignPanel.tsx +224 -0
- package/src/editor/EditorLayout.tsx +196 -0
- package/src/editor/GenerationOverlay.tsx +201 -0
- package/src/editor/JsonDrawer.tsx +139 -0
- package/src/editor/LayersPanel.tsx +259 -0
- package/src/editor/LeftSidebar.tsx +136 -0
- package/src/editor/PropertiesPanel.tsx +564 -0
- package/src/editor/RightSidebar.tsx +85 -0
- package/src/editor/ShortcutsModal.tsx +126 -0
- package/src/editor/VersionHistory.tsx +110 -0
- package/src/index.ts +133 -0
- package/src/lib/block-metadata.ts +167 -0
- package/src/lib/export-html.ts +1424 -0
- package/src/lib/generate-site.ts +206 -0
- package/src/lib/generation-prompt.ts +64 -0
- package/src/lib/markdown.ts +88 -0
- package/src/lib/templates.ts +139 -0
- package/src/lib/theme-presets.ts +330 -0
- package/src/lib/useGoogleFonts.ts +49 -0
- package/src/lib/useScrollReveal.ts +28 -0
- package/src/settings/GeminiKeyInputField.tsx +82 -0
- package/src/store/configStore.ts +344 -0
- package/src/store/editorStore.ts +50 -0
- 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
|
+
}
|