Add date-based post scheduling + daily deploy cron, and six draft posts #1

Merged
makiolaj merged 2 commits from blog-release-pipeline into main 2026-07-01 14:42:28 +00:00
13 changed files with 568 additions and 15 deletions

View File

@@ -0,0 +1,49 @@
# Daily rebuild so date-scheduled posts go live on their pubDate.
#
# The site is static, so "now" is frozen at build time. This cron triggers a
# fresh Coolify deploy once a day; the rebuild re-evaluates the pubDate gate in
# src/utils/posts.ts, and any post whose date has arrived (and draft: false)
# appears. No file is mutated and no repo write access is needed — the job only
# pings the deploy hook.
#
# Setup (once):
# - A Gitea Actions runner must be registered and online for this repo/org,
# advertising a label that matches `runs-on` below (default act_runner maps
# `ubuntu-latest`; change it if your runner uses a different label).
# - Add a repo secret COOLIFY_DEPLOY_HOOK = the app's deploy URL from Coolify
# (app → Webhooks → "Deploy Webhook", e.g.
# https://coolify.example.com/api/v1/deploy?uuid=<uuid>&force=false).
# - Add a repo secret COOLIFY_TOKEN = a Coolify API token (Bearer) with deploy
# permission. Required for the /api/v1/deploy endpoint.
#
# Your normal push-to-main deploy still runs independently; this only covers the
# "a new day rolled over" case. Trigger it by hand from the Actions tab (this
# workflow has workflow_dispatch) to smoke-test before relying on the cron.
name: scheduled-deploy
on:
schedule:
# 06:15 Europe/Berlin daily. Posts are eligible from 00:00 UTC on their
# pubDate; this morning rebuild publishes them. Adjust the time to taste.
- cron: 'TZ=Europe/Berlin 15 6 * * *'
workflow_dispatch: {}
jobs:
trigger-deploy:
runs-on: ubuntu-latest
steps:
- name: Ping Coolify deploy hook
env:
COOLIFY_DEPLOY_HOOK: ${{ secrets.COOLIFY_DEPLOY_HOOK }}
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
run: |
if [ -z "$COOLIFY_DEPLOY_HOOK" ]; then
echo "COOLIFY_DEPLOY_HOOK secret is not set." >&2
exit 1
fi
# Coolify's /api/v1/deploy endpoint is a GET authenticated with a
# Bearer token. -f makes curl exit non-zero on HTTP errors.
curl -fsS -X GET "$COOLIFY_DEPLOY_HOOK" \
${COOLIFY_TOKEN:+-H "Authorization: Bearer $COOLIFY_TOKEN"}
echo "Deploy triggered."

View File

