@siteable/core 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteable/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Siteable core engine — blocks, visual editor, config store, theme presets, HTML export. JSON-first website builder engine.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  import { useState } from 'react'
2
2
  import { ChevronDown } from 'lucide-react'
3
3
  import type { BlockConfig } from '../types'
4
+ import { defaultFaqItems } from '@/lib/block-default-content'
4
5
 
5
6
  interface FaqItem {
6
7
  question: string
@@ -13,12 +14,9 @@ interface FaqProps {
13
14
  items?: FaqItem[]
14
15
  }
15
16
 
16
- const defaultFaqs: FaqItem[] = [
17
- { question: 'What is OpenPage?', answer: 'OpenPage is a visual website builder that uses structured JSON config as the source of truth. Both humans and AI agents can edit the same config to build beautiful websites.' },
18
- { question: 'How does the JSON config work?', answer: 'Every website is represented as a JSON document with blocks, styles, and content. The visual editor reads and writes this JSON, and agents can make surgical edits via the API.' },
19
- { question: 'Can I use my own components?', answer: 'Yes! OpenPage supports custom components. You can build your own blocks following our component schema and register them in the block registry.' },
20
- { question: 'Is it free to use?', answer: 'OpenPage offers a free tier for personal projects with up to 5 blocks. Pro and Team plans unlock unlimited blocks, custom domains, and priority support.' },
21
- ]
17
+ // ISS-005: fallback FAQs are the block-default-content contract, shared with
18
+ // the prop normalizer (was an inline literal, value-identical).
19
+ const defaultFaqs = defaultFaqItems as FaqItem[]
22
20
 
23
21
  function AccordionItem({ item, isOpen, onToggle }: { item: FaqItem; isOpen: boolean; onToggle: () => void }) {
24
22
  return (
@@ -1,4 +1,5 @@
1
1
  import type { BlockConfig } from '../types'
2
+ import { defaultFooterColumns } from '@/lib/block-default-content'
2
3
 
3
4
  interface FooterProps {
4
5
  logo: string
@@ -41,12 +42,9 @@ function FooterSimple({ props }: { props: FooterProps }) {
41
42
  }
42
43
 
43
44
  function FooterMultiColumn({ props }: { props: FooterProps }) {
44
- const columns = props.columns || [
45
- { title: 'Product', links: ['Features', 'Pricing', 'Changelog', 'Roadmap'] },
46
- { title: 'Company', links: ['About', 'Blog', 'Careers', 'Press'] },
47
- { title: 'Resources', links: ['Documentation', 'API Reference', 'Guides', 'Community'] },
48
- { title: 'Legal', links: ['Privacy', 'Terms', 'Security', 'Cookie Policy'] },
49
- ]
45
+ // ISS-005: fallback columns are the block-metadata contract (defaultFooterColumns),
46
+ // shared with the prop normalizer so shape and render default can never drift.
47
+ const columns = props.columns || defaultFooterColumns
50
48
 
51
49
  return (
52
50
  <footer className="px-6 @md:px-10 py-12 border-t border-border-subtle">
@@ -1,5 +1,9 @@
1
1
  import { ImageIcon } from 'lucide-react'
2
2
  import type { BlockConfig } from '../types'
3
+ // ISS-005: fallback images are the block-default-content contract, shared with
4
+ // the prop normalizer (was an inline literal; src/caption:'' additions are
5
+ // falsy → placeholder icon + no caption div, byte-identical render).
6
+ import { defaultGalleryImages } from '@/lib/block-default-content'
3
7
 
4
8
  interface GalleryImage {
5
9
  src?: string
@@ -7,11 +11,6 @@ interface GalleryImage {
7
11
  caption?: string
8
12
  }
9
13
 
10
- const defaultImages: GalleryImage[] = [
11
- { alt: 'Image 1' }, { alt: 'Image 2' }, { alt: 'Image 3' },
12
- { alt: 'Image 4' }, { alt: 'Image 5' }, { alt: 'Image 6' },
13
- ]
14
-
15
14
  function ImageCard({ image, tall }: { image: GalleryImage; tall?: boolean }) {
16
15
  return (
17
16
  <div className={`rounded-lg overflow-hidden border border-border-default group ${tall ? 'row-span-2' : ''}`}>
@@ -34,7 +33,7 @@ export function GalleryBlock({ block }: { block: BlockConfig }) {
34
33
  const title = props.title as string | undefined
35
34
  const images = ((props.images as GalleryImage[]) || []).length > 0
36
35
  ? (props.images as GalleryImage[])
37
- : defaultImages
36
+ : defaultGalleryImages
38
37
 
39
38
  if (variant === 'masonry') {
40
39
  return (
@@ -1,5 +1,9 @@
1
1
  import { ImageIcon } from 'lucide-react'
2
2
  import type { BlockConfig } from '../types'
3
+ // ISS-005: grid placeholder items are the block-default-content contract, shared
4
+ // with the prop normalizer (was an inline literal; src:'' is falsy like the
5
+ // original src:undefined → identical Placeholder render).
6
+ import { defaultImageGridItems } from '@/lib/block-default-content'
3
7
 
4
8
  function Placeholder({ className }: { className?: string }) {
5
9
  return (
@@ -41,7 +45,7 @@ export function ImageBlock({ block }: { block: BlockConfig }) {
41
45
  if (variant === 'grid') {
42
46
  const gridImages = images.length > 0
43
47
  ? images.slice(0, 4)
44
- : [{ src: undefined, alt: '1' }, { src: undefined, alt: '2' }, { src: undefined, alt: '3' }, { src: undefined, alt: '4' }]
48
+ : defaultImageGridItems
45
49
 
46
50
  return (
47
51
  <div className="px-6 py-12 @lg:px-16 @lg:py-16">
@@ -1,12 +1,13 @@
1
1
  import type { BlockConfig } from '../types'
2
+ // ISS-005: fallback logos are the block-default-content contract, shared with
3
+ // the prop normalizer (was an inline literal, value-identical).
4
+ import { defaultLogos } from '@/lib/block-default-content'
2
5
 
3
6
  interface LogoCloudProps {
4
7
  title?: string
5
8
  logos?: string[]
6
9
  }
7
10
 
8
- const defaultLogos = ['Vercel', 'Stripe', 'GitHub', 'Figma', 'Notion', 'Linear']
9
-
10
11
  function LogoPlaceholder({ name }: { name: string }) {
11
12
  return (
12
13
  <div className="group cursor-pointer transition-all">
@@ -1,5 +1,6 @@
1
1
  import type { BlockConfig } from '../types'
2
2
  import { Check, Star } from 'lucide-react'
3
+ import { defaultPricingTiers } from '@/lib/block-default-content'
3
4
 
4
5
  interface PricingTier {
5
6
  name: string
@@ -17,33 +18,9 @@ interface PricingProps {
17
18
  tiers?: PricingTier[]
18
19
  }
19
20
 
20
- const defaultTiers: PricingTier[] = [
21
- {
22
- name: 'Starter',
23
- price: '$0',
24
- period: '/month',
25
- description: 'For personal projects',
26
- features: ['1 website', '5 blocks', 'Basic export', 'Community support'],
27
- cta: 'Get Started',
28
- },
29
- {
30
- name: 'Pro',
31
- price: '$19',
32
- period: '/month',
33
- description: 'For professionals',
34
- features: ['Unlimited websites', 'All blocks', 'Custom domains', 'Priority support', 'Agent API access', 'Version history'],
35
- cta: 'Upgrade to Pro',
36
- featured: true,
37
- },
38
- {
39
- name: 'Team',
40
- price: '$49',
41
- period: '/month',
42
- description: 'For teams and agencies',
43
- features: ['Everything in Pro', 'Team collaboration', 'Custom components', 'SSO', 'Dedicated support'],
44
- cta: 'Contact Sales',
45
- },
46
- ]
21
+ // ISS-005: fallback tiers are the block-metadata contract (defaultPricingTiers),
22
+ // shared with the prop normalizer so shape and render default can never drift.
23
+ const defaultTiers = defaultPricingTiers as PricingTier[]
47
24
 
48
25
  function PricingSimple({ props }: { props: PricingProps }) {
49
26
  const tiers = props.tiers || defaultTiers
@@ -1,4 +1,5 @@
1
1
  import type { BlockConfig } from '../types'
2
+ import { defaultStatItems } from '@/lib/block-default-content'
2
3
 
3
4
  interface StatItem {
4
5
  value: string
@@ -10,12 +11,9 @@ interface StatsProps {
10
11
  items?: StatItem[]
11
12
  }
12
13
 
13
- const defaultStats: StatItem[] = [
14
- { value: '10K+', label: 'Sites built' },
15
- { value: '99.9%', label: 'Uptime' },
16
- { value: '50ms', label: 'Avg. response' },
17
- { value: '4.9/5', label: 'User rating' },
18
- ]
14
+ // ISS-005: fallback items are the block-default-content contract, shared with
15
+ // the prop normalizer (was an inline literal, value-identical).
16
+ const defaultStats = defaultStatItems as StatItem[]
19
17
 
20
18
  function StatsGrid({ props }: { props: StatsProps }) {
21
19
  const items = props.items || defaultStats
@@ -1,4 +1,5 @@
1
1
  import type { BlockConfig } from '../types'
2
+ import { defaultTeamMembers } from '@/lib/block-default-content'
2
3
 
3
4
  interface TeamMember {
4
5
  name: string
@@ -12,12 +13,9 @@ interface TeamProps {
12
13
  members?: TeamMember[]
13
14
  }
14
15
 
15
- const defaultMembers: TeamMember[] = [
16
- { name: 'Alex Rivera', role: 'CEO & Founder' },
17
- { name: 'Jordan Lee', role: 'CTO' },
18
- { name: 'Sam Patel', role: 'Head of Design' },
19
- { name: 'Casey Morgan', role: 'Lead Engineer' },
20
- ]
16
+ // ISS-005: fallback members are the block-default-content contract, shared with
17
+ // the prop normalizer (was an inline literal, value-identical).
18
+ const defaultMembers = defaultTeamMembers as TeamMember[]
21
19
 
22
20
  export function TeamBlock({ block }: { block: BlockConfig }) {
23
21
  const props = block.props as unknown as TeamProps
@@ -1,6 +1,10 @@
1
1
  import { useState } from 'react'
2
2
  import { ChevronLeft, ChevronRight, Star, Quote } from 'lucide-react'
3
3
  import type { BlockConfig } from '../types'
4
+ // ISS-005: fallback testimonials are the block-default-content contract, shared
5
+ // with the prop normalizer (was an inline literal; avatar:'' additions are
6
+ // falsy → initials branch renders byte-identically).
7
+ import { defaultTestimonials } from '@/lib/block-default-content'
4
8
 
5
9
  interface Testimonial {
6
10
  name: string
@@ -16,12 +20,6 @@ interface TestimonialsProps {
16
20
  items?: Testimonial[]
17
21
  }
18
22
 
19
- const defaultTestimonials: Testimonial[] = [
20
- { name: 'Sarah Chen', role: 'CEO at TechCorp', quote: 'OpenPage completely changed how we build landing pages. The JSON config approach is genius.', rating: 5 },
21
- { name: 'Marcus Johnson', role: 'Lead Developer', quote: 'Finally, a tool where both designers and AI agents can work together seamlessly.', rating: 5 },
22
- { name: 'Emma Wilson', role: 'Product Manager', quote: 'We shipped our marketing site in half the time. The component library is incredible.', rating: 4 },
23
- ]
24
-
25
23
  function StarRating({ rating }: { rating: number }) {
26
24
  return (
27
25
  <div className="flex gap-0.5">
@@ -87,7 +87,9 @@ function generateResponse(input: string, blocks: { id: string; type: string; var
87
87
  id: `block-${Date.now()}`,
88
88
  type: meta.type,
89
89
  variant: meta.variants[0],
90
- props: { ...meta.defaultProps },
90
+ // ISS-005 review item 3: deep copy — defaultProps now hold shared array-of-object
91
+ // constants; a shallow spread would let this block's edits mutate every future insert.
92
+ props: structuredClone(meta.defaultProps),
91
93
  }
92
94
  return { action: 'addBlock', block, message: `Adding a ${meta.label} block.` }
93
95
  }
@@ -195,7 +195,9 @@ export function LayersPanel() {
195
195
  id: `block-${Date.now()}`,
196
196
  type,
197
197
  variant: meta.variants[0],
198
- props: { ...meta.defaultProps },
198
+ // ISS-005 review item 3: deep copy — defaultProps now hold shared array-of-object
199
+ // constants; a shallow spread would let this block's edits mutate every future insert.
200
+ props: structuredClone(meta.defaultProps),
199
201
  }
200
202
  addBlock(block)
201
203
  selectBlock(block.id)
@@ -44,7 +44,9 @@ function ComponentsPanel() {
44
44
  id: generateBlockId(),
45
45
  type,
46
46
  variant: meta.variants[0],
47
- props: { ...meta.defaultProps },
47
+ // ISS-005 review item 3: deep copy — defaultProps now hold shared array-of-object
48
+ // constants; a shallow spread would let this block's edits mutate every future insert.
49
+ props: structuredClone(meta.defaultProps),
48
50
  }
49
51
  addBlock(block)
50
52
  selectBlock(block.id)
@@ -0,0 +1,110 @@
1
+ /**
2
+ * ISS-005 (class fix) — single source of truth for block default CONTENT.
3
+ *
4
+ * These literals were previously inline fallbacks inside block components
5
+ * (or absent entirely), so the shape-driven prop normalizer had no contract
6
+ * to coerce AI-returned values against. Each constant is value-identical to
7
+ * the fallback it replaced: valid configs render byte-for-byte the same.
8
+ *
9
+ * block-metadata references them from defaultProps; the blocks import them
10
+ * as render fallbacks (still needed for legacy persisted configs).
11
+ */
12
+
13
+ // ISS-005 (review item 1): logocloud logos were inline-only; LogoPlaceholder
14
+ // does name.charAt(0) + renders {name} → object entries crash exactly like
15
+ // navbar links did.
16
+ export const defaultLogos = ['Vercel', 'Stripe', 'GitHub', 'Figma', 'Notion', 'Linear']
17
+
18
+ export const defaultFooterColumns: { title: string; links: string[] }[] = [
19
+ { title: 'Product', links: ['Features', 'Pricing', 'Changelog', 'Roadmap'] },
20
+ { title: 'Company', links: ['About', 'Blog', 'Careers', 'Press'] },
21
+ { title: 'Resources', links: ['Documentation', 'API Reference', 'Guides', 'Community'] },
22
+ { title: 'Legal', links: ['Privacy', 'Terms', 'Security', 'Cookie Policy'] },
23
+ ]
24
+
25
+ export const defaultPricingTiers: {
26
+ name: string
27
+ price: string
28
+ period?: string
29
+ description?: string
30
+ features: string[]
31
+ cta: string
32
+ featured?: boolean
33
+ }[] = [
34
+ {
35
+ name: 'Starter',
36
+ price: '$0',
37
+ period: '/month',
38
+ description: 'For personal projects',
39
+ features: ['1 website', '5 blocks', 'Basic export', 'Community support'],
40
+ cta: 'Get Started',
41
+ },
42
+ {
43
+ name: 'Pro',
44
+ price: '$19',
45
+ period: '/month',
46
+ description: 'For professionals',
47
+ features: ['Unlimited websites', 'All blocks', 'Custom domains', 'Priority support', 'Agent API access', 'Version history'],
48
+ cta: 'Upgrade to Pro',
49
+ featured: true,
50
+ },
51
+ {
52
+ name: 'Team',
53
+ price: '$49',
54
+ period: '/month',
55
+ description: 'For teams and agencies',
56
+ features: ['Everything in Pro', 'Team collaboration', 'Custom components', 'SSO', 'Dedicated support'],
57
+ cta: 'Contact Sales',
58
+ },
59
+ ]
60
+
61
+ export const defaultStatItems: { value: string; label: string }[] = [
62
+ { value: '10K+', label: 'Sites built' },
63
+ { value: '99.9%', label: 'Uptime' },
64
+ { value: '50ms', label: 'Avg. response' },
65
+ { value: '4.9/5', label: 'User rating' },
66
+ ]
67
+
68
+ export const defaultFaqItems: { question: string; answer: string }[] = [
69
+ { question: 'What is OpenPage?', answer: 'OpenPage is a visual website builder that uses structured JSON config as the source of truth. Both humans and AI agents can edit the same config to build beautiful websites.' },
70
+ { question: 'How does the JSON config work?', answer: 'Every website is represented as a JSON document with blocks, styles, and content. The visual editor reads and writes this JSON, and agents can make surgical edits via the API.' },
71
+ { question: 'Can I use my own components?', answer: 'Yes! OpenPage supports custom components. You can build your own blocks following our component schema and register them in the block registry.' },
72
+ { question: 'Is it free to use?', answer: 'OpenPage offers a free tier for personal projects with up to 5 blocks. Pro and Team plans unlock unlimited blocks, custom domains, and priority support.' },
73
+ ]
74
+
75
+ export const defaultTeamMembers: { name: string; role: string }[] = [
76
+ { name: 'Alex Rivera', role: 'CEO & Founder' },
77
+ { name: 'Jordan Lee', role: 'CTO' },
78
+ { name: 'Sam Patel', role: 'Head of Design' },
79
+ { name: 'Casey Morgan', role: 'Lead Engineer' },
80
+ ]
81
+
82
+ // avatar:'' added vs the original inline literal (falsy → renders the initials
83
+ // fallback exactly as before) so the normalizer has a string shape for it.
84
+ export const defaultTestimonials: {
85
+ name: string
86
+ role: string
87
+ quote: string
88
+ rating?: number
89
+ avatar?: string
90
+ }[] = [
91
+ { name: 'Sarah Chen', role: 'CEO at TechCorp', quote: 'OpenPage completely changed how we build landing pages. The JSON config approach is genius.', rating: 5, avatar: '' },
92
+ { name: 'Marcus Johnson', role: 'Lead Developer', quote: 'Finally, a tool where both designers and AI agents can work together seamlessly.', rating: 5, avatar: '' },
93
+ { name: 'Emma Wilson', role: 'Product Manager', quote: 'We shipped our marketing site in half the time. The component library is incredible.', rating: 4, avatar: '' },
94
+ ]
95
+
96
+ // src/caption:'' added vs the original { alt } literals — falsy renders the
97
+ // placeholder icon + skips the caption div, byte-identical output, and gives
98
+ // the normalizer full string shapes for every GalleryImage field.
99
+ export const defaultGalleryImages: { src?: string; alt?: string; caption?: string }[] = [
100
+ { src: '', alt: 'Image 1', caption: '' }, { src: '', alt: 'Image 2', caption: '' }, { src: '', alt: 'Image 3', caption: '' },
101
+ { src: '', alt: 'Image 4', caption: '' }, { src: '', alt: 'Image 5', caption: '' }, { src: '', alt: 'Image 6', caption: '' },
102
+ ]
103
+
104
+ // Was inline in ImageBlock grid variant as { src: undefined, alt: '1' }.
105
+ // src promoted to '' — renders identically (falsy → Placeholder) but gives
106
+ // the normalizer a string contract for src. Type mirrors ImageBlock's
107
+ // declared { src?, alt? } so props.images || FALLBACK unions stay assignable.
108
+ export const defaultImageGridItems: { src?: string; alt: string }[] = [
109
+ { src: '', alt: '1' }, { src: '', alt: '2' }, { src: '', alt: '3' }, { src: '', alt: '4' },
110
+ ]
@@ -1,4 +1,15 @@
1
1
  import type { BlockType } from '@/blocks/types'
2
+ import {
3
+ defaultFooterColumns,
4
+ defaultLogos,
5
+ defaultPricingTiers,
6
+ defaultStatItems,
7
+ defaultFaqItems,
8
+ defaultTeamMembers,
9
+ defaultTestimonials,
10
+ defaultGalleryImages,
11
+ defaultImageGridItems,
12
+ } from '@/lib/block-default-content'
2
13
 
3
14
  export interface BlockMeta {
4
15
  type: BlockType
@@ -9,6 +20,10 @@ export interface BlockMeta {
9
20
  defaultProps: Record<string, unknown>
10
21
  }
11
22
 
23
+ // ISS-005 (class fix): defaultProps below ARE the prop contracts the generation
24
+ // normalizer (src/lib/prop-normalization.ts) coerces AI output against.
25
+ // Content defaults live in src/lib/block-default-content.ts.
26
+
12
27
  export const blockMetadata: BlockMeta[] = [
13
28
  {
14
29
  type: 'navbar',
@@ -24,7 +39,9 @@ export const blockMetadata: BlockMeta[] = [
24
39
  description: 'Full-width hero section with headline and CTAs',
25
40
  category: 'Hero',
26
41
  variants: ['centered', 'split', 'gradient', 'minimal'],
27
- defaultProps: { headline: 'Your Headline Here', subheadline: 'A compelling subheadline that explains your value proposition.', primaryCta: 'Get Started', secondaryCta: 'Learn More' },
42
+ // badge:'' gives the normalizer a string contract for badge (renders only
43
+ // when truthy → byte-identical output vs the key being absent) — ISS-005 review item 1.
44
+ defaultProps: { badge: '', headline: 'Your Headline Here', subheadline: 'A compelling subheadline that explains your value proposition.', primaryCta: 'Get Started', secondaryCta: 'Learn More' },
28
45
  },
29
46
  {
30
47
  type: 'features',
@@ -40,7 +57,7 @@ export const blockMetadata: BlockMeta[] = [
40
57
  description: 'Pricing tiers with feature comparison',
41
58
  category: 'Commerce',
42
59
  variants: ['simple', 'comparison'],
43
- defaultProps: { title: 'Pricing', subtitle: 'Choose the plan that fits your needs' },
60
+ defaultProps: { title: 'Pricing', subtitle: 'Choose the plan that fits your needs', tiers: defaultPricingTiers },
44
61
  },
45
62
  {
46
63
  type: 'cta',
@@ -56,7 +73,7 @@ export const blockMetadata: BlockMeta[] = [
56
73
  description: 'Page footer with links and copyright',
57
74
  category: 'Navigation',
58
75
  variants: ['simple', 'multi-column', 'minimal'],
59
- defaultProps: { logo: 'Brand', copyright: '2026 Brand. All rights reserved.', links: ['Privacy', 'Terms'] },
76
+ defaultProps: { logo: 'Brand', copyright: '2026 Brand. All rights reserved.', links: ['Privacy', 'Terms'], columns: defaultFooterColumns },
60
77
  },
61
78
  {
62
79
  type: 'testimonials',
@@ -64,7 +81,7 @@ export const blockMetadata: BlockMeta[] = [
64
81
  description: 'Customer testimonials with quotes and ratings',
65
82
  category: 'Social Proof',
66
83
  variants: ['cards', 'carousel', 'spotlight'],
67
- defaultProps: { title: 'What our customers say' },
84
+ defaultProps: { title: 'What our customers say', items: defaultTestimonials },
68
85
  },
69
86
  {
70
87
  type: 'stats',
@@ -72,7 +89,7 @@ export const blockMetadata: BlockMeta[] = [
72
89
  description: 'Key metrics and statistics display',
73
90
  category: 'Social Proof',
74
91
  variants: ['grid', 'bar', 'counter'],
75
- defaultProps: { title: 'By the numbers' },
92
+ defaultProps: { title: 'By the numbers', items: defaultStatItems },
76
93
  },
77
94
  {
78
95
  type: 'faq',
@@ -80,7 +97,7 @@ export const blockMetadata: BlockMeta[] = [
80
97
  description: 'Frequently asked questions accordion',
81
98
  category: 'Content',
82
99
  variants: ['accordion'],
83
- defaultProps: { title: 'Frequently Asked Questions' },
100
+ defaultProps: { title: 'Frequently Asked Questions', items: defaultFaqItems },
84
101
  },
85
102
  {
86
103
  type: 'team',
@@ -88,7 +105,7 @@ export const blockMetadata: BlockMeta[] = [
88
105
  description: 'Team member grid with photos and roles',
89
106
  category: 'Content',
90
107
  variants: ['grid'],
91
- defaultProps: { title: 'Meet the Team' },
108
+ defaultProps: { title: 'Meet the Team', members: defaultTeamMembers },
92
109
  },
93
110
  {
94
111
  type: 'contact',
@@ -112,7 +129,7 @@ export const blockMetadata: BlockMeta[] = [
112
129
  description: 'Company logos with hover effects',
113
130
  category: 'Social Proof',
114
131
  variants: ['default'],
115
- defaultProps: { title: 'Trusted by leading companies' },
132
+ defaultProps: { title: 'Trusted by leading companies', logos: defaultLogos },
116
133
  },
117
134
  {
118
135
  type: 'content',
@@ -128,7 +145,7 @@ export const blockMetadata: BlockMeta[] = [
128
145
  description: 'Image with text overlay or side-by-side layout',
129
146
  category: 'Media',
130
147
  variants: ['hero-image', 'side-by-side', 'grid'],
131
- defaultProps: { title: 'Visual Storytelling', subtitle: 'A picture is worth a thousand words.', imageSide: 'left' },
148
+ defaultProps: { title: 'Visual Storytelling', subtitle: 'A picture is worth a thousand words.', imageSide: 'left', images: defaultImageGridItems },
132
149
  },
133
150
  {
134
151
  type: 'video',
@@ -144,7 +161,7 @@ export const blockMetadata: BlockMeta[] = [
144
161
  description: 'Image gallery in grid or masonry layout',
145
162
  category: 'Media',
146
163
  variants: ['grid', 'masonry'],
147
- defaultProps: { title: 'Gallery' },
164
+ defaultProps: { title: 'Gallery', images: defaultGalleryImages },
148
165
  },
149
166
  {
150
167
  type: 'divider',
@@ -160,7 +177,7 @@ export const blockMetadata: BlockMeta[] = [
160
177
  description: 'Announcement bar or ribbon',
161
178
  category: 'Content',
162
179
  variants: ['ribbon', 'bar'],
163
- defaultProps: { text: 'New: We just launched v2.0!', linkText: 'Learn more' },
180
+ defaultProps: { text: 'New: We just launched v2.0!', linkText: 'Learn more', linkUrl: '#' },
164
181
  },
165
182
  ]
166
183
 
@@ -1,6 +1,7 @@
1
1
  import type { SiteConfig, BlockConfig, ThemeConfig } from '@/blocks/types'
2
2
  import { blockMetadata } from '@/lib/block-metadata'
3
3
  import { GENERATION_PROMPT } from '@/lib/generation-prompt'
4
+ import { normalizeBlockProps } from '@/lib/prop-normalization'
4
5
  import { getTemplateForPrompt } from '@/lib/templates'
5
6
 
6
7
  const VALID_BLOCK_TYPES = new Set<string>(blockMetadata.map((b) => b.type))
@@ -35,7 +36,12 @@ export async function generateSiteConfig(
35
36
  // 2. Injected server fallback (caller-supplied; the engine is route-agnostic)
36
37
  if (onServerFallback) {
37
38
  try {
38
- const config = await onServerFallback(prompt, signal)
39
+ const raw = await onServerFallback(prompt, signal)
40
+ // ISS-005: the host's result must go through the SAME validation as tier 1 —
41
+ // previously it was returned unchecked (object props reached blocks → crash).
42
+ // validateSiteConfig never throws (all inputs type-guarded), so an unexpected
43
+ // throw would still be caught here and degrade to the template fallback.
44
+ const config = validateSiteConfig(raw, prompt)
39
45
  return { config, source: 'ai' }
40
46
  } catch (err) {
41
47
  if (err instanceof Error && err.name === 'AbortError') throw err
@@ -113,6 +119,9 @@ function validateTheme(raw: Record<string, unknown>): Partial<ThemeConfig> {
113
119
  }
114
120
 
115
121
  function validateBlock(raw: Record<string, unknown>, index: number): BlockConfig | null {
122
+ // ISS-005 hardening: blocks arrays may contain null/primitives from hostile or
123
+ // malformed AI output — validateSiteConfig must never throw by design.
124
+ if (!raw || typeof raw !== 'object') return null
116
125
  const type = raw.type as string
117
126
  if (!type || !VALID_BLOCK_TYPES.has(type)) return null
118
127
 
@@ -123,7 +132,11 @@ function validateBlock(raw: Record<string, unknown>, index: number): BlockConfig
123
132
  }
124
133
 
125
134
  const defaultProps = DEFAULT_PROPS_MAP[type] || {}
126
- const props = typeof raw.props === 'object' && raw.props ? { ...defaultProps, ...raw.props } : defaultProps
135
+ // ISS-005 root fix: raw prop VALUES are normalized against the defaultProps
136
+ // shape (generic, no per-block guards) before merging over the defaults.
137
+ const props = typeof raw.props === 'object' && raw.props
138
+ ? { ...defaultProps, ...normalizeBlockProps(raw.props, defaultProps) }
139
+ : defaultProps
127
140
 
128
141
  return {
129
142
  id: typeof raw.id === 'string' && raw.id ? raw.id : `block-${type}-${index}-${Date.now()}`,
@@ -27,12 +27,12 @@ Each page has its own blocks array. Generate at least 2 pages: Home and one addi
27
27
 
28
28
  ## Available Block Types
29
29
 
30
- 1. navbar (variants: default, centered) - Props: { logo, links[], ctaText }
30
+ 1. navbar (variants: default, centered) - Props: { logo, links: string[], ctaText }
31
31
  2. hero (variants: centered, split, gradient, minimal) - Props: { badge?, headline, subheadline, primaryCta, secondaryCta? }
32
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? }] }
33
+ 4. pricing (variants: simple, comparison) - Props: { title, subtitle?, tiers?: [{ name, price, period?, description?, features: string[], cta, featured? }] }
34
34
  5. cta (variants: simple, split) - Props: { headline, subheadline?, buttonText }
35
- 6. footer (variants: simple, multi-column, minimal) - Props: { logo, copyright, links[] }
35
+ 6. footer (variants: simple, multi-column, minimal) - Props: { logo, copyright, links: string[], columns?: [{ title, links: string[] }] }
36
36
  7. testimonials (variants: cards, carousel, spotlight) - Props: { title?, items?: [{ name, role?, quote, rating? }] }
37
37
  8. stats (variants: grid, bar, counter) - Props: { title?, items?: [{ value, label }] }
38
38
  9. faq (variants: accordion) - Props: { title?, items?: [{ question, answer }] }
@@ -60,5 +60,6 @@ Fonts: DM Sans, Inter, Space Grotesk, Poppins, Manrope, Outfit, Plus Jakarta San
60
60
  6. Do NOT use placeholder text like "Lorem ipsum"
61
61
  7. Make copy compelling and specific to the described business
62
62
  8. Each page needs a unique id (page-home, page-about, etc.), a name, and a path (/, /about, etc.)
63
+ 9. Link/label arrays (navbar links, footer links, footer columns[].links, pricing tiers[].features) are plain strings — NEVER objects like {label, href}
63
64
 
64
65
  Return ONLY valid JSON. No markdown, no code fences, no explanation.`