--- /dev/null
+---
+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>
--- /dev/null
+---
+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>
--- /dev/null
+---
+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 && <> · <span>{wordCount} words</span></>}
+ {readingTime && <> · <span>{readingTime} min</span></>}
+</em></p>
--- /dev/null
+---
+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>
--- /dev/null
+---
+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>
---
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 } };
});
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>