@acms-subir/core 0.3.19 → 0.3.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/cli/main.mjs +3 -2
  3. package/cli/migrations.generated.mjs +12 -1
  4. package/docs/server.md +144 -0
  5. package/integration.mjs +8 -3
  6. package/package.json +2 -1
  7. package/src/actions/index.ts +13 -1
  8. package/src/components/EventPage.astro +7 -4
  9. package/src/components/calendar/CalendarBoard.tsx +11 -2
  10. package/src/components/editor/event/OrganizeCard.tsx +1 -1
  11. package/src/lib/booking/holds.ts +4 -2
  12. package/src/lib/booking/service.ts +4 -1
  13. package/src/lib/calendar/agent.ts +1 -1
  14. package/src/lib/calendar/api.ts +8 -5
  15. package/src/lib/calendar/board.ts +6 -7
  16. package/src/lib/calendar/cards.ts +8 -6
  17. package/src/lib/calendar/config.ts +12 -0
  18. package/src/lib/calendar/data.ts +10 -3
  19. package/src/lib/calendar/events.ts +35 -2
  20. package/src/lib/calendar/ics-write.ts +27 -3
  21. package/src/lib/calendar/schedule.ts +45 -4
  22. package/src/lib/calendar/seo.ts +2 -2
  23. package/src/lib/content-service.ts +4 -2
  24. package/src/lib/mobile/calendar.ts +4 -3
  25. package/src/lib/mobile/schemas.ts +3 -3
  26. package/src/lib/mobile/search.ts +1 -1
  27. package/src/lib/repo/migrations.ts +12 -1
  28. package/src/lib/repo/sql.ts +13 -8
  29. package/src/lib/repo/types.ts +3 -1
  30. package/src/lib/schema.ts +4 -0
  31. package/src/lib/sections.ts +2 -2
  32. package/src/lib/sync/catalog.ts +1 -0
  33. package/src/pages/api/calendar/docs.ts +1 -1
  34. package/src/pages/api/calendar/events/[id].ts +1 -1
  35. package/src/pages/api/calendar/events/index.ts +6 -4
  36. package/src/pages/calendar/index.astro +1 -1
  37. package/src/pages/events/[slug].astro +4 -3
  38. package/src/server/index.ts +141 -0
  39. package/src/theme-sdk/calendar.ts +6 -2
  40. package/src/virtual.d.ts +1 -1
@@ -6,7 +6,7 @@ import type { ContentRepository } from '../repo/types';
6
6
  import type { CalEvent, Calendar, Settings } from '../schema';
7
7
  import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
8
8
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
9
- import { CALENDAR, eventUrl, resourceUrl } from './config';
9
+ import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
10
10
 
11
11
  export interface ViewEvent extends CalendarEvent {
12
12
  url?: string;
@@ -63,6 +63,13 @@ export async function collectViewEvents(
63
63
  });
64
64
 
65
65
  const events: ViewEvent[] = [];
66
+ // public views (cards, sections, theme helpers) never carry private fields
67
+ const shown = new Map<string, CalEvent>();
68
+ const view = (e: CalEvent) => {
69
+ if (scope.audience === 'admin') return e;
70
+ if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(e));
71
+ return shown.get(e.id)!;
72
+ };
66
73
  for (const o of expandEvents(natives, settings, win)) {
67
74
  const e = o.event;
68
75
  const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
@@ -77,11 +84,11 @@ export async function collectViewEvents(
77
84
  ref: e.id,
78
85
  url: eventUrl(e.slug) + on,
79
86
  color: cal ? safeColor(cal.color) : undefined,
80
- cancelled: e.cancelled,
87
+ cancelled: o.cancelled,
81
88
  image: e.cover || undefined,
82
89
  imageAlt: e.coverAlt || undefined,
83
90
  calendarName: cal?.name,
84
- event: e,
91
+ event: view(e),
85
92
  });
86
93
  }
87
94
 
