#!/usr/bin/env python3 """Website harness: compact spec in, complete HTML out. This deliberately avoids asking the model to write an entire website. The model, if used, should only produce a small JSON spec. This renderer owns the HTML structure, closing tags, responsive CSS, image policy, and validation. """ from __future__ import annotations import html import json import re from dataclasses import dataclass, field from pathlib import Path from typing import Any IMAGE_BANK: dict[str, list[str]] = { "barber": [ "https://images.unsplash.com/photo-1585747860715-2ba37e788b70?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1621605815971-fbc98d665033?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1503951914875-452162b0f3f1?auto=format&fit=crop&w=900&q=80", ], "bakery": [ "https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1517433670267-08bbd4be890f?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1555507036-ab1f4038808a?auto=format&fit=crop&w=900&q=80", ], "food": [ "https://images.unsplash.com/photo-1562967914-608f82629710?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1604908176997-125f25cc6f3d?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1550547660-d9450f859349?auto=format&fit=crop&w=900&q=80", ], "contractor": [ "https://images.unsplash.com/photo-1504307651254-35680f356dfd?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1541888946425-d81bb19240f5?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1503387762-592deb58ef4e?auto=format&fit=crop&w=900&q=80", ], "bookkeeping": [ "https://images.unsplash.com/photo-1554224155-6726b3ff858f?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1554224154-26032ffc0d07?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?auto=format&fit=crop&w=900&q=80", ], "roofing": [ "https://images.unsplash.com/photo-1632759145351-1d592919f522?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1504307651254-35680f356dfd?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?auto=format&fit=crop&w=900&q=80", ], "cleaning": [ "https://images.unsplash.com/photo-1581578731548-c64695cc6952?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1527515637462-cff94eecc1ac?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1603712725038-e9334ae8f39f?auto=format&fit=crop&w=900&q=80", ], "fitness": [ "https://images.unsplash.com/photo-1517836357463-d25dfeac3438?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1540497077202-7c8a3999166f?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1534368420009-621bfab424a8?auto=format&fit=crop&w=900&q=80", ], "salon": [ "https://images.unsplash.com/photo-1522335789203-aabd1fc54bc9?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1560066984-138dadb4c035?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1521590832167-7bcbfaa6381f?auto=format&fit=crop&w=900&q=80", ], "restaurant": [ "https://images.unsplash.com/photo-1555396273-367ea4eb4db5?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1544148103-0773bf10d330?auto=format&fit=crop&w=900&q=80", ], "real_estate": [ "https://images.unsplash.com/photo-1560518883-ce09059eeffa?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?auto=format&fit=crop&w=900&q=80", ], "pets": [ "https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1525253013412-55c1a69a5738?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1548199973-03cce0bbc87b?auto=format&fit=crop&w=900&q=80", ], "auto": [ "https://images.unsplash.com/photo-1607860108855-64acf2078ed9?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1503376780353-7e6692767b70?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1525609004556-c46c7d6cf023?auto=format&fit=crop&w=900&q=80", ], "landscaping": [ "https://images.unsplash.com/photo-1558904541-efa843a96f01?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1416879595882-3373a0480b5b?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?auto=format&fit=crop&w=900&q=80", ], "legal": [ "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1450101499163-c8848c66ca85?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1505664194779-8beaceb93744?auto=format&fit=crop&w=900&q=80", ], "plumbing": [ "https://images.unsplash.com/photo-1607472586893-edb57bdc0e39?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1585704032915-c3400ca199e7?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1621905252507-b35492cc74b4?auto=format&fit=crop&w=900&q=80", ], "electrical": [ "https://images.unsplash.com/photo-1621905251189-08b45d6a269e?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1581092160607-ee22621dd758?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1621905252507-b35492cc74b4?auto=format&fit=crop&w=900&q=80", ], "med_spa": [ "https://images.unsplash.com/photo-1570172619644-dfd03ed5d881?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1512290923902-8a9f81dc236c?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?auto=format&fit=crop&w=900&q=80", ], "dental": [ "https://images.unsplash.com/photo-1606811971618-4486d14f3f99?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1629909613654-28e377c37b09?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1587314919482-0f8f6f8d9d5d?auto=format&fit=crop&w=900&q=80", ], "education": [ "https://images.unsplash.com/photo-1522202176988-66273c2fd55f?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1513258496099-48168024aec0?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1503676260728-1c00da094a0b?auto=format&fit=crop&w=900&q=80", ], "default": [ "https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=1400&q=80", "https://images.unsplash.com/photo-1556761175-b413da4baf72?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1556761175-4b46a572b786?auto=format&fit=crop&w=900&q=80", ], } PALETTES: dict[str, dict[str, str]] = { "gold": {"ink": "#11100d", "paper": "#fff8ea", "accent": "#f4ad32", "card": "#ffffff"}, "blue": {"ink": "#07111f", "paper": "#f4f9ff", "accent": "#38bdf8", "card": "#ffffff"}, "green": {"ink": "#07180f", "paper": "#f4fff7", "accent": "#22c55e", "card": "#ffffff"}, "rose": {"ink": "#1d1017", "paper": "#fff5f8", "accent": "#fb7185", "card": "#ffffff"}, "slate": {"ink": "#101827", "paper": "#f8fafc", "accent": "#f59e0b", "card": "#ffffff"}, } LAYOUT_BY_TYPE: dict[str, str] = { "auto": "kinetic", "fitness": "kinetic", "landscaping": "gallery-forward", "med_spa": "editorial", "salon": "editorial", "bakery": "editorial", "food": "editorial", "restaurant": "editorial", "roofing": "proof", "plumbing": "proof", "electrical": "proof", "contractor": "proof", "bookkeeping": "proof", "legal": "proof", "dental": "proof", } @dataclass class WebsiteSpec: business_name: str business_type: str location: str = "Atlanta" headline: str = "Professional work without the runaround." subheadline: str = "Clear pricing, polished execution, and a result customers can trust." cta: str = "Book now" services: list[str] = field(default_factory=list) sections: list[str] = field(default_factory=list) testimonials: list[str] = field(default_factory=list) hours: str = "Mon-Fri 9am-6pm; Saturday 10am-3pm; Sunday closed" contact_phone: str = "(404) 555-0199" contact_email: str = "hello@example.com" palette: str = "gold" image_urls: list[str] = field(default_factory=list) def slugify(value: str) -> str: return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "site" def clean_text(value: Any, fallback: str) -> str: if not isinstance(value, str): return fallback value = re.sub(r"\s+", " ", value).strip() return value or fallback def title_case_service(value: str) -> str: return clean_text(value, "Service").strip(" .").title() def infer_business_name(prompt: str) -> str: patterns = [ r"(?:website|site|landing page|homepage)\s+for\s+([A-Z][A-Za-z0-9 &'&-]{2,80}?)(?:\.|,|\s+at\s+|\s+with|\s+include|\s+that|\s+put|\s+and\s+put|$)", r"for\s+([A-Z][A-Za-z0-9 &'&-]{2,80}?)(?:\.|,|\s+at\s+|\s+with|\s+include|\s+that|\s+put|\s+and\s+put|$)", r"(?:food\s+business|business|company|firm|restaurant|clinic|shop),\s+([A-Z][A-Za-z0-9 &'&-]{2,80}?)(?:\.|,|\s+with|\s+have|\s+include|\s+for|\s+that|$)", r"(?:business|company|firm|restaurant|clinic|shop)\s+named\s+([A-Z][A-Za-z0-9 &'&-]{2,80}?)(?:\.|,|\s+with|\s+have|\s+include|\s+for|\s+that|$)", r"(?:business|company|firm|restaurant|clinic|shop)\s+called\s+([A-Z][A-Za-z0-9 &'&-]{2,80}?)(?:\.|,|\s+with|\s+have|\s+include|\s+for|\s+that|$)", ] for pattern in patterns: match = re.search(pattern, prompt) if match: candidate = clean_text(match.group(1), "Local Business").rstrip(".") if not re.search(r"\bfolder\b|\bdirectory\b|\bdesktop\b", candidate, re.IGNORECASE): return candidate return "Local Business" def infer_business_type(prompt: str) -> str: lower = prompt.lower() choices = [ ("food", ["food business", "fried chicken", "chicken", "chickens", "wings", "family meals", "food truck"]), ("restaurant", ["restaurant", "omakase", "dining", "menu", "cuisine", "reserve a table", "reservation"]), ("barber", ["barber", "skin fade", "beard", "haircut"]), ("bakery", ["bakery", "baked", "pastry", "croissant", "cake"]), ("roofing", ["roof", "roofing", "gutter", "storm"]), ("contractor", ["contractor", "remodel", "construction", "hvac", "repair"]), ("bookkeeping", ["bookkeeping", "bookkeeper", "payroll", "accounting", "accountant", "financial reporting"]), ("cleaning", ["cleaning", "maid", "janitorial"]), ("fitness", ["fitness", "gym", "trainer"]), ("med_spa", ["med spa", "medical spa", "botox", "injectables", "facial", "skincare", "skin care"]), ("salon", ["salon", "esthetician", "hair stylist", "hair studio"]), ("real_estate", ["real estate", "realtor", "homes", "property"]), ("pets", ["pet", "dog", "groomer", "veterinary", "vet"]), ("auto", ["auto", "car", "detailer", "mechanic", "repair shop"]), ("landscaping", ["landscaping", "lawn", "garden", "outdoor"]), ("legal", ["law", "lawyer", "attorney", "legal"]), ("plumbing", ["plumber", "plumbing", "pipe", "drain", "water heater"]), ("electrical", ["electrician", "electrical", "breaker", "panel", "wiring"]), ("dental", ["dental", "dentist", "dentistry", "cleanings", "cosmetic dentistry"]), ("education", ["tutor", "tutoring", "school", "course", "teacher", "education"]), ] for business_type, terms in choices: if any(term_matches(lower, term) for term in terms): return business_type return "default" def infer_location(prompt: str) -> str: city_state = re.search(r"\b([A-Z][A-Za-z .'-]{2,40}),\s*([A-Z]{2})(?:\s+\d{5})?\b", prompt) if city_state: return f"{clean_text(city_state.group(1), 'Atlanta')}, {city_state.group(2)}" in_city = re.search(r"\bin\s+([A-Z][A-Za-z .'-]{2,40})(?:\.|,|\s+with|\s+include|\s+for|\s+that|$)", prompt) if in_city: candidate = clean_text(in_city.group(1), "Atlanta").rstrip(".") if not re.search(r"\bfolder\b|\bdesktop\b|\bsection\b", candidate, re.IGNORECASE): return candidate return "Atlanta" def contact_phone_for_location(location: str) -> str: lower = location.lower() area_codes = [ ("san francisco", "415"), ("austin", "512"), ("boston", "617"), ("charlotte", "704"), ("new york", "212"), ("los angeles", "323"), ("chicago", "312"), ("miami", "305"), ("atlanta", "404"), ] for city, code in area_codes: if city in lower: return f"({code}) 555-0199" state_codes = { " ca": "415", " tx": "512", " ma": "617", " nc": "704", " ny": "212", " fl": "305", " ga": "404", } for marker, code in state_codes.items(): if lower.endswith(marker) or f",{marker}" in lower: return f"({code}) 555-0199" return "(404) 555-0199" def term_matches(lower_text: str, term: str) -> bool: if " " in term: return term in lower_text return re.search(rf"\b{re.escape(term)}\b", lower_text) is not None def infer_services(prompt: str, business_type: str) -> list[str]: defaults = { "barber": ["Signature Cuts", "Skin Fades", "Beard Trims", "Hot Towel Shaves"], "bakery": ["Fresh Pastries", "Custom Cakes", "Coffee Bar", "Catering Boxes"], "food": ["Signature Chicken", "Family Meals", "Catering Trays", "Sides & Drinks"], "roofing": ["Roof Repair", "Full Replacement", "Storm Inspection", "Gutter Service"], "contractor": ["Repairs", "Installations", "Project Planning", "Emergency Help"], "bookkeeping": ["Monthly Bookkeeping", "Payroll Support", "Financial Reporting", "Cleanup Projects"], "cleaning": ["Deep Cleaning", "Recurring Service", "Move-Out Cleaning", "Office Cleaning"], "fitness": ["Strength Training", "Nutrition Coaching", "Mobility Work", "Accountability"], "salon": ["Signature Service", "Treatment Plans", "Consultations", "Aftercare"], "restaurant": ["Daily Specials", "Catering", "Private Events", "Online Orders"], "real_estate": ["Home Valuation", "Buyer Guidance", "Listing Strategy", "Relocation Help"], "pets": ["Full Grooming", "Bath & Brush", "Nail Trim", "Puppy Care"], "auto": ["Interior Detail", "Exterior Wash", "Paint Protection", "Maintenance Check"], "landscaping": ["Lawn Care", "Seasonal Cleanup", "Planting Plans", "Hardscape Support"], "legal": ["Consultation", "Document Review", "Business Counsel", "Estate Planning"], "plumbing": ["Leak Repair", "Drain Cleaning", "Water Heaters", "Emergency Service"], "electrical": ["Panel Upgrades", "Lighting Install", "Troubleshooting", "Emergency Repairs"], "med_spa": ["Facials", "Injectables", "Skin Treatments", "Consultations"], "dental": ["Preventive Cleanings", "Cosmetic Dentistry", "Emergency Visits", "Insurance Guidance"], "education": ["One-on-One Tutoring", "Test Prep", "Homework Support", "Progress Plans"], "default": ["Consultation", "Planning", "Delivery", "Support"], } lower = prompt.lower() package_match = re.search(r"(?:service\s+)?packages?\s+with\s+prices?\s*\(([^)]{20,220})\)", prompt, re.IGNORECASE) if package_match: packages = [] for item in re.split(r",|;", package_match.group(1)): name = re.sub(r"\$[\d,]+(?:/mo)?", "", item, flags=re.IGNORECASE).strip(" -:") if len(name) > 2: packages.append(title_case_service(name)) if len(packages) >= 3: return packages[:4] if business_type == "restaurant": simple_menu_match = re.search( r"menu highlights with\s+(.+?)(?:,\s*omakase|,\s*reservation|,\s*hours|\.|$)", prompt, re.IGNORECASE | re.DOTALL, ) if simple_menu_match: dishes = [] normalized_menu = re.sub(r"\s+and\s+", ", ", simple_menu_match.group(1)) for item in re.split(r",|;", normalized_menu): cleaned = re.sub(r"\([^)]*\)|\$[\d,]+", "", item).strip(" -:") if len(cleaned) > 3: dishes.append(title_case_service(cleaned)) if len(dishes) >= 3: return dishes[:4] parenthetical_dishes = [] for match in re.finditer(r"([A-Z][A-Za-z0-9 '&-]{3,60}?)\s*\(", prompt): dish = title_case_service(match.group(1)) if len(dish) > 3 and dish.lower() not in {"hours", "location", "phone", "email"}: parenthetical_dishes.append(dish) if len(parenthetical_dishes) >= 3: return parenthetical_dishes[:4] bullet_dishes = [] for match in re.finditer(r"^\s*-\s*([A-Z][^\n(]+?)(?:\s*\(|$)", prompt, re.MULTILINE): dish = title_case_service(match.group(1)) if len(dish) > 3 and dish.lower() not in {"hours", "address", "phone", "email"}: bullet_dishes.append(dish) if len(bullet_dishes) >= 3: return bullet_dishes[:4] menu_match = re.search(r"(?:items\s+like|include\s+items\s+like|signature\s+dishes(?:\s+with\s+prices)?)[^:]*:\s*([^.]*)", prompt, re.IGNORECASE) if menu_match: dishes = [] for item in re.split(r",|;", menu_match.group(1)): name = re.sub(r"\([^)]*\)|\$[\d,]+", "", item).strip(" -:") if len(name) > 3: dishes.append(title_case_service(name)) if len(dishes) >= 3: return dishes[:4] if business_type == "bakery" and any(term in lower for term in ["weekly specials", "catering", "order-ahead", "order ahead"]): bakery_services = ["Fresh Pastries"] if "weekly specials" in lower: bakery_services.append("Weekly Specials") if "catering" in lower: bakery_services.append("Catering Boxes") if "cake" in lower or "cakes" in lower: bakery_services.append("Custom Cakes") if len(bakery_services) < 4: bakery_services.append("Coffee Bar") return bakery_services[:4] if business_type == "contractor" and re.search(r"project sectors?", lower): return ["Ground-Up Construction", "Tenant Improvements", "Industrial Buildouts", "Preconstruction Planning"] if "services" in lower: after = re.split(r"services[:\s]", prompt, flags=re.IGNORECASE, maxsplit=1) if len(after) == 2: candidates = re.split(r",|;|\band\b", after[1])[:6] cleaned = [title_case_service(item) for item in candidates if len(item.strip()) > 3] if len(cleaned) >= 3: return cleaned[:4] return defaults.get(business_type, defaults["default"]) def infer_sections(prompt: str) -> list[str]: lower = prompt.lower() sections = ["hero", "services", "pricing", "hours", "testimonials", "contact"] optional = [ ("gallery", ["gallery", "pictures", "photos", "images", "before-and-after"]), ("faq", ["faq", "questions"]), ("service_area", ["service area", "areas served"]), ("trust", ["trust", "badges", "proof", "licensed", "insured"]), ("contact_form", ["contact form", "form"]), ] for section, terms in optional: if any(term in lower for term in terms) and section not in sections: sections.append(section) return sections def infer_palette(prompt: str, business_type: str) -> str: lower = prompt.lower() if "blue" in lower: return "blue" if "green" in lower: return "green" if "pink" in lower or "rose" in lower: return "rose" if business_type in {"salon", "med_spa"}: return "rose" if business_type in {"fitness", "contractor", "roofing", "legal", "real_estate", "plumbing", "electrical", "education", "dental"}: return "blue" if business_type in {"cleaning", "landscaping", "pets"}: return "green" return "gold" def infer_cta(prompt: str, business_type: str) -> str: lower = prompt.lower() quoted = re.search(r"['\"]([A-Za-z][A-Za-z0-9 &'/-]{2,50})['\"]\s+CTA", prompt) if quoted: return clean_text(quoted.group(1), "Get started").rstrip(" .") if "reserve a table" in lower or "reservation" in lower: return "Reserve a Table" if "cleanup-call" in lower or "cleanup call" in lower: return "Book a Cleanup Call" if "bid-request" in lower or "bid request" in lower: return "Request a Bid" if "order-ahead" in lower or "order ahead" in lower or "online order" in lower: return "Order Ahead" match = re.search( r"\bCTA\s+(?:is\s+|should\s+be\s+|button\s+is\s+)?[\"']?([A-Za-z][A-Za-z0-9 &'/-]{2,50}?)[\"']?(?:[.!?;,]|$)", prompt, ) if match: candidate = clean_text(match.group(1), "Get started").rstrip(" .") candidate_lower = candidate.lower() if ( candidate_lower not in {"button", "section", "call", "contact"} and not candidate_lower.startswith("and ") and not any(term in candidate_lower for term in ("testimonial", "price section", "hero section")) ): return candidate if business_type == "food": return "Order Now" if business_type == "barber": return "Book a chair" if "emergency" in prompt.lower(): return "Request emergency help" return "Get started" def spec_from_prompt(prompt: str) -> WebsiteSpec: business_type = infer_business_type(prompt) name = infer_business_name(prompt) location = infer_location(prompt) services = infer_services(prompt, business_type) cta = infer_cta(prompt, business_type) return WebsiteSpec( business_name=name, business_type=business_type, location=location, headline=headline_for(name, business_type), subheadline=subheadline_for(business_type), cta=cta, services=services, sections=infer_sections(prompt), testimonials=[ "Fast, clear, and professional from the first message.", "The work looked better than expected and the process was simple.", "I knew what was happening at every step.", ], palette=infer_palette(prompt, business_type), contact_phone=contact_phone_for_location(location), ) def headline_for(name: str, business_type: str) -> str: options = { "barber": "Sharp cuts. Clean fades. Easy booking.", "bakery": "Fresh pastries, warm coffee, zero guesswork.", "food": "Hot chicken, clear prices, easy ordering.", "roofing": "Roofing help when it actually matters.", "contractor": "Reliable work, clear scope, cleaner results.", "bookkeeping": "Clean books, clear payroll, better owner decisions.", "cleaning": "A cleaner home without the scheduling mess.", "fitness": "Private training built around real consistency.", "salon": "Skin, style, and care that feels considered.", "restaurant": "Where tradition meets the harbor.", "real_estate": "Confident moves for buyers and sellers.", "pets": "Gentle grooming for pets people love.", "auto": "Mobile detailing that makes the car feel new.", "landscaping": "Outdoor spaces that look cared for.", "legal": "Clear legal guidance without the runaround.", "plumbing": "Fast plumbing help with clear next steps.", "electrical": "Safe electrical work without vague estimates.", "med_spa": "Polished care, clear services, confident booking.", "dental": "Friendly dental care with clear visits and simple scheduling.", "education": "Clear tutoring support that moves students forward.", "default": f"{name} makes the next step simple.", } return options.get(business_type, options["default"]) def subheadline_for(business_type: str) -> str: options = { "barber": "Premium barbering with visible services, simple pricing, real photos, and a direct chair-booking CTA.", "bakery": "A warm neighborhood page with menu highlights, catering, hours, and a clear path to order.", "food": "A simple food-business site with strong photos, clear prices, customer proof, and a direct order CTA.", "roofing": "A trust-first contractor page with emergency help, service areas, proof, and fast contact.", "contractor": "A practical local-service site that explains the work, builds trust, and gets customers to act.", "bookkeeping": "Bookkeeping, payroll, reporting, and cleanup support presented with clear packages and a strong consultation path.", "cleaning": "Recurring cleaning, deep cleaning, and move-out help presented with clear packages and proof.", "fitness": "Coaching, nutrition, schedule, and testimonials organized for people ready to start.", "salon": "A polished service page with treatments, packages, testimonials, and online booking.", "restaurant": "Refined Japanese dining with omakase, private rooms, signature dishes, and a clear reservation path.", "real_estate": "Buying, selling, valuation, and local guidance packaged into a clear conversion path.", "pets": "Friendly grooming services, pricing, hours, and booking for busy pet owners.", "auto": "Packages, proof, photos, and mobile booking for customers who want the car handled right.", "landscaping": "Services, photos, service area, and estimate CTA for homeowners who want the yard handled.", "legal": "Practice areas, trust proof, FAQ, and consultation flow for customers who need clarity.", "plumbing": "Emergency help, service packages, proof, hours, and a direct request path for homeowners.", "electrical": "Service calls, safety-focused messaging, clear pricing, and a direct estimate CTA.", "med_spa": "Treatments, photos, consultation flow, testimonials, and booking in one polished page.", "dental": "Preventive care, cosmetic options, urgent visits, insurance context, and scheduling in one clear page.", "education": "Tutoring offers, outcomes, schedule, testimonials, and a simple inquiry path.", "default": "A polished local business site with clear services, proof, hours, and a direct next step.", } return options.get(business_type, options["default"]) def normalize_spec(raw: dict[str, Any] | WebsiteSpec, prompt: str = "") -> WebsiteSpec: if isinstance(raw, WebsiteSpec): spec = raw else: fallback = spec_from_prompt(prompt) business_type = clean_text(raw.get("business_type"), fallback.business_type).lower() if business_type in {"default", "local service business", "business"} and fallback.business_type != "default": business_type = fallback.business_type services = raw.get("services") if isinstance(raw.get("services"), list) else fallback.services sections = raw.get("sections") if isinstance(raw.get("sections"), list) else fallback.sections testimonials = raw.get("testimonials") if isinstance(raw.get("testimonials"), list) else fallback.testimonials image_urls = raw.get("image_urls") if isinstance(raw.get("image_urls"), list) else [] spec = WebsiteSpec( business_name=clean_text(raw.get("business_name"), fallback.business_name), business_type=business_type, location=clean_text(raw.get("location"), fallback.location), headline=clean_text(raw.get("headline"), fallback.headline), subheadline=clean_text(raw.get("subheadline"), fallback.subheadline), cta=infer_cta(prompt, fallback.business_type) if re.search(r"\bCTA\b", prompt) else clean_text(raw.get("cta"), fallback.cta), services=[title_case_service(item) for item in services if isinstance(item, str)][:6] or fallback.services, sections=[clean_text(item, "").lower().replace(" ", "_") for item in sections if isinstance(item, str)] or fallback.sections, testimonials=[clean_text(item, "") for item in testimonials if isinstance(item, str)][:3] or fallback.testimonials, hours=clean_text(raw.get("hours"), fallback.hours), contact_phone=clean_text(raw.get("contact_phone"), fallback.contact_phone), contact_email=clean_text(raw.get("contact_email"), fallback.contact_email), palette=clean_text(raw.get("palette"), fallback.palette).lower(), image_urls=[clean_text(item, "") for item in image_urls if isinstance(item, str)], ) if "hero" not in spec.sections: spec.sections.insert(0, "hero") for required in ["services", "contact"]: if required not in spec.sections: spec.sections.append(required) for prompt_section in infer_sections(prompt): if prompt_section not in spec.sections: spec.sections.append(prompt_section) if len(spec.services) < 3: spec.services = infer_services(prompt, spec.business_type) bank = IMAGE_BANK.get(spec.business_type, IMAGE_BANK["default"]) valid_images = [url for url in spec.image_urls if url.startswith("https://")] spec.image_urls = (valid_images + bank)[:3] if spec.palette not in PALETTES: spec.palette = "gold" return spec def esc(value: str) -> str: return html.escape(value, quote=True) def layout_for(spec: WebsiteSpec) -> str: return LAYOUT_BY_TYPE.get(spec.business_type, "classic") def service_copy(spec: WebsiteSpec, service: str) -> str: copy_by_type = { "auto": "A focused package with before-and-after clarity, clean timing, and mobile convenience.", "landscaping": "Reliable yard care with a clear scope, visible outcomes, and simple estimate follow-up.", "roofing": "Built for urgent homeowner trust: scope, timing, proof, and the next safe step.", "plumbing": "Fast help with plain-language diagnosis, clear pricing, and no vague handoff.", "electrical": "Safety-first service with clear estimates, careful work, and practical scheduling.", "med_spa": "A polished service path with consultation, expectations, aftercare, and booking clarity.", "dental": "A patient-ready service path with visit clarity, insurance context, and simple scheduling.", "bookkeeping": "A business-owner-ready package with clear monthly scope, reporting rhythm, and cleanup support.", "barber": "A clean chair-ready offer with visible pricing, easy booking, and a premium feel.", "bakery": "A warm customer offer with photos, order clarity, and an easy way to inquire.", "food": "A customer-ready menu offer with strong photos, simple pricing, and an easy ordering path.", "restaurant": "A signature dining experience with seasonal ingredients, careful presentation, and reservation-ready detail.", "legal": "A plain-English service area with trust, next steps, and consultation clarity.", } fallback = "Clear scope, practical guidance, and a clean result without confusing back-and-forth." return copy_by_type.get(spec.business_type, fallback).replace("service", service.lower(), 1) def render_cards(spec: WebsiteSpec) -> str: cards = [] for item in spec.services[:4]: safe = esc(item) cards.append( f"

