| |
| """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"<article class=\"card\"><h3>{safe}</h3><p>{esc(service_copy(spec, item))}</p></article>" |
| ) |
| 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"<li><span>{esc(item)}</span><strong>{esc(price)}</strong></li>") |
| return "\n".join(rows) |
|
|
|
|
| def render_gallery(spec: WebsiteSpec) -> str: |
| images = "\n".join( |
| f"<figure><img src=\"{esc(url)}\" alt=\"{esc(spec.business_name)} work sample {index + 1}\"><figcaption>Recent customer work</figcaption></figure>" |
| for index, url in enumerate(spec.image_urls[:3]) |
| ) |
| return f"""<section id="gallery" class="section"> |
| <div class="section-head"><p class="eyebrow">Gallery</p><h2>Real work, clean presentation.</h2></div> |
| <div class="gallery">{images}</div> |
| </section>""" |
|
|
|
|
| def render_optional_sections(spec: WebsiteSpec) -> str: |
| chunks: list[str] = [] |
| if "trust" in spec.sections: |
| chunks.append( |
| """<section id="trust" class="trust-strip"> |
| <span>Licensed where required</span><span>Clear estimates</span><span>Local service</span><span>Fast follow-up</span> |
| </section>""" |
| ) |
| if "gallery" in spec.sections: |
| chunks.append(render_gallery(spec)) |
| if "service_area" in spec.sections: |
| chunks.append( |
| f"""<section id="service-area" class="section split"> |
| <div><p class="eyebrow">Service area</p><h2>Built for {esc(spec.location)} and nearby customers.</h2></div> |
| <p>We serve local customers who want a simple process, clear communication, and dependable execution.</p> |
| </section>""" |
| ) |
| if "faq" in spec.sections: |
| chunks.append( |
| """<section id="faq" class="section split"> |
| <div><p class="eyebrow">FAQ</p><h2>Quick answers.</h2></div> |
| <div class="faq"><details open><summary>How fast do you reply?</summary><p>Most requests get a same-day reply during business hours.</p></details><details><summary>Can I get a clear estimate?</summary><p>Yes. The first step is always scope, timing, and price clarity.</p></details></div> |
| </section>""" |
| ) |
| 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"<blockquote>{esc(item)}</blockquote>" 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"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{esc(spec.business_name)} | {esc(spec.business_type.title())}</title> |
| <meta name="description" content="{esc(spec.business_name)} is a polished local {esc(spec.business_type)} serving {esc(spec.location)}."> |
| <style> |
| :root{{--ink:{palette["ink"]};--paper:{palette["paper"]};--accent:{palette["accent"]};--card:{palette["card"]};--muted:#667085;--line:rgba(16,24,40,.14)}}*{{box-sizing:border-box}}html{{scroll-behavior:smooth}}body{{margin:0;font-family:Avenir Next,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at top left,rgba(244,173,50,.22),transparent 30%),var(--paper);color:var(--ink)}}a{{color:inherit;text-decoration:none}}img{{max-width:100%;display:block}}.wrap{{max-width:1160px;margin:auto;padding:24px}}nav{{display:flex;align-items:center;justify-content:space-between;gap:18px}}.brand{{font-size:25px;font-weight:950;letter-spacing:-.025em}}.links{{display:flex;gap:18px;color:var(--muted);font-weight:850}}.btn{{display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:999px;background:var(--accent);color:#111;padding:13px 20px;font-weight:950;box-shadow:0 16px 42px rgba(0,0,0,.16)}}.hero{{display:grid;grid-template-columns:1.02fr .98fr;gap:34px;align-items:center;padding:60px 0 42px}}.eyebrow{{color:var(--accent);font-size:12px;font-weight:950;letter-spacing:.18em;text-transform:uppercase}}h1{{font-size:clamp(44px,6.4vw,76px);line-height:.95;letter-spacing:-.045em;margin:12px 0 16px;max-width:10ch}}h2{{font-size:clamp(30px,4vw,48px);line-height:1;letter-spacing:-.035em;margin:0 0 12px}}h3{{margin:0 0 8px;font-size:20px}}p{{font-size:18px;line-height:1.6;color:var(--muted)}}.hero-media{{position:relative}}.hero-media img{{width:100%;height:490px;object-fit:cover;border-radius:32px;box-shadow:0 30px 80px rgba(0,0,0,.24)}}.floating{{position:absolute;left:24px;bottom:24px;background:rgba(255,255,255,.92);backdrop-filter:blur(14px);border-radius:22px;padding:18px;max-width:280px;border:1px solid var(--line);color:var(--ink)}}.section{{padding:44px 0;border-top:1px solid var(--line)}}.section-head{{display:flex;align-items:end;justify-content:space-between;gap:22px;margin-bottom:18px}}.grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}}.card,.panel,blockquote,figure{{background:var(--card);border:1px solid var(--line);border-radius:24px;padding:20px;box-shadow:0 14px 45px rgba(15,23,42,.08)}}.split{{display:grid;grid-template-columns:1fr 1fr;gap:22px;align-items:start}}ul{{list-style:none;margin:0;padding:0}}li{{display:flex;justify-content:space-between;gap:20px;padding:14px 0;border-bottom:1px solid var(--line);font-size:18px}}blockquote{{margin:0;font-size:18px;line-height:1.55;color:var(--ink)}}.gallery{{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}}figure{{margin:0;padding:10px}}figure img{{width:100%;height:230px;object-fit:cover;border-radius:18px}}figcaption{{padding:10px 4px 2px;color:var(--muted);font-weight:800}}.trust-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:14px 0}}.trust-strip span{{background:var(--ink);color:white;border-radius:999px;text-align:center;padding:12px 14px;font-weight:900}}.contact{{background:var(--ink);color:white;border-radius:30px;padding:32px;margin:44px 0 0}}.contact p{{color:rgba(255,255,255,.78)}}input,textarea{{width:100%;border:1px solid rgba(255,255,255,.18);border-radius:16px;background:rgba(255,255,255,.08);color:white;padding:14px;margin:0 0 12px;font:inherit}}footer{{padding:28px 0;color:var(--muted)}}.layout-proof .hero{{background:linear-gradient(135deg,var(--ink),#1f2937);border-radius:34px;color:white;margin-top:26px;padding:44px}}.layout-proof .hero p{{color:rgba(255,255,255,.76)}}.layout-proof .hero-media img{{height:430px}}.layout-proof .floating{{right:22px;left:auto;color:var(--ink)}}.layout-proof .floating p{{color:var(--muted)}}.layout-editorial .hero{{grid-template-columns:.92fr 1.08fr;padding-top:78px}}.layout-editorial h1{{font-size:clamp(52px,7vw,88px)}}.layout-editorial .hero-media img{{border-radius:999px 999px 34px 34px}}.layout-gallery-forward #gallery .section-head{{display:grid;grid-template-columns:.32fr 1fr;align-items:start}}.layout-gallery-forward #gallery h2{{font-size:clamp(38px,5vw,64px)}}.layout-kinetic .hero-media img{{border-radius:36px 36px 110px 36px}}.layout-kinetic .card:nth-child(even){{transform:translateY(18px)}}@media(max-width:880px){{.hero,.split,.layout-editorial .hero{{grid-template-columns:1fr}}.layout-proof .hero{{padding:28px}}.grid,.gallery,.trust-strip{{grid-template-columns:1fr 1fr}}.links{{display:none}}.hero-media img,.layout-proof .hero-media img{{height:360px}}h1{{max-width:12ch}}}}@media(max-width:560px){{.wrap{{padding:18px}}.grid,.gallery,.trust-strip{{grid-template-columns:1fr}}h1,.layout-editorial h1{{font-size:44px;max-width:none}}.layout-kinetic .card:nth-child(even){{transform:none}}}} |
| </style> |
| </head> |
| <body class="{layout_class}"> |
| <div class="wrap"> |
| <nav><a class="brand" href="#">{esc(spec.business_name)}</a><div class="links"><a href="#services">Services</a><a href="#pricing">Pricing</a><a href="#hours">Hours</a><a href="#contact">Contact</a></div><a class="btn" href="#contact">{esc(spec.cta)}</a></nav> |
| <main> |
| <header class="hero" id="top"><div><p class="eyebrow">{esc(spec.location)} {esc(spec.business_type)}</p><h1>{esc(spec.headline)}</h1><p>{esc(spec.subheadline)}</p><a class="btn" href="#contact">{esc(spec.cta)}</a></div><div class="hero-media"><img src="{hero_image}" alt="{esc(spec.business_name)} customer experience"><div class="floating"><strong>Same-week availability</strong><p>Clear next steps, clean communication, and a professional result.</p></div></div></header> |
| {optional_sections} |
| <section id="services" class="section"><div class="section-head"><div><p class="eyebrow">Services</p><h2>What we handle.</h2></div><p>Simple offers customers can understand quickly.</p></div><div class="grid">{service_cards}</div></section> |
| <section id="pricing" class="section split"><div><p class="eyebrow">Pricing</p><h2>Clear starting points.</h2><p>Use this as a clean estimate structure. Final price depends on scope, timing, and details.</p><img src="{secondary_image}" alt="{esc(spec.business_name)} service detail" style="height:220px;width:100%;object-fit:cover;border-radius:22px"></div><div class="panel"><ul>{pricing}</ul></div></section> |
| <section id="hours" class="section split"><div><p class="eyebrow">Hours</p><h2>Open this week.</h2><p>{esc(spec.hours).replace(";", "<br>")}</p></div><div><p class="eyebrow">Testimonials</p><div class="grid" style="grid-template-columns:1fr">{testimonials}</div></div></section> |
| <section id="contact" class="contact split"><div><p class="eyebrow">Contact</p><h2>{esc(spec.cta)}</h2><p>Call {esc(spec.contact_phone)}<br>Email {esc(spec.contact_email)}<br>{esc(spec.location)}</p></div><form><input aria-label="Name" placeholder="Name"><input aria-label="Email" placeholder="Email"><textarea aria-label="Project details" placeholder="What do you need?"></textarea><button class="btn" type="button">Send request</button></form></section> |
| </main> |
| <footer>Copyright 2026 {esc(spec.business_name)}. Built as a complete one-file website.</footer> |
| </div> |
| </body> |
| </html>""" |
|
|
|
|
| def validate_html(rendered: str, spec: WebsiteSpec | None = None) -> list[str]: |
| lower = rendered.lower() |
| readable_lower = html.unescape(rendered).lower() |
| errors: list[str] = [] |
| required = ["<!doctype html", "<html", "<head", "</head>", "<body", "</body>", "</html>"] |
| for token in required: |
| if token not in lower: |
| errors.append(f"missing {token}") |
| if not lower.strip().endswith("</html>"): |
| errors.append("document does not end with </html>") |
| 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 "<img " not in lower or "https://images.unsplash.com/" not in lower: |
| errors.append("missing verified external image") |
| for section in ["services", "pricing", "hours", "contact"]: |
| if f'id="{section}"' not in lower: |
| errors.append(f"missing #{section} section") |
| if spec: |
| for service in spec.services[:3]: |
| if service.lower() not in readable_lower: |
| errors.append(f"missing service: {service}") |
| return errors |
|
|
|
|
| def render_from_prompt(prompt: str) -> 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) |
|
|