@@ -116,6 +116,25 @@ export interface Occurrence {
116
116
  end: number;
117
117
  allDay: boolean;
118
118
  days: number;
119
+ // this occurrence's start as wall time ('YYYY-MM-DDTHH:mm' in the event's
120
+ // zone, 'YYYY-MM-DD' for all-day): what exdates and cancelledDates store
121
+ key: string;
122
+ // the whole event is cancelled, or just this date
123
+ cancelled: boolean;
124
+ }
125
+
126
+ /**
127
+ * The occurrence key for an occurrence start. Timed events use the event's
128
+ * own zone; all-day occurrences start at midnight in the display zone (how
129
+ * the expander places them), so their date is read there.
130
+ */
131
+ export function occurrenceKey(e: Pick<CalEvent, 'allDay' | 'timezone'>, start: number, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): string {
132
+ if (e.allDay) {
133
+ const p = tzParts(start, displayTz || 'UTC');
134
+ return `${p.y}-${pad(p.m)}-${pad(p.d)}`;
135
+ }
136
+ const p = tzParts(start, eventTimeZone(e, settings));
137
+ return `${p.y}-${pad(p.m)}-${pad(p.d)}T${pad(p.hh)}:${pad(p.mm)}`;
119
138
  }
120
139
 
121
140
  export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timezone'>, win: CalendarWindow): Occurrence[] {
@@ -124,13 +143,26 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
124
143
  const tz = eventTimeZone(e, settings);
125
144
  const v = toVEvent(e, tz);
126
145
  if (!v) continue;
146
+ // ?? []: editor previews pass unsaved events that may lack the field
147
+ const cancelledDates = new Set(e.cancelledDates ?? []);
127
148
  for (const o of expandVEvents([v], tz, win, 0)) {
128
- out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days });
149
+ const key = occurrenceKey(e, o.start, settings, win.displayTz);
150
+ out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key) });
129
151
  }
130
152
  }
131
153
  return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
132
154
  }
133
155
 
156
+ // The occurrence of a series with this key (null when the series has no
157
+ // such date: never generated, skipped by exdates, or the series moved)
158
+ export function findOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): Occurrence | null {
159
+ const w = parseWall(key);
160
+ if (!w || w.dateOnly !== e.allDay) return null;
161
+ const at = e.allDay ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz || 'UTC') : wallToMs(w, eventTimeZone(e, settings));
162
+ return expandEvents([e], settings, { displayTz, now: at, windowStartMs: at - DAY, windowEndMs: at + DAY })
163
+ .find((o) => o.key === key && o.start === at) ?? null;
164
+ }
165
+
134
166
  // The occurrence to feature for an event: the next one that hasn't ended,
135
167
  // else the most recent. `on` ('YYYY-MM-DD') picks a specific day's instance
136
168
  // (links from the calendar grid carry it).
@@ -151,7 +183,8 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
151
183
  const s = parseWall(e.start);
152
184
  const en = parseWall(e.end) ?? s;
153
185
  const days = e.allDay && s && en ? Math.round((wallMinutes(en) - wallMinutes(s)) / 1440) + 1 : 1;
154
- return { event: e, start: startAt, end: endAt, allDay: e.allDay, days };
186
+ const key = occurrenceKey(e, startAt, settings, displayTz);
187
+ return { event: e, start: startAt, end: endAt, allDay: e.allDay, days, key, cancelled: e.cancelled || (e.cancelledDates ?? []).includes(key) };
155
188
  }
156
189
  const next = expandEvents([e], settings, { displayTz, now, windowStartMs: now, windowEndMs: now + 3 * 366 * DAY })[0];
157
190
  if (next) return next;
@@ -3,9 +3,11 @@
3
3
  // events are written in UTC (unambiguous everywhere); recurring timed events
4
4
  // keep their wall clock with TZID=<IANA zone> so a weekly 10am stays at 10am
5
5
  // across DST — Google, Apple and Outlook all resolve IANA zone ids. All-day
6
- // events are VALUE=DATE with the RFC's exclusive DTEND.
6
+ // events are VALUE=DATE with the RFC's exclusive DTEND. A single cancelled
7
+ // date of a series is an override VEVENT (same UID, RECURRENCE-ID) with
8
+ // STATUS:CANCELLED, so calendar apps strike that one date.
7
9
  import type { CalEvent, Calendar } from '../schema';
8
- import { addWallDays, eventInstants, eventTimeZone, locationLine, parseWall, type Wall } from './events';
10
+ import { addWallDays, eventInstants, eventTimeZone, findOccurrence, locationLine, parseWall, type Wall } from './events';
9
11
  import { zonedToUtc } from './ics';
10
12
  import { cleanRrule, rruleMap } from './rrule';
11
13
 
