]> juplo.de Git - website/commitdiff
Schritt 6: Layout-Migration — BaseLayout + Blog-Navigation
authorKai Moritz <kai.milan.moritz@googlemail.com>
Sat, 6 Jun 2026 07:50:57 +0000 (07:50 +0000)
committerKai Moritz <kai.milan.moritz@googlemail.com>
Sat, 6 Jun 2026 07:50:57 +0000 (07:50 +0000)
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 <noreply@anthropic.com>
src/components/BlogNav.astro [new file with mode: 0644]
src/components/Breadcrumb.astro [new file with mode: 0644]
src/components/PostMeta.astro [new file with mode: 0644]
src/components/TaxonomyAside.astro [new file with mode: 0644]
src/layouts/BaseLayout.astro [new file with mode: 0644]
src/pages/[slug].astro

diff --git a/src/components/BlogNav.astro b/src/components/BlogNav.astro
new file mode 100644 (file)
index 0000000..d4da282
--- /dev/null
@@ -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<string, CollectionEntry<'blog'>[]> = {};
+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<string, number>();
+const tagSet = new Map<string, number>();
+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;
+}
+---
+
+<nav id="nav">
+  <hr class="n"/>
+  <a class="hide" href="#top" title="Show Content">Jump back to the top of the page</a>
+  <h1 class="nav">Navigation</h1>
+  <h2 class="nav menu">Section-Menu</h2>
+  <ul id="menu" class="cf">
+    {mainSections.map(s => (
+      <li class={`m ${s.name}`}>
+        <a href={s.url} class={activeSection === s.name ? 'm selected' : 'm'}>{s.title}</a>
+      </li>
+    ))}
+  </ul>
+  <h2 class="nav submenu">
+    <a class="s selected" href="/">Home</a>
+  </h2>
+  <ul id="submenu" class="s active">
+    {/* Blog subtree */}
+    <li class="s sub">
+      <a href="/blog/" class={blogSelected ? 's selected' : 's'}>
+        {blogSelected ? <strong>Blog</strong> : 'Blog'}
+      </a>
+      <ul class={blogSelected ? 's active' : 's'}>
+        <li class="s sub">
+          <a href="/blog/archive/" class={onArchive ? 's selected' : 's'}>
+            {onArchive ? <strong>Archive</strong> : 'Archive'}
+          </a>
+          <ul class={onArchive ? 's active' : 's'}>
+            {years.map(year => {
+              const active = isActiveYear(year);
+              return (
+                <li class={`s${onBlog ? ' off' : ''}`}>
+                  <a href={`/blog/archive/${year}/`} class={active ? 's selected' : 's'}>
+                    {active ? <strong>{year}</strong> : year}
+                  </a>
+                  <ul class="s">
+                    {postsByYear[year].map(post => {
+                      const pUrl = postUrl(post);
+                      const current = pUrl === currentUrl;
+                      return (
+                        <li class="s nav-leaf">
+                          <a href={pUrl} class={current ? 's selected' : 's'}>
+                            {current ? <strong>{post.data.title}</strong> : post.data.title}
+                          </a>
+                        </li>
+                      );
+                    })}
+                  </ul>
+                </li>
+              );
+            })}
+          </ul>
+        </li>
+        {/* Categories subtree */}
+        {categories.length > 0 && (
+          <li class={`s${onBlog ? '' : ' off'}`}>
+            <a href="/blog/categories/" class="s">Categories</a>
+            <ul class="s">
+              {categories.map(cat => (
+                <li class="s nav-leaf">
+                  <a href={cat.url} class="s">{cat.displayName}</a>
+                </li>
+              ))}
+            </ul>
+          </li>
+        )}
+        {/* Tags subtree */}
+        {tags.length > 0 && (
+          <li class={`s${onBlog ? '' : ' off'}`}>
+            <a href="/blog/tags/" class="s">Tags</a>
+            <ul class="s">
+              {tags.map(tag => (
+                <li class="s nav-leaf">
+                  <a href={tag.url} class="s">{tag.displayName}</a>
+                </li>
+              ))}
+            </ul>
+          </li>
+        )}
+      </ul>
+    </li>
+    {/* Projects subtree — always off in blog context */}
+    <li class="s sub off">
+      <a href="/projects/" class="s">Projects</a>
+      <ul class="s"></ul>
+    </li>
+    {/* About subtree — always off in blog context */}
+    <li class="s sub off">
+      <a href="/about.html" class="s">About</a>
+      <ul class="s"></ul>
+    </li>
+  </ul>
+  <hr class="n"/>
+</nav>
diff --git a/src/components/Breadcrumb.astro b/src/components/Breadcrumb.astro
new file mode 100644 (file)
index 0000000..76f624f
--- /dev/null
@@ -0,0 +1,23 @@
+---
+interface Crumb {
+  title: string;
+  url?: string;
+}
+
+interface Props {
+  crumbs: Crumb[];
+}
+
+const { crumbs } = Astro.props;
+---
+<strong class="b title">You are here:</strong>
+<ol class="b">
+  {crumbs.map((crumb, i) => (
+    <li class="b">
+      {crumb.url
+        ? <a class="b" href={crumb.url}>{crumb.title}</a>
+        : <strong class="b">{crumb.title}</strong>
+      }
+    </li>
+  ))}
+</ol>
diff --git a/src/components/PostMeta.astro b/src/components/PostMeta.astro
new file mode 100644 (file)
index 0000000..c17a0e8
--- /dev/null
@@ -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;
+---
+<p><em>
+  <span title={date.toISOString()}>Posted on {dateStr}</span>
+  {wordCount && <>&nbsp;·&nbsp;<span>{wordCount} words</span></>}
+  {readingTime && <>&nbsp;·&nbsp;<span>{readingTime} min</span></>}
+</em></p>
diff --git a/src/components/TaxonomyAside.astro b/src/components/TaxonomyAside.astro
new file mode 100644 (file)
index 0000000..06848a7
--- /dev/null
@@ -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);
+---
+<aside class="m">
+  {categories.length > 0 && (
+    <>
+      <h1>Categories</h1>
+      <ul>
+        {categories.map(cat => (
+          <li><a href={cat.url}>{cat.displayName}</a> <span>{cat.count}</span></li>
+        ))}
+      </ul>
+      <a href={categoriesIndexUrl}>Show all Categories</a>
+    </>
+  )}
+  {tags.length > 0 && (
+    <>
+      <h1>Tags</h1>
+      <ul>
+        {tags.map(tag => (
+          <li><a href={tag.url}>{tag.displayName}</a> <span>{tag.count}</span></li>
+        ))}
+      </ul>
+      <a href={tagsIndexUrl}>Show all Tags</a>
+    </>
+  )}
+</aside>
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
new file mode 100644 (file)
index 0000000..b818166
--- /dev/null
@@ -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();
+---
+<!DOCTYPE html>
+<html lang={lang} dir="ltr" data-layout="classic">
+<head>
+  <meta charset="utf-8">
+  <script>try{document.documentElement.dataset.layout=localStorage.getItem('layout')||'classic'}catch(e){}</script>
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>{fullTitle}</title>
+  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+  <link rel="stylesheet" media="only screen" type="text/css" href="/css/screen.css">
+  <link rel="stylesheet" media="only screen" type="text/css" href="/css/resurrection.css">
+  <link rel="stylesheet" media="print" type="text/css" href="/css/print.css">
+  <link rel="canonical" href={resolvedCanonical}>
+  <script src="/js/main.js" crossorigin="anonymous"></script>
+</head>
+<body id="top" class="menu">
+  <div id="page" class="cf">
+    <header id="header">
+      <h1 id="logo">
+        <a href="/" title="Home" class="l">
+          <img class="l" src="/img/logo.svg" alt="Home"/>
+        </a>
+      </h1>
+      <span id="slogan"><strong>Java</strong> bits from nerds for nerds</span>
+      <hr class="h" />
+    </header>
+    <div id="breadcrumb">
+      <slot name="breadcrumb" />
+      <a class="hide" href="#nav" title="Show navigation menu">Jump to navigation</a>
+      <hr class="b" />
+    </div>
+    <main class="content cf">
+      <article id="content" class="main">
+        <slot name="article" />
+      </article>
+      <div class="marginal">
+        <slot name="menu" />
+        <slot name="marginalcontent" />
+      </div>
+    </main>
+    <footer id="footer">
+      <hr class="f" />
+      <ul id="footerlinks">
+        <li class="f" id="copyright">© <strong>mo</strong> {new Date().getFullYear()}</li>
+        <li id="layout-switcher">
+          <span class="layout-label">Layout: </span>
+          <a href="#" data-layout="resurrection">Resurrection</a>
+          |
+          <a href="#" data-layout="classic">Classic</a>
+          |
+          <a href="#" data-layout="none">None</a>
+        </li>
+        <slot name="footerlinks">
+          <li class="f"><a class="f" href="/impressum.html">Impressum</a></li>
+          <li class="f"><a class="f" href="/datenschutz.html">Datenschutz</a></li>
+        </slot>
+      </ul>
+    </footer>
+  </div>
+</body>
+</html>
index 1a2c4f895d34e35de2e6e3188d8c34a4863b0570..143e2ec744434e190bf429171d241c0fbee159ed 100644 (file)
@@ -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<string, number>();
+const tagCounts = new Map<string, number>();
+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' });
 ---
-<html lang="en">
-<head><meta charset="utf-8"><title>{post.data.title} | juplo</title></head>
-<body>
-  <article>
-    <h1>{post.data.title}</h1>
-    <p><em>Veröffentlicht: {dateStr}</em></p>
+
+<BaseLayout title={title} canonical={canonical}>
+  <Fragment slot="breadcrumb">
+    <Breadcrumb crumbs={crumbs} />
+  </Fragment>
+
+  <Fragment slot="article">
+    <header>
+      <h1>{title}</h1>
+    </header>
     <Content />
-  </article>
-  <p><a href="/blog/">← Blog</a></p>
-</body>
-</html>
+    <div>
+      <em>Written:</em>
+      <time datetime={dateIso}>{dateDisplay}</time>
+    </div>
+  </Fragment>
+
+  <Fragment slot="menu">
+    <BlogNav currentUrl={postUrl} />
+  </Fragment>
+
+  <Fragment slot="marginalcontent">
+    <TaxonomyAside
+      categories={postCategories}
+      tags={postTags}
+      categoriesIndexUrl="/blog/categories/"
+      tagsIndexUrl="/blog/tags/"
+    />
+  </Fragment>
+</BaseLayout>