diff --git a/.gitignore b/.gitignore index 16d54bb..c234678 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ pnpm-debug.log* # jetbrains setting folder .idea/ + +.history/ +public/images/transformed/ +public/pagefind \ No newline at end of file diff --git a/astro.config.mjs b/astro.config.mjs index 508cbec..b1aa7b3 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,10 +1,25 @@ // @ts-check import { defineConfig } from 'astro/config'; - +import svelte from '@astrojs/svelte'; +import sitemap from '@astrojs/sitemap'; +import icon from 'astro-icon'; import tailwindcss from '@tailwindcss/vite'; // https://astro.build/config +// site wird für Sitemap und canonical/og-URLs genutzt (PUBLIC_SITE_URL oder Fallback) +const site = process.env.PUBLIC_SITE_URL || 'https://example.com'; + export default defineConfig({ + site, + integrations: [ + svelte(), + sitemap(), + icon({ + include: { + mdi: ['close', 'link', 'magnify', 'menu'], + }, + }), + ], vite: { plugins: [tailwindcss()] } diff --git a/package.json b/package.json index bd00e7c..86121ff 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,32 @@ "type": "module", "version": "0.0.1", "scripts": { - "dev": "astro dev", - "build": "astro build", + "dev": "yarn generate:api-types; astro dev", + "build": "yarn generate:api-types && astro build && pagefind --site dist && cp -r dist/pagefind public/", "preview": "astro preview", "astro": "astro", - "generate:api-types": "node scripts/generate-api-types.mjs" + "generate:api-types": "node scripts/generate-api-types.mjs", + "postinstall": "yarn generate:api-types || true" }, "dependencies": { + "@astrojs/sitemap": "^3.7.0", + "@astrojs/svelte": "^7.2.5", + "@fontsource-variable/lora": "^5.2.8", + "@fontsource-variable/rubik": "^5.2.8", + "@iconify/svelte": "^5.2.1", "@tailwindcss/vite": "^4.1.18", "astro": "^5.17.1", + "marked": "^17.0.2", + "svelte": "^5.51.2", "tailwindcss": "^4.1.18" }, "devDependencies": { - "openapi-typescript": "^7.4.0" + "@iconify-json/mdi": "^1.2.3", + "@types/node": "^22.10.0", + "astro-icon": "^1.1.5", + "openapi-typescript": "^7.4.0", + "pagefind": "^1.4.0", + "typescript": "^5.9.3" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/components/BlogOverview.svelte b/src/components/BlogOverview.svelte new file mode 100644 index 0000000..93d50a9 --- /dev/null +++ b/src/components/BlogOverview.svelte @@ -0,0 +1,160 @@ + + +
+ {#if tags.length > 0} +
+ +
+ {/if} + + {#if showPagination} +
+
+
+ {#if hasPrev} + + ← + + {/if} + {#each pageNumbers as num} + + {num} + + {/each} + {#if hasNext} + + → + + {/if} +
+
+
+ {/if} + +
+ {#each posts as post} + + {/each} +
+ +
+ {#if showPagination} +
+
+ {#if hasPrev} + + ← + + {/if} + {#each pageNumbers as num} + + {num} + + {/each} + {#if hasNext} + + → + + {/if} +
+
+ {/if} +
+
+
+ {#if totalCount > 0} + {posts.length} von {totalCount} Beiträgen, Seite {currentPage} von {totalPages} + {:else} + Seite {currentPage} von {totalPages} + {/if} +
+
+
+
diff --git a/src/components/ContentRows.svelte b/src/components/ContentRows.svelte new file mode 100644 index 0000000..c20c22f --- /dev/null +++ b/src/components/ContentRows.svelte @@ -0,0 +1,136 @@ + + +
+ {#each rows as row} + {#if row.content.length > 0} +
+ {#each row.content as item} + {#if isResolvedBlock(item)} + {#snippet blockContent()} + {#if blockType(item) === "markdown"} + + {:else if blockType(item) === "headline"} + + {:else if blockType(item) === "html"} + + {:else if blockType(item) === "iframe"} + + {:else if blockType(item) === "image"} + + {:else if blockType(item) === "image_gallery"} + + {:else if blockType(item) === "youtube_video"} + + {:else if blockType(item) === "quote"} + + {:else if blockType(item) === "link_list"} + + {:else if blockType(item) === "post_overview"} + + {:else if blockType(item) === "searchable_text"} + + {:else} +
+ Unbekannter Block: {blockType(item)} +
+ {/if} + {/snippet} + {#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)} +
+
+
+ {@render blockContent()} +
+
+
+ {:else} + {@render blockContent()} + {/if} + {/if} + {/each} +
+ {/if} + {/each} +
diff --git a/src/components/Footer.svelte b/src/components/Footer.svelte new file mode 100644 index 0000000..983dada --- /dev/null +++ b/src/components/Footer.svelte @@ -0,0 +1,50 @@ + + +{#if footer} + +{/if} diff --git a/src/components/Header.svelte b/src/components/Header.svelte new file mode 100644 index 0000000..beb3052 --- /dev/null +++ b/src/components/Header.svelte @@ -0,0 +1,213 @@ + + +
+
+ + + + + + +
+ + +
+
+ + + + + {#if menuOpen} + + {/if} +
diff --git a/src/components/HeaderOverlay.svelte b/src/components/HeaderOverlay.svelte new file mode 100644 index 0000000..9ee9851 --- /dev/null +++ b/src/components/HeaderOverlay.svelte @@ -0,0 +1,17 @@ + + +{#if $overlayOpen} + +{/if} diff --git a/src/components/PostCard.svelte b/src/components/PostCard.svelte new file mode 100644 index 0000000..fcb3dc9 --- /dev/null +++ b/src/components/PostCard.svelte @@ -0,0 +1,56 @@ + + + + {#if resolvedImg} + + {/if} +
+ +
+
+ {post.headline ?? post.linkName ?? post.slug ?? post._slug} +
+ {#if post.subheadline} +
+ {post.subheadline} +
+ {/if} + {#if post.excerpt} +

+ {post.excerpt} +

+ {/if} +
+
+
diff --git a/src/components/PostMeta.svelte b/src/components/PostMeta.svelte new file mode 100644 index 0000000..63405b6 --- /dev/null +++ b/src/components/PostMeta.svelte @@ -0,0 +1,29 @@ + + +
+ {#if date} + + {/if} +
+ +
+
diff --git a/src/components/RustyImage.astro b/src/components/RustyImage.astro new file mode 100644 index 0000000..2102c10 --- /dev/null +++ b/src/components/RustyImage.astro @@ -0,0 +1,44 @@ +--- +/** + * RustyImage: Bild-URL über RustyCMS /api/transform laden, in public speichern, lokal ausliefern. + * Nutzung: + * Unterstützt alle API-Transformationen: width, height, ar, fit, format, quality. + * Im Svelte-Kontext: RustyImage.svelte verwenden und dort den bereits aufgelösten Pfad als src übergeben. + */ +import { ensureTransformedImage } from '../lib/rusty-image'; + +interface Props { + /** Externe Bild-URL (z. B. Contentful). */ + src: string; + width?: number; + height?: number; + /** Aspect ratio, z. B. "1:1" oder "16:9". */ + ar?: string; + fit?: 'fill' | 'contain' | 'cover'; + format?: 'jpeg' | 'png' | 'webp' | 'avif'; + quality?: number; + alt?: string; + class?: string; + loading?: 'lazy' | 'eager'; +} + +const { src, width, height, ar, fit, format, quality, alt = '', class: className = '', loading = 'lazy' } = Astro.props; + +const resolvedSrc = await ensureTransformedImage(src, { + width, + height, + ar, + fit, + format, + quality, +}); +--- + +{alt} diff --git a/src/components/RustyImage.svelte b/src/components/RustyImage.svelte new file mode 100644 index 0000000..e594da0 --- /dev/null +++ b/src/components/RustyImage.svelte @@ -0,0 +1,29 @@ + + + diff --git a/src/components/Search.svelte b/src/components/Search.svelte new file mode 100644 index 0000000..0b7fece --- /dev/null +++ b/src/components/Search.svelte @@ -0,0 +1,93 @@ + + +{#if open} +
+
+
+ {#if !pagefindLoaded} +
+
+
+ {/if} +
+
+{/if} + + diff --git a/src/components/Tag.svelte b/src/components/Tag.svelte new file mode 100644 index 0000000..61b9800 --- /dev/null +++ b/src/components/Tag.svelte @@ -0,0 +1,42 @@ + + +{#if href} + {label} +{:else if onclick} + +{:else} + {label} +{/if} diff --git a/src/components/Tags.svelte b/src/components/Tags.svelte new file mode 100644 index 0000000..b4f5f7d --- /dev/null +++ b/src/components/Tags.svelte @@ -0,0 +1,37 @@ + + +{#each (tags ?? []) as t} + {@const name = displayName(t)} + {#if name} + + {/if} +{/each} diff --git a/src/components/TopBanner.svelte b/src/components/TopBanner.svelte new file mode 100644 index 0000000..6e15e59 --- /dev/null +++ b/src/components/TopBanner.svelte @@ -0,0 +1,80 @@ + + +{#if showBanner} +
+ {#if hasImage} +
+ {banner?.headline + {#if hasPageTitle} +
+
+ {#if headlineHtml} +

{@html headlineHtml}

+ {/if} +
+ {#if subheadlineHtml} +

{@html subheadlineHtml}

+ {/if} +
+
+ {/if} +
+ {:else if hasPageTitle} +
+ {#if headlineHtml} +

{@html headlineHtml}

+ {/if} + {#if subheadlineHtml} +

{@html subheadlineHtml}

+ {/if} +
+ {/if} + {#if hasText} +
+
+
{banner!.text}
+
+
+ {/if} +
+{/if} diff --git a/src/components/blocks/HeadlineBlock.svelte b/src/components/blocks/HeadlineBlock.svelte new file mode 100644 index 0000000..dbb2b66 --- /dev/null +++ b/src/components/blocks/HeadlineBlock.svelte @@ -0,0 +1,25 @@ + + +
+ + {block.text ?? ""} + +
diff --git a/src/components/blocks/HtmlBlock.svelte b/src/components/blocks/HtmlBlock.svelte new file mode 100644 index 0000000..6c38d41 --- /dev/null +++ b/src/components/blocks/HtmlBlock.svelte @@ -0,0 +1,16 @@ + + +
+ {@html block.html ?? ""} +
diff --git a/src/components/blocks/IframeBlock.svelte b/src/components/blocks/IframeBlock.svelte new file mode 100644 index 0000000..d953b8b --- /dev/null +++ b/src/components/blocks/IframeBlock.svelte @@ -0,0 +1,60 @@ + + +
+ {#if src} +
+ + {#if overlayActive} +
e.key === "Enter" && activateMap()} + > +
+ {/if} +
+ {:else} +

Iframe-URL fehlt oder ist ungültig.

+ {/if} +
diff --git a/src/components/blocks/ImageBlock.svelte b/src/components/blocks/ImageBlock.svelte new file mode 100644 index 0000000..572a34b --- /dev/null +++ b/src/components/blocks/ImageBlock.svelte @@ -0,0 +1,48 @@ + + +
+ {#if imageUrl} +
+ {block.caption + {#if block.caption} +
{block.caption}
+ {/if} +
+ {:else} +

Bild nicht verfügbar.

+ {/if} +
diff --git a/src/components/blocks/ImageGalleryBlock.svelte b/src/components/blocks/ImageGalleryBlock.svelte new file mode 100644 index 0000000..7bd2453 --- /dev/null +++ b/src/components/blocks/ImageGalleryBlock.svelte @@ -0,0 +1,125 @@ + + +
+ {#if descriptionHtml} +
+ {@html descriptionHtml} +
+ {/if} + + {#if withUrl.length === 0} +

Keine Bilder in der Galerie.

+ {:else if withUrl.length === 1} +
+ {withUrl[0].img.description + {#if withUrl[0].img.description} +
{withUrl[0].img.description}
+ {/if} +
+ {:else} +
+
+ {#key currentIndex} + {@const current = withUrl[currentIndex]} +
+ {current.img.description + {#if current.img.description} +
+ {current.img.description} +
+ {/if} +
+ {/key} +
+ + + + +
+ {#each withUrl as _, i} + + {/each} +
+
+ {/if} +
diff --git a/src/components/blocks/LinkListBlock.svelte b/src/components/blocks/LinkListBlock.svelte new file mode 100644 index 0000000..ad8a5dc --- /dev/null +++ b/src/components/blocks/LinkListBlock.svelte @@ -0,0 +1,37 @@ + + +
+ {#if block.headline} +

{block.headline}

+ {/if} + +
diff --git a/src/components/blocks/MarkdownBlock.svelte b/src/components/blocks/MarkdownBlock.svelte new file mode 100644 index 0000000..e64be6d --- /dev/null +++ b/src/components/blocks/MarkdownBlock.svelte @@ -0,0 +1,35 @@ + + +
+
+ {@html html} +
+
diff --git a/src/components/blocks/PostOverviewBlock.svelte b/src/components/blocks/PostOverviewBlock.svelte new file mode 100644 index 0000000..d8c2ebf --- /dev/null +++ b/src/components/blocks/PostOverviewBlock.svelte @@ -0,0 +1,48 @@ + + +
+ {#if block.headline} +

{block.headline}

+ {/if} + {#if textHtml} +
{@html textHtml}
+ {/if} + + {#if design === "list" && posts.length > 0} +
+ {#each posts as post} + + {/each} +
+ {:else if design === "cards" && posts.length > 0} +
+ {#each posts as post} + + {/each} +
+ {/if} +
diff --git a/src/components/blocks/QuoteBlock.svelte b/src/components/blocks/QuoteBlock.svelte new file mode 100644 index 0000000..a5aaaca --- /dev/null +++ b/src/components/blocks/QuoteBlock.svelte @@ -0,0 +1,19 @@ + + +
+
+

"{block.quote ?? ""}"

+ {#if block.author} + — {block.author} + {/if} +
+
+ diff --git a/src/components/blocks/SearchableTextBlock.svelte b/src/components/blocks/SearchableTextBlock.svelte new file mode 100644 index 0000000..f3658d5 --- /dev/null +++ b/src/components/blocks/SearchableTextBlock.svelte @@ -0,0 +1,207 @@ + + +
+
+ {#if block.title} +

{block.title}

+ {/if} + {#if descriptionHtml} +
{@html descriptionHtml}
+ {/if} +
+ +
+
+ + + Sie können nach Stichwörtern in Titeln und Inhalten suchen. Über die Schlagwörter darunter lassen sich die Einträge eingrenzen. + +
+
+ +
+
+
+ (searchQuery = (e.target as HTMLInputElement).value)} + aria-label="Suche in Titeln und Inhalten" + /> + {#if searchQuery} + + {/if} +
+ {#if allTags.length > 0} +
+
+ (selectedTag = "all")} + /> + {#each allTags as tag} + (selectedTag = tag)} + /> + {/each} +
+
+ {/if} +
+
+ +
+ {#if visibleFragments.length === 0} + Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort. + {:else} + + {visibleFragments.length === fragments.length + ? `${visibleFragments.length} Einträge` + : `${visibleFragments.length} von ${fragments.length} Einträgen`} + + {/if} +
+ +
+ {#each visibleFragments as fragment} +
+ +
{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}
+ {#if fragment.tags.length > 0} + + + + {/if} +
+
+ {@html highlightInHtml(fragment.textHtml, searchQuery.trim())} +
+
+ {/each} +
+
diff --git a/src/components/blocks/YoutubeVideoBlock.svelte b/src/components/blocks/YoutubeVideoBlock.svelte new file mode 100644 index 0000000..f4aa088 --- /dev/null +++ b/src/components/blocks/YoutubeVideoBlock.svelte @@ -0,0 +1,33 @@ + + +
+ {#if block.youtubeId} +
+ +
+ {#if block.description} +

{block.description}

+ {/if} + {:else} +

YouTube-Video-ID fehlt.

+ {/if} +
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 7463377..8a3b7bb 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -1,13 +1,340 @@ --- -import '../styles/global.css'; +import "../styles/global.css"; +import "../lib/iconify-offline"; +import Header from "../components/Header.svelte"; +import HeaderOverlay from "../components/HeaderOverlay.svelte"; +import Footer from "../components/Footer.svelte"; +import TopBanner from "../components/TopBanner.svelte"; +import { + getNavigationByKey, + NavigationKeys, + getPages, + getPosts, + getFooterBySlug, + getFullwidthBannerBySlug, + getPageConfigBySlug, +} from "../lib/cms"; +import type { NavLinkEntry, FullwidthBannerEntry } from "../lib/cms"; +import { resolveImageUrls, ensureTransformedImage } from "../lib/rusty-image"; +import { + DEFAULT_SOCIAL_IMAGE_URL, + ROW_RESOLVE, + SOCIAL_IMAGE_TRANSFORM, +} from "../lib/constants"; +import { marked } from "marked"; + +marked.setOptions({ gfm: true }); + +/** Aufgelöster Banner (Objekt mit image[]) oder Slug (string). */ +type TopBannerInput = FullwidthBannerEntry | string | null | undefined; + +function isResolvedBanner(v: TopBannerInput): v is FullwidthBannerEntry { + return typeof v === "object" && v !== null && "name" in v; +} interface Props { title?: string; description?: string; + /** Headline der aktuellen Page (wird im TopBanner angezeigt, falls gepflegt). */ + headline?: string; + /** Subheadline der aktuellen Page (Markdown). */ + subheadline?: string; + /** Fullwidth-Banner: aufgelöst (Objekt mit image[]) bei _resolve, sonst Slug. Nur wenn gesetzt → TopBanner anzeigen. */ + topFullwidthBanner?: TopBannerInput; + /** true = Layout zeigt TopBanner (Titel dort). false = kein TopBanner, Seite zeigt Titel im Content. */ + showBannerInLayout?: boolean; + /** Optional: absoluter oder relativer URL für og:image / twitter:image (z. B. Post-Bild). */ + image?: string | null; } -const { title = 'RustyAstro', description } = Astro.props; +const { + title = "RustyAstro", + description, + headline, + subheadline, + topFullwidthBanner, + showBannerInLayout = false, + image: imageProp = null, +} = Astro.props; + +// Header: Navigation laden und Links bauen +interface NavLink { + href: string; + label: string; +} +let headerLinks: NavLink[] = []; +/** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */ +function normalizeSlug(s: string | undefined): string { + return (s ?? "").replace(/^\//, "").trim(); +} +function getSlugFromEntry(entry: NavLinkEntry): string { + if (typeof entry === "string") return entry; + const e = entry as { _slug?: string; slug?: string }; + return e.slug ?? e._slug ?? ""; +} +try { + const nav = await getNavigationByKey(NavigationKeys.header, { + locale: "de", + resolve: "links", + }); + const rawLinks = nav?.links ?? []; + const [pages, posts] = await Promise.all([getPages(), getPosts()]); + const slugToLabel = new Map(); + /** Für Pages: Nav-Key (_slug oder slug) → kanonischer Slug für URL (ohne führenden Slash). */ + const slugToPageHref = new Map(); + for (const p of pages) { + const label = p.linkName ?? p.name ?? p.headline ?? p.slug ?? p._slug ?? ""; + const canonicalSlug = normalizeSlug(p.slug ?? p._slug) || "home"; + const keySlug = normalizeSlug(p.slug); + const keyUnderscore = normalizeSlug(p._slug); + if (keySlug) { + slugToLabel.set(keySlug, label); + slugToPageHref.set(keySlug, canonicalSlug); + } + if (keyUnderscore && keyUnderscore !== keySlug) { + slugToLabel.set(keyUnderscore, label); + slugToPageHref.set(keyUnderscore, canonicalSlug); + } + } + /** Für Posts: Nav-Key → kanonische URL /post/{slug} (slug = URL). */ + const slugToPostHref = new Map(); + for (const p of posts) { + const label = p.linkName ?? p.headline ?? p.slug ?? p._slug ?? ""; + const urlSlug = normalizeSlug(p.slug ?? p._slug); + const canonical = urlSlug ? `/post/${encodeURIComponent(urlSlug)}` : ""; + const keySlug = normalizeSlug(p.slug); + const keyUnderscore = normalizeSlug(p._slug); + if (keySlug) { + slugToLabel.set(keySlug, label); + slugToPostHref.set(keySlug, canonical); + } + if (keyUnderscore && keyUnderscore !== keySlug) { + slugToLabel.set(keyUnderscore, label); + slugToPostHref.set(keyUnderscore, canonical); + } + } + for (const entry of rawLinks) { + const asObj = + typeof entry === "object" && entry !== null + ? (entry as { url?: string; linkName?: string; name?: string }) + : null; + if (asObj?.url) { + const href = + asObj.url.startsWith("/") || asObj.url.startsWith("http") + ? asObj.url + : `/${asObj.url.replace(/^\//, "")}`; + const label = asObj.linkName ?? asObj.name ?? asObj.url ?? ""; + headerLinks.push({ href, label }); + continue; + } + const raw = getSlugFromEntry(entry); + const slug = normalizeSlug(raw) || "home"; + const label = slugToLabel.get(slug) ?? slug; + const postHref = slugToPostHref.get(slug); + const pageSlug = slugToPageHref.get(slug) ?? slug; + const hrefSlug = normalizeSlug(pageSlug) || "home"; + const href = + postHref ?? + (hrefSlug === "home" ? "/" : `/${encodeURIComponent(hrefSlug)}`); + headerLinks.push({ href, label }); + } +} catch { + // CMS nicht erreichbar +} + +// Social-Media-Links aus navigation-social-media (icon = iconify string) +interface SocialLink { + href: string; + icon: string; + label?: string; +} +let socialLinks: SocialLink[] = []; +try { + const socialNav = await getNavigationByKey(NavigationKeys.socialMedia, { + locale: "de", + resolve: "all", + }); + const rawSocialLinks = socialNav?.links ?? []; + for (const entry of rawSocialLinks) { + const asObj = + typeof entry === "object" && entry !== null + ? (entry as { + url?: string; + icon?: string; + linkName?: string; + name?: string; + }) + : null; + if (!asObj?.url) continue; + const href = + asObj.url.startsWith("/") || asObj.url.startsWith("http") + ? asObj.url + : `/${asObj.url.replace(/^\//, "")}`; + const icon = asObj.icon ?? "mdi:link"; + const label = asObj.linkName ?? asObj.name ?? ""; + socialLinks.push({ href, icon, label }); + } +} catch { + /* Social-Links optional */ +} + +// Footer laden (mit Row-Resolve) +let footerData: Awaited> = null; +try { + footerData = await getFooterBySlug("footer-main", { + locale: "de", + resolve: ROW_RESOLVE, + }); +} catch { + // CMS nicht erreichbar +} + +// Logo und SEO-Templates aus page_config (wie windwiderstand). +let logoUrl: string | null = null; +let pageConfig: Awaited> | null = null; +try { + // Zuerst page-config-default (häufiger Slug), dann default – vermeidet doppelten 404 + pageConfig = + (await getPageConfigBySlug("page-config-default", { + locale: "de", + resolve: "logo", + })) ?? + (await getPageConfigBySlug("default", { locale: "de", resolve: "logo" })); + const rawLogo = pageConfig?.logo; + const logoFileUrl = + typeof rawLogo === "string" && rawLogo.startsWith("http") + ? rawLogo + : (( + rawLogo as + | { src?: string; _slug?: string; file?: { url?: string } } + | undefined + )?.src ?? + (rawLogo as { _slug?: string; file?: { url?: string } } | undefined) + ?._slug ?? + (rawLogo as { file?: { url?: string } } | undefined)?.file?.url); + if (logoFileUrl) { + const u = logoFileUrl.startsWith("//") + ? `https:${logoFileUrl}` + : logoFileUrl; + logoUrl = await ensureTransformedImage(u, { + height: 56, + fit: "contain", + format: "webp", + }); + } +} catch { + /* Logo optional */ +} + +// SEO Title/Description: Page/Post-Wert + page_config-Template ($1 = Platzhalter, wie windwiderstand) +let seoTitle = title ?? "RustyAstro"; +let seoDescription = description ?? ""; +if (pageConfig?.seoTitle) + seoTitle = pageConfig.seoTitle.replace(/\$1/g, seoTitle); +if (pageConfig?.seoDescription) + seoDescription = pageConfig.seoDescription.replace(/\$1/g, seoDescription); + +// SEO: canonical und Social-Image (nach logoUrl) +// Lokale Entwicklung: echte Origin (localhost:4321); sonst PUBLIC_SITE_URL / site aus Config +const siteFromEnv = (import.meta.env.SITE || "").replace(/\/$/, ""); +const isLocalhost = + Astro.url.origin.startsWith("http://localhost") || + Astro.url.origin.startsWith("http://127.0.0.1"); +const baseUrl = isLocalhost ? Astro.url.origin : (siteFromEnv || Astro.url.origin); +const pathname = Astro.url.pathname || "/"; +const canonical = `${baseUrl}${pathname}`; + +// Social-Image immer über RustyImage (externe URLs transformieren; lokale Pfade unverändert nutzen) +const socialImageSource = imageProp ?? logoUrl ?? null; +const needsTransform = + !socialImageSource || + socialImageSource.startsWith("http://") || + socialImageSource.startsWith("https://"); +const urlToTransform = socialImageSource ?? DEFAULT_SOCIAL_IMAGE_URL; +let socialImageResolved: string | null = null; +if (needsTransform) { + try { + const transformed = await ensureTransformedImage( + urlToTransform, + SOCIAL_IMAGE_TRANSFORM, + ); + socialImageResolved = transformed.startsWith("/") + ? `${baseUrl}${transformed}` + : transformed; + } catch { + socialImageResolved = null; + } +} else { + // Bereits lokaler Pfad (z. B. von Post), nur baseUrl davor + socialImageResolved = `${baseUrl}${urlToTransform.startsWith("/") ? "" : "/"}${urlToTransform}`; +} +const socialImage = socialImageResolved ?? ""; + +// Top-Banner nur laden, wenn die Seite einen Banner anzeigen soll (showBannerInLayout + topFullwidthBanner) +let topBanner: FullwidthBannerEntry | null = null; +let topBannerResolvedImages: string[] = []; +if ( + showBannerInLayout && + topFullwidthBanner != null && + (typeof topFullwidthBanner === "string" ? topFullwidthBanner !== "" : true) +) { + try { + if (isResolvedBanner(topFullwidthBanner)) { + topBanner = topFullwidthBanner; + const bannerImages = Array.isArray(topBanner.image) + ? topBanner.image + : topBanner.image != null + ? [topBanner.image] + : []; + if (bannerImages.length > 0) { + topBannerResolvedImages = await resolveImageUrls(bannerImages, { + width: 2000, + height: 750, + fit: "cover", + format: "webp", + }); + } + } else if ( + typeof topFullwidthBanner === "string" && + topFullwidthBanner !== "" + ) { + topBanner = await getFullwidthBannerBySlug(topFullwidthBanner, { + locale: "de", + }); + const bannerImages = Array.isArray(topBanner?.image) + ? topBanner.image + : topBanner?.image != null + ? [topBanner.image] + : []; + if (bannerImages.length > 0) { + topBannerResolvedImages = await resolveImageUrls(bannerImages, { + width: 2000, + height: 750, + fit: "cover", + format: "webp", + }); + } + } + } catch { + // CMS nicht erreichbar oder kein Banner + } +} + +// Titelblock (Headline/Subheadline als Markdown), wenn kein Banner angezeigt wird +const showTitleInContent = + !showBannerInLayout && + ((headline != null && headline !== "") || + (subheadline != null && subheadline !== "")); +const titleHeadlineHtml = + showTitleInContent && headline?.trim() + ? (marked.parseInline(headline) as string) + : ""; +const titleSubheadlineHtml = + showTitleInContent && subheadline?.trim() + ? (marked.parseInline(subheadline) as string) + : ""; --- + @@ -16,10 +343,65 @@ const { title = 'RustyAstro', description } = Astro.props; - {title} - {description && } + {seoTitle} + {seoDescription && } + + + + + + + { + seoDescription && ( + + ) + } + {socialImage && } + + + + + { + seoDescription && ( + + ) + } + {socialImage && } + - - + +
+ + { + showBannerInLayout && + (topBanner != null || topBannerResolvedImages.length > 0) && ( + + ) + } +
+
+ { + showTitleInContent && (titleHeadlineHtml || titleSubheadlineHtml) && ( +
+ {titleHeadlineHtml &&

} + {titleSubheadlineHtml &&

} +

+ ) + } + +
+
+