@@ -99,10 +101,32 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
99
101
  const cats = e.calendarIds.map((id) => opts.calendars?.get(id)?.name).filter((n): n is string => Boolean(n));
100
102
  if (cats.length) lines.push(`CATEGORIES:${cats.map(escapeText).join(',')}`);
101
103
  if (e.cancelled) lines.push('STATUS:CANCELLED');
102
- lines.push(`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`, 'END:VEVENT');
104
+ const stamps = [`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`];
105
+ lines.push(...stamps, 'END:VEVENT');
106
+ if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps));
103
107
  return lines;
104
108
  }
105
109
 
110
+ // one override per cancelled date that is still part of the series (keys
111
+ // left behind by a moved series are ignored)
112
+ function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
113
+ const out: string[] = [];
114
+ const toMin = (w: Wall) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm) / 60_000;
115
+ const lenMin = e.allDay ? (toMin({ ...en, hh: 0, mm: 0 }) - toMin({ ...s, hh: 0, mm: 0 })) + 1440 : Math.max(0, toMin(en) - toMin(s));
116
+ for (const key of [...new Set(e.cancelledDates ?? [])].sort()) {
117
+ const w = parseWall(key);
118
+ if (!w || !findOccurrence(e, key, { timezone: siteTz }, siteTz || 'UTC')) continue;
119
+ const endAt = new Date((toMin(w) + lenMin) * 60_000);
120
+ const end: Wall = { y: endAt.getUTCFullYear(), m: endAt.getUTCMonth() + 1, d: endAt.getUTCDate(), hh: endAt.getUTCHours(), mm: endAt.getUTCMinutes(), dateOnly: w.dateOnly };
121
+ const when = e.allDay
122
+ ? [`RECURRENCE-ID;VALUE=DATE:${dateValue(w)}`, `DTSTART;VALUE=DATE:${dateValue(w)}`, `DTEND;VALUE=DATE:${dateValue(end)}`]
123
+ : [`RECURRENCE-ID;TZID=${tz}:${localValue(w)}`, `DTSTART;TZID=${tz}:${localValue(w)}`, `DTEND;TZID=${tz}:${localValue(end)}`];
124
+ out.push('BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
125
+ `SUMMARY:${escapeText(e.title || 'Untitled event')}`, `URL:${url}`, 'STATUS:CANCELLED', ...stamps, 'END:VEVENT');
126
+ }
127
+ return out;
128
+ }
129
+
106
130
  export function buildFeed(events: CalEvent[], siteTz: string, opts: FeedOptions): string {
107
131
  const lines = [
108
132
  'BEGIN:VCALENDAR',
@@ -3,13 +3,15 @@
3
3
  // save/apply path as the event editor, so hooks, feeds, search and webhooks
4
4
  // see the same changes. A move applies at once, even on a published event —
5
5
  // the editor's "stage until Update" flow is for content edits, not dates.
6
+ // Cancelling one date of a series applies at once too, without touching any
7
+ // staged edits.
6
8
  import type { ContentRepository } from '../repo/types';
7
9
  import type { CalEvent } from '../schema';
8
10
  import { EventLocation, slugify } from '../schema';
9
11
  import { defaultPlaceLocation } from '../maps/places';
10
12
  import { ApiError } from '../api';
11
- import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
12
- import { addWallDays, formatWall, parseWall } from './events';
13
+ import { applyEventUpdate, eventSideEffects, saveEvent, type SaveHooks } from '../content-service';
14
+ import { addWallDays, findOccurrence, formatWall, parseWall } from './events';
13
15
  import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
14
16
  import { eventDeleted } from './hooks';
15
17
  import { waitUntilFrom } from '../webhooks';
@@ -104,10 +106,15 @@ export async function moveSeries(repo: ContentRepository, id: string, days: numb
104
106
  if (!e) throw new ApiError(404, 'Event not found.');
105
107
  if (!Number.isInteger(days) || Math.abs(days) > 3660) throw new ApiError(400, 'Pick a day to move it to.');
106
108
  const start = shift(e.start, days);
107
- const event = await saveNow(repo, e, {
109
+ let event = await saveNow(repo, e, {
108
110
  start, end: shift(e.end, days), allDay: e.allDay,
109
111
  ...(e.rrule ? { rrule: shiftRrule(e.rrule, days, start), exdates: e.exdates.map((x) => shift(x, days)) } : {}),
110
112
  }, hooks);
113
+ // cancelled dates move with the series, like skipped ones
114
+ if (e.rrule && e.cancelledDates.length) {
115
+ await repo.setEventCancelledDates(id, e.cancelledDates.map((x) => shift(x, days)));
116
+ event = (await repo.getEvent(id))!;
117
+ }
111
118
  return { event, undo: { kind: 'series', id, days: -days } };
112
119
  }
113
120
 
@@ -135,7 +142,9 @@ export async function moveOccurrence(repo: ContentRepository, id: string, occurr
135
142
  const copy = await repo.createEvent({
136
143
  title: e.title, slug: await uniqueSlug(repo, e.title), start: formatWall(newStart), end: endStr, allDay: e.allDay,
137
144
  timezone: e.timezone, status: 'draft', excerpt: e.excerpt, bodyJson: e.bodyJson, bodyHtml: e.bodyHtml, cover: e.cover, coverAlt: e.coverAlt,
138
- location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags, cancelled: e.cancelled, rsvp: e.rsvp,
145
+ location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags,
146
+ // a cancelled date stays cancelled on its new day
147
+ cancelled: e.cancelled || e.cancelledDates.includes(occurrence), rsvp: e.rsvp,
139
148
  template: e.template, featured: false,
140
149
  });
141
150
  const event = live(e.status) ? await saveEvent(repo, copy.id, { status: 'published' }, hooks) : copy;
@@ -156,6 +165,38 @@ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: S
156
165
  if (series) await saveNow(repo, series, { exdates: series.exdates.filter((x) => x !== undo.occurrence) }, hooks);
157
166
  }
158
167
 
168
+ // ---- cancel one date ----------------------------------------------------------
169
+ // The date stays on the calendar struck through ("Cancelled"), its room holds
170
+ // are released, and feeds mark it STATUS:CANCELLED. These do no permission
171
+ // checks: callers (admin actions, site code through @acms-subir/core/server)
172
+ // decide who may do this.
173
+
174
+ async function setOccurrenceCancelled(repo: ContentRepository, id: string, key: string, cancel: boolean, hooks: SaveHooks, now = Date.now()): Promise<CalEvent> {
175
+ const e = await repo.getEvent(id);
176
+ if (!e) throw new ApiError(404, 'Event not found.');
177
+ if (!e.rrule) throw new ApiError(400, 'Only a repeating event has single dates to cancel. Cancel the whole event instead.');
178
+ const settings = await repo.getSettings();
179
+ const o = typeof key === 'string' && key.length <= 20 ? findOccurrence(e, key, settings) : null;
180
+ if (!o) throw new ApiError(400, 'That date isn’t part of this event.');
181
+ if (o.start <= now) throw new ApiError(400, 'That date has already started or passed.');
182
+ const has = e.cancelledDates.includes(key);
183
+ if (has === cancel) return e; // already so: nothing to change
184
+ await repo.setEventCancelledDates(id, cancel ? [...e.cancelledDates, key].sort() : e.cancelledDates.filter((x) => x !== key));
185
+ const after = (await repo.getEvent(id))!;
186
+ await eventSideEffects(repo, hooks, e, after);
187
+ return after;
188
+ }
189
+
190
+ /** Cancel one date of a repeating event, at once (also on a published event). Idempotent. */
191
+ export function cancelOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number): Promise<CalEvent> {
192
+ return setOccurrenceCancelled(repo, id, key, true, hooks, now);
193
+ }
194
+
195
+ /** Undo cancelOccurrence for one date. Idempotent. */
196
+ export function restoreOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number): Promise<CalEvent> {
197
+ return setOccurrenceCancelled(repo, id, key, false, hooks, now);
198
+ }
199
+
159
200
  export async function duplicateEvent(repo: ContentRepository, id: string): Promise<CalEvent> {
160
201
  const e = await repo.getEvent(id);
161
202
  if (!e) throw new ApiError(404, 'Event not found.');
@@ -17,7 +17,7 @@ export function isoWithOffset(ms: number, tz: string): string {
17
17
 
18
18
  const isMeetingUrl = (u: string) => /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si|youtube\.com\/live|twitch\.tv/i.test(u);
19
19
 
20
- export function eventJsonLd(e: CalEvent, o: Pick<Occurrence, 'start' | 'end' | 'allDay'>, settings: Settings, origin: string, pageUrl: string) {
20
+ export function eventJsonLd(e: CalEvent, o: Pick<Occurrence, 'start' | 'end' | 'allDay'> & { cancelled?: boolean }, settings: Settings, origin: string, pageUrl: string) {
21
21
  const tz = eventTimeZone(e, settings);
22
22
  const date = (ms: number) => {
23
23
  const p = tzParts(ms, tz);
@@ -39,7 +39,7 @@ export function eventJsonLd(e: CalEvent, o: Pick<Occurrence, 'start' | 'end' | '
39
39
  ...(e.excerpt || e.seoDesc ? { description: e.seoDesc || e.excerpt } : {}),
40
40
  startDate: o.allDay ? date(o.start) : isoWithOffset(o.start, tz),
41
41
  endDate: o.allDay ? date(o.end - 86_400_000) : isoWithOffset(o.end, tz),
42
- eventStatus: e.cancelled ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',
42
+ eventStatus: e.cancelled || o.cancelled ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',
43
43
  eventAttendanceMode: online && place
44
44
  ? 'https://schema.org/MixedEventAttendanceMode'
45
45
  : online ? 'https://schema.org/OnlineEventAttendanceMode' : 'https://schema.org/OfflineEventAttendanceMode',
@@ -35,7 +35,9 @@ export type PostSave = Parameters<ContentRepository['updatePost']>[1];
35
35
  export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blocks'>;
36
36
  // location may be partial: missing pin fields are filled from the current
37
37
  // location (see mergeLocationInput) — older clients send name/address/url only
38
- export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location'>> & {
38
+ // cancelledDates is left out: single dates are cancelled at once through
39
+ // cancelOccurrence/restoreOccurrence (lib/calendar/schedule.ts), never staged
40
+ export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location' | 'cancelledDates'>> & {
39
41
  location?: Partial<EventLocation>;
40
42
  };
41
43
  type EventClean = Omit<EventSave, 'location'> & { location?: EventLocation };
@@ -200,7 +202,7 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
200
202
  return out;
201
203
  }
202
204
 
203
- async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, before: CalEvent, after: CalEvent, applied = false) {
205
+ export async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, before: CalEvent, after: CalEvent, applied = false) {
204
206
  const settings = await repo.getSettings();
205
207
  await eventChanged(repo, before, after, {
206
208
  origin: siteOrigin(settings, hooks.url), waitUntil: waitUntilFrom(hooks.locals), applied, onRedirect: hooks.onRedirect,
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
3
  import type { BookingLink, CalEvent, Calendar, Resource, Settings } from '../schema';
4
4
  import { ApiError } from '../api';
5
- import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl } from '../calendar/config';
5
+ import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
6
6
  import { calendarToJson, eventToJson } from '../calendar/api';
7
7
  import { eventIsListed, eventIsPublic, expandEvents } from '../calendar/events';
8
8
  import { availabilityContext } from '../booking/service';
@@ -54,6 +54,7 @@ export async function listEvents(repo: ContentRepository, url: URL) {
54
54
  locale, timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
55
55
  occurrences: occurrences.slice(start, start + q.limit).map(o => ({
56
56
  id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay,
57
+ cancelled: o.cancelled,
57
58
  event: publicEventDto(o.event, origin, publicCalendars, settings.timezone, locale),
58
59
  })),
59
60
  pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
@@ -63,8 +64,8 @@ export async function listEvents(repo: ContentRepository, url: URL) {
63
64
  export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, siteTz: string, locale: string | null) {
64
65
  const event = eventToJson(e, origin, publicCalendars, siteTz);
65
66
  event.url = localizedUrl(event.url, origin, locale);
66
- // Only configured public event fields; no undeclared imported metadata.
67
- event.fields = Object.fromEntries(CALENDAR.eventFields.filter(f => Object.hasOwn(e.fields, f.key)).map(f => [f.key, e.fields[f.key]]));
67
+ // Only configured public event fields; no undeclared imported metadata, no private fields.
68
+ event.fields = publicEventFields(e.fields);
68
69
  event.image = safeUrl(e.cover, origin);
69
70
  event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
70
71
  return event;
@@ -28,18 +28,18 @@ export const Page = ContentSummary.extend({
28
28
  });
29
29
  export const Event = CalEvent.pick({
30
30
  id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
31
- allDay: true, timezone: true, rrule: true, exdates: true, location: true, cta: true, tags: true, fields: true,
31
+ allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
32
32
  }).extend({ rsvp: CalEvent.shape.rsvp.optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
33
33
  calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
34
34
  });
35
35
  export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
36
- occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, event: Event })),
36
+ occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, event: Event })),
37
37
  });
38
38
  const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
39
39
  const ContentHit = ContentSummary.extend({ snippet: Snippet });
40
40
  export const SearchResults = z.object({ locale, query: str,
41
41
  posts: z.array(ContentHit), pages: z.array(ContentHit),
42
- events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool }).nullable(), event: Event })),
42
+ events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool }).nullable(), event: Event })),
43
43
  more: z.object({ posts: bool, pages: bool, events: bool }),
