From bb6ec20d456fe57344ee2f49a0d1abdd450e25c8 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Sun, 22 Feb 2026 12:14:43 +0100 Subject: [PATCH] Enhance project structure and functionality by adding new components and integrations. Updated .gitignore to exclude history and transformed images. Integrated Svelte, sitemap, and icon support in astro.config.mjs. Added multiple new components including BlogOverview, ContentRows, Footer, Header, and various block components for improved content management. Updated package.json with new dependencies and modified scripts for build and development processes. --- .gitignore | 4 + astro.config.mjs | 17 +- package.json | 21 +- src/components/BlogOverview.svelte | 160 + src/components/ContentRows.svelte | 136 + src/components/Footer.svelte | 50 + src/components/Header.svelte | 213 + src/components/HeaderOverlay.svelte | 17 + src/components/PostCard.svelte | 56 + src/components/PostMeta.svelte | 29 + src/components/RustyImage.astro | 44 + src/components/RustyImage.svelte | 29 + src/components/Search.svelte | 93 + src/components/Tag.svelte | 42 + src/components/Tags.svelte | 37 + src/components/TopBanner.svelte | 80 + src/components/blocks/HeadlineBlock.svelte | 25 + src/components/blocks/HtmlBlock.svelte | 16 + src/components/blocks/IframeBlock.svelte | 60 + src/components/blocks/ImageBlock.svelte | 48 + .../blocks/ImageGalleryBlock.svelte | 125 + src/components/blocks/LinkListBlock.svelte | 37 + src/components/blocks/MarkdownBlock.svelte | 35 + .../blocks/PostOverviewBlock.svelte | 48 + src/components/blocks/QuoteBlock.svelte | 19 + .../blocks/SearchableTextBlock.svelte | 207 + .../blocks/YoutubeVideoBlock.svelte | 33 + src/layouts/Layout.astro | 394 +- src/lib/block-layout.ts | 151 + src/lib/block-types.ts | 176 + src/lib/blog-utils.ts | 335 + src/lib/cms-api.generated.ts | 7514 ++++++++++++++++- src/lib/cms.ts | 472 +- src/lib/constants.ts | 32 + src/lib/iconify-offline.ts | 12 + src/lib/overlay-store.ts | 7 + src/lib/rusty-image.ts | 234 + src/pages/[slug].astro | 58 +- src/pages/blog/index.astro | 4 + src/pages/blog/page/[page].astro | 22 + src/pages/blog/tag/[tag].astro | 25 + src/pages/blog/tag/[tag]/page/[page].astro | 33 + src/pages/index.astro | 139 +- src/pages/p/[slug].astro | 16 + src/pages/post/[slug].astro | 126 + src/pages/posts/index.astro | 64 + src/pages/posts/page/[page].astro | 76 + src/pages/posts/tag/[tag].astro | 88 + src/pages/posts/tag/[tag]/page/[page].astro | 101 + src/pages/robots.txt.ts | 25 + src/styles/global.css | 271 +- yarn.lock | 659 +- 52 files changed, 12536 insertions(+), 179 deletions(-) create mode 100644 src/components/BlogOverview.svelte create mode 100644 src/components/ContentRows.svelte create mode 100644 src/components/Footer.svelte create mode 100644 src/components/Header.svelte create mode 100644 src/components/HeaderOverlay.svelte create mode 100644 src/components/PostCard.svelte create mode 100644 src/components/PostMeta.svelte create mode 100644 src/components/RustyImage.astro create mode 100644 src/components/RustyImage.svelte create mode 100644 src/components/Search.svelte create mode 100644 src/components/Tag.svelte create mode 100644 src/components/Tags.svelte create mode 100644 src/components/TopBanner.svelte create mode 100644 src/components/blocks/HeadlineBlock.svelte create mode 100644 src/components/blocks/HtmlBlock.svelte create mode 100644 src/components/blocks/IframeBlock.svelte create mode 100644 src/components/blocks/ImageBlock.svelte create mode 100644 src/components/blocks/ImageGalleryBlock.svelte create mode 100644 src/components/blocks/LinkListBlock.svelte create mode 100644 src/components/blocks/MarkdownBlock.svelte create mode 100644 src/components/blocks/PostOverviewBlock.svelte create mode 100644 src/components/blocks/QuoteBlock.svelte create mode 100644 src/components/blocks/SearchableTextBlock.svelte create mode 100644 src/components/blocks/YoutubeVideoBlock.svelte create mode 100644 src/lib/block-layout.ts create mode 100644 src/lib/block-types.ts create mode 100644 src/lib/blog-utils.ts create mode 100644 src/lib/constants.ts create mode 100644 src/lib/iconify-offline.ts create mode 100644 src/lib/overlay-store.ts create mode 100644 src/lib/rusty-image.ts create mode 100644 src/pages/blog/index.astro create mode 100644 src/pages/blog/page/[page].astro create mode 100644 src/pages/blog/tag/[tag].astro create mode 100644 src/pages/blog/tag/[tag]/page/[page].astro create mode 100644 src/pages/p/[slug].astro create mode 100644 src/pages/post/[slug].astro create mode 100644 src/pages/posts/index.astro create mode 100644 src/pages/posts/page/[page].astro create mode 100644 src/pages/posts/tag/[tag].astro create mode 100644 src/pages/posts/tag/[tag]/page/[page].astro create mode 100644 src/pages/robots.txt.ts 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 &&

} +

+ ) + } + +
+
+