@acms-subir/core 0.3.22 → 0.3.23

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.23
4
+
5
+ ### Patch Changes
6
+
7
+ - 1a0ceb2: Narrow event lists by tag. Section sources take `tag` (only items with that tag) and `excludeTag` (leave them out) for events as well as posts, so one shared calendar can feed a Classes page (`tag: "Class"`) and a home page that leaves classes out (`excludeTag: "Class"`). The section editor shows both pickers, listing the tags used on events. The same options work in `upcomingEvents()` from `@acms-subir/core/theme`, `listEvents()` and `listOccurrences()` from `@acms-subir/core/server`, the app API (`/api/v1/events?tag=&excludeTag=`, and `events({ tag, excludeTag })` in the client), and `/api/calendar/events`. A filter matches a tag's name in any case or its slug ("bible-study" matches "Bible Study"); internal `#` tags match by their full name and are never matched by the public APIs. Section tags with spaces or a leading `#` no longer fail the pre-publish check.
8
+
3
9
  ## 0.3.22
4
10
 
5
11
  ### Patch Changes
package/docs/sections.md CHANGED
@@ -97,11 +97,11 @@ Field keys are camelCase. Every field takes `label`, optional `help` (one line s
97
97
  | `date` | `YYYY-MM-DD` | string. Format with `formatDay()` | one-off dates |
98
98
  | `time` | `HH:MM` | string. Format with `formatTime()` | times of day |
99
99
  | `list` | array of rows | array of rows (sub-fields resolved the same way) | repeating things: services, people, plans, photos |
100
- | `source` | `{ calendar?, collection?, tag?, resource?, limit }` | fresh `EventItem[]` / `PostItem[]` / `BookingItem[]` | content that must stay current on its own |
100
+ | `source` | `{ calendar?, collection?, tag?, excludeTag?, resource?, limit }` | fresh `EventItem[]` / `PostItem[]` / `BookingItem[]` | content that must stay current on its own |
101
101
 
102
102
  `list` options: `fields` (scalar sub-fields only; a list can't contain a list or source), `itemLabel` (the sub-field shown on collapsed rows), `min`, `max` (default 50).
103
103
 
104
- `source` options: `source: "events" | "posts" | "bookings"`. The owner picks which calendar / collection + tag / resource, and how many (1–24).
104
+ `source` options: `source: "events" | "posts" | "bookings"`. The owner picks which calendar / collection / resource, and how many (1–24). Events and posts can also be narrowed by tag: `tag` shows only items with that tag, and `excludeTag` leaves out items with it. Both take a tag's name (any case) or slug, so a Classes page can show events tagged "Class" while the home page leaves them out. A theme can preset them in the field's `default`, e.g. `{ "tag": "Class", "limit": 6 }`.
105
105
 
106
106
  **When to use `source` instead of `list`:** if the thing already exists elsewhere in ACMS (calendar events, blog posts, booking links), link to it with `source`. Then the homepage updates itself when the calendar changes. Use a `list` only for content that lives nowhere else.
107
107
 
package/docs/server.md CHANGED
@@ -27,9 +27,9 @@ use, so a site can replace any of them, or turn them all off, and keep the same
27
27
  ```ts
28
28
  getRepo(locals: unknown): Promise<Repo>
29
29
  currentUser(ctx: { request: Request; cookies: AstroCookies; locals: unknown }): Promise<ServerUser | null>
30
- listEvents(repo: Repo, opts?: { publishedOnly?: boolean; calendar?: string; hostedBy?: string }): Promise<CalEvent[]>
30
+ listEvents(repo: Repo, opts?: { publishedOnly?: boolean; calendar?: string; hostedBy?: string; tag?: string; excludeTag?: string }): Promise<CalEvent[]>
31
31
  getEvent(repo: Repo, id: string): Promise<CalEvent | null>
32
- listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number }): Promise<Occurrence[]>
32
+ listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number; tag?: string; excludeTag?: string }): Promise<Occurrence[]>
33
33
  cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
34
34
  restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
35
35
 
@@ -74,12 +74,14 @@ The types `Repo`, `Role`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOpt
74
74
  - **`listEvents(repo, opts)`**: events by first start date, earliest first. By default,
75
75
  every status except archived. `publishedOnly: true` returns only published events.
76
76
  `calendar` (an id or slug) limits the list to one calendar. `hostedBy` (a user id)
77
- limits it to events that person hosts (`event.hostIds`). Values are the live ones;
78
- unpublished edits staged in the admin are not merged in.
77
+ limits it to events that person hosts (`event.hostIds`). `tag` keeps only events with
78
+ that tag and `excludeTag` leaves them out; both take a tag's name (any case) or slug,
79
+ and an internal `#` tag by its full name. Values are the live ones; unpublished edits
80
+ staged in the admin are not merged in.
79
81
  - **`getEvent(repo, id)`**: one event, or `null`.
80
- - **`listOccurrences(repo, { eventId?, from, to })`**: occurrences in `[from, to)`,
81
- soonest first, for one event or all non-archived events. The window can be up to 400
82
- days. Each occurrence has its `key` (below) and `cancelled`, which is true when the
82
+ - **`listOccurrences(repo, { eventId?, from, to, tag?, excludeTag? })`**: occurrences in
83
+ `[from, to)`, soonest first, for one event or all non-archived events, narrowed by tag
84
+ like `listEvents`. The window can be up to 400 days. Each occurrence has its `key` (below) and `cancelled`, which is true when the
83
85
  whole event or just that date is cancelled. Drafts are included, so check
84
86
  `o.event.status` before showing anything publicly.
85
87
  - **`cancelOccurrence(repo, eventId, key, { url, locals })`**: cancels one date of a
@@ -55,7 +55,7 @@ const date = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: Astr
55
55
  </section>}
56
56
  ```
57
57
 
58
- `calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
58
+ `calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them. `tag` keeps only events with that tag and `excludeTag` leaves them out (a tag's name in any case, or its slug), e.g. `upcomingEvents(Astro, { tag: 'Class' })` for a Classes page. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
59
59
 
60
60
  Options default to `{ limit: 6, horizonDays: 400 }`. The limit must be a non-negative safe integer and the horizon a positive finite number. Each `UpcomingEvent` contains `{ event, start, end, allDay, days }`, with start/end in Unix milliseconds. Format occurrence timestamps using the site's timezone rather than the authored `event.start` when displaying recurring events. `UpcomingEvent`, `UpcomingEventsOptions`, and `ThemeCalendarConfig` are exported types.
61
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -25,6 +25,8 @@ export interface SectionTargets {
25
25
  calendars: { slug: string; name: string }[];
26
26
  collections: { key: string; label: string }[];
27
27
  tags: string[];
28
+ /** tags on published events (the events source picks from these) */
29
+ eventTags?: string[];
28
30
  resources: { slug: string; name: string }[];
29
31
  features: { events: boolean; bookings: boolean };
30
32
  ai?: { enabled: boolean; languages: string[] };
@@ -321,27 +323,46 @@ function SourceInput({ id, field, value, onChange, targets }: InputProps<SourceF
321
323
  const off = (field.source === 'events' && !targets.features.events) || (field.source === 'bookings' && !targets.features.bookings);
322
324
  let from = '';
323
325
  let picker: ReactNode = null;
326
+ // only items with one tag, and/or leaving out another ("events not tagged Class")
327
+ const tag = String(v.tag ?? ''), excludeTag = String(v.excludeTag ?? '');
328
+ const tagged = `${tag ? ` tagged “${tag}”` : ''}${tag && excludeTag ? ',' : ''}${excludeTag ? ` not tagged “${excludeTag}”` : ''}`;
329
+ const tagPickers = (names: string[]) => {
330
+ // a saved tag no item uses any more stays listed, so the picker shows what's stored
331
+ const list = [...new Set([...names, tag, excludeTag].filter(Boolean))];
332
+ return (
333
+ <>
334
+ <select aria-label="Only items with this tag" value={tag} onChange={(e) => set({ tag: e.target.value })}>
335
+ <option value="">Any tag</option>
336
+ {list.map((t) => <option key={t} value={t}>{t}</option>)}
337
+ </select>
338
+ <select aria-label="Leave out items with this tag" value={excludeTag} onChange={(e) => set({ excludeTag: e.target.value })}>
339
+ <option value="">Leave nothing out</option>
340
+ {list.map((t) => <option key={t} value={t}>Leave out “{t}”</option>)}
341
+ </select>
342
+ </>
343
+ );
344
+ };
324
345
  if (field.source === 'events') {
325
346
  const cal = targets.calendars.find((c) => c.slug === v.calendar);
326
- from = `Calendar${cal ? ` → ${cal.name}` : ' · all public calendars'}`;
347
+ from = `${cal ? `Calendar → ${cal.name}` : 'All public calendars'}${tagged ? ` · events${tagged}` : ''}`;
327
348
  picker = (
328
- <select id={id} aria-label="Calendar" value={String(v.calendar ?? '')} onChange={(e) => set({ calendar: e.target.value })}>
329
- <option value="">All public calendars</option>
330
- {targets.calendars.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
331
- </select>
349
+ <>
350
+ <select id={id} aria-label="Calendar" value={String(v.calendar ?? '')} onChange={(e) => set({ calendar: e.target.value })}>
351
+ <option value="">All public calendars</option>
352
+ {targets.calendars.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
353
+ </select>
354
+ {tagPickers(targets.eventTags ?? targets.tags)}
355
+ </>
332
356
  );
333
357
  } else if (field.source === 'posts') {
334
358
  const col = targets.collections.find((c) => c.key === (v.collection ?? 'posts'));
335
- from = `${col?.label ?? 'Posts'}${v.tag ? ` tagged “${v.tag}”` : ''}`;
359
+ from = `${col?.label ?? 'Posts'}${tagged}`;
336
360
  picker = (
337
361
  <>
338
362
  <select id={id} aria-label="Collection" value={String(v.collection ?? 'posts')} onChange={(e) => set({ collection: e.target.value === 'posts' ? '' : e.target.value })}>
339
363
  {targets.collections.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
340
364
  </select>
341
- <select aria-label="Tag" value={String(v.tag ?? '')} onChange={(e) => set({ tag: e.target.value })}>
342
- <option value="">Any tag</option>
343
- {targets.tags.map((t) => <option key={t} value={t}>{t}</option>)}
344
- </select>
365
+ {tagPickers(targets.tags)}
345
366
  </>
346
367
  );
347
368
  } else {
@@ -2,7 +2,7 @@
2
2
  // block (data-dyn="events"), the /calendar page and embeds. Stable
3
3
  // .pc-ev-card* classes are the styling API, like .pc-cal-* for the grid.
4
4
  import type { ContentRepository } from '../repo/types';
5
- import type { CalEvent, Settings } from '../schema';
5
+ import { tagFilter, type CalEvent, type Settings } from '../schema';
6
6
  import { siteLocale } from '../dates';
7
7
  import { eventImage } from './images';
8
8
  import { eventUrl } from './config';
@@ -62,21 +62,23 @@ export function eventCardHtml(
62
62
  );
63
63
  }
64
64
 
65
- // next occurrence of each distinct event, soonest first
65
+ // next occurrence of each distinct event, soonest first; tag/excludeTag
66
+ // narrow by event tag (name or slug, any case)
66
67
  export async function upcomingEvents(
67
68
  repo: ContentRepository,
68
69
  settings: Settings,
69
70
  now: number,
70
- opts: { calendarIds?: string[]; limit: number; horizonDays?: number }
71
+ opts: { calendarIds?: string[]; limit: number; horizonDays?: number; tag?: string; excludeTag?: string }
71
72
  ) {
72
73
  const tz = settings.timezone || 'UTC';
74
+ const tagged = tagFilter(opts.tag, opts.excludeTag);
73
75
  const { events } = await collectViewEvents(repo, settings, {
74
76
  displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
75
77
  }, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false });
76
78
  const seen = new Set<string>();
77
79
  const out: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>[] = [];
78
80
  for (const v of events) {
79
- if (!v.event || v.end <= now || seen.has(v.event.id)) continue;
81
+ if (!v.event || v.end <= now || seen.has(v.event.id) || !tagged(v.event.tags)) continue;
80
82
  seen.add(v.event.id);
81
83
  out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled) });
82
84
  if (out.length >= opts.limit) break;
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import type { BookingLink, CalEvent, Calendar, Resource, Settings } from '../schema';
3
+ import { publicTags, tagFilter, type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
4
4
  import { ApiError } from '../api';
5
5
  import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
6
6
  import { calendarToJson, eventToJson } from '../calendar/api';
@@ -23,7 +23,9 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
23
23
  const origin = siteOrigin(await repo.getSettings(), url);
24
24
  return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
25
25
  }
26
- export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(), calendar: z.string().min(1).max(200).optional(), q: z.string().max(100).optional() }).strict();
26
+ // tag/excludeTag: a public tag's name or slug (internal "#" tags are never matched, as they are never shown)
27
+ export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(), calendar: z.string().min(1).max(200).optional(),
28
+ tag: z.string().min(1).max(100).optional(), excludeTag: z.string().min(1).max(100).optional(), q: z.string().max(100).optional() }).strict();
27
29
  export async function listEvents(repo: ContentRepository, url: URL) {
28
30
  requireCalendar();
29
31
  const q = eventsQuery.parse(queryParams(url));
@@ -44,7 +46,9 @@ export async function listEvents(repo: ContentRepository, url: URL) {
44
46
  const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
45
47
  if (q.calendar && !cal) throw new ApiError(404, 'Calendar not found.');
46
48
  const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || c.id === cal?.id).map(c => [c.id, c]));
49
+ const tagged = tagFilter(q.tag, q.excludeTag);
47
50
  const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
51
+ && tagged(publicTags(e.tags))
48
52
  && (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
49
53
  const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
50
54
  occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
package/src/lib/schema.ts CHANGED
@@ -155,6 +155,18 @@ export type Post = z.infer<typeof Post>;
155
155
  // and for post-list blocks.
156
156
  export const isInternalTag = (name: string) => name.startsWith('#');
157
157
  export const publicTags = (names: string[]) => names.filter((n) => !isInternalTag(n));
158
+ // Filters name a tag by its name (any case) or its slug: "Bible Study",
159
+ // "bible study" and "bible-study" all match a "Bible Study" tag. Internal
160
+ // tags match by name only, so "class" never picks up "#class".
161
+ export function hasTag(names: string[], wanted: string): boolean {
162
+ const w = wanted.trim().toLowerCase();
163
+ if (!w) return false;
164
+ const slug = /^[a-z0-9-]+$/.test(w);
165
+ return names.some((n) => n.trim().toLowerCase() === w || (slug && !isInternalTag(n.trim()) && slugify(n) === w));
166
+ }
167
+ /** only `tag` (when set), never `excludeTag` (when set) */
168
+ export const tagFilter = (tag?: string, excludeTag?: string) => (names: string[]) =>
169
+ (!tag?.trim() || hasTag(names, tag)) && (!excludeTag?.trim() || !hasTag(names, excludeTag));
158
170
 
159
171
  export const MediaVariant = z.object({ w: z.number(), key: z.string() });
160
172
  export type MediaVariant = z.infer<typeof MediaVariant>;
@@ -18,6 +18,8 @@ export interface SectionTargets {
18
18
  calendars: { slug: string; name: string }[];
19
19
  collections: { key: string; label: string }[];
20
20
  tags: string[];
21
+ /** tags on published events (the events source picks from these) */
22
+ eventTags: string[];
21
23
  resources: { slug: string; name: string }[];
22
24
  features: { events: boolean; bookings: boolean };
23
25
  /** AI help for text fields: provider configured + languages to translate into */
@@ -26,6 +28,13 @@ export interface SectionTargets {
26
28
  i18n: { defaultLocale: string; locales: { code: string; name: string }[]; drafts: boolean };
27
29
  }
28
30
 
31
+ // one entry per tag, ignoring case (the first spelling wins), A–Z
32
+ function uniqueTags(names: string[]) {
33
+ const seen = new Map<string, string>();
34
+ for (const n of names.map((x) => x.trim()).filter(Boolean)) if (!seen.has(n.toLowerCase())) seen.set(n.toLowerCase(), n);
35
+ return [...seen.values()].sort((a, b) => a.localeCompare(b));
36
+ }
37
+
29
38
  export async function sectionTargets(repo: ContentRepository, locals?: unknown): Promise<SectionTargets> {
30
39
  const bookings = bookingsEnabled();
31
40
  const settings = await repo.getSettings();
@@ -54,6 +63,7 @@ export async function sectionTargets(repo: ContentRepository, locals?: unknown):
54
63
  calendars: calendars.filter((c) => c.visibility !== 'private').map((c) => ({ slug: c.slug, name: c.name })),
55
64
  collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
56
65
  tags: [...new Set(tags.map((t) => t.name))].sort((a, b) => a.localeCompare(b)),
66
+ eventTags: uniqueTags(events.flatMap((e) => e.tags)),
57
67
  resources: publicResources.map((r) => ({ slug: r.slug, name: r.name })),
58
68
  features: { events: CALENDAR.enabled, bookings },
59
69
  ai: { enabled: Boolean(settings.ai.enabled && settings.ai.model), languages: languageNames() },
@@ -1,7 +1,7 @@
1
1
  import sanitizeHtml from 'sanitize-html';
2
2
  import type { z } from 'zod';
3
3
  import type { ContentRepository } from './repo/types';
4
- import { authorSlug, type Settings, type User } from './schema';
4
+ import { authorSlug, tagFilter, type Settings, type User } from './schema';
5
5
  import { htmlToPlaintext } from './plaintext';
6
6
  import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
7
7
  import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
@@ -21,7 +21,8 @@ import { localizeDoc, isExtraLocale } from './translations';
21
21
  // • migrations/<type>.mjs upgrades older instances on the fly
22
22
  // • link fields resolve their ref (page/post/event/booking) to today's URL
23
23
  // • richtext is sanitized
24
- // • source fields ("next 3 Youth events") become fresh item arrays
24
+ // • source fields ("next 3 Youth events", "events not tagged Class") become
25
+ // fresh item arrays
25
26
 
26
27
  type Instance = z.infer<typeof SectionInstanceSchema>;
27
28
  type Definition = z.infer<typeof SectionDefinition>;
@@ -121,7 +122,7 @@ function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = s
121
122
  }
122
123
 
123
124
  async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value: unknown, ctx: ResolveContext): Promise<unknown[]> {
124
- const cfg = (value ?? {}) as { calendar?: string; collection?: string; tag?: string; resource?: string; limit?: number };
125
+ const cfg = (value ?? {}) as { calendar?: string; collection?: string; tag?: string; excludeTag?: string; resource?: string; limit?: number };
125
126
  const limit = Math.max(1, Math.min(SOURCE_LIMIT_MAX, Number(cfg.limit) || 3));
126
127
  const { repo, settings, now } = ctx;
127
128
  // another language (settings.locale is set to it by the renderer)
@@ -137,7 +138,7 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
137
138
  }
138
139
  const tz = settings.timezone || 'UTC';
139
140
  const locale = siteLocale(settings);
140
- return (await upcomingEvents(repo, settings, now, { calendarIds, limit })).map((o): EventItem => {
141
+ return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag })).map((o): EventItem => {
141
142
  const e = lang ? localizeDoc(o.event, lang) : o.event;
142
143
  const f = formatOccurrence(o, tz, locale);
143
144
  const ctaHref = safeUrl(e.cta.href);
@@ -152,7 +153,8 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
152
153
  if (field.source === 'posts') {
153
154
  const collection = cfg.collection && CONTENT.collections[cfg.collection] ? cfg.collection : 'posts';
154
155
  let posts = await repo.listPublishedPosts(collection);
155
- if (cfg.tag) posts = posts.filter((p) => p.tags.some((t) => t.toLowerCase() === cfg.tag!.toLowerCase()));
156
+ const tagged = tagFilter(cfg.tag, cfg.excludeTag);
157
+ posts = posts.filter((p) => tagged(p.tags));
156
158
  const shown = posts.slice(0, limit);
157
159
  // public byline only: name, profile URL, avatar — never emails or roles
158
160
  const users = shown.some((p) => p.authorIds.length || p.authorId) ? new Map((await repo.listUsers()).map((u) => [u.id, u])) : new Map();
@@ -14,14 +14,16 @@ import { readJson } from '../../../../lib/api';
14
14
  import { runAgentTool } from '../../../../lib/agent';
15
15
  import { waitUntilFrom } from '../../../../lib/webhooks';
16
16
  import { fail } from '../../../../lib/calendar/http';
17
+ import { publicTags, tagFilter } from '../../../../lib/schema';
17
18
 
18
19
  // Public read API for other sites, apps and lobby screens.
19
20
  // GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
21
+ // GET /api/calendar/events?tag=class (or excludeTag=class; a tag's name or slug)
20
22
  // → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] }
21
23
  // GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
22
24
  // Anonymous callers see public calendars (and an unlisted one when asked by
23
25
  // slug). A key with events:read also sees private calendars and private
24
- // custom fields.
26
+ // custom fields, and its tag filters also match internal "#" tags.
25
27
  const CORS = {
26
28
  'Access-Control-Allow-Origin': '*',
27
29
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -55,6 +57,11 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
55
57
  return full ? true : eventIsListed(e, map);
56
58
  });
57
59
  if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
60
+ const tag = url.searchParams.get('tag')?.slice(0, 100), excludeTag = url.searchParams.get('excludeTag')?.slice(0, 100);
61
+ if (tag || excludeTag) {
62
+ const tagged = tagFilter(tag, excludeTag);
63
+ events = events.filter((e) => tagged(full ? e.tags : publicTags(e.tags)));
64
+ }
58
65
 
59
66
  if (url.searchParams.get('expand') === '0') {
60
67
  return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full })) }));
@@ -44,6 +44,10 @@ export interface ListEventsOptions {
44
44
  calendar?: string;
45
45
  /** only events this Team member hosts (CalEvent.hostIds) */
46
46
  hostedBy?: string;
47
+ /** only events with this tag (name or slug, any case) */
48
+ tag?: string;
49
+ /** leave out events with this tag (name or slug, any case) */
50
+ excludeTag?: string;
47
51
  }
48
52
 
49
53
  export interface OccurrenceWindow {
@@ -52,6 +56,10 @@ export interface OccurrenceWindow {
52
56
  /** window start and end, as a Date or Unix milliseconds; at most 400 days */
53
57
  from: Date | number;
54
58
  to: Date | number;
59
+ /** only events with this tag (name or slug, any case) */
60
+ tag?: string;
61
+ /** leave out events with this tag (name or slug, any case) */
62
+ excludeTag?: string;
55
63
  }
56
64
 
57
65
  /** Where a write came from: used for webhook URLs and background work. */
@@ -108,6 +116,10 @@ export async function listEvents(repo: Repo, opts: ListEventsOptions = {}): Prom
108
116
  events = events.filter((e) => e.calendarIds.includes(cal.id));
109
117
  }
110
118
  if (opts.hostedBy) events = events.filter((e) => e.hostIds.includes(opts.hostedBy!));
119
+ if (opts.tag || opts.excludeTag) {
120
+ const tagged = (await import('../lib/schema')).tagFilter(opts.tag, opts.excludeTag);
121
+ events = events.filter((e) => tagged(e.tags));
122
+ }
111
123
  return events;
112
124
  }
113
125
 
@@ -126,7 +138,7 @@ export async function listOccurrences(repo: Repo, window: OccurrenceWindow): Pro
126
138
  if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw new RangeError('listOccurrences needs a "to" after "from".');
127
139
  if (to - from > MAX_WINDOW) throw new RangeError('listOccurrences windows are at most 400 days.');
128
140
  const r = internal(repo);
129
- const [{ expandEvents }, settings] = await Promise.all([import('../lib/calendar/events'), r.getSettings()]);
141
+ const [{ expandEvents }, { tagFilter }, settings] = await Promise.all([import('../lib/calendar/events'), import('../lib/schema'), r.getSettings()]);
130
142
  let events: CalEvent[];
131
143
  if (window.eventId) {
132
144
  const e = await r.getEvent(window.eventId);
@@ -134,7 +146,8 @@ export async function listOccurrences(repo: Repo, window: OccurrenceWindow): Pro
134
146
  } else {
135
147
  events = (await r.listEvents()).filter((e) => e.status !== 'archived');
136
148
  }
137
- return expandEvents(events, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
149
+ const tagged = tagFilter(window.tag, window.excludeTag);
150
+ return expandEvents(events.filter((e) => tagged(e.tags)), settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
138
151
  }
139
152
 
140
153
  /**
@@ -20,6 +20,10 @@ export interface UpcomingEventsOptions {
20
20
  limit?: number;
21
21
  /** Look-ahead window in days; defaults to 400. */
22
22
  horizonDays?: number;
23
+ /** Only events with this tag (name or slug, any case). */
24
+ tag?: string;
25
+ /** Leave out events with this tag (name or slug, any case). */
26
+ excludeTag?: string;
23
27
  }
24
28
 
25
29
  /**
@@ -32,7 +36,7 @@ export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay
32
36
  /** Server-side helper for Astro templates and endpoints. No repository access needed. */
33
37
  export async function upcomingEvents(
34
38
  context: { locals: unknown },
35
- { limit = 6, horizonDays = 400 }: UpcomingEventsOptions = {},
39
+ { limit = 6, horizonDays = 400, tag, excludeTag }: UpcomingEventsOptions = {},
36
40
  ): Promise<UpcomingEvent[]> {
37
41
  if (!Number.isSafeInteger(limit) || limit < 0) {
38
42
  throw new RangeError('upcomingEvents limit must be a non-negative safe integer');
@@ -48,5 +52,5 @@ export async function upcomingEvents(
48
52
  ]);
49
53
  const repo = await getRepo(context.locals);
50
54
  const settings = await repo.getSettings();
51
- return loadUpcomingEvents(repo, settings, Date.now(), { limit, horizonDays });
55
+ return loadUpcomingEvents(repo, settings, Date.now(), { limit, horizonDays, tag, excludeTag });
52
56
  }
@@ -7,7 +7,8 @@
7
7
  // number → number toggle → boolean
8
8
  // link → { label, href, ref? } (ref = 'page:<id>' | 'post:<id>' | 'event:<id>' | 'booking:<id>')
9
9
  // list → Array<Record<subKey, scalar | link>>
10
- // source → { calendar?, collection?, tag?, resource?, limit? } (resolved to items at render time)
10
+ // source → { calendar?, collection?, tag?, excludeTag?, resource?, limit? } (resolved to items at render time;
11
+ // tags are names or slugs, for events and posts)
11
12
 
12
13
  export const SCALAR_TYPES = /** @type {const} */ (['text', 'textarea', 'richtext', 'image', 'url', 'link', 'select', 'number', 'toggle', 'date', 'time']);
13
14
  export const SOURCE_KINDS = /** @type {const} */ (['events', 'posts', 'bookings']);
@@ -218,9 +219,13 @@ export function valueIssues(field, value, path = field.label) {
218
219
  if (!isObject(value)) return bad();
219
220
  const limit = value.limit ?? 3;
220
221
  if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > SOURCE_LIMIT_MAX) return bad(`${field.label}: show between 1 and ${SOURCE_LIMIT_MAX}`);
221
- for (const k of ['calendar', 'collection', 'tag', 'resource']) {
222
+ for (const k of ['calendar', 'collection', 'resource']) {
222
223
  if (value[k] !== undefined && (typeof value[k] !== 'string' || !/^[a-z0-9-]{0,100}$/i.test(value[k]))) return bad();
223
224
  }
225
+ // tag names, like "Bible Study" or "#class"
226
+ for (const k of ['tag', 'excludeTag']) {
227
+ if (value[k] !== undefined && (typeof value[k] !== 'string' || value[k].length > 100 || /[\u0000-\u001f\u007f]/.test(value[k]))) return bad();
228
+ }
224
229
  return [];
225
230
  }
226
231
  default: return typeof value === 'string' ? [] : bad();