@@ -0,0 +1,79 @@
---
title: Why your CalDAV events show sixty shades of the same blue
description: >-
Two open colour reports on Calendula come from one root — an overstuffed picker
on CalDAV calendars and hard-to-read dark event titles — and both trace back to
how event colour flows through Android's calendar provider.
pubDate: 2026-07-08
tags: [android, calendula, caldav, accessibility]
draft: true
---
Two colour reports landed on Calendula in the same week, and both are still open.
One is a bug: the colour picker on a CalDAV calendar shows a full screen of
colours, many of them near-duplicates ([#22]). The other is a request: make
event titles readable on dark backgrounds so busy weeks don't turn into a smear
([#21]). They look unrelated. They come from the same place — how event colour
actually flows through Android's calendar provider — which is why I'm writing up
the diagnosis before I write the fix.
## Palette vs. free-for-all
Android's
[`CalendarContract`](https://developer.android.com/reference/android/provider/CalendarContract)
has two ways to colour an event, and which one you get depends on the account.
Google and local calendars expose a **palette**: a small, indexed set of colours
through
[`CalendarContract.Colors`](https://developer.android.com/reference/android/provider/CalendarContract.Colors).
An event stores a `color_key`, the provider maps it to a swatch, and a picker can
show exactly those choices — a tidy dozen.
CalDAV is different. The iCalendar
[`COLOR`](https://datatracker.ietf.org/doc/html/rfc7986#section-5.9) property is
defined as a CSS3 colour *name* — a set of about 150, already far bigger than a
palette — and in practice clients push arbitrary hex through vendor extensions
like `X-APPLE-CALENDAR-COLOR`. Either way, by the time it reaches the provider
there's no `color_key` and no shared index: the event just carries a raw
`EVENT_COLOR`. So a picker that tries to *build* a palette from what it finds
ends up gathering every distinct value that ever appeared, including a dozen
blues that differ by a rounding error you can't see. That's the "too many
colours" bug: it isn't showing junk, it's faithfully showing an *unbounded* space
as though it were a fixed menu. The fix is to stop pretending CalDAV has a
palette — offer a sane curated set and map to the nearest real colour, rather
than enumerating every ghost.
## The contrast problem is the same problem
Once an event can be *any* colour, you can no longer assume the text on top is
readable. A pale event with dark text is fine; a deep navy block with the same
dark text is a smear. Right now Calendula draws event titles in a fixed near-
black — which is exactly the assumption that breaks. Google Calendar flips to
white text on dark fills, and [#21] asks for the same.
The rule for "is this colour dark" isn't the average of its channels — the eye
isn't equally sensitive to red, green and blue. The
[WCAG relative luminance](https://www.w3.org/TR/WCAG21/#dfn-relative-luminance)
formula weights them the way perception does (green counts far more than blue),
linearises each channel, and yields a single brightness figure. Pick a threshold,
and below it the title renders white (or off-white), above it near-black. It's a
few lines of maths applied to the same event colour the picker just handed you —
which is the point. The overstuffed picker and the unreadable title are two ends
of one pipe: *the moment colour stops being a fixed palette and becomes
arbitrary, both the input and the output need taming.* One end is a bug to close;
the other is a threshold to add. Same pipe.
## Standard first, taste second
The tidy path would be to store my own colour for every event and never touch the
provider's mess. I don't — for the same reason Calendula owns no other part of
your data. The `COLOR` on a CalDAV event belongs to the event, and the sync
adapter carries it out to every other client that reads your calendar; if I
overwrote it with a private value, that portability would be gone. My job isn't
to replace the colour. It's to present a bounded, readable *view* of it — a
curated picker on the way in, a luminance-aware title on the way out — while the
value itself stays yours and portable. Two open issues, one fix: let you read
your own calendar without taking it over.
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22

View File

@@ -0,0 +1,75 @@
---
title: "\"This event only\": the edit that quietly did nothing"
description: >-
Editing a single instance of a recurring event is one of the sharpest edges in
the calendar model. A bug in Calendula, and why recurrence makes "just save
it" surprisingly hard.
pubDate: 2026-07-05
tags: [android, calendula, caldav]
draft: true
---
A recurring event isn't a list of events. It's *one* row plus a rule. "Every
Tuesday at 10:00" is a single event with an
[`RRULE`](https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5.3), and
the individual Tuesdays don't exist as stored rows — they're *expanded* from the
rule on demand. That single fact is why editing a recurring event is one of the
sharpest edges in the whole calendar model, and why Calendula had a bug ([#16])
where choosing "this event only" and saving bounced you back to the edit screen
without changing anything.
## Three answers to one save
Tap save on a recurring event and every calendar app asks the same question:
**this event, this and following, or all events?** They're three genuinely
different operations against the data model:
- **All events** edits the master row and its rule. Easy — it's the thing that
actually exists.
- **This and following** splits the series: the old rule gets an end, a new
event picks up from the edit point with the changes.
- **This event only** is the awkward one. You're editing an instance that has no
row of its own. To change it, you have to *create* one.
## What "this event only" actually does
Under the hood, "this event only" doesn't edit anything — it manufactures an
**exception**. The iCalendar model calls it a
[`RECURRENCE-ID`](https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.4.4)
override: a new component that says "for the occurrence that *would* have landed
at this original time, use these values instead." Android's
[`CalendarContract`](https://developer.android.com/reference/android/provider/CalendarContract)
exposes this through the exceptions URI and an `ORIGINAL_INSTANCE_TIME` — you
insert a one-off event pinned to the timestamp of the occurrence you're
replacing, and the provider excludes the original instance from the expansion in
its place.
So the operation is: identify the exact original instance time, insert an
exception bound to it, and let the provider stop expanding the rule at that slot.
Get the original-time bookkeeping wrong and the provider has nothing valid to
attach the exception to — the write doesn't land, and the UI, having nothing to
show for the save, falls back to the edit screen. Which is exactly the shape of
[#16]: the popup appeared correctly, the other two options worked, and "this
event only" silently did nothing. It wasn't the save logic — it was the one
branch that has to fabricate a row instead of updating one.
## Why I let the provider handle it
It would be tempting to sidestep all of this by keeping my own event table and
doing the expansion myself. I deliberately don't. Calendula reads and writes
through the system calendar provider, and recurrence exceptions are precisely
the kind of thing the provider — and the CalDAV sync adapter behind it — already
knows how to round-trip. An exception I write lands as a proper `RECURRENCE-ID`
override: DAVx5 pushes it to your server, and every other client that reads the
calendar sees the same override. If I expanded rules into my own private rows,
I'd own every one of these edge cases forever, and my "edits" would be invisible
to everything else that reads your calendar.
The hard part stays hard either way. Recurrence has been accreting rules and
corrections in [RFC 5545] for two decades, and there's no shortcut through it.
The trade is that by living inside the standard, the difficulty is *shared*: when
I get the original-instance bookkeeping right, the exception is correct
everywhere, not just in my app.
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[RFC 5545]: https://datatracker.ietf.org/doc/html/rfc5545

View File

@@ -0,0 +1,74 @@
---
title: The feature I said no to (and the one I found instead)
description: >-
A user asked Calendula to sync itself to a plain .ics file. Talking it through
in the open turned a "no" into a better, smaller feature — and clarified what
the app is actually for.
pubDate: 2026-07-10
tags: [open-source, calendula, open-standards]
draft: true
---
One of the more useful things that has happened to Calendula wasn't a feature I
added. It was one I turned down — in public, in a comment thread, with the
person who asked for it ([#7]).
The request: let Calendula save its calendar state to a file. Prompt for an
`.ics` on startup, write back on every change, so events are never "trapped" in
the app. Reasonable on its face. My answer was no — but the interesting part is
how we got from there to something I *did* ship.
## Why it was a no
Calendula doesn't own your data. It's a client for Android's system calendar
provider, and the syncing is left to whatever CalDAV adapter you've already set
up — DAVx5, SmoothSync, whatever moves your events today. A "write to an .ics on
every change" feature quietly reintroduces the exact thing the app exists to
avoid: it starts handling sync itself.
And live file sync is genuinely hard, for a reason that has nothing to do with
effort. The moment two sides can edit the same event — a local change and a
synced-in change landing on the same slot — *something* has to decide which wins.
CalDAV has machinery for that. The `.ics` format doesn't; it was designed for
backups and for handing events from one place to another, not for being written
to continuously. Build the feature and you inherit a merge engine you now have to
get right forever. As I put it in the thread: as a developer you want a database
to talk to, not to hand-roll storage and eat all the hiccups that come with it.
## Why saying no in the open mattered
I could have just closed the issue with "out of scope." Instead we talked it
through, and the request opened up into something more specific than the first
sentence. What the reporter was after wasn't live bidirectional sync — it was a
guarantee that their events could exist as a file that isn't hostage to one app.
Their words, as we got there: "just a way to automatically write and read into a
file without it ever being trapped in the application."
That's a completely different, and much smaller, feature. Not a sync engine —
a **periodical auto-export**: set an interval, and at that interval your
calendar is written out to an `.ics`. No merge problem, because it's one
direction. No philosophy problem, because export *is* what the format is for.
That became [#8], and it satisfied the actual need — "That satisfies me!" was
how the thread closed.
I also pointed them at [ICSx5] and mentioned a separate app, the DAVx5 pattern,
would be the honest home for true file sync — because a real solution to their
original idea deserves to exist, just not bolted into a calendar client.
## What the thread was really for
The trade I keep making is that
[open standards are a boundary, not a checkbox](/blog/open-standards): the
`.ics` request sat just outside that boundary, and holding the line kept the app
simple enough to stay trustworthy. But the lesson wasn't "learn to say no." It
was that a good "no" is a conversation. The user leaves with something that
works, I leave with a sharper sense of what the app is *for*, and the thread
stays public so the next person can read the whole reasoning instead of a closed
issue with one terse label.
You don't get that from a roadmap. You get it from taking the request seriously
enough to argue with it.
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
[ICSx5]: https://icsx5.bitfire.at/

View File

@@ -0,0 +1,76 @@
---
title: The translator who emailed me
description: >-
I stood up a whole self-hosted Weblate instance to invite translators. A
missing email setting silently blocked the first one who showed up — until he
emailed me directly. By the next morning: Italian at 40%.
pubDate: 2026-07-06
tags: [open-source, calendula, localization]
draft: true
---
If you want your app translated, the conventional wisdom is: lower the barrier.
Don't make people file pull requests against a strings file. Give them a web UI
where they can see the English, type the translation, and submit — no git, no
build, no friction. So I did the thorough thing and stood up my own
[Weblate](https://weblate.org/) instance, self-hosted next to everything else I
run, wired to Calendula's repo. The pipeline was ready. Then it sat there, empty,
because building the invitation is not the same as being invited.
## The bug that only a person could find
The first translator didn't come through the polished pipeline. He came through
my inbox.
Roberto — Italian, and generous enough to lead with why rather than what. He'd
been waiting years, he said, for a well-made Android calendar that was open
source and privacy-focused, and he wanted to help translate it.
But he couldn't register on my Weblate. The registration page told him there
were problems with the server. He'd signed up fine on the public Weblate site,
so he rightly suspected the fault was on my self-hosted end and asked whether it
could be fixed.
It was, and the cause was almost funny in how mundane it was: I'd never
configured email for the instance. Weblate sends a verification message on
registration; with no mail server behind it, that step failed silently, for
everyone. My own testing never caught it because I was already an admin. The
carefully-built front door had no doorbell, and I had no way of knowing until
someone stood outside it and told me.
Same day, I set up mail, verified that registration worked end to end, and wrote
back. His reply, once he was in, was simply that it was working — he'd started
translating.
## What the infrastructure couldn't do
Here's the part I keep thinking about. I'd invested in the *scalable* solution —
a hosted platform anyone in the world could use without talking to me. And its
first real outcome was a silent failure that turned every prospective
contributor away, invisibly, until one person chose the *un*-scalable path of
emailing a stranger to say "your thing is broken, I'd like to help anyway."
The tooling is still worth it. But the tooling didn't get Calendula translated —
a person who cared did, and who cared enough to push past a broken sign-up
instead of shrugging and closing the tab. No form submission would have told me
the form was broken.
## By morning
I fixed the mail config in the evening. By the next morning the Italian
translation was at **40%** and Spanish had appeared at **34%** — two languages
moving, from basically nothing, within a day of the sign-up actually working.
That's the whole arc of a small open-source project in miniature: you build the
proper infrastructure because it's the right thing to do, it fails in some dumb
invisible way, someone who genuinely wants the thing to exist reaches through the
gap to tell you, you fix it in an evening, and suddenly your calendar speaks
Italian. Not because the pipeline was clever. Because Roberto wrote an email.
If you're the kind of person who emails the developer instead of quietly giving
up: thank you. You are worth more than the analytics.
---
*Want to help translate [Calendula](/work/calendula)? The door works now —
[weblate.dev.jeanlucmakiola.de](https://weblate.dev.jeanlucmakiola.de).*

View File

@@ -0,0 +1,88 @@
---
title: The week my calendar app stopped being just mine
description: >-
Calendula was a thing I built for myself. Then strangers started relying on it,
filing issues, debating design in the comments — and it turned into something
with a small community around it.
pubDate: 2026-07-03
tags: [open-source, calendula]
draft: true
---
I built Calendula for myself. I wanted a fast, good-looking, privacy-respecting
calendar that didn't own my data, couldn't find one, and so I made one. For a
while that was all it was: my app, my phone, my taste.
Then people showed up. Not many, not all at once — but enough, in about two
weeks, that the project stopped feeling like a thing I *have* and started
feeling like a thing I'm *responsible for*. This is a note about that shift,
because nobody tells you how quickly it happens.
## From requests to a rhythm
The first issues were classic solo-dev fare: someone asked for a setting to
choose which view the app opens on. I shipped it in
[v2.9.0](https://codeberg.org/jlmakiola/calendula/issues/1) within hours, and
"that was quick!" came back. Then a tappable month grid; then a Saturday
week-start — each one small, each one turned around fast. Then: limit the agenda
view to today, or this week, or the next 30 days — and here the exchange got
interesting, because I stopped just building the literal request and started
asking *what options would you actually want*, and we designed it together in the
thread.
That rhythm — report, discuss, ship, "thank you" — is the engine of a small open
project. It's also a trap if you let it: not every request should be built. The
hard skill isn't saying yes fast, it's
[saying no well](/blog/the-feature-i-said-no-to), and keeping the app inside its
[lane](/blog/open-standards) while the person still leaves happy.
## The moment it gets real
There's a specific sentence that changes how you feel about a side project. Mine
arrived on an issue about registering an intent filter, from someone who'd hit a
papercut on GrapheneOS:
> I have been looking out for a well-designed and fast calendar since switching
> to Android four years ago and your software seems to be on point.
Four years of waiting. That's the kind of line that reframes a side project —
someone who's been holding out for exactly this and is ready to depend on it. The
same thread is where
I admitted a limitation honestly (there's no Android API to set yourself as the
default calendar, so no in-app button is possible — only the system picker), and
that honesty landed better than a workaround would have. Relied-upon software is
built on trust, and trust is mostly just not overpromising.
## When users become co-designers
The real tell that a community is forming isn't traffic — it's when people start
doing the *thinking* with you, not just the reporting.
On a request to make the view quick-switch button configurable, the conversation
turned into an actual design debate: should reordering live in a settings tab, or
should you long-press and drag items in place? Would drag-in-place cause
accidental reordering? We went back and forth on the interaction, not the
feature. On the contact-birthdays feature, a user who *wasn't even the original
reporter* created test contacts — one Google, one CardDAV — to help me isolate
why some birthdays showed up and others didn't. That's not filing a bug. That's
debugging *with* me.
I don't have a big community. I have a handful of people who care about a good
calendar as much as I do, and who've started treating its rough edges as *our*
problem. That's the shift.
## The unglamorous parts, on purpose
None of this is a highlight reel. In the same fortnight I shipped a release that
mangled the month widget's switching buttons — a regression a user caught and I
had to go dig out of a recent change. The birthday feature still doesn't handle
every account type. Writing this, I could have quietly left those out. But the
point of doing this in the open, on a public tracker, is that the reasoning and
the mistakes are all readable — the terse "closed" label replaced by an actual
conversation anyone can follow later.
A project stops being *just yours* the moment someone else would notice if it
broke. Calendula crossed that line quietly, in a week of issue threads, and it
changed how I write every commit since. The question stopped being "does this
please me" and became "would I want to explain this decision to the people in the
comments." The second one is the one worth answering.

View File

@@ -0,0 +1,92 @@
---
title: Compose for widgets, RemoteViews underneath
description: >-
Jetpack Glance lets you write home-screen widgets in Compose. But it compiles
down to RemoteViews — so the old constraints still bite through the nice API.
Three Calendula bugs that proved it.
pubDate: 2026-07-02
tags: [android, architecture, calendula]
draft: true
---
Calendula's home-screen widgets are written in
[Jetpack Glance](https://developer.android.com/develop/ui/compose/glance) — the
Compose-style API for widgets. You declare a `GlanceAppWidget`, write something
that looks like a composable, and attach behaviour with familiar-feeling
modifiers. It's a genuine relief compared to hand-assembling widget layouts.
But Glance isn't a new widget runtime. It's a translation layer: your
composition is compiled down to
[`RemoteViews`](https://developer.android.com/reference/android/widget/RemoteViews)
— a serialized description of a layout that *another* process (the launcher)
inflates and renders. Your app isn't running while the widget is on screen, and
it never gets the touch events. Glance hides that, but it can't repeal it. Three
Calendula widget bugs in a row were really the same lesson: the RemoteViews
constraints reach up through the Compose gloss and bite anyway.
## There's no click handler, only an action
In a real Compose UI you'd write `Modifier.clickable { doThing() }` and `doThing`
runs in your process. In Glance you write
`GlanceModifier.clickable(actionStartActivity<…>())` — and that difference is
the whole story. There's no lambda that runs on tap, because there's no code of
yours running on the far side. Glance only lets you attach an **action**
`actionStartActivity` to launch something, `actionRunCallback` to fire a
registered callback — and those compile down to the `PendingIntent`s RemoteViews
has always required. Taps don't call a function; they launch something.
That reframes every "make X tappable" request. When the **month widget's**
arrows and header did nothing ([#18]), it wasn't a broken handler — there was no
handler to break, and no action wired to the view either, so the launcher had
nothing to fire. The fix isn't "handle the tap," it's "attach the action": one
that advances the month, one that opens the app in month view. Same shape in
[#20] — the agenda widget's header should open your default view, again a
missing action, not a bug in one.
And because every day cell needs to open *its own* date, each one carries its
own action. Glance's `clickable` makes that look like an ordinary per-item
modifier, but underneath it's still one addressed intent per cell — a month grid
is a grid of them. An earlier round ([#2]) fixed exactly this: the month grid
wasn't interactive at all until each day was made to launch itself.
## The far side has to be told what "back" means
Because a tap launches an intent rather than navigating, the app has to
reconstruct context on arrival. Tapping a day in the **agenda** widget and
tapping a day in the **month** widget both open a day — but where should *Back*
take you? The widget knows; the freshly-started activity does not, unless the
action says so.
So `actionStartActivity` carries not just "open this day" but "you came from the
agenda context," and the app rebuilds a back stack from that: day → agenda →
your default view, or day → month → default. It looks like normal navigation to
the user. Under the hood it's the app trusting a breadcrumb the widget packed
into the launch, because a widget can't hand over a live navigation state —
Glance composition or not, all it can serialize is data.
## "Upcoming" has to actually mean upcoming
The last one wasn't about interaction. The agenda widget is titled **Upcoming**,
yet it listed every event of the day, finished ones included ([#12]). In a
normal Compose list you'd just filter as the list recomposes. A widget can't —
what the launcher renders is baked in when the composition is snapshotted to
RemoteViews, not recomputed as you scroll. So "don't show past events" becomes a
property of the data you build *before* Glance serializes it, exposed as a
`PastEventDisplay` preference: hide finished events outright, or dim them in
place for people who still want them visible.
## The lesson
Glance is a real improvement — declaring widgets in Compose beats the old
ceremony. But it's a nicer handle on the same box. There's still no process of
yours, still no live view tree, still only data you snapshot and actions you
pre-address, rendered by someone else. Once I stopped thinking "how do I handle
this tap" and started thinking "what action should this fire, and what does the
far side need to know," the three bugs stopped looking separate. A widget can't
*do* anything. It can only describe what should happen — and Compose syntax
doesn't change that, it just makes the describing pleasant.
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20

View File

@@ -1,10 +1,11 @@
---
import { getCollection, render } from 'astro:content';
import { render } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
import type { GetStaticPaths } from 'astro';
import { getPublishedPosts } from '../../utils/posts';
export const getStaticPaths = (async () => {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const posts = await getPublishedPosts();
return posts.map((post) => ({
params: { slug: post.id },
props: { post },

View File

@@ -1,11 +1,9 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import FormattedDate from '../../components/FormattedDate.astro';
import { getCollection } from 'astro:content';
import { getPublishedPosts } from '../../utils/posts';
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const posts = await getPublishedPosts();
---
<BaseLayout title="Blog" description="Writing and notes by Jean-Luc Makiola." width="narrow">

View File

@@ -3,10 +3,9 @@ import BaseLayout from '../layouts/BaseLayout.astro';
import { Icon } from 'astro-icon/components';
import { getCollection } from 'astro:content';
import { SITE } from '../consts';
import { getPublishedPosts } from '../utils/posts';
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
.slice(0, 4);
const posts = (await getPublishedPosts()).slice(0, 4);
const fmt = (d: Date) =>
d.toLocaleDateString(SITE.lang, { year: 'numeric', month: 'short' });

View File

@@ -1,11 +1,9 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import { SITE } from '../consts';
import { getPublishedPosts } from '../utils/posts';
export async function GET(context) {
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const posts = await getPublishedPosts();
return rss({
title: SITE.title,

View File

@@ -1,11 +1,11 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import FormattedDate from '../../components/FormattedDate.astro';
import { getCollection } from 'astro:content';
import type { GetStaticPaths } from 'astro';
import { getPublishedPosts } from '../../utils/posts';
export const getStaticPaths = (async () => {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const posts = await getPublishedPosts();
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
return tags.map((tag) => ({
params: { tag },

24
src/utils/posts.ts Normal file
View File

@@ -0,0 +1,24 @@
import { getCollection, type CollectionEntry } from 'astro:content';
/**
* Published blog posts, newest first.
*
* A post is published when it is not a draft AND its `pubDate` has arrived.
* That makes `pubDate` the single source of truth for scheduling: set a post
* to `draft: false` with a future date and it goes live on its own once a
* build runs on or after that day (see the daily deploy cron).
*
* In dev the gate is lifted entirely so drafts and future-scheduled posts can
* be previewed locally; production applies both the draft and date gates.
*/
export async function getPublishedPosts(): Promise<CollectionEntry<'blog'>[]> {
const now = Date.now();
const posts = await getCollection('blog', ({ data }) => {
// Dev server: show everything, including drafts and not-yet-due posts.
if (import.meta.env.DEV) return true;
if (data.draft) return false;
if (data.pubDate.valueOf() > now) return false;
return true;
});
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}