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.
This commit is contained in:
+388
-6
@@ -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<string, string>();
|
||||
/** Für Pages: Nav-Key (_slug oder slug) → kanonischer Slug für URL (ohne führenden Slash). */
|
||||
const slugToPageHref = new Map<string, string>();
|
||||
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<string, string>();
|
||||
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<ReturnType<typeof getFooterBySlug>> = 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<ReturnType<typeof getPageConfigBySlug>> | 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)
|
||||
: "";
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
@@ -16,10 +343,65 @@ const { title = 'RustyAstro', description } = Astro.props;
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
{description && <meta name="description" content={description} />}
|
||||
<title>{seoTitle}</title>
|
||||
{seoDescription && <meta name="description" content={seoDescription} />}
|
||||
<link rel="canonical" href={canonical} />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:title" content={seoTitle} />
|
||||
{
|
||||
seoDescription && (
|
||||
<meta property="og:description" content={seoDescription} />
|
||||
)
|
||||
}
|
||||
{socialImage && <meta property="og:image" content={socialImage} />}
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content={canonical} />
|
||||
<meta name="twitter:title" content={seoTitle} />
|
||||
{
|
||||
seoDescription && (
|
||||
<meta name="twitter:description" content={seoDescription} />
|
||||
)
|
||||
}
|
||||
{socialImage && <meta name="twitter:image" content={socialImage} />}
|
||||
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<slot />
|
||||
<body class="flex min-h-screen flex-col text-zinc-900">
|
||||
<Header
|
||||
links={headerLinks}
|
||||
socialLinks={socialLinks}
|
||||
logoUrl={logoUrl}
|
||||
client:load
|
||||
/>
|
||||
<HeaderOverlay client:load />
|
||||
{
|
||||
showBannerInLayout &&
|
||||
(topBanner != null || topBannerResolvedImages.length > 0) && (
|
||||
<TopBanner
|
||||
banner={topBanner}
|
||||
resolvedImages={topBannerResolvedImages}
|
||||
headline={headline}
|
||||
subheadline={subheadline}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<main class="flex-1 min-h-[60em]">
|
||||
<div class="container-custom pb-12">
|
||||
{
|
||||
showTitleInContent && (titleHeadlineHtml || titleSubheadlineHtml) && (
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
{titleHeadlineHtml && <h1 set:html={titleHeadlineHtml} />}
|
||||
{titleSubheadlineHtml && <h2 set:html={titleSubheadlineHtml} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<Footer footer={footerData} />
|
||||
<script src="/pagefind/pagefind-ui.js" is:inline />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user