since3-portal/modules/templates/lib/content.ts
2026-10-10 02:38:50 +02:00

174 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<D extends TemplateData>(def: TemplateDefinition<D>) {
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<string, string> = {
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<string, string> }
// Editor-Beschreibung aus den Standardinhalten ableiten; nur Sektionsnamen und Listen-Bezeichnungen sind nötig
export function describeSections<D extends TemplateData>(defaults: D, meta: Record<keyof D & string, SectionMeta>): SectionDef[] {
return Object.entries(defaults).map(([key, section]) => {
const info = typeof meta[key] === "string" ? { label: meta[key] as string } : (meta[key] as Exclude<SectionMeta, string>)
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<string, unknown> {
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<D extends TemplateData>(def: TemplateDefinition<D>, raw: unknown): TemplateContent<D> {
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
}