From a5dd8b4cda1a90214cc1ac3d7c665d4a8f9cc690 Mon Sep 17 00:00:00 2001 From: Kai Moritz Date: Sat, 6 Jun 2026 07:50:57 +0000 Subject: [PATCH] =?utf8?q?Schritt=206:=20Layout-Migration=20=E2=80=94=20Ba?= =?utf8?q?seLayout=20+=20Blog-Navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Astro-native Umsetzung des Hugo-Layouts (identisches HTML-Markup): BaseLayout.astro: - Gerüst: #page > header#header, #breadcrumb, main.content.cf, footer#footer - Inline-Script für Layout-Switcher (Classic/Resurrection/None via localStorage) - Named Slots: breadcrumb, article, menu, marginalcontent, footerlinks BlogNav.astro: - Exakt gleiches Markup wie menu/blog.html (inkl. CSS-Klassen): - Section-Menu: m + m-selected auf aktivem Abschnitt - Submenu: s, sub, off, selected, nav-leaf identisch zu Hugo - Blog-Post-URLs zählen als "im Archiv" (off-Verhalten korrekt) - Categories/Tags mit off-Klasse auf Post-Seiten Breadcrumb.astro, TaxonomyAside.astro, PostMeta.astro als eigenständige Komponenten. src/pages/[slug].astro nutzt das vollständige Layout. HTML-Vergleich mit Hugo-Referenz bestanden (bidirectional-association-post). Co-Authored-By: Claude Sonnet 4.6 --- src/components/BlogNav.astro | 171 +++++++++++++++++++++++++++++ src/components/Breadcrumb.astro | 23 ++++ src/components/PostMeta.astro | 16 +++ src/components/TaxonomyAside.astro | 43 ++++++++ src/layouts/BaseLayout.astro | 72 ++++++++++++ src/pages/[slug].astro | 85 +++++++++++--- 6 files changed, 396 insertions(+), 14 deletions(-) create mode 100644 src/components/BlogNav.astro create mode 100644 src/components/Breadcrumb.astro create mode 100644 src/components/PostMeta.astro create mode 100644 src/components/TaxonomyAside.astro create mode 100644 src/layouts/BaseLayout.astro diff --git a/src/components/BlogNav.astro b/src/components/BlogNav.astro new file mode 100644 index 00000000..d4da2825 --- /dev/null +++ b/src/components/BlogNav.astro @@ -0,0 +1,171 @@ +--- +import { getCollection } from 'astro:content'; +import type { CollectionEntry } from 'astro:content'; + +interface Props { + currentUrl: string; +} + +const { currentUrl } = Astro.props; + +const allPosts = (await getCollection('blog', ({ data }) => !data.draft)) + .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); + +// Group posts by year, years sorted descending +const postsByYear: Record[]> = {}; +for (const post of allPosts) { + const year = post.data.date.getFullYear().toString(); + if (!postsByYear[year]) postsByYear[year] = []; + postsByYear[year].push(post); +} +const years = Object.keys(postsByYear).sort((a, b) => Number(b) - Number(a)); + +// Collect unique categories and tags from all posts +const categorySet = new Map(); +const tagSet = new Map(); +for (const post of allPosts) { + for (const cat of post.data.categories ?? []) { + categorySet.set(cat, (categorySet.get(cat) ?? 0) + 1); + } + for (const tag of post.data.tags ?? []) { + tagSet.set(tag, (tagSet.get(tag) ?? 0) + 1); + } +} + +const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); +const categories = [...categorySet.entries()].map(([name, count]) => ({ + name, + displayName: capitalize(name), + url: `/categories/${name}/`, + count, +})).sort((a, b) => a.displayName.localeCompare(b.displayName)); +const tags = [...tagSet.entries()].map(([name, count]) => ({ + name, + displayName: capitalize(name), + url: `/tags/${name}/`, + count, +})).sort((a, b) => a.displayName.localeCompare(b.displayName)); + +const mainSections = [ + { name: 'blog', url: '/blog/', title: 'Blog' }, + { name: 'projects', url: '/projects/', title: 'Projects' }, + { name: 'about', url: '/about.html', title: 'About' }, +]; + +const onBlog = currentUrl === '/blog/'; +// A blog post URL (flat, not /blog/...) still counts as "in archive" +const isOnBlogPost = allPosts.some(p => (p.data.url ?? `/${p.id}/`) === currentUrl); +const onArchive = currentUrl.startsWith('/blog/archive/') || isOnBlogPost; +const blogSelected = onBlog || onArchive; + +// Determine active section +const activeSection = mainSections.find(s => { + if (s.name === 'blog') return blogSelected; + return currentUrl.startsWith(s.url); +})?.name ?? ''; + +// Helper: get post url +function postUrl(post: CollectionEntry<'blog'>) { + return post.data.url ?? `/${post.id}/`; +} + +// Determine active year from currentUrl +function isActiveYear(year: string) { + return currentUrl === `/blog/archive/${year}/` || + postsByYear[year]?.some(p => postUrl(p) === currentUrl) === true; +} +--- + + diff --git a/src/components/Breadcrumb.astro b/src/components/Breadcrumb.astro new file mode 100644 index 00000000..76f624f0 --- /dev/null +++ b/src/components/Breadcrumb.astro @@ -0,0 +1,23 @@ +--- +interface Crumb { + title: string; + url?: string; +} + +interface Props { + crumbs: Crumb[]; +} + +const { crumbs } = Astro.props; +--- +You are here: +
    + {crumbs.map((crumb, i) => ( +
  1. + {crumb.url + ? {crumb.title} + : {crumb.title} + } +
  2. + ))} +
