import type { AiSettings, ColorOverrides, FieldDef, ListFieldDef, ListItem, SectionData, SectionDef, TemplateContent, TemplateData, TemplateDefinition, TemplateStyle } from "./types" const MAX_TEXT = 4000 const MAX_ITEMS = 40 const HEX_COLOR = /^#[0-9a-f]{6}$/i const LIBRARY_KEY = /^[a-z0-9]+:[a-z0-9]+$/ export const IMAGE_STYLES: TemplateStyle["images"][] = ["original", "mono", "warm", "soft", "contrast"] function libraryKey(value: unknown, own: { key: string }[]) { if (typeof value !== "string") return null return own.some((item) => item.key === value) || LIBRARY_KEY.test(value) ? value : null } export const RICH_HINT = "Enter = neue Zeile, *Wort* = hervorgehoben" // Leitet den Datentyp der Vorlage aus ihren Standardinhalten ab export function defineTemplate(def: TemplateDefinition) { return def } export function unsplash(id: string, width = 2000) { return `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${width}&q=80` } const FIELD_LABELS: Record = { name: "Name", tagline: "Untertitel", suffix: "Zusatz", role: "Rolle", eyebrow: "Überzeile", badge: "Hinweis", title: "Überschrift", text: "Text", lead: "Einleitung", note: "Kleingedrucktes", cta: "Button", ctaSecondary: "Zweiter Button", image: "Bild", imageSecondary: "Zweites Bild", imageThird: "Drittes Bild", src: "Bild", caption: "Bildunterschrift", price: "Preis", period: "Zeitraum", duration: "Dauer", author: "Von", quote: "Zitat", label: "Beschriftung", value: "Wert", time: "Uhrzeit", day: "Tag", location: "Ort", place: "Ort", year: "Jahr", phone: "Telefon", email: "E-Mail", address: "Adresse", hours: "Öffnungszeiten", category: "Kategorie", description: "Beschreibung", meta: "Details", features: "Merkmale (eine pro Zeile)", tasks: "Aufgaben (eine pro Zeile)", question: "Frage", answer: "Antwort", options: "Antworten (mit | trennen)", level: "Intensität (1–3)", trainer: "Coach", company: "Firma", speaker: "Speaker", room: "Raum", highlight: "Hervorheben (ja = hervorgehoben)", left: "Restplätze (leer = keine Anzeige)", area: "Bereich", type: "Art", rooms: "Zimmer", rating: "Bewertung", reviews: "Bewertungen (Text)", count: "Anzahl", number: "Nummer", open: "Öffnet (HH:MM, leer = geschlossen)", close: "Schließt (HH:MM)", deadline: "Aktionsende (JJJJ-MM-TT)", deadlineLabel: "Text vor dem Countdown", success: "Text nach dem Absenden", slots: "Uhrzeiten (mit Komma trennen)", roles: "Wechselnde Jobtitel (mit Komma trennen)", experience: "Erfahrungsstufen (mit Komma trennen)", bullets: "Vorteile (eine pro Zeile)", discount: "Rabatt bei jährlicher Zahlung (%)", button: "Button", dateLabel: "Datum (Anzeige)", date: "Startzeit (JJJJ-MM-TTTHH:MM)", before: "Beschriftung vorher", after: "Beschriftung nachher", closing: "Schlusssatz", signature: "Unterschrift", book: "Buchtitel", factor: "Ersparnis-Faktor (0 bis 1)", min: "Minimum (€)", max: "Maximum (€)", sqm: "Wohnfläche (m²)", instagram: "Instagram", km: "Kilometerstand", power: "Leistung", gearbox: "Getriebe", payload: "Nutzlast / zGG", condition: "Zustand", rate: "Zinssatz (% p. a.)", color: "Farbe (#rrggbb)", x: "Position waagerecht (%)", y: "Position senkrecht (%)", grade: "Pflegegrad", amount: "Betrag (€)", emergency: "Hinweis in der Notruf-Leiste", areas: "Einzugsgebiet (PLZ-Anfänge, mit Komma)", strip: "Bilder", variants: "Farbvarianten", sticker: "Sticker", stickerSecondary: "Zweiter Sticker", status: "Status (z. B. fast weg, ausverkauft)", handle: "Social-Handle", offer: "Hinweisleiste", oldPrice: "Alter Preis", breakdown: "Verteilung 5 bis 1 Sterne in % (mit Komma)", box: "Lieferumfang (eine pro Zeile)", city: "Stadt", distance: "Entfernung", score: "Bewertungsnote", guests: "Gäste", size: "Größe", subtitle: "Unterzeile", avatar: "Profilbild", bio: "Kurzbeschreibung", url: "Link", tag: "Kennzeichnung", tags: "Merkmale (z. B. vegan, scharf, glutenfrei)", allergens: "Allergene", table: "Tischnummer", wifi: "WLAN-Hinweis", short: "Kürzel (Wappen)", founded: "Gründungsjahr", away: "Gastmannschaft", awayShort: "Kürzel Gast", venue: "Spielort", league: "Liga / Spieltag", home: "Heim", team: "Verein", games: "Spiele", points: "Punkte", group: "Gruppe", training: "Training", logo: "Logo (Bild-Link; leer = Name als Text)", link: "Externer Link (z. B. mobile.de)", linkLabel: "Text für den Link", values: "Werte (mit Komma trennen)", steps: "Schritte (mit Komma trennen)", items: "Einträge", plans: "Tarife", members: "Personen", questions: "Fragen", milestones: "Meilensteine", facts: "Kennzahlen", } const TEXTAREA_KEYS = new Set(["title", "text", "lead", "quote", "description", "answer", "features", "tasks", "bullets"]) const IMAGE_KEY = /^(image|src|photo|portrait|avatar|logo)/ function humanize(key: string) { const spaced = key.replace(/([A-Z])/g, " $1").toLowerCase() return spaced.charAt(0).toUpperCase() + spaced.slice(1) } function describeField(key: string, sample: string): FieldDef { const label = FIELD_LABELS[key] ?? humanize(key) if (IMAGE_KEY.test(key)) return { key, label, type: "image" } if (key === "title") return { key, label, type: "textarea", hint: RICH_HINT } if (TEXTAREA_KEYS.has(key) || sample.includes("\n") || sample.length > 90) return { key, label, type: "textarea" } return { key, label } } type SectionMeta = string | { label: string; optional?: boolean; items?: Record } // Editor-Beschreibung aus den Standardinhalten ableiten; nur Sektionsnamen und Listen-Bezeichnungen sind nötig export function describeSections(defaults: D, meta: Record): SectionDef[] { return Object.entries(defaults).map(([key, section]) => { const info = typeof meta[key] === "string" ? { label: meta[key] as string } : (meta[key] as Exclude) const fields = Object.entries(section).map(([fieldKey, value]): FieldDef | ListFieldDef => { if (!Array.isArray(value)) return describeField(fieldKey, value) const shape = value[0] ?? {} return { key: fieldKey, label: FIELD_LABELS[fieldKey] ?? humanize(fieldKey), type: "list", itemLabel: info.items?.[fieldKey] ?? "Eintrag", fields: Object.entries(shape).map(([itemKey, sample]) => describeField(itemKey, sample)), } }) return { key, label: info.label, optional: info.optional, fields } }) } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } function text(value: unknown, fallback: string) { return typeof value === "string" ? value.slice(0, MAX_TEXT) : fallback } function mergeItem(shape: ListItem, input: unknown): ListItem { const source = isRecord(input) ? input : {} return Object.fromEntries(Object.keys(shape).map((key) => [key, text(source[key], "")])) } function mergeSection(defaults: SectionData, input: unknown): SectionData { const source = isRecord(input) ? input : {} const result: SectionData = {} for (const [key, fallback] of Object.entries(defaults)) { const value = source[key] if (Array.isArray(fallback)) { const shape = fallback[0] ?? {} result[key] = Array.isArray(value) ? value.slice(0, MAX_ITEMS).map((item) => mergeItem(shape, item)) : fallback } else { result[key] = text(value, fallback) } } return result } // Gespeicherte Inhalte immer gegen die aktuelle Vorlage auflösen: neue Felder bekommen ihren // Standardwert, entfernte oder fremde Felder fallen weg. So überstehen Varianten Änderungen an der Vorlage. export function resolveContent(def: TemplateDefinition, raw: unknown): TemplateContent { const source = isRecord(raw) ? raw : {} const data = isRecord(source.data) ? source.data : {} const optional = new Set(def.sections.filter((section) => section.optional).map((section) => section.key)) const hidden = Array.isArray(source.hidden) ? source.hidden.filter((key): key is string => typeof key === "string" && optional.has(key)) : [] const ai = isRecord(source.ai) ? source.ai : {} const colors = isRecord(source.colors) ? source.colors : {} const style = isRecord(source.style) ? source.style : {} const optionalKeys = def.sections.filter((section) => section.optional).map((section) => section.key) const savedOrder = Array.isArray(source.order) ? source.order.filter((key): key is string => typeof key === "string" && optional.has(key)) : [] const hex = (value: unknown) => (typeof value === "string" && HEX_COLOR.test(value) ? value : "") return { // Eigene Schlüssel der Vorlage oder "vorlage:schlüssel" aus der Bibliothek (aufgelöst in lib/theme.ts) palette: libraryKey(source.palette, def.palettes) ?? def.palettes[0].key, fonts: libraryKey(source.fonts, def.fonts) ?? def.fonts[0].key, colors: { bg: hex(colors.bg), fg: hex(colors.fg), muted: hex(colors.muted), surface: hex(colors.surface), // Früher gab es nur `accent` auf oberster Ebene accent: hex(colors.accent) || hex(source.accent), } satisfies ColorOverrides, hidden: [...new Set(hidden)], order: [...new Set([...savedOrder, ...optionalKeys])], style: { images: IMAGE_STYLES.includes(style.images as TemplateStyle["images"]) ? (style.images as TemplateStyle["images"]) : "original", motion: style.motion === "subtle" ? "subtle" : "full", } satisfies TemplateStyle, ai: { chat: ai.chat === true, phone: ai.phone === true, assistant: ai.assistant === true, content: ai.content === true, name: text(ai.name, "").slice(0, 60), greeting: text(ai.greeting, "").slice(0, 400), knowledge: typeof ai.knowledge === "string" ? ai.knowledge.slice(0, 8000) : "", } satisfies AiSettings, data: Object.fromEntries( Object.entries(def.defaults).map(([key, section]) => [key, mergeSection(section, data[key])]) ) as D, } } export function brandName(content: TemplateContent) { const name = content.data.brand?.name return typeof name === "string" && name.trim() ? name.trim() : null }