{safe}

{esc(service_copy(spec, item))}

" ) return "\n".join(cards) def render_pricing(services: list[str]) -> str: rows = [] explicit_prices = { "starter": "$299/mo", "growth": "$599/mo", "enterprise": "$999/mo", "omakase": "$120", "wagyu": "$85", "black cod": "$42", "ramen": "$28", "signature chicken": "$14+", "family meals": "$38+", "catering trays": "$120+", "sides": "$5+", } for index, item in enumerate(services[:4]): lower = item.lower() price = next((value for key, value in explicit_prices.items() if key in lower), f"${45 + index * 25}+") rows.append(f"
  • {esc(item)}{esc(price)}
  • ") return "\n".join(rows) def render_gallery(spec: WebsiteSpec) -> str: images = "\n".join( f"
    \"{esc(spec.business_name)}
    Recent customer work
    " for index, url in enumerate(spec.image_urls[:3]) ) return f"""""" def render_optional_sections(spec: WebsiteSpec) -> str: chunks: list[str] = [] if "trust" in spec.sections: chunks.append( """
    Licensed where requiredClear estimatesLocal serviceFast follow-up
    """ ) if "gallery" in spec.sections: chunks.append(render_gallery(spec)) if "service_area" in spec.sections: chunks.append( f"""

    Service area

    Built for {esc(spec.location)} and nearby customers.

    We serve local customers who want a simple process, clear communication, and dependable execution.

    """ ) if "faq" in spec.sections: chunks.append( """

    FAQ

    Quick answers.

    How fast do you reply?

    Most requests get a same-day reply during business hours.

    Can I get a clear estimate?

    Yes. The first step is always scope, timing, and price clarity.

    """ ) return "\n".join(chunks) def render_html(raw_spec: dict[str, Any] | WebsiteSpec, prompt: str = "") -> str: spec = normalize_spec(raw_spec, prompt) palette = PALETTES[spec.palette] service_cards = render_cards(spec) pricing = render_pricing(spec.services) testimonials = "\n".join(f"
    {esc(item)}
    " for item in spec.testimonials[:3]) optional_sections = render_optional_sections(spec) hero_image = esc(spec.image_urls[0]) secondary_image = esc(spec.image_urls[1]) layout_class = esc(f"layout-{layout_for(spec)}") return f""" {esc(spec.business_name)} | {esc(spec.business_type.title())}

    {esc(spec.location)} {esc(spec.business_type)}

    {esc(spec.headline)}

    {esc(spec.subheadline)}

    {esc(spec.cta)}
    {esc(spec.business_name)} customer experience
    Same-week availability

    Clear next steps, clean communication, and a professional result.

    {optional_sections}

    Services

    What we handle.

    Simple offers customers can understand quickly.

    {service_cards}

    Pricing

    Clear starting points.

    Use this as a clean estimate structure. Final price depends on scope, timing, and details.

    {esc(spec.business_name)} service detail
      {pricing}

    Hours

    Open this week.

    {esc(spec.hours).replace(";", "
    ")}

    Testimonials

    {testimonials}

    Contact

    {esc(spec.cta)}

    Call {esc(spec.contact_phone)}
    Email {esc(spec.contact_email)}
    {esc(spec.location)}

    """ def validate_html(rendered: str, spec: WebsiteSpec | None = None) -> list[str]: lower = rendered.lower() readable_lower = html.unescape(rendered).lower() errors: list[str] = [] required = ["", "", ""] for token in required: if token not in lower: errors.append(f"missing {token}") if not lower.strip().endswith(""): errors.append("document does not end with ") if "```" in rendered: errors.append("markdown fence found") if re.search(r"opacity\s*:\s*0", rendered, flags=re.IGNORECASE): errors.append("hidden opacity:0 content found") if "viewport" not in lower: errors.append("missing mobile viewport") if "@media" not in lower: errors.append("missing responsive media query") if " tuple[WebsiteSpec, str, list[str]]: spec = spec_from_prompt(prompt) rendered = render_html(spec, prompt) return spec, rendered, validate_html(rendered, spec) def write_html(path: Path, rendered: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(rendered, encoding="utf-8") def spec_to_json(spec: WebsiteSpec) -> str: return json.dumps(spec.__dict__, indent=2, ensure_ascii=False)