diff --git a/src/components/PostMeta.astro b/src/components/PostMeta.astro new file mode 100644 index 00000000..c17a0e81 --- /dev/null +++ b/src/components/PostMeta.astro @@ -0,0 +1,16 @@ +--- +interface Props { + date: Date; + wordCount?: number; +} + +const { date, wordCount } = Astro.props; +const dateStr = date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); +const dateIso = date.toISOString().replace(/\.\d{3}Z$/, '+00:00'); +const readingTime = wordCount ? Math.ceil(wordCount / 200) : undefined; +--- +

+ Posted on {dateStr} + {wordCount && <> Â· {wordCount} words} + {readingTime && <> Â· {readingTime} min} +

diff --git a/src/components/TaxonomyAside.astro b/src/components/TaxonomyAside.astro new file mode 100644 index 00000000..06848a79 --- /dev/null +++ b/src/components/TaxonomyAside.astro @@ -0,0 +1,43 @@ +--- +interface TaxonomyEntry { + name: string; + displayName: string; + url: string; + count: number; +} + +interface Props { + categories: TaxonomyEntry[]; + tags: TaxonomyEntry[]; + categoriesIndexUrl: string; + tagsIndexUrl: string; +} + +const { categories, tags, categoriesIndexUrl, tagsIndexUrl } = Astro.props; + +const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); +--- + diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 00000000..b818166d --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -0,0 +1,72 @@ +--- +interface Props { + title: string; + canonical?: string; + lang?: string; +} + +const { title, canonical, lang = 'en-US' } = Astro.props; +const siteTitle = 'juplo'; +const fullTitle = title === siteTitle ? siteTitle : `${title} | ${siteTitle}`; +const resolvedCanonical = canonical ?? new URL(Astro.url.pathname, Astro.site).toString(); +--- + + + + + + + {fullTitle} + + + + + + + + +
+ + +
+
+ +
+
+ + +
+
+ +
+ + diff --git a/src/pages/[slug].astro b/src/pages/[slug].astro index 1a2c4f89..143e2ec7 100644 --- a/src/pages/[slug].astro +++ b/src/pages/[slug].astro @@ -1,10 +1,13 @@ --- import { getCollection, render } from 'astro:content'; +import BaseLayout from '../layouts/BaseLayout.astro'; +import Breadcrumb from '../components/Breadcrumb.astro'; +import BlogNav from '../components/BlogNav.astro'; +import TaxonomyAside from '../components/TaxonomyAside.astro'; export async function getStaticPaths() { const posts = await getCollection('blog', ({ data }) => !data.draft); return posts.map(post => { - // url field is like "/my-post/" — strip slashes to get slug const slug = (post.data.url ?? `/${post.id}/`).replace(/^\/|\/$/g, ''); return { params: { slug }, props: { post } }; }); @@ -12,18 +15,72 @@ export async function getStaticPaths() { const { post } = Astro.props; const { Content } = await render(post); -const dateStr = post.data.date.toLocaleDateString('de-DE', { - day: '2-digit', month: '2-digit', year: 'numeric' -}); +const { title, date, url, categories = [], tags = [] } = post.data; +const postUrl = url ?? `/${post.id}/`; +const year = date.getFullYear().toString(); + +const canonical = new URL(postUrl, Astro.site).toString(); + +const crumbs = [ + { title: 'Home', url: '/' }, + { title: 'Blog', url: '/blog/' }, + { title: 'Archive', url: '/blog/archive/' }, + { title: year, url: `/blog/archive/${year}/` }, + { title }, +]; + +const allPosts = await getCollection('blog', ({ data }) => !data.draft); +const categoryCounts = new Map(); +const tagCounts = new Map(); +for (const p of allPosts) { + for (const c of p.data.categories ?? []) categoryCounts.set(c, (categoryCounts.get(c) ?? 0) + 1); + for (const t of p.data.tags ?? []) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1); +} +const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +const postCategories = categories.map(c => ({ + name: c, + displayName: capitalize(c), + url: `/categories/${c}/`, + count: categoryCounts.get(c) ?? 0, +})); +const postTags = tags.map(t => ({ + name: t, + displayName: capitalize(t), + url: `/tags/${t}/`, + count: tagCounts.get(t) ?? 0, +})); + +const dateIso = date.toISOString().replace(/\.\d{3}Z$/, '+00:00'); +const dateDisplay = date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); --- - -{post.data.title} | juplo - -
-

{post.data.title}

-

Veröffentlicht: {dateStr}

+ + + + + + + +
+

{title}

+
-
-

← Blog

- - +
+ Written: + +
+ + + + + + + + + + -- 2.39.5