10 Commits
Author SHA1 Message Date
makiolaj ddc2c22a30 Merge pull request 'Render the app privacy policies from the app repos' (#9) from privacy-from-app-repos into main 2026-09-09 14:53:33 +00:00
makiolaj 5056a03a05 Merge pull request 'Codeberg sources, a Play button, and current project descriptions' (#8) from codeberg-sources-play-store into main
Reviewed-on: #8
2026-09-09 14:46:46 +00:00
makiolaj 8a1eb54a45 Render the app privacy policies from the app repos
Each policy now has exactly one copy: docs/PRIVACY.md in the app's own
repository. The pages keep their URLs and chrome and render that file
through a content collection, so the published page and the app's own
documentation cannot drift.

scripts/sync-external.mjs shallow-clones both repos into external/ from
prebuild and predev — not from CI: Coolify builds the site from the repo,
so a checkout that only ran in a Gitea job would never reach the deploy.
It falls back to the raw file if git is unavailable, and takes <APP>_REF
or <APP>_LOCAL for work against a branch or an unpushed working copy.

A missing, empty or malformed policy fails the build, verified against the
real image: the deploy stops rather than publishing an empty privacy page.
2026-09-09 16:33:11 +02:00
makiolaj ff3c0fa437 Point sources at Codeberg, add the Play button, refresh descriptions
Codeberg is where the apps live now, so the source link, its icon and the
release lookup follow. Forgejo serves Gitea's /api/v1, so the version badge
keeps working — with a fallback to the newest pre-release, since a pre-1.0
app publishes nothing else (Agendula showed a stale v0.2.0 before).

Calendula is on Google Play as well as F-Droid; adds a tonal button for it,
and its fifteen community translations replace "German and English".

Agendula's entry described the app it stopped being: it now carries its own
VTODO store, with an external provider as a choice rather than a requirement,
plus export and its own CalDAV sync in progress. floret-kit's modules have
grown past core-time. Footer forge link and the /uses infrastructure list
separate the two roles: Codeberg for source, self-hosted Gitea for builds.
2026-09-09 16:09:30 +02:00
makiolaj 6311baccb7 Merge pull request 'Add the Calendula and Agendula privacy policies' (#7) from calendula-privacy-policy into main
Reviewed-on: #7
2026-09-09 14:02:01 +00:00
makiolaj 92472937cc Restore spaces stripped at wrapped inline-tag boundaries
Astro drops the whitespace run when a line break falls exactly at an inline
tag boundary, so "the\n<a ...>" renders glued. Adds {' '} at the wrap.
The footer one showed on every page.
2026-09-09 16:01:16 +02:00
makiolaj 0fe0eaf2bd Add the Agendula app privacy policy
Same structure as the Calendula page, but Agendula holds INTERNET and syncs
CalDAV itself, so the wording turns on "only to the server you entered"
rather than on the absence of the permission. Facts from the app repo:
storage modes, Keystore-encrypted credentials, RFC 6764 discovery,
Nextcloud Login Flow v2, Auto Backup exclusions, crash-report allowlist.
2026-09-09 16:01:11 +02:00
makiolaj d18692b693 Merge pull request 'Add the Calendula privacy policy, switch to business@' (#6) from calendula-privacy-policy into main
scheduled-deploy / trigger-deploy (push) Successful in 22s
Reviewed-on: #6
2026-07-28 17:11:08 +00:00
makiolaj bf40e27820 Switch the contact address to business@ 2026-07-28 19:01:29 +02:00
makiolaj 4181ef35ba Add the Calendula app privacy policy 2026-07-28 19:01:29 +02:00
19 changed files with 445 additions and 63 deletions
+4
View File
@@ -5,3 +5,7 @@ dist
.env
.env.production
*.log
# Fetched during the build by scripts/sync-external.mjs; never copied in — a
# local checkout may be a symlink to a working copy on the developer's machine.
external
+3
View File
@@ -12,3 +12,6 @@ node_modules/
*.log
# local screenshots
.shots/
# App repositories fetched at build time (see scripts/sync-external.mjs).
external/
+8
View File
@@ -7,6 +7,14 @@ WORKDIR /app
# Use npm install (not npm ci): Astro 7's wasm32 optional deps (@emnapi/*)
# make npm ci's strict lock-sync check fail across npm/node versions. install
# reconciles the lockfile and builds reliably.
# git: the build fetches the app repositories whose docs/PRIVACY.md this site
# renders (scripts/sync-external.mjs, run from `prebuild`). node:slim ships
# without it; the script would fall back to fetching the raw files over HTTPS,
# but a shallow clone is the intended path and keeps the failure modes obvious.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
+43
View File
@@ -15,6 +15,49 @@ npm run build # output -> dist/
npm run preview # serve the built site locally
```
## App privacy policies (rendered from the app repos)
`/calendula/privacy` and `/agendula/privacy` hold **no copy** of their prose.
Each policy lives in its own app repository, as `docs/PRIVACY.md` with
`title` / `description` / `updated` frontmatter, and is the single copy of that
policy anywhere:
| Page | Source |
|------|--------|
| `/calendula/privacy` | [`jlmakiola/calendula`](https://codeberg.org/jlmakiola/calendula) → `docs/PRIVACY.md` |
| `/agendula/privacy` | [`jlmakiola/agendula`](https://codeberg.org/jlmakiola/agendula) → `docs/PRIVACY.md` |
`scripts/sync-external.mjs` clones them (shallow, single-branch, no
credentials) into `external/` before every build and dev server — it is wired
to `prebuild` and `predev`, **not** to CI, because Coolify builds the site from
the repo and a checkout that only ran in a Gitea job would never reach the
deployed page. `src/content.config.ts` exposes each file as a content
collection; the pages render it and supply the `<h1>` from `title`.
**To change a policy, edit it in the app repo, in a PR.** It reaches the live
page on the site's next build.
```sh
npm run sync:external # refresh the checkouts by hand
CALENDULA_REF=some/branch npm run build # build against a branch
AGENDULA_LOCAL=../agendula npm run dev # render a working copy, no network
```
The build **fails** if either policy is missing, empty, or malformed. That is
deliberate: a privacy page silently rendering nothing is the one failure this
arrangement exists to prevent, and it is worth a red deploy.
### Getting a policy edit onto the site
A build is what publishes it, so:
- **Floor:** the daily `scheduled-deploy` cron rebuilds every morning, so any
edit is live within a day without anyone doing anything.
- **Immediate:** add a webhook in the app repo (Codeberg → Settings → Webhooks)
pointing at the same Coolify deploy URL the cron uses, so a merge to the app's
`main` triggers a site rebuild at once. One-time setup per app repo; the
secret lives in Coolify, not here.
## Writing a post
Create a Markdown file in `src/content/blog/`, e.g. `my-post.md`:
+3
View File
@@ -4,6 +4,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"sync:external": "node scripts/sync-external.mjs",
"predev": "npm run sync:external",
"prebuild": "npm run sync:external",
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
+146
View File
@@ -0,0 +1,146 @@
// Fetch the app repositories whose Markdown this site renders.
//
// Each app's docs/PRIVACY.md is the single copy of that policy: the app repo
// owns it, and this site renders it through a content collection (see
// src/content.config.ts). Nothing here is authored in this repository, and
// nothing is written back — this only makes the sources present at build time.
//
// It runs from `prebuild` and `predev`, NOT from CI: Coolify builds the site
// straight from the repo, so a checkout that only happened in a Gitea job
// would never reach the deployed page.
//
// Refs default to `main`. Point a source at a branch with e.g.
// AGENDULA_REF=feat/caldav-sync, or at a working copy on this machine with
// AGENDULA_LOCAL=/path/to/agendula (which skips the network entirely).
//
// The guardrail is the point of the script: if a policy source is missing,
// empty, or malformed, the build FAILS. A privacy page that silently renders
// nothing is the one outcome this design exists to prevent.
import { execFileSync } from 'node:child_process';
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const EXTERNAL = join(ROOT, 'external');
const SOURCES = [
{
name: 'calendula',
repo: 'https://codeberg.org/jlmakiola/calendula.git',
raw: (ref) => `https://codeberg.org/jlmakiola/calendula/raw/branch/${ref}/docs/PRIVACY.md`,
file: 'docs/PRIVACY.md',
},
{
name: 'agendula',
repo: 'https://codeberg.org/jlmakiola/agendula.git',
raw: (ref) => `https://codeberg.org/jlmakiola/agendula/raw/branch/${ref}/docs/PRIVACY.md`,
file: 'docs/PRIVACY.md',
},
];
const REQUIRED_FRONTMATTER = ['title', 'description', 'updated'];
const MIN_BODY_CHARS = 400;
const env = (name, suffix) => process.env[`${name.toUpperCase()}_${suffix}`]?.trim() || '';
const log = (msg) => console.log(`[external] ${msg}`);
const git = (args, cwd) =>
execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
/** Shallow single-branch clone, or a shallow fetch if it is already there. */
function syncRepo(source, ref, dir) {
if (existsSync(join(dir, '.git'))) {
git(['fetch', '--depth', '1', 'origin', ref], dir);
git(['checkout', '--detach', 'FETCH_HEAD'], dir);
return `fetched ${ref}`;
}
rmSync(dir, { recursive: true, force: true });
git(['clone', '--depth', '1', '--single-branch', '--branch', ref, source.repo, dir]);
return `cloned ${ref}`;
}
/** Last resort when git is unavailable or the clone fails: the raw file. */
async function fetchRaw(source, ref, dir) {
const res = await fetch(source.raw(ref), { headers: { Accept: 'text/plain' } });
if (!res.ok) throw new Error(`raw fetch returned HTTP ${res.status}`);
const body = await res.text();
const target = join(dir, source.file);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, body);
return `fetched ${source.file} at ${ref} over HTTPS`;
}
/** Parse just enough frontmatter to know the entry will satisfy the schema. */
function validate(source, dir) {
const path = join(dir, source.file);
if (!existsSync(path)) return `${source.file} is missing`;
const text = readFileSync(path, 'utf8');
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return `${source.file} has no frontmatter block`;
const [, frontmatter, body] = match;
const missing = REQUIRED_FRONTMATTER.filter(
(key) => !new RegExp(`^${key}:\\s*\\S`, 'm').test(frontmatter)
);
if (missing.length) return `${source.file} frontmatter is missing: ${missing.join(', ')}`;
// Strip HTML comments — the maintainer note must not count as content.
const prose = body.replace(/<!--[\s\S]*?-->/g, '').trim();
if (prose.length < MIN_BODY_CHARS) return `${source.file} body is empty or too short`;
if (!/^##\s/m.test(prose)) return `${source.file} body has no sections`;
return null;
}
let failed = false;
mkdirSync(EXTERNAL, { recursive: true });
for (const source of SOURCES) {
const dir = join(EXTERNAL, source.name);
const local = env(source.name, 'LOCAL');
const ref = env(source.name, 'REF') || 'main';
if (local) {
// Development escape hatch: render a working copy on this machine, for a
// policy edit that is not pushed yet. Never set in CI or on the server.
const target = isAbsolute(local) ? local : resolve(ROOT, local);
if (!existsSync(target)) {
console.error(`[external] ${source.name}: ${source.name.toUpperCase()}_LOCAL points at ${target}, which does not exist`);
failed = true;
continue;
}
if (existsSync(dir) || lstatSync(dir, { throwIfNoEntry: false })) {
rmSync(dir, { recursive: true, force: true });
}
symlinkSync(target, dir, 'dir');
log(`${source.name}: local override -> ${target}`);
} else {
try {
log(`${source.name}: ${syncRepo(source, ref, dir)}`);
} catch (error) {
const reason = (error.stderr?.toString() || error.message).split('\n')[0];
log(`${source.name}: git failed (${reason}); falling back to the raw file`);
try {
log(`${source.name}: ${await fetchRaw(source, ref, dir)}`);
} catch (fallbackError) {
log(`${source.name}: fallback failed (${fallbackError.message})`);
}
}
}
const problem = validate(source, dir);
if (problem) {
console.error(`[external] ${source.name}: ${problem}`);
failed = true;
} else {
log(`${source.name}: ${source.file} ok`);
}
}
if (failed) {
console.error(
'\n[external] A privacy policy source is unusable, so the build stops here.\n' +
' These pages must never render empty — fix the source, or set\n' +
' <APP>_REF / <APP>_LOCAL if you are working against a branch.'
);
process.exit(1);
}
+1 -1
View File
@@ -7,7 +7,7 @@ const year = new Date().getFullYear();
<footer class="site-footer">
<div class="site-footer__inner">
<p>
© {year} {SITE.author} · <a href="/uses">Uses</a> ·
© {year} {SITE.author} · <a href="/uses">Uses</a> ·{' '}
<a href="/impressum">Impressum</a> · <a href="/datenschutz">Datenschutz</a>
</p>
{
+3 -3
View File
@@ -16,13 +16,13 @@ export const LEGAL = {
person: SITE.author, // natural person responsible — § 18 Abs. 2 MStV
street: 'Mahlerstraße 10',
city: '14772 Brandenburg an der Havel',
email: 'mail@jeanlucmakiola.de',
email: 'business@jeanlucmakiola.de',
phone: '',
} 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: 'Codeberg', href: 'https://codeberg.org/jlmakiola', icon: 'simple-icons:codeberg' },
{ label: 'Ko-fi', href: 'https://ko-fi.com/jeanlucmakiola', icon: 'simple-icons:kofi' },
{ label: 'Email', href: 'mailto:mail@jeanlucmakiola.de', icon: 'mdi:email-outline' },
{ label: 'Email', href: `mailto:${LEGAL.email}`, icon: 'mdi:email-outline' },
];
+25 -2
View File
@@ -39,8 +39,10 @@ const projects = defineCollection({
.default([]),
links: z
.object({
gitea: z.string().url().optional(),
// Canonical source is Codeberg; the self-hosted Gitea is build infra.
codeberg: z.string().url().optional(),
fdroid: z.string().url().optional(),
play: z.string().url().optional(),
donate: z.string().url().optional(),
translate: z.string().url().optional(),
})
@@ -48,4 +50,25 @@ const projects = defineCollection({
}),
});
export const collections = { blog, projects };
// App privacy policies. The Markdown lives in each app's own repository and is
// the only copy of that policy; scripts/sync-external.mjs puts the repo under
// external/ before a build or a dev server, and the pages under
// src/pages/<app>/privacy.astro render it. Nothing here is authored in this
// repo — edit the policy in the app repo, in a PR.
const policySchema = z.object({
title: z.string(),
description: z.string(),
updated: z.coerce.date(),
});
const calendulaPolicy = defineCollection({
loader: glob({ pattern: 'PRIVACY.md', base: './external/calendula/docs' }),
schema: policySchema,
});
const agendulaPolicy = defineCollection({
loader: glob({ pattern: 'PRIVACY.md', base: './external/agendula/docs' }),
schema: policySchema,
});
export const collections = { blog, projects, calendulaPolicy, agendulaPolicy };
+33 -19
View File
@@ -6,37 +6,51 @@ 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.
The task-list sibling to Calendula. Keeps your tasks in its own store built
around iCalendar VTODOs — or on top of a tasks provider you already sync —
with no account required.
tech:
- Kotlin
- Jetpack Compose
- Material 3 Expressive
- Room
features:
- title: Provider-native
- title: Two places to keep tasks
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
By default, Agendula's own database, designed around RFC 5545's VTODO —
no account, no permission, no other app needed. Or a provider you already
have (OpenTasks, tasks.org), left to whatever syncs it for you.
- title: Coexists, never replaces
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
Agendula's store is an ordinary app database, published to nothing, so
installing it never breaks OpenTasks — and if you already sync through a
provider, that keeps working exactly as it did.
- title: Nothing quietly lost
body: >-
The Material 3 Expressive screens are being built on top of the tested
data layer, one at a time.
Recurring tasks are expanded per RFC 5545, and anything the schema doesn't
model is round-tripped verbatim rather than dropped. Export to standard
.ics files is built in, because data you can't take with you isn't yours.
- 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.
CalDAV, iCalendar, and DecSync are the lane; a CalDAV sync engine of
Agendula's own is being built now. Google Tasks and Microsoft To Do are
out of scope by design.
links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/agendula
codeberg: https://codeberg.org/jlmakiola/agendula
donate: https://ko-fi.com/jeanlucmakiola
translate: https://weblate.dev.jeanlucmakiola.de/engage/agendula/
---
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.
carries **its own store** — a database designed against `VTODO`, the same shape
DAVx5 (and SmoothSync, DecSync, …) syncs out of a CalDAV server. Using a tasks
provider you already have is a choice rather than a requirement. 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.
**Where it stands:** the store, reminders, export, and the Material 3 Expressive
screens through task detail, editing and settings are built; German and
Brazilian Portuguese are the first community translations. Still ahead: a Glance
widget, the first F-Droid release, and Agendula's own CalDAV sync. Its
[privacy policy](/agendula/privacy) is already published.
+9 -2
View File
@@ -32,10 +32,12 @@ features:
- 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.
leaves the device. Dynamic color on Android 12+, and fifteen community
translations alongside English.
links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/calendula
codeberg: https://codeberg.org/jlmakiola/calendula
fdroid: https://f-droid.org/packages/de.jeanlucmakiola.calendula/
play: https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula
donate: https://ko-fi.com/jeanlucmakiola
translate: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
---
@@ -46,3 +48,8 @@ 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.**
Install it from F-Droid or Google Play — same app, same MIT source on Codeberg,
and still no internet permission in either build. There is also a
[privacy policy](/calendula/privacy) covering what the app does and doesn't do
with your data.
+16 -7
View File
@@ -10,22 +10,31 @@ summary: >-
app family — so each app draws from one bloom instead of reinventing it.
tech:
- Kotlin
- Jetpack Compose
- 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
- title: Identity and components
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
The M3 Expressive theme factory, navigation motion and predictive-back
peek, plus the shared Compose vocabulary each screen is written out of —
grouped rows, inline fields, option cards, full-screen pickers,
collapsing scaffolds.
- title: Plumbing, not looks
body: >-
Identity/theme, components, screen recipes, and provider / prefs /
reminders / crash plumbing land as they're extracted from the apps.
core-time (local-day windows, locale-aware formatting), core-reminders
(lead-time model and codec), core-locale (per-app language), core-crash
(on-device capture and issue hand-off) — each app layers its own storage
and strings on top.
- title: Mechanics shared, look per-app
body: >-
The principle the kit is held to: a component moves here once two apps
need the same behaviour, while palette and personality stay with the app.
links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/floret-kit
codeberg: https://codeberg.org/jlmakiola/floret-kit
donate: https://ko-fi.com/jeanlucmakiola
---
+31
View File
@@ -0,0 +1,31 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getPolicy } from '../../utils/policy';
// The policy itself lives in the Agendula repository, at docs/PRIVACY.md, and is
// rendered from there — this page holds no copy of the prose, so the published
// page and the app's own documentation cannot drift apart. Edit the policy in
// https://codeberg.org/jlmakiola/agendula, in a PR.
//
// The URL is load-bearing: /agendula/privacy is what the app's Settings row
// opens and what the Play Console field holds. Do not move or rename it.
//
// Title and description come from the file's frontmatter; the body carries its
// own "last updated" line and deliberately starts below the h1 this page
// supplies.
const entry = await getPolicy('agendulaPolicy');
const { Content } = await render(entry);
---
<BaseLayout
title={entry.data.title}
description={entry.data.description}
type="article"
width="narrow"
>
<article class="prose">
<h1 class="page-title">{entry.data.title}</h1>
<Content />
</article>
</BaseLayout>
+31
View File
@@ -0,0 +1,31 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getPolicy } from '../../utils/policy';
// The policy itself lives in the Calendula repository, at docs/PRIVACY.md, and is
// rendered from there — this page holds no copy of the prose, so the published
// page and the app's own documentation cannot drift apart. Edit the policy in
// https://codeberg.org/jlmakiola/calendula, in a PR.
//
// The URL is load-bearing: /calendula/privacy is what the app's Settings row
// opens and what the Play Console field holds. Do not move or rename it.
//
// Title and description come from the file's frontmatter; the body carries its
// own "last updated" line and deliberately starts below the h1 this page
// supplies.
const entry = await getPolicy('calendulaPolicy');
const { Content } = await render(entry);
---
<BaseLayout
title={entry.data.title}
description={entry.data.description}
type="article"
width="narrow"
>
<article class="prose">
<h1 class="page-title">{entry.data.title}</h1>
<Content />
</article>
</BaseLayout>
+2 -2
View File
@@ -2,7 +2,7 @@
import BaseLayout from '../layouts/BaseLayout.astro';
import { Icon } from 'astro-icon/components';
import { getCollection } from 'astro:content';
import { SITE } from '../consts';
import { SITE, LEGAL } from '../consts';
import { getPublishedPosts } from '../utils/posts';
const posts = (await getPublishedPosts()).slice(0, 4);
@@ -33,7 +33,7 @@ const projects = (await getCollection('projects')).sort(
<div class="hero__actions">
<a class="btn btn--filled" href="#work">View work</a>
<a class="text-link" href="/blog">Read the blog <span class="dot">→</span></a>
<a class="text-link" href="mailto:mail@jeanlucmakiola.de">
<a class="text-link" href={`mailto:${LEGAL.email}`}>
<Icon name="mdi:email-outline" /> Get in touch
</a>
</div>
+23 -11
View File
@@ -1,5 +1,6 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { LEGAL } from '../consts';
// A /uses colophon — the stack behind this site and the Floret apps.
// Add a "Desk / hardware" section here if you'd like to list your machine,
@@ -14,7 +15,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<article class="prose">
<h1 class="page-title">Uses</h1>
<p>
A running colophon of the tools and stack behind this site, the
A running colophon of the tools and stack behind this site, the{' '}
<a href="/work">Floret apps</a>, and the infrastructure they run on. The
throughline: open, self-hostable standards, and as little reliance on
third-party services as I can manage.
@@ -28,14 +29,14 @@ import BaseLayout from '../layouts/BaseLayout.astro';
the repo.
</li>
<li>
Typefaces are <a href="https://rsms.me/inter/" rel="noopener">Inter</a>
Typefaces are <a href="https://rsms.me/inter/" rel="noopener">Inter</a>{' '}
and <a href="https://www.jetbrains.com/lp/mono/" rel="noopener">JetBrains
Mono</a>, self-hosted via Fontsource — no Google Fonts CDN, so no
third-party request on page load.
</li>
<li>
Analytics is self-hosted <a href="https://umami.is" rel="noopener">Umami</a>:
cookieless, no cross-site tracking, no personal profiles. See the
cookieless, no cross-site tracking, no personal profiles. See the{' '}
<a href="/datenschutz">Datenschutzerklärung</a> for what that means.
</li>
<li>Icons from <a href="https://iconify.design" rel="noopener">Iconify</a> (Material Design Icons + Simple Icons).</li>
@@ -45,31 +46,42 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<ul>
<li><strong>Kotlin</strong> and <strong>Jetpack Compose</strong>, designed in <strong>Material 3 Expressive</strong>.</li>
<li>
No reinvented sync stack — each app is a front-end over a platform
provider (<code>CalendarContract</code>, the OpenTasks
<code>TaskContract</code>) and open standards like CalDAV, iCalendar, and
DecSync.
Open standards over proprietary services — CalDAV, iCalendar, DecSync.
Calendula is a pure front-end over Android's <code>CalendarContract</code>;
Agendula keeps its own <code>VTODO</code> store, or rides a tasks
provider you already sync.
</li>
<li>
A shared design system, <a href="/work/floret-kit">floret-kit</a>, wired
in as a git submodule via a Gradle composite build — no published
artifacts, so every app stays reproducible.
</li>
<li>Released on <a href="https://f-droid.org" rel="noopener">F-Droid</a>, MIT-licensed, zero telemetry.</li>
<li>
Released on <a href="https://f-droid.org" rel="noopener">F-Droid</a> and
Google Play, MIT-licensed, zero telemetry.
</li>
</ul>
<h2>Self-hosted infrastructure</h2>
<p>Code, translations, builds, and this site all run on infrastructure I host myself.</p>
<ul>
<li><a href="https://gitea.jeanlucmakiola.de/makiolaj" rel="noopener">Gitea</a> for source hosting and as the home for smaller experiments.</li>
<li>
<a href="https://codeberg.org/jlmakiola" rel="noopener">Codeberg</a> is
where the apps live — source, issues, releases.
</li>
<li>
Self-hosted <a href="https://gitea.jeanlucmakiola.de/makiolaj" rel="noopener">Gitea</a>{' '}
runs the build side of that — signing, the F-Droid repo, release
pipelines — and is home to smaller experiments.
</li>
<li><a href="https://weblate.org" rel="noopener">Weblate</a> for community translations of the apps.</li>
<li><a href="https://coolify.io" rel="noopener">Coolify</a> to build and deploy this site.</li>
<li>Umami for the privacy-respecting analytics above.</li>
</ul>
<p class="muted">
This list grows as the stack does. Spotted something you'd ask about?
<a href="mailto:mail@jeanlucmakiola.de">Get in touch</a>.
This list grows as the stack does. Spotted something you'd ask about?{' '}
<a href={`mailto:${LEGAL.email}`}>Get in touch</a>.
</p>
</article>
</BaseLayout>
+38 -16
View File
@@ -5,26 +5,41 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async () => {
// Build-time: resolve the latest release tag from the project's Gitea repo.
// Build-time: resolve the latest release tag from the project's Codeberg
// repo. Forgejo serves Gitea's /api/v1, so the same call fits both.
// `releases/latest` skips pre-releases, and a pre-1.0 app publishes nothing
// else — so fall back to the newest release of any kind before giving up.
// Never throws — returns null on 404/offline so the build can't break.
// (Defined inside getStaticPaths: Astro extracts this fn into its own scope.)
const fetchLatestRelease = async (repoUrl?: string): Promise<string | null> => {
if (!repoUrl) return null;
const tagFrom = async (url: string): Promise<string | null> => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
try {
const res = await fetch(url, {
signal: ctrl.signal,
headers: { Accept: 'application/json' },
});
if (!res.ok) return null;
const data = await res.json();
const release = Array.isArray(data) ? data[0] : data;
return typeof release?.tag_name === 'string' ? release.tag_name : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
};
try {
const u = new URL(repoUrl);
const [, owner, repo] = u.pathname.split('/');
if (!owner || !repo) return null;
const api = `${u.origin}/api/v1/repos/${owner}/${repo}/releases/latest`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
const res = await fetch(api, {
signal: ctrl.signal,
headers: { Accept: 'application/json' },
});
clearTimeout(timer);
if (!res.ok) return null;
const data = await res.json();
return typeof data.tag_name === 'string' ? data.tag_name : null;
const base = `${u.origin}/api/v1/repos/${owner}/${repo}/releases`;
return (
(await tagFrom(`${base}/latest`)) ??
(await tagFrom(`${base}?draft=false&limit=1`))
);
} catch {
return null;
}
@@ -34,7 +49,7 @@ export const getStaticPaths = (async () => {
return Promise.all(
projects.map(async (project) => ({
params: { slug: project.id },
props: { project, version: await fetchLatestRelease(project.data.links.gitea) },
props: { project, version: await fetchLatestRelease(project.data.links.codeberg) },
}))
);
}) satisfies GetStaticPaths;
@@ -67,9 +82,16 @@ const { Content } = await render(project);
)
}
{
links.gitea && (
<a class="btn btn--outlined" href={links.gitea} rel="noopener">
<Icon name="simple-icons:gitea" /> Source on Gitea
links.play && (
<a class="btn btn--tonal" href={links.play} rel="noopener">
<Icon name="simple-icons:googleplay" /> Get it on Google Play
</a>
)
}
{
links.codeberg && (
<a class="btn btn--outlined" href={links.codeberg} rel="noopener">
<Icon name="simple-icons:codeberg" /> Source on Codeberg
</a>
)
}
+5
View File
@@ -212,6 +212,11 @@ a:hover { text-decoration-color: currentColor; }
.btn:active { transform: scale(0.97); }
.btn--filled { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); }
.btn--filled:hover { box-shadow: var(--md-sys-elevation-2); }
.btn--tonal {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
.btn--tonal:hover { box-shadow: var(--md-sys-elevation-1); }
.btn--outlined {
background: transparent;
color: var(--md-sys-color-on-surface);
+21
View File
@@ -0,0 +1,21 @@
import { getCollection } from 'astro:content';
/**
* The single entry of an app's privacy-policy collection.
*
* The Markdown lives in the app's own repository (see scripts/sync-external.mjs);
* this site holds no copy. If the checkout is missing or empty the collection
* resolves nothing, and a privacy page that renders nothing is worse than a
* failed build — so this throws rather than returning undefined.
*/
export async function getPolicy(collection: 'calendulaPolicy' | 'agendulaPolicy') {
const entries = await getCollection(collection);
if (entries.length !== 1) {
throw new Error(
`[policy] ${collection} resolved ${entries.length} entries, expected exactly 1. ` +
'The app repository under external/ is missing, empty, or holds more than ' +
'one PRIVACY.md. Run `npm run sync:external` and read its output.'
);
}
return entries[0];
}