]> juplo.de Git - website/commitdiff
Step 8: Taxonomie-Seiten (Kategorien und Tags)
authorKai Moritz <kai.milan.moritz@googlemail.com>
Sat, 6 Jun 2026 10:45:54 +0000 (10:45 +0000)
committerKai Moritz <kai.milan.moritz@googlemail.com>
Sat, 6 Jun 2026 10:45:54 +0000 (10:45 +0000)
Vier neue Seiten für das Blog-Taxonomie-System:
- /blog/categories/ — Übersicht aller Kategorien
- /categories/[term]/ — Artikel einer Kategorie (mit Wortanzahl/Lesezeit)
- /blog/tags/ — Übersicht aller Tags
- /tags/[term]/ — Artikel eines Tags (mit Wortanzahl/Lesezeit)

Zugleich BlogNav überarbeitet: korrekte active/off-Zustände für
Taxonomy-Kontext (Archiv-Subtree wird nur bei onArchive/onBlog gerendert,
Category/Tag-Items erhalten nav-leaf-Klasse wenn nicht im Taxonomy-Kontext).

Entspricht Hugos categories/taxonomy.html und categories/term.html Layouts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
src/components/BlogNav.astro
src/pages/blog/categories/index.astro [new file with mode: 0644]
src/pages/blog/tags/index.astro [new file with mode: 0644]
src/pages/categories/[term].astro [new file with mode: 0644]
src/pages/tags/[term].astro [new file with mode: 0644]

index d4da282514656ef6a06631edce6d1858847ece11..fa44f53021a42d2484610f50d8b317a5466a8876 100644 (file)
@@ -52,11 +52,29 @@ const mainSections = [
   { name: 'about', url: '/about.html', title: 'About' },
 ];
 
+// URL classification
 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;
