since3-portal/modules/templates/lib/content.ts
2026-10-11 18:22:32 +02:00

216 lines
13 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, Block, BlockDefinition, ColorOverrides, FieldDef, ListFieldDef, ListItem, SectionData, SectionDef, TemplateContent, TemplateData, TemplateDefinition, TemplateStyle } from "./types"
const MAX_TEXT = 4000
const MAX_ITEMS = 40
const MAX_BLOCKS = 40
const HEX_COLOR = /^#[0-9a-f]{6}$/i
const LIBRARY_KEY = /^[a-z0-9]+:[a-z0-9]+$/
const BLOCK_ID = /^[a-z0-9-]{1,40}$/i
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)",
menu: "Menüpunkt (leer = nicht im Menü)", href: "Ziel des Buttons (#abschnitt, mailto:… oder https://…)",
hrefSecondary: "Ziel des zweiten Buttons", keywords: "Schlagworte (mit Komma trennen)", word: "Schriftzug (leer = Name)",
edition: "Kopfzeile", topics: "Themen zur Auswahl (mit Komma trennen)", secondary: "Zweite Zeile (leer = keine)",
ribbon: "Hervorhebung (z. B. Beliebt; leer = keine)",
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> }
// Felder einer Sektion aus ihren Standardinhalten; `items` benennt die Einträge von Listen
function describeFields(section: SectionData, items?: Record<string, string>): (FieldDef | ListFieldDef)[] {
return 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: items?.[fieldKey] ?? "Eintrag",
fields: Object.entries(shape).map(([itemKey, sample]) => describeField(itemKey, sample)),
}
})
}
// 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>)
return { key, label: info.label, optional: info.optional, fields: describeFields(section, info.items) }
})
}
// Baustein für den Baukasten; Felder werden wie bei describeSections aus den Standardinhalten abgeleitet.
// Listen brauchen mindestens einen Eintrag, er gibt die Felder neuer Einträge vor.
export function defineBlock<D extends SectionData>(block: Omit<BlockDefinition<D>, "fields"> & { items?: Record<string, string> }) {
const { items, ...rest } = block
return { ...rest, fields: describeFields(block.defaults, items) } satisfies BlockDefinition<D>
}
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
}
// Bausteine gegen die Bibliothek auflösen: unbekannte Typen fallen weg, Inhalte wie bei Sektionen.
// Ohne gespeicherte Liste (neue Variante) gilt die Startaufstellung der Vorlage.
function resolveBlocks(builder: NonNullable<TemplateDefinition["builder"]>, raw: unknown): Block[] {
const library = new Map(builder.library.map((block) => [block.type, block]))
const source: unknown[] = Array.isArray(raw) ? raw : builder.seed.map((seed, i) => ({ id: `b${i + 1}`, ...seed }))
const used = new Set<string>()
const blocks: Block[] = []
for (const item of source.slice(0, MAX_BLOCKS)) {
if (!isRecord(item)) continue
const block = typeof item.type === "string" ? library.get(item.type) : undefined
if (!block) continue
let id = typeof item.id === "string" && BLOCK_ID.test(item.id) && !used.has(item.id) ? item.id : ""
for (let n = blocks.length + 1; !id; n++) {
if (!used.has(`b${n}`)) id = `b${n}`
}
used.add(id)
blocks.push({ id, type: block.type, hidden: item.hidden === true, data: mergeSection(block.defaults, item.data) })
}
return blocks
}
// 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,
blocks: def.builder ? resolveBlocks(def.builder, source.blocks) : [],
}
}
export function brandName(content: TemplateContent) {
const name = content.data.brand?.name
return typeof name === "string" && name.trim() ? name.trim() : null
}