From 074199c6566aba7d64c52863d9c407e5b2521448 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 13:39:08 +0200 Subject: [PATCH] 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) --- .dockerignore | 7 + .gitignore | 14 + Caddyfile | 16 + Dockerfile | 18 + PLANNING.md | 81 + README.md | 64 + astro.config.mjs | 12 + package-lock.json | 5069 ++++++++++++++++++++++++++++ package.json | 22 + public/brand/logo-mark.svg | 6 + public/favicon.svg | 6 + public/robots.txt | 4 + src/components/Analytics.astro | 10 + src/components/BaseHead.astro | 59 + src/components/Footer.astro | 23 + src/components/FormattedDate.astro | 16 + src/components/Header.astro | 30 + src/consts.ts | 16 + src/content.config.ts | 51 + src/content/blog/hello-world.md | 14 + src/content/projects/agendula.md | 42 + src/content/projects/calendula.md | 48 + src/content/projects/floret-kit.md | 37 + src/layouts/BaseLayout.astro | 38 + src/layouts/BlogPost.astro | 35 + src/pages/blog/[...slug].astro | 20 + src/pages/blog/index.astro | 27 + src/pages/index.astro | 110 + src/pages/rss.xml.js | 21 + src/pages/tags/[tag].astro | 41 + src/pages/work/[slug].astro | 134 + src/pages/work/index.astro | 39 + src/styles/global.css | 593 ++++ src/styles/tokens.css | 226 ++ tsconfig.json | 5 + 35 files changed, 6954 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 PLANNING.md create mode 100644 README.md create mode 100644 astro.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/brand/logo-mark.svg create mode 100644 public/favicon.svg create mode 100644 public/robots.txt create mode 100644 src/components/Analytics.astro create mode 100644 src/components/BaseHead.astro create mode 100644 src/components/Footer.astro create mode 100644 src/components/FormattedDate.astro create mode 100644 src/components/Header.astro create mode 100644 src/consts.ts create mode 100644 src/content.config.ts create mode 100644 src/content/blog/hello-world.md create mode 100644 src/content/projects/agendula.md create mode 100644 src/content/projects/calendula.md create mode 100644 src/content/projects/floret-kit.md create mode 100644 src/layouts/BaseLayout.astro create mode 100644 src/layouts/BlogPost.astro create mode 100644 src/pages/blog/[...slug].astro create mode 100644 src/pages/blog/index.astro create mode 100644 src/pages/index.astro create mode 100644 src/pages/rss.xml.js create mode 100644 src/pages/tags/[tag].astro create mode 100644 src/pages/work/[slug].astro create mode 100644 src/pages/work/index.astro create mode 100644 src/styles/global.css create mode 100644 src/styles/tokens.css create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1f82636 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.astro +.git +.env +.env.production +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8929305 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# build output +dist/ +# generated types +.astro/ +# deps +node_modules/ +# env +.env +.env.production +# os / editor +.DS_Store +*.log +# local screenshots +.shots/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..68b3ddf --- /dev/null +++ b/Caddyfile @@ -0,0 +1,16 @@ +# Caddy serves the static build inside the container on :80. +# Coolify's proxy terminates TLS and handles the public domain. +:80 { + root * /srv + encode zstd gzip + file_server + + # Astro builds "/foo" -> "/foo/index.html"; try clean URLs first. + try_files {path} {path}/ {path}.html /404.html + + # Long-cache fingerprinted assets, revalidate HTML. + @assets path /_astro/* + header @assets Cache-Control "public, max-age=31536000, immutable" + @html path *.html / + header @html Cache-Control "public, max-age=0, must-revalidate" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cf9a36d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# --- Build stage: produce static dist/ --- +FROM node:22-alpine AS build +WORKDIR /app + +# Install deps against the lockfile for reproducible builds. +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +# PUBLIC_* env vars are inlined at build time. In Coolify, set them as +# build-time variables so analytics is baked into the static output. +RUN npm run build + +# --- Runtime stage: serve dist/ with Caddy --- +FROM caddy:2-alpine AS runtime +COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/dist /srv +EXPOSE 80 diff --git a/PLANNING.md b/PLANNING.md new file mode 100644 index 0000000..3b2824a --- /dev/null +++ b/PLANNING.md @@ -0,0 +1,81 @@ +# jeanlucmakiola.de — personal website + +Status: **discussion / not started** · Started 2026-06-28 + +## Goal +A simple personal website (with room to grow into a blog), self-hosted on +Coolify, privacy-respecting analytics, solid SEO, and an AI-driven authoring +flow (content written as files by an agent, reviewed via git diff, auto-deployed +on push). + +## Hard requirements +- Self-hosted deploy on **Coolify** (own infra) +- Privacy-respecting **analytics** (Umami preferred) +- **SEO**: sitemap, RSS, canonical URLs, Open Graph, fast/static +- **AI-driven flow**: agent can author/edit content via CLI/files (and/or API) +- Keep it simple; nothing crazy. Blog is a likely-but-later addition. + +## Recommended stack (proposed) +| Concern | Choice | Why | +|----------------|------------------------------------------|-----| +| Framework | **Astro** (static output) | Content-first, zero-JS default, content collections, great SEO | +| Content | **Markdown/MDX in git** (`src/content/`) | AI writes files; git is the API; no lock-in | +| Analytics | **Umami**, self-hosted on Coolify | Privacy-respecting, cookieless, one `} diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro new file mode 100644 index 0000000..461abab --- /dev/null +++ b/src/components/BaseHead.astro @@ -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}`; +--- + + + + + +{fullTitle} + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Footer.astro b/src/components/Footer.astro new file mode 100644 index 0000000..9a7b40e --- /dev/null +++ b/src/components/Footer.astro @@ -0,0 +1,23 @@ +--- +import { Icon } from 'astro-icon/components'; +import { SITE, SOCIALS } from '../consts'; +const year = new Date().getFullYear(); +--- + +
+ +
diff --git a/src/components/FormattedDate.astro b/src/components/FormattedDate.astro new file mode 100644 index 0000000..17065dc --- /dev/null +++ b/src/components/FormattedDate.astro @@ -0,0 +1,16 @@ +--- +import { SITE } from '../consts'; +interface Props { + date: Date; +} +const { date } = Astro.props; +--- + diff --git a/src/components/Header.astro b/src/components/Header.astro new file mode 100644 index 0000000..0f1ad76 --- /dev/null +++ b/src/components/Header.astro @@ -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' }, +]; +--- + + diff --git a/src/consts.ts b/src/consts.ts new file mode 100644 index 0000000..a40e395 --- /dev/null +++ b/src/consts.ts @@ -0,0 +1,16 @@ +// Central site config. Imported by astro.config.mjs, layouts, RSS, and SEO head. +export const SITE = { + url: 'https://jeanlucmakiola.de', + title: 'Jean-Luc Makiola', + description: 'Personal site of Jean-Luc Makiola — projects, writing, and notes.', + author: 'Jean-Luc Makiola', + lang: 'en', + locale: 'en_US', +} as const; + +// Social / external links shown in the footer. `icon` is an Iconify name. +export const SOCIALS: { label: string; href: string; icon: string }[] = [ + { label: 'Gitea', href: 'https://gitea.jeanlucmakiola.de/makiolaj', icon: 'simple-icons:gitea' }, + { label: 'Ko-fi', href: 'https://ko-fi.com/jeanlucmakiola', icon: 'simple-icons:kofi' }, + { label: 'Email', href: 'mailto:mail@jeanlucmakiola.de', icon: 'mdi:email-outline' }, +]; diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..f9c398d --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,51 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +// Blog posts: Markdown/MDX files in src/content/blog/. +// Frontmatter is validated against this schema at build time. +const blog = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), + schema: z.object({ + title: z.string(), + description: z.string(), + // Accepts an ISO date string in frontmatter, parsed to a Date. + pubDate: z.coerce.date(), + updatedDate: z.coerce.date().optional(), + tags: z.array(z.string()).default([]), + // Set true to keep a post out of listings/feeds while you work on it. + draft: z.boolean().default(false), + }), +}); + +// Projects / apps: one Markdown file per app in src/content/projects/. +// Frontmatter holds the structured bits (status, links); the body is the +// long-form overview + features rendered on the per-app page. +const projects = defineCollection({ + loader: glob({ pattern: '**/*.md', base: './src/content/projects' }), + schema: z.object({ + name: z.string(), + // lower = earlier in listings + order: z.number().default(99), + status: z.string(), + released: z.boolean().default(false), + platform: z.string().optional(), + license: z.string().optional(), + summary: z.string(), + // tech stack chips shown in the "At a glance" panel + tech: z.array(z.string()).default([]), + // structured feature cards (rendered as a grid, not prose) + features: z + .array(z.object({ title: z.string(), body: z.string() })) + .default([]), + links: z + .object({ + gitea: z.string().url().optional(), + fdroid: z.string().url().optional(), + donate: z.string().url().optional(), + translate: z.string().url().optional(), + }) + .default({}), + }), +}); + +export const collections = { blog, projects }; diff --git a/src/content/blog/hello-world.md b/src/content/blog/hello-world.md new file mode 100644 index 0000000..1d1eda7 --- /dev/null +++ b/src/content/blog/hello-world.md @@ -0,0 +1,14 @@ +--- +title: Hello, world +description: First post — what this site is and how it's built. +pubDate: 2026-06-28 +tags: [meta] +draft: false +--- + +This is the first post on my new site. It's built with [Astro](https://astro.build), +deployed on a self-hosted [Coolify](https://coolify.io), and kept honest with +privacy-respecting analytics. + +Posts live as Markdown files in the repository — so writing here is just editing +text and pushing a commit. More to come. diff --git a/src/content/projects/agendula.md b/src/content/projects/agendula.md new file mode 100644 index 0000000..21ba1ec --- /dev/null +++ b/src/content/projects/agendula.md @@ -0,0 +1,42 @@ +--- +name: Agendula +order: 2 +status: In development +released: false +platform: Android 10+ +license: MIT +summary: >- + The task-list sibling to Calendula — a pure front-end over the OpenTasks + provider (CalDAV VTODOs), with no reinvented sync stack. +tech: + - Kotlin + - Jetpack Compose + - Material 3 Expressive +features: + - title: Provider-native + body: >- + A pure front-end over the OpenTasks TaskContract provider. DAVx5, + SmoothSync, and DecSync sync your CalDAV VTODOs in; Agendula reads and + writes them — no own database. + - title: Data layer done + body: >- + Provider resolution, live-updating reads, writes, smart-list filtering, + and a self-scheduled reminder engine are built and unit-tested. + - title: UI in progress + body: >- + The Material 3 Expressive screens are being built on top of the tested + data layer, one at a time. + - title: Open standards only + body: >- + CalDAV, iCalendar, and DecSync are the lane. Proprietary task services are + out of scope by design — they would mean owning a sync stack. +links: + gitea: https://gitea.jeanlucmakiola.de/makiolaj/agendula + donate: https://ko-fi.com/jeanlucmakiola +--- + +Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula +is a pure front-end over the **OpenTasks `TaskContract` provider**. The name +rhymes with its sibling on purpose: *Agendula* is *agenda* — Latin for "things +to be done" — given Calendula's `-ula` ending. A Calendula flower head is a +cluster of many small *florets*, so the two apps are florets of one bloom. diff --git a/src/content/projects/calendula.md b/src/content/projects/calendula.md new file mode 100644 index 0000000..975fd24 --- /dev/null +++ b/src/content/projects/calendula.md @@ -0,0 +1,48 @@ +--- +name: Calendula +order: 1 +status: Released +released: true +platform: Android 10+ +license: MIT +summary: >- + A Material 3 Expressive calendar for Android. Reads, writes, and reminds on + top of the system calendar — any CalDAV account just appears — with zero + network access of its own. +tech: + - Kotlin + - Jetpack Compose + - Material 3 Expressive +features: + - title: Calendar views + body: >- + Month, week, and day views with a one-tap switcher. Full event detail — + attendees and responses, reminders, humanized recurrence, availability, + visibility, and foreign time zones. + - title: Safe editing + body: >- + Scoped writes for recurring events (only this, this-and-following, or the + whole series), a recurrence picker for presets and custom rules, and + conflict-safe saves that ask before overwriting. + - title: Built-in reminders + body: >- + Delivered by Calendula itself as notifications — essential when it's your + only calendar app, since Android delegates reminder delivery to calendar + apps. Tap a reminder to land on the event. + - title: Private by default + body: >- + Zero telemetry, zero analytics, no internet permission — your data never + leaves the device. Dynamic color on Android 12+, German and English UI. +links: + gitea: https://gitea.jeanlucmakiola.de/makiolaj/calendula + fdroid: https://f-droid.org/packages/de.jeanlucmakiola.calendula/ + donate: https://ko-fi.com/jeanlucmakiola + translate: https://weblate.dev.jeanlucmakiola.de/engage/calendula/ +--- + +Calendula is named after the flower whose name — like the word *calendar* — +comes from the Latin *kalendae*, the first day of the month. It lives entirely +on top of Android's `CalendarContract`: any calendar synced to your device +(CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply appears, and +everything you create or edit syncs back the same way. **No own database, no +sync stack reinvented.** diff --git a/src/content/projects/floret-kit.md b/src/content/projects/floret-kit.md new file mode 100644 index 0000000..545ed07 --- /dev/null +++ b/src/content/projects/floret-kit.md @@ -0,0 +1,37 @@ +--- +name: floret-kit +order: 3 +status: Design system +released: false +platform: Android library +license: MIT +summary: >- + The shared Material 3 Expressive design system and plumbing behind the Floret + app family — so each app draws from one bloom instead of reinventing it. +tech: + - Kotlin + - Gradle composite build +features: + - title: Built from source + body: >- + Embedded as a git submodule and wired in with a Gradle composite build — + no published artifacts, which keeps every app reproducible for F-Droid. + - title: core-time + body: >- + Pure-Kotlin date/time helpers: local-day windows for smart-list logic and + locale/zone-aware display formatting. No Android, no dependencies. + - title: Growing + body: >- + Identity/theme, components, screen recipes, and provider / prefs / + reminders / crash plumbing land as they're extracted from the apps. +links: + gitea: https://gitea.jeanlucmakiola.de/makiolaj/floret-kit + donate: https://ko-fi.com/jeanlucmakiola +--- + +"Floret" is the family's design language: a Calendula flower head is a cluster +of many small *florets*, and each app is one of them. floret-kit is the bloom +they share — so a new app doesn't reinvent a settings screen, an edit form, or a +theme; it draws from here. Gradle's dependency substitution maps +`de.jeanlucmakiola.floret:` to local source, so editing the kit updates +every app immediately. diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..3d32568 --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -0,0 +1,38 @@ +--- +import BaseHead from '../components/BaseHead.astro'; +import Analytics from '../components/Analytics.astro'; +import Header from '../components/Header.astro'; +import Footer from '../components/Footer.astro'; +import { SITE } from '../consts'; + +// Self-hosted brand fonts (privacy-respecting — no Google CDN). +import '@fontsource-variable/inter'; +import '@fontsource-variable/jetbrains-mono'; +import '../styles/global.css'; + +interface Props { + title: string; + description?: string; + image?: string; + type?: 'website' | 'article'; + /** 'narrow' caps width for long-form reading (blog, posts, tags). */ + width?: 'wide' | 'narrow'; +} + +const { title, description, image, type, width = 'wide' } = Astro.props; +--- + + + + + + + + +
+
+ +
+