+
+const onCategoriesIndex = currentUrl === '/blog/categories/';
+const onCategoryTerm = currentUrl.startsWith('/categories/');
+const onTagsIndex = currentUrl === '/blog/tags/';
+const onTagTerm = currentUrl.startsWith('/tags/');
+
+const blogSelected = onBlog || onArchive || onCategoriesIndex || onCategoryTerm || onTagsIndex || onTagTerm;
+
+// Active state flags
+const categoriesSelected = onCategoriesIndex || onCategoryTerm;
+const tagsSelected = onTagsIndex || onTagTerm;
+
+// "off" class logic derived from Hugo nav output:
+// On archive pages: Categories and Tags are off; Archive is normal (selected)
+// On category term: Archive and Tags are off
+// On tag term: Archive and Categories are off
+const archiveOff = onCategoryTerm || onTagTerm;
+const categoriesOff = onArchive || onTagTerm;
+const tagsOff = onArchive || onCategoryTerm;
 
 // Determine active section
 const activeSection = mainSections.find(s => {
@@ -74,6 +92,10 @@ function isActiveYear(year: string) {
   return currentUrl === `/blog/archive/${year}/` ||
     postsByYear[year]?.some(p => postUrl(p) === currentUrl) === true;
 }
+
+// Active taxonomy term from URL
+const activeCatSlug = onCategoryTerm ? currentUrl.replace(/^\/categories\//, '').replace(/\/$/, '') : '';
+const activeTagSlug = onTagTerm ? currentUrl.replace(/^\/tags\//, '').replace(/\/$/, '') : '';
 ---
 
 <nav id="nav">
@@ -98,60 +120,99 @@ function isActiveYear(year: string) {
         {blogSelected ? <strong>Blog</strong> : 'Blog'}
       </a>
       <ul class={blogSelected ? 's active' : 's'}>
-        <li class="s sub">
+        {/* Archive — only render subtree on blog/archive pages */}
+        <li class={`s sub${archiveOff ? ' off' : ''}`}>
           <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>
+          {(onArchive || onBlog) && (
+            <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 class={`s${categoriesOff ? ' off' : ''}${onCategoryTerm ? ' sub' : ''}`}>
+            <a href="/blog/categories/" class={categoriesSelected ? 's selected' : 's'}>
+              {categoriesSelected ? <strong>Categories</strong> : 'Categories'}
+            </a>
+            {onCategoryTerm && (
+              <ul class="s active">
+                {categories.map(cat => {
+                  const active = cat.name === activeCatSlug;
+                  return (
+                    <li class="s">
+                      <a href={cat.url} class={active ? 's selected' : 's'}>
+                        {active ? <strong>{cat.displayName}</strong> : cat.displayName}
+                      </a>
+                    </li>
+                  );
+                })}
+              </ul>
+            )}
+            {!onCategoriesIndex && !onCategoryTerm && (
+              <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 class={`s${tagsOff ? ' off' : ''}${onTagTerm ? ' sub' : ''}`}>
+            <a href="/blog/tags/" class={tagsSelected ? 's selected' : 's'}>
+              {tagsSelected ? <strong>Tags</strong> : 'Tags'}
+            </a>
+            {onTagTerm && (
+              <ul class="s active">
+                {tags.map(tag => {
+                  const active = tag.name === activeTagSlug;
+                  return (
+                    <li class="s">
+                      <a href={tag.url} class={active ? 's selected' : 's'}>
+                        {active ? <strong>{tag.displayName}</strong> : tag.displayName}
+                      </a>
+                    </li>
+                  );
+                })}
+              </ul>
+            )}
+            {!onTagsIndex && !onTagTerm && (
+              <ul class="s">
+                {tags.map(tag => (
+                  <li class="s nav-leaf">
+                    <a href={tag.url} class="s">{tag.displayName}</a>
+                  </li>
+                ))}
+              </ul>
+            )}
           </li>
         )}
       </ul>
diff --git a/src/pages/blog/categories/index.astro b/src/pages/blog/categories/index.astro
new file mode 100644 (file)
index 0000000..64d7e5f
--- /dev/null
@@ -0,0 +1,52 @@
+---
+import { getCollection } from 'astro:content';
+import BaseLayout from '../../../layouts/BaseLayout.astro';
+import Breadcrumb from '../../../components/Breadcrumb.astro';
+import BlogNav from '../../../components/BlogNav.astro';
+
+const currentUrl = '/blog/categories/';
+
+const posts = await getCollection('blog', ({ data }) => !data.draft);
+
+const categoryMap = new Map<string, number>();
+for (const post of posts) {
+  for (const c of post.data.categories ?? []) {
+    categoryMap.set(c, (categoryMap.get(c) ?? 0) + 1);
+  }
+}
+const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
+const categories = [...categoryMap.entries()]
+  .map(([name, count]) => ({ name, displayName: capitalize(name), count }))
+  .sort((a, b) => a.displayName.localeCompare(b.displayName));
+
+const crumbs = [
+  { title: 'Home', url: '/' },
+  { title: 'Blog', url: '/blog/' },
+  { title: 'Categories' },
+];
+---
+
+<BaseLayout title="Blog - Categories">
+  <Fragment slot="breadcrumb">
+    <Breadcrumb crumbs={crumbs} />
+  </Fragment>
+
+  <Fragment slot="article">
+    <header>
+      <h1>Blog - Categories</h1>
+    </header>
+    <ul class="t">
+      {categories.map(cat => (
+        <li>
+          <a href={`/categories/${cat.name}/`}>
+            <span class="title">{cat.displayName}</span><span class="sep"> | </span><span class="count">{cat.count}</span>
+          </a>
+        </li>
+      ))}
+    </ul>
+  </Fragment>
+
+  <Fragment slot="menu">
+    <BlogNav currentUrl={currentUrl} />
+  </Fragment>
+</BaseLayout>
diff --git a/src/pages/blog/tags/index.astro b/src/pages/blog/tags/index.astro
new file mode 100644 (file)
index 0000000..6a27f52
--- /dev/null
@@ -0,0 +1,52 @@
+---
+import { getCollection } from 'astro:content';
+import BaseLayout from '../../../layouts/BaseLayout.astro';
+import Breadcrumb from '../../../components/Breadcrumb.astro';
+import BlogNav from '../../../components/BlogNav.astro';
+
+const currentUrl = '/blog/tags/';
+
+const posts = await getCollection('blog', ({ data }) => !data.draft);
+
+const tagMap = new Map<string, number>();
+for (const post of posts) {
+  for (const t of post.data.tags ?? []) {
+    tagMap.set(t, (tagMap.get(t) ?? 0) + 1);
+  }
+}
+const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
+const tags = [...tagMap.entries()]
+  .map(([name, count]) => ({ name, displayName: capitalize(name), count }))
+  .sort((a, b) => a.displayName.localeCompare(b.displayName));
+
+const crumbs = [
+  { title: 'Home', url: '/' },
+  { title: 'Blog', url: '/blog/' },
+  { title: 'Tags' },
+];
+---
+
+<BaseLayout title="Blog - Tags">
+  <Fragment slot="breadcrumb">
+    <Breadcrumb crumbs={crumbs} />
+  </Fragment>
+
+  <Fragment slot="article">
+    <header>
+      <h1>Blog - Tags</h1>
+    </header>
+    <ul class="t">
+      {tags.map(tag => (
+        <li>
+          <a href={`/tags/${tag.name}/`}>
+            <span class="title">{tag.displayName}</span><span class="sep"> | </span><span class="count">{tag.count}</span>
+          </a>
+        </li>
+      ))}
+    </ul>
+  </Fragment>
+
+  <Fragment slot="menu">
+    <BlogNav currentUrl={currentUrl} />
+  </Fragment>
+</BaseLayout>
diff --git a/src/pages/categories/[term].astro b/src/pages/categories/[term].astro
new file mode 100644 (file)
index 0000000..daf1693
--- /dev/null
@@ -0,0 +1,93 @@
+---
+import { getCollection } from 'astro:content';
+import BaseLayout from '../../layouts/BaseLayout.astro';
+import Breadcrumb from '../../components/Breadcrumb.astro';
+import BlogNav from '../../components/BlogNav.astro';
+
+export async function getStaticPaths() {
+  const allPosts = await getCollection('blog', ({ data }) => !data.draft);
+  const termMap = new Map<string, typeof allPosts>();
+  for (const post of allPosts) {
+    for (const cat of post.data.categories ?? []) {
+      if (!termMap.has(cat)) termMap.set(cat, []);
+      termMap.get(cat)!.push(post);
+    }
+  }
+  return [...termMap.entries()].map(([term, posts]) => ({
+    params: { term },
+    props: {
+      posts: posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()),
+    },
+  }));
+}
+
+const { term } = Astro.params;
+const { posts } = Astro.props;
+
+const currentUrl = `/categories/${term}/`;
+const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
+const termDisplay = capitalize(term);
+
+function postUrl(post: any): string {
+  return post.data.url ?? `/${post.id}/`;
+}
+
+function wordCount(body: string): number {
+  return body.replace(/```[\s\S]*?```/g, '').split(/\s+/).filter(w => w.length > 0).length;
+}
+
+const crumbs = [
+  { title: 'Home', url: '/' },
+  { title: 'Blog', url: '/blog/' },
+  { title: 'Categories', url: '/blog/categories/' },
+  { title: termDisplay },
+];
+---
+
+<BaseLayout title={termDisplay}>
+  <Fragment slot="breadcrumb">
+    <Breadcrumb crumbs={crumbs} />
+  </Fragment>
+
+  <Fragment slot="article">
+    <header>
+      <h1>
+        <a href={`/categories/${term}/index.xml`} title="RSS" aria-label="RSS">
+          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"
+            stroke-linecap="round" stroke-linejoin="round" height="23">
+            <path d="M4 11a9 9 0 0 1 9 9" />
+            <path d="M4 4a16 16 0 0 1 16 16" />
+            <circle cx="5" cy="19" r="1" />
+          </svg>
+          Categories: {termDisplay}
+        </a>
+      </h1>
+    </header>
+    {posts.map(post => {
+      const url = postUrl(post);
+      const date = post.data.date;
+      const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
+      const dateIso = date.toISOString();
+      const wc = wordCount(post.body ?? '');
+      const readTime = Math.ceil(wc / 200);
+      return (
+        <div class="archive-entry">
+          <h3 class="archive-entry-title entry-hint-parent">
+            <a class="entry-link" aria-label={`post link to ${post.data.title}`} href={url}>{post.data.title}</a>
+          </h3>
+          <div class="archive-meta">
+            <p><em>
+              <span title={dateIso}>Posted on {dateStr}</span>
+              &nbsp;·&nbsp;<span>{wc} words</span>
+              &nbsp;·&nbsp;<span>{readTime} min</span>
+            </em></p>
+          </div>
+        </div>
+      );
+    })}
+  </Fragment>
+
+  <Fragment slot="menu">
+    <BlogNav currentUrl={currentUrl} />
+  </Fragment>
+</BaseLayout>
diff --git a/src/pages/tags/[term].astro b/src/pages/tags/[term].astro
new file mode 100644 (file)
index 0000000..0ee71e5
--- /dev/null
@@ -0,0 +1,93 @@
+---
+import { getCollection } from 'astro:content';
+import BaseLayout from '../../layouts/BaseLayout.astro';
+import Breadcrumb from '../../components/Breadcrumb.astro';
+import BlogNav from '../../components/BlogNav.astro';
+
+export async function getStaticPaths() {
+  const allPosts = await getCollection('blog', ({ data }) => !data.draft);
+  const termMap = new Map<string, typeof allPosts>();
+  for (const post of allPosts) {
+    for (const tag of post.data.tags ?? []) {
+      if (!termMap.has(tag)) termMap.set(tag, []);
+      termMap.get(tag)!.push(post);
+    }
+  }
+  return [...termMap.entries()].map(([term, posts]) => ({
+    params: { term },
+    props: {
+      posts: posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()),
+    },
+  }));
+}
+
+const { term } = Astro.params;
+const { posts } = Astro.props;
+
+const currentUrl = `/tags/${term}/`;
+const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
+const termDisplay = capitalize(term);
+
+function postUrl(post: any): string {
+  return post.data.url ?? `/${post.id}/`;
+}
+
+function wordCount(body: string): number {
+  return body.replace(/```[\s\S]*?```/g, '').split(/\s+/).filter(w => w.length > 0).length;
+}
+
+const crumbs = [
+  { title: 'Home', url: '/' },
+  { title: 'Blog', url: '/blog/' },
+  { title: 'Tags', url: '/blog/tags/' },
+  { title: termDisplay },
+];
+---
+
+<BaseLayout title={termDisplay}>
+  <Fragment slot="breadcrumb">
+    <Breadcrumb crumbs={crumbs} />
+  </Fragment>
+
+  <Fragment slot="article">
+    <header>
+      <h1>
+        <a href={`/tags/${term}/index.xml`} title="RSS" aria-label="RSS">
+          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"
+            stroke-linecap="round" stroke-linejoin="round" height="23">
+            <path d="M4 11a9 9 0 0 1 9 9" />
+            <path d="M4 4a16 16 0 0 1 16 16" />
+            <circle cx="5" cy="19" r="1" />
+          </svg>
+          Tags: {termDisplay}
+        </a>
+      </h1>
+    </header>
+    {posts.map(post => {
+      const url = postUrl(post);
+      const date = post.data.date;
+      const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
+      const dateIso = date.toISOString();
+      const wc = wordCount(post.body ?? '');
+      const readTime = Math.ceil(wc / 200);
+      return (
+        <div class="archive-entry">
+          <h3 class="archive-entry-title entry-hint-parent">
+            <a class="entry-link" aria-label={`post link to ${post.data.title}`} href={url}>{post.data.title}</a>
+          </h3>
+          <div class="archive-meta">
+            <p><em>
+              <span title={dateIso}>Posted on {dateStr}</span>
+              &nbsp;·&nbsp;<span>{wc} words</span>
+              &nbsp;·&nbsp;<span>{readTime} min</span>
+            </em></p>
+          </div>
+        </div>
+      );
+    })}
+  </Fragment>
+
+  <Fragment slot="menu">
+    <BlogNav currentUrl={currentUrl} />
+  </Fragment>
+</BaseLayout>