44
44
  });
45
45
  const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
@@ -86,7 +86,7 @@ export async function search(repo: ContentRepository, url: URL) {
86
86
  return {
87
87
  snippet: plainSnippet(snippet),
88
88
  // the next occurrence, or the latest one when the event is over
89
- occurrence: o ? { start: iso(o.start), end: iso(o.end), allDay: o.allDay } : null,
89
+ occurrence: o ? { start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled } : null,
90
90
  event: publicEventDto(e, origin, listed, settings.timezone, locale),
91
91
  };
92
92
  }),
@@ -655,9 +655,20 @@ export const MIGRATIONS: Migration[] = [
655
655
  `DROP TRIGGER content_version_settings_update`,
656
656
  ...contentVersionTriggers('settings', [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale']),
657
657
  ] },
658
+ // one cancelled date of a repeating event (CalEvent.cancelledDates)
659
+ { version: 55, statements: [
660
+ `ALTER TABLE events ADD COLUMN cancelled_dates TEXT NOT NULL DEFAULT '[]'`,
661
+ `DROP TRIGGER content_version_events_update`,
662
+ // [insert, delete, update]: only the update trigger names columns
663
+ contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'])[2],
664
+ ] },
658
665
  ];
659
666
 
660
667
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
661
- export const CURRENT_CONTENT_VERSION_COLUMNS = { ...CONTENT_VERSION_COLUMNS, settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale'] };
668
+ export const CURRENT_CONTENT_VERSION_COLUMNS = {
669
+ ...CONTENT_VERSION_COLUMNS,
670
+ settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale'],
671
+ events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
672
+ };
662
673
 
663
674
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -149,7 +149,7 @@ function rowToEvent(r: Row): CalEvent {
149
149
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
150
150
  featured: Boolean(r.featured), tags: json(r.tags, []), template: r.template ?? '',
151
151
  start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
152
- rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
152
+ rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []),
153
153
  location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
154
154
  fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
155
155
  startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
@@ -833,8 +833,8 @@ export class SqlRepository implements ContentRepository {
833
833
  await this.driver.run(
834
834
  `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
835
835
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
836
- published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
837
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
836
+ published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates)
837
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
838
838
  [
839
839
  id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
840
840
  data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
@@ -842,7 +842,7 @@ export class SqlRepository implements ContentRepository {
842
842
  data.cancelled ? 1 : 0, JSON.stringify(data.location ?? {}), JSON.stringify(data.cta ?? {}), JSON.stringify(data.calendarIds ?? []),
843
843
  JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
844
844
  data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
845
- JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}),
845
+ JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
846
846
  ]
847
847
  );
848
848
  await this.syncEventIndex(id);
@@ -867,7 +867,7 @@ export class SqlRepository implements ContentRepository {
867
867
  if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
868
868
  }
869
869
  const jsonCols: Record<string, string> = {
870
- tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
870
+ tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
871
871
  };
872
872
  for (const [k, col] of Object.entries(jsonCols)) {
873
873
  const v = (data as Record<string, unknown>)[k];
@@ -927,6 +927,11 @@ export class SqlRepository implements ContentRepository {
927
927
  if (!draft) return;
928
928
  await this.updateEvent(id, pickEventDraft(draft));
929
929
  }
930
+ // per-date cancellations apply at once and never touch a staged draft
931
+ async setEventCancelledDates(id: string, dates: string[]) {
932
+ await this.ready;
933
+ await this.driver.run('UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', [JSON.stringify(dates), Date.now(), id]);
934
+ }
930
935
  async discardEventDraft(id: string) {
931
936
  await this.ready;
932
937
  await this.driver.run('UPDATE events SET draft = NULL WHERE id = ?', [id]);
@@ -1779,14 +1784,14 @@ export class SqlRepository implements ContentRepository {
1779
1784
  ...(content.events ?? []).map((e) => ({
1780
1785
  sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
1781
1786
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
1782
- draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
1783
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1787
+ draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates)
1788
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1784
1789
  args: [
1785
1790
  e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
1786
1791
  JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
1787
1792
  e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
1788
1793
  e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
1789
- JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}),
1794
+ JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}), JSON.stringify(e.cancelledDates ?? []),
1790
1795
  ],
1791
1796
  })),
1792
1797
  ...(content.forms ?? []).map(formInsert),
@@ -45,7 +45,7 @@ export type EventUpdate = Partial<
45
45
  CalEvent,
46
46
  | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
47
47
  | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
48
- | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
48
+ | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates'
49
49
  | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
50
50
  | 'publishedAt' | 'scheduledAt'
51
51
  >
@@ -143,6 +143,8 @@ export interface ContentRepository {
143
143
  saveEventDraft(id: string, data: Omit<EventUpdate, 'status' | 'scheduledAt' | 'publishedAt'>): Promise<void>;
144
144
  applyEventDraft(id: string): Promise<void>;
145
145
  discardEventDraft(id: string): Promise<void>;
146
+ /** replace an event's cancelled occurrence keys at once, keeping any staged draft */
147
+ setEventCancelledDates(id: string, dates: string[]): Promise<void>;
146
148
  deleteEvent(id: string): Promise<void>;
147
149
  // saved-place sync (lib/maps/places.ts): rewrite just the location of the
148
150
  // live row and/or the staged draft, leaving every other field — and the
package/src/lib/schema.ts CHANGED
@@ -365,6 +365,10 @@ export const CalEvent = z.object({
365
365
  // a cancelled event stays visible (struck through) so people aren't left
366
366
  // wondering where it went; feeds emit STATUS:CANCELLED
367
367
  cancelled: z.boolean().default(false),
368
+ // occurrence keys (same format as exdates) of single dates that are
369
+ // cancelled: shown struck through, but they don't hold rooms. Keys that no
370
+ // longer match an occurrence (the series moved) are ignored.
371
+ cancelledDates: z.array(z.string()).default([]),
368
372
  location: EventLocation.default({}),
369
373
  cta: EventCta.default({}),
370
374
  calendarIds: z.array(z.string()).default([]),
@@ -144,8 +144,8 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
144
144
  return {
145
145
  title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
146
146
  location: locationLine(e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
147
- allDay: o.allDay, cancelled: e.cancelled, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
148
- cta: e.cta.label && ctaHref && !e.cancelled ? { label: e.cta.label, href: ctaHref } : null,
147
+ allDay: o.allDay, cancelled: o.cancelled, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
148
+ cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
149
149
  };
150
150
  });
151
151
  }
@@ -125,6 +125,7 @@ const jsonFields = new Set([
125
125
  "rsvp",
126
126
  "roomHold",
127
127
  "exdates",
128
+ "cancelledDates",
128
129
  "amenities",
129
130
  "hours",
130
131
  "overrides",
@@ -18,7 +18,7 @@ Errors: { "error": "message" } with 400 invalid · 401 no/invalid key · 403 mis
18
18
  ## Events
19
19
 
20
20
  GET /api/calendar/events?from=YYYY-MM-DD&to=YYYY-MM-DD&calendar=<slug>&q=<text>&limit=200
21
- → { timezone, from, to, occurrences: [{ id, start, end, allDay, event }] } (repeats expanded)
21
+ → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] } (repeats expanded; cancelled: the whole event or just that date, listed in event.cancelledDates)
22
22
  GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
23
23
  GET /api/calendar/events/<id|slug>
24
24
  POST /api/calendar/events (events:write) → creates a DRAFT
@@ -32,7 +32,7 @@ export const GET: APIRoute = async ({ request, locals, url, params }) => {
32
32
  const full = who ? hasScope(who, 'events:read') : false;
33
33
  if (!e || (!full && !eventIsPublic(e, map))) throw new ApiError(404, 'Event not found.');
34
34
  const view = full ? mergeEventDraft(e) : e;
35
- return withCors(json({ event: { ...eventToJson(view, siteOrigin(settings, url), map, settings.timezone), ...(full ? { hasPendingDraft: Boolean(e.draft), bodyHtml: view.bodyHtml } : {}) } }));
35
+ return withCors(json({ event: { ...eventToJson(view, siteOrigin(settings, url), map, settings.timezone, { includePrivate: full }), ...(full ? { hasPendingDraft: Boolean(e.draft), bodyHtml: view.bodyHtml } : {}) } }));
36
36
  } catch (err) { return fail(err); }
37
37
  };
38
38
 
@@ -17,10 +17,11 @@ import { fail } from '../../../../lib/calendar/http';
17
17
 
18
18
  // Public read API for other sites, apps and lobby screens.
19
19
  // GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
20
- // → { timezone, from, to, occurrences: [{ id, start, end, allDay, event }] }
20
+ // → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] }
21
21
  // GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
22
22
  // Anonymous callers see public calendars (and an unlisted one when asked by
23
- // slug). A key with events:read also sees private calendars.
23
+ // slug). A key with events:read also sees private calendars and private
24
+ // custom fields.
24
25
  const CORS = {
25
26
  'Access-Control-Allow-Origin': '*',
26
27
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -56,7 +57,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
56
57
  if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
57
58
 
58
59
  if (url.searchParams.get('expand') === '0') {
59
- return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone)) }));
60
+ return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full })) }));
60
61
  }
61
62
  const day = (param: string, fallback: number) => {
62
63
  const w = parseWall(url.searchParams.get(param) ?? '');
@@ -70,7 +71,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
70
71
  const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to }).slice(0, limit);
71
72
  const cache = new Map<string, ReturnType<typeof eventToJson>>();
72
73
  const toJson = (id: string) => {
73
- if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone));
74
+ if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone, { includePrivate: full }));
74
75
  return cache.get(id)!;
75
76
  };
76
77
  return withCors(json({
@@ -84,6 +85,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
84
85
  start: o.allDay ? isoWithOffset(o.start, etz).slice(0, 10) : isoWithOffset(o.start, etz),
85
86
  end: o.allDay ? isoWithOffset(o.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.end, etz),
86
87
  allDay: o.allDay,
88
+ cancelled: o.cancelled,
87
89
  event: toJson(o.event.id),
88
90
  };
89
91
  }),
@@ -166,7 +166,7 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
166
166
  {view !== 'list' && comingUp.length > 0 && (
167
167
  <section class="pc-calpage-up">
168
168
  <h2>Coming up</h2>
169
- <div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days }, settings, { showImage: true })).join('')} />
169
+ <div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: v.cancelled }, settings, { showImage: true })).join('')} />
170
170
  </section>
171
171
  )}
172
172
 
@@ -16,7 +16,7 @@ import { validateRequestSession } from '../../lib/auth/request';
16
16
  import { effectiveSettings } from '../../lib/designPreview';
17
17
  import { resolveDesign } from '../../lib/theme';
18
18
  import { siteOrigin } from '../../lib/seo';
19
- import { CALENDAR, eventUrl } from '../../lib/calendar/config';
19
+ import { CALENDAR, eventUrl, withoutPrivateFields } from '../../lib/calendar/config';
20
20
  import { eventIsPublic, featuredOccurrence } from '../../lib/calendar/events';
21
21
  import { eventJsonLd } from '../../lib/calendar/seo';
22
22
  import { upcomingEvents } from '../../lib/calendar/cards';
@@ -31,7 +31,8 @@ if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not
31
31
  const repo = await getRepo(Astro.locals);
32
32
  const locale = requestLocale(Astro.url, Astro.locals);
33
33
  const rawEvent = await repo.getEventBySlug(Astro.params.slug ?? '');
34
- const event = rawEvent ? localizeDoc(rawEvent, locale) : null;
34
+ // private custom fields never reach the page or a theme's event template
35
+ const event = rawEvent ? withoutPrivateFields(localizeDoc(rawEvent, locale)) : null;
35
36
  const [rawSettings, allCalendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
36
37
  const calMap = new Map(allCalendars.map((c) => [c.id, c]));
37
38
  const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
@@ -68,7 +69,7 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
68
69
  const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
69
70
  const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
70
71
  const rsvp = event.rsvp.enabled
71
- ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues, open: occurrence.end > now && !event.cancelled }
72
+ ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues, open: occurrence.end > now && !occurrence.cancelled }
72
73
  : null;
73
74
  const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
74
75
  const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));