Skip to content

feat(content-freshness): unpublished changes and expired entries - #14

Merged
danielmlr merged 5 commits into
mainfrom
feat/content-freshness-phase3
Sep 25, 2026
Merged

danielmlr merged 5 commits into
mainfrom
feat/content-freshness-phase3

Conversation

@danielmlr

Copy link
Copy Markdown
Member

Phase 3 of the content-freshness plan adds two rules. The package stays "private": true: no changeset, no publish.

The rules

  • Unpublished changes. A published entry has changes saved in a draft revision that nobody has published for longer than a threshold (new setting, default 14 days).
    • A save to a published entry leaves updatedAt untouched. Revision IDs are ULIDs, though, so the draft revision's ID gives the time of the save, without an extra call.
    • Publishing or discarding the draft clears draft_revision_id (checked at the emdash@0.39.0 tag).
    • The rule only counts in collections that keep revisions, and not while the changes are scheduled to go live.
    • "Mark as reviewed" holds the finding back until the threshold runs out.
  • Expired. A published entry whose expiry date has passed.
    • "Ignore" holds it back, for an event page kept up as an archive. When the date is moved and passes again, the finding comes back.

Changed from the plan: expiry is opt-in

The plan had the expiry field detected from its name and switched on automatically. The review showed what that does on a site that keeps past events online: a finding for every one of them, straight after installing.

So expiry is now chosen per collection. When a collection has a datetime field named like an expiry date (valid_until, end_date, expires_at, valid_through, deadline, …), the report says "Expiry dates not checked: Offers (Valid until)" and links to the settings. There you choose the field, another field, or "Never expires", which also hides the suggestion.

If you'd rather have it automatic, it's a one-line change in expiryFor.

Also in this PR

  • Stale drafts are now dated by their last save. In a collection with revisions, a save leaves updatedAt alone, so a draft edited yesterday looked untouched since it was created.
  • A save counts as work in progress. An entry saved within the stale threshold is no longer reported as stale; if the editor stops, "unpublished changes" reports it instead.
  • The sweep state has a version. A sweep started by an earlier version of the plugin starts over.

Code review

/code-review high ran before this PR and found 10 issues. 9 are fixed; each has a test that fails with the fix removed, checked one by one.

  1. EmDash stores every datetime value as a UTC instant. A value without a time counts as midnight in the site's timezone; there is no date-only mode. My "bare date lasts until the end of the day" path therefore never ran on real data; only the test host keeps bare strings. The plugin can't read the site timezone, so it compares the instant as stored and names it to the minute in UTC: "Valid until was 2026-01-30 23:00 UTC".
  2. Unpublished changes counted even when scheduled. No longer.
  3. Stale drafts were dated by updatedAt. Now by the last save.
  4. An ignored expiry stayed ignored after the date changed. It's now tied to its date.
  5. Collections without revisions got false "unpublished changes". A restore can leave a draft pointer there that nothing clears. The rule now needs supports: revisions.
  6. Expiry flooded archive sites. It's now opt-in with a suggestion (see above).
  7. A sweep started before an upgrade lacked the collection details. The version check covers it.
  8. Detection missed common names (expiration, valid_to, valid_through, *_datetime).
  9. The date-field filter was written three times. Now one helper, plus DAY_MS in one place.

Not changed: schema.getCollection on every check of an entry. It runs in parallel with the other reads, the worst case is 7 of 10 calls, and the revisions check needs it anyway.

Verified

  • 119 tests, emdash-plugin validate, typecheck, build. The bundle is 31.3 KB.

  • scripts/compat-matrix.sh 0.39.0 0.39.1 0.40.0 on top of the 0.40.1 baseline: content-freshness 119, analytics 312 on every version.

  • Bridge calls per invocation (limit 10, the budget test checks each):

    Invocation Calls
    Run that starts a sweep 9
    Follow-up 8
    Cleanup 6
    Content hook 6
    Panel 6
    First dashboard view 8
    First report view 9
    Paging, Audit now, saving settings at most 8
  • A live run and screenshots come with the release (plan Phase 4).

Two rules that need nothing the content API does not already return.

- Unpublished changes: a published entry whose saved changes have waited
  in a draft revision for longer than a threshold (14 days by default).
  A save on a published entry leaves `updatedAt` alone, but revision ids
  are ULIDs, so the draft revision's id says when the changes were saved.
  Changes saved within the stale threshold also keep the entry from
  counting as stale: someone is working on it. "Mark as reviewed" holds
  the finding back for the threshold.
- Expired: a published entry whose expiry date has passed. The field is
  the collection's first `datetime` field named like valid_until,
  end_date, expires_at or deadline; each collection's settings can name
  another field or switch expiry off. A date without a time lasts until
  the end of that day. "Ignore" holds it back for a page kept online as
  an archive.

The sweep reads the collections' `datetime` fields with the collection
list it already fetches; a check of one entry reads its collection once.
- EmDash stores every datetime value as a UTC instant, reading one
  without a time as midnight in the site's timezone; a datetime field has
  no date-only mode. The "bare date lasts to the end of the day" branch
  therefore never ran, and the finding named the UTC day, a day early for
  sites east of UTC. A plugin cannot read the site's timezone, so the
  instant is compared as it is and named to the minute in UTC.
- Expiry is opt-in per collection. Detected automatically, it would have
  given a site that keeps past events online a finding for each of them
  on install. The report suggests a field named like an expiry date until
  the collection has one chosen or is set to "Never expires".
- An ignored expiry remembers its date, so moving the date forward and
  letting it pass again is reported.
- Unpublished changes count only in collections that keep revisions,
  where a draft revision means staged changes, and not while they are
  scheduled to go live, which is overdue-schedule's to report.
- Stale drafts are dated by their last save: in a collection with
  revisions a save leaves `updatedAt` alone, so a draft edited yesterday
  looked untouched since its creation.
- A sweep carries a version; one started by an earlier version of the
  plugin starts over instead of auditing without the collection details.
- More expiry field names (expiration, valid_to, valid_through, *_datetime),
  and one helper for a collection's datetime fields.
@changeset-bot

changeset-bot Bot commented Sep 25, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9e0272a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@danielmlr
danielmlr merged commit 8ec675b into main Sep 25, 2026
2 checks passed
@danielmlr
danielmlr deleted the feat/content-freshness-phase3 branch September 25, 2026 23:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant