Initial site: Astro + M3 Expressive, projects, blog

Personal site & blog for jeanlucmakiola.de.

- Astro 7 static output; self-hosted Inter + JetBrains Mono (Fontsource)
- Material 3 Expressive design tokens (red accent over neutral surfaces)
- Home (hero + about + selected work + latest writing)
- /work index + per-app pages (Calendula, Agendula, floret-kit) with
  build-time Gitea release tags, brand-icon link rows, features grid,
  and an at-a-glance facts panel
- Blog via Markdown content collection; RSS, sitemap, robots, OG/SEO
- Privacy-respecting Umami analytics, env-gated (off in dev)
- Dockerfile (build -> Caddy) + Caddyfile for Coolify deploy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Luc Makiola
2026-06-28 13:39:08 +02:00
commit 074199c656
35 changed files with 6954 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
---
// Umami analytics. Renders nothing unless both env vars are set, so analytics
// stays off in local dev. Configure in production (Coolify env):
// PUBLIC_UMAMI_SRC e.g. https://umami.yourdomain/script.js
// PUBLIC_UMAMI_WEBSITE the website id (UUID) from your Umami dashboard
const src = import.meta.env.PUBLIC_UMAMI_SRC;
const websiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE;
const enabled = Boolean(src && websiteId);
---
{enabled && <script is:inline defer src={src} data-website-id={websiteId}></script>}

View File

@@ -0,0 +1,59 @@
---
// SEO + document head. Renders title, meta description, canonical URL,
// Open Graph / Twitter tags, RSS autodiscovery, and the favicon.
import { SITE } from '../consts';
interface Props {
title: string;
description?: string;
/** Optional absolute or root-relative OG image path. */
image?: string;
/** 'website' for pages, 'article' for blog posts. */
type?: 'website' | 'article';
}
const {
title,
description = SITE.description,
image = '/og-default.png',
type = 'website',
} = Astro.props;
// Astro.site comes from astro.config `site`. Canonical = site + current path.
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const imageURL = new URL(image, Astro.site);
const fullTitle = title === SITE.title ? title : `${title} · ${SITE.title}`;
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<meta name="author" content={SITE.author} />
<link rel="canonical" href={canonicalURL} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link
rel="alternate"
type="application/rss+xml"
title={SITE.title}
href={new URL('rss.xml', Astro.site)}
/>
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- Open Graph -->
<meta property="og:type" content={type} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:image" content={imageURL} />
<meta property="og:site_name" content={SITE.title} />
<meta property="og:locale" content={SITE.locale} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageURL} />

View File

@@ -0,0 +1,23 @@
---
import { Icon } from 'astro-icon/components';
import { SITE, SOCIALS } from '../consts';
const year = new Date().getFullYear();
---
<footer class="site-footer">
<div class="site-footer__inner">
<p>© {year} {SITE.author}</p>
{
SOCIALS.length > 0 && (
<nav aria-label="Social" class="socials">
{SOCIALS.map((s) => (
<a href={s.href} rel="me noopener">
<Icon name={s.icon} />
{s.label}
</a>
))}
</nav>
)
}
</div>
</footer>

View File

@@ -0,0 +1,16 @@
---
import { SITE } from '../consts';
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString(SITE.lang, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</time>

View File

@@ -0,0 +1,30 @@
---
import { SITE } from '../consts';
const path = Astro.url.pathname;
const isActive = (href: string) =>
href === '/' ? path === '/' : path.startsWith(href);
const nav = [
{ label: 'Home', href: '/' },
{ label: 'Work', href: '/work' },
{ label: 'Blog', href: '/blog' },
];
---
<header class="site-header">
<div class="site-header__inner">
<a class="logotype" href="/" aria-label={`${SITE.title} — home`}>
JLM<span class="dot">.</span>
</a>
<nav aria-label="Primary">
{
nav.map((item) => (
<a href={item.href} aria-current={isActive(item.href) ? 'page' : undefined}>
{item.label}
</a>
))
}
</nav>
</div>
</header>