@pmeweb/content 2.1.0 → 2.2.0

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/README.md CHANGED
@@ -8,9 +8,9 @@ collections in `src/content.schema.json`; this package is what reads that declar
8
8
  | `@pmeweb/content/types` | `CollectionDecl`, `Field`, `ScheduleDecl`, `ContentSchema` | sites, portal |
9
9
  | `@pmeweb/content/zodFrom` | declaration -> Astro zod schema (needs `astro`) | sites |
10
10
  | `@pmeweb/content/fields` | `isDate`, `isDateTime`, `isTime`, `isDayDate`, `TIME_RE` | portal tier 1, `zodFrom` |
11
- | `@pmeweb/content/schedule` | `scheduleState`, `visibleOnSite`, `portalPreview`, `scheduleErrors`, `jourQuebec`, `quebecDay`, `quebecMidnight`, `nextQuebecDay`, `scheduledCollections`, `entryBoundaries`, `nextChangeAt`, `Schedule` | sites, portal |
11
+ | `@pmeweb/content/schedule` | `scheduleState`, `visibleOnSite`, `portalPreview`, `scheduleErrors`, `jourQuebec`, `quebecDay`, `quebecMidnight`, `nextQuebecDay`, `scheduledCollections`, `entryBoundaries`, `nextChangeAt`, `Schedule`, `itemsField`, `itemState`, `itemStates`, `changePassed`, `entryEvents`, `ScheduleEvent` | sites, portal |
12
12
  | `@pmeweb/content/collection` | `titleFieldError`, `thumbnailError`, `richErrors`, `collectionErrors`, `schemaErrors`, `richBodyField`, `hasThumbnails` | portal, `zodFrom` |
13
- | `@pmeweb/content/published` | `getPublished`, the one filter every site's pages go through | sites |
13
+ | `@pmeweb/content/published` | `getPublished`, the one filter every site's pages go through; `getPublishedItems` | sites |
14
14
  | `@pmeweb/content/prose.css` | the structure of a row of images in a rich body | sites' `Prose.astro` |
15
15
 
16
16
  ## Dated kinds
@@ -30,6 +30,15 @@ collections in `src/content.schema.json`; this package is what reads that declar
30
30
  - `removeAt`: one path or several; the first that yields a value wins. `dates[].debut` takes the
31
31
  latest value in the array. Always read as a Quebec day: the entry stays through that whole day
32
32
  and comes off at the start of the next one.
33
+ - `items` (2.2.0): `{ "field": "dates", "publishAt": "publierLe", "removeAt": "debut" }` -- an
34
+ array whose items go on and come off one by one, by the same rules on their sub-fields
35
+ (`itemState`); the entry is live while any item is. A site filters them with
36
+ `getPublishedItems(data, decl.<collection>, now, APERCU)`. NESTED so an older reader ignores it.
37
+ An entry-level `removeAt` path into the same array is refused (one fact, two declarations).
38
+ - `changesAt` (2.2.0): `["prixFin"]` -- top-level dates whose passing changes the page without
39
+ changing whether the entry is on it. A `date` passes at the start of the next Quebec day, a
40
+ `datetime` at its instant. A site reads them with `changePassed(data, decl, field, now)`, which
41
+ THROWS for a field not listed, so `/schedule.json` always announces what the page compares.
33
42
 
34
43
  A site that declares a schedule serves `/schedule.json` (`{ next }`, built with
35
44
  `scheduledCollections`, `entryBoundaries` and `nextChangeAt`; the boilerplate ships the endpoint),
@@ -45,6 +54,18 @@ reads both once, `const APERCU = portalPreview(process.env)`, and filters with
45
54
  drafts in the preview, removed ones only in a preview that asked for them. Only the exact
46
55
  string `'1'` counts, and the past flag means nothing without the preview flag.
47
56
 
57
+ ## 2.2.0
58
+
59
+ Additive: every 2.1.0 declaration is valid and behaves identically.
60
+
61
+ - `schedule.items` and `schedule.changesAt` (above), validated by `scheduleErrors`.
62
+ - `itemState`, `itemStates`, `itemsField`; `scheduleState` reads `items`.
63
+ - `changePassed`.
64
+ - `entryEvents(data, decl, { ignoreDraft? })`: every moment an entry's page changes
65
+ (`publish` / `remove` / `change`, with the instant, the typed day, the field and the item). The
66
+ portal's calendar reads it; `entryBoundaries` is its instants.
67
+ - `getPublishedItems` in `@pmeweb/content/published`.
68
+
48
69
  ## 2.1.0
49
70
 
50
71
  Additive, for `/schedule.json`:
@@ -10,6 +10,8 @@ export interface CollectionLike {
10
10
  publishAt?: unknown;
11
11
  removeAt?: unknown;
12
12
  draft?: unknown;
13
+ items?: unknown;
14
+ changesAt?: unknown;
13
15
  };
14
16
  }
15
17
  export declare function titleFieldError(c: CollectionLike): string | null;
@@ -2,3 +2,10 @@ import { type PortalPreview, type SchedulableLike } from './schedule.js';
2
2
  export declare function getPublished<E extends {
3
3
  data: object;
4
4
  }>(entries: readonly E[], decl: SchedulableLike, now: Date, preview: PortalPreview): E[];
5
+ /**
6
+ * The items of `schedule.items.field` the site builds (2.2.0): each by its own `itemState`, through
7
+ * the same `visibleOnSite` as `getPublished`. Order kept: the site sorts. `I` is the site's item
8
+ * type -- the one cast, since the field is named by the declaration, not by the type. THROWS on a
9
+ * collection with no `items`: the call is a bug, and a build is where it should fail.
10
+ */
11
+ export declare function getPublishedItems<I = unknown>(data: object, decl: SchedulableLike, now: Date, preview: PortalPreview): I[];
package/dist/published.js CHANGED
@@ -5,10 +5,25 @@
5
5
  //
6
6
  // NO ASTRO IMPORT. The site passes `getCollection(...)`'s result, so `astro` stays an optional
7
7
  // peer and this file stays testable.
8
- import { scheduleState, visibleOnSite } from './schedule.js';
8
+ import { itemState, itemsField, scheduleState, visibleOnSite } from './schedule.js';
9
9
  // `data: object`, NOT `Record<string, unknown>`: every site types its entries with an
10
10
  // `interface` (`BlogData`, `ArticleData`), and an interface has no implicit index signature, so
11
11
  // the narrower bound would refuse exactly the call this function exists for.
12
12
  export function getPublished(entries, decl, now, preview) {
13
13
  return entries.filter((e) => visibleOnSite(scheduleState(e.data, decl, now), preview));
14
14
  }
15
+ /**
16
+ * The items of `schedule.items.field` the site builds (2.2.0): each by its own `itemState`, through
17
+ * the same `visibleOnSite` as `getPublished`. Order kept: the site sorts. `I` is the site's item
18
+ * type -- the one cast, since the field is named by the declaration, not by the type. THROWS on a
19
+ * collection with no `items`: the call is a bug, and a build is where it should fail.
20
+ */
21
+ export function getPublishedItems(data, decl, now, preview) {
22
+ const field = itemsField(decl);
23
+ if (field === null)
24
+ throw new Error('getPublishedItems : la collection ne déclare pas schedule.items');
25
+ const arr = data[field];
26
+ if (!Array.isArray(arr))
27
+ return [];
28
+ return arr.filter((it) => visibleOnSite(itemState(it, decl, now), preview));
29
+ }
@@ -16,6 +16,8 @@ export interface SchedulableLike {
16
16
  publishAt?: unknown;
17
17
  removeAt?: unknown;
18
18
  draft?: unknown;
19
+ items?: unknown;
20
+ changesAt?: unknown;
19
21
  };
20
22
  }
21
23
  /**
@@ -49,8 +51,30 @@ export declare function quebecDay(value: unknown, kind: string): string | null;
49
51
  * The site's `zodFrom` THROWS on any of these, so a bad declaration fails the build that
50
52
  * introduced it. The portal DROPS the declaration with a warning: a list that hid nothing is a
51
53
  * better failure than a page that does not open.
54
+ *
55
+ * 2.2.0 adds `items` and `changesAt`; a 2.1.0 declaration gets exactly the sentences it got before.
52
56
  */
53
57
  export declare function scheduleErrors(c: SchedulableLike): string[];
58
+ /** The array `schedule.items` publishes item by item, or null when the collection declares none. */
59
+ export declare function itemsField(c: SchedulableLike): string | null;
60
+ export type ItemState = 'scheduled' | 'live' | 'removed';
61
+ /**
62
+ * Where ONE item of `schedule.items.field` stands at `now`: the entry's rules on its sub-fields.
63
+ * `scheduled` before its `publishAt`, `removed` once the Quebec day is past its `removeAt`,
64
+ * `scheduled` beats `removed`. No item-level draft. A non-object item, and any item of an invalid
65
+ * declaration, is `live`: never hide what cannot be read.
66
+ */
67
+ export declare function itemState(item: unknown, c: SchedulableLike, now: Date): ItemState;
68
+ /** Every item's state, by index; `[]` when the array is absent; null when no `items` is declared. */
69
+ export declare function itemStates(data: object, c: SchedulableLike, now: Date): ItemState[] | null;
70
+ /**
71
+ * Whether the change a `changesAt` field announces has happened at `now`.
72
+ *
73
+ * THROWS when `field` is not in `schedule.changesAt`: a page that compares the clock to a date its
74
+ * `/schedule.json` does not announce is a page the scheduler never rebuilds, and a throw fails the
75
+ * build that introduced it, as `zodFrom` does for a bad declaration.
76
+ */
77
+ export declare function changePassed(data: object, c: SchedulableLike, field: string, now: Date): boolean;
54
78
  /**
55
79
  * Where an entry stands at `now`.
56
80
  *
@@ -60,6 +84,9 @@ export declare function scheduleErrors(c: SchedulableLike): string[];
60
84
  * mis-entered, and « Programmé » is the state that makes someone look at it; « Retiré » would
61
85
  * bury it behind the portal's filter. An invalid declaration answers `live`: it must never hide
62
86
  * anything.
87
+ *
88
+ * WITH `items`, the entry-level state wins when it is not `live`; otherwise the entry is live
89
+ * while any item is (`itemStates`).
63
90
  */
64
91
  export declare function scheduleState(data: Record<string, unknown>, c: SchedulableLike, now: Date): ScheduleState;
65
92
  /**
@@ -71,22 +98,36 @@ export declare function scheduleState(data: Record<string, unknown>, c: Schedula
71
98
  */
72
99
  export declare function scheduledCollections(schema: Record<string, SchedulableLike>): string[];
73
100
  /**
74
- * Every instant at which `scheduleState` of this entry changes, and only those, in time order.
75
- *
76
- * THE SAME RULES AS `scheduleState`, read from the same helpers, which is why this lives here:
77
- * publication is `publishInstant`; removal is the start of the Quebec day AFTER `removeDay`. A
78
- * `draft` has none: it is hidden by a rule that has nothing to do with the clock. A removal that
79
- * falls at or before the publication is not a change of its own (`scheduled` beats `removed`, so
80
- * the entry goes straight from `scheduled` to `removed` at the publication), and is left out.
81
- *
82
- * A site's `/schedule.json` is the earliest of these across its entries (`nextChangeAt`), and the
83
- * portal's hourly scheduler rebuilds the site once that instant has passed. A boundary missing
84
- * here is a site that silently stops aging.
85
- *
86
- * Only what the DECLARATION says. A site that filters by time on fields its `schedule` does not
87
- * name (jessyoga's per-date `publierLe`, its `prixFin`) must add those instants itself.
101
+ * One moment an entry's page changes. `at` is the instant it changes; `day` the Quebec day of the
102
+ * TYPED value (the calendar places by it); `timed` when `at` IS the typed instant (a `datetime`
103
+ * publication or change -- a removal always happens at a Quebec midnight, so it never is);
104
+ * `field` the field that gave it (the sub-field for an item); `item` the item's index or null.
105
+ */
106
+ export interface ScheduleEvent {
107
+ kind: 'publish' | 'remove' | 'change';
108
+ at: number;
109
+ day: string;
110
+ timed: boolean;
111
+ field: string;
112
+ item: number | null;
113
+ }
114
+ /**
115
+ * Every moment this entry's page changes, in time order -- THE ONE LIST both a site's
116
+ * `/schedule.json` (through `entryBoundaries`) and the portal's calendar read, so they cannot
117
+ * disagree. The entry's publication and removal (`scheduleState`'s rules), each item's
118
+ * (`itemState`'s), each `changesAt` field (`changePassed`'s). A draft has none, unless
119
+ * `ignoreDraft` (the calendar's « prévu si publié »).
120
+ */
121
+ export declare function entryEvents(data: object, c: SchedulableLike, opts?: {
122
+ ignoreDraft?: boolean;
123
+ }): ScheduleEvent[];
124
+ /**
125
+ * Every instant at which this entry's page changes -- `entryEvents`' instants. For a 2.1.0
126
+ * declaration the same list, in the same order, as 2.1.0 returned. A site's `/schedule.json` is
127
+ * the earliest across its entries (`nextChangeAt`); a boundary missing here is a site that
128
+ * silently stops aging.
88
129
  */
89
- export declare function entryBoundaries(data: Record<string, unknown>, c: SchedulableLike): number[];
130
+ export declare function entryBoundaries(data: object, c: SchedulableLike): number[];
90
131
  /**
91
132
  * The earliest candidate strictly after `now`, as ISO (`toISOString`), or null when nothing is
92
133
  * pending. STRICTLY after: an instant that has arrived was already rendered by the build computing
package/dist/schedule.js CHANGED
@@ -14,7 +14,8 @@
14
14
  // A STATIC SITE APPLIES THIS WHEN IT BUILDS. `now` is build time; the site serves the next instant
15
15
  // its output changes at `/schedule.json` (`scheduledCollections` + `entryBoundaries` +
16
16
  // `nextChangeAt`), and the portal's hourly scheduler (pmeweb-portail/src/server/schedule/) rebuilds
17
- // it once that instant has passed.
17
+ // it once that instant has passed. Since 2.2.0 both `/schedule.json` and the portal's calendar
18
+ // read ONE list, `entryEvents`: the publications, removals and declared changes of an entry.
18
19
  import { isDate, isDateTime } from './fields.js';
19
20
  /** The zone every PmeWeb client lives in. One constant, named where it is used. */
20
21
  export const QUEBEC = 'America/Toronto';
@@ -95,12 +96,46 @@ function paths(v) {
95
96
  return [];
96
97
  return Array.isArray(v) ? v : [v];
97
98
  }
99
+ /**
100
+ * `schedule.items`'s faults, pushed onto `out`; the array field's name when the declaration names
101
+ * a real array (even if a sub-field is wrong), so the overlap with `removeAt` can still be judged.
102
+ */
103
+ function itemsErrors(c, items, out) {
104
+ if (items === undefined)
105
+ return null;
106
+ if (typeof items !== 'object' || items === null || Array.isArray(items)) {
107
+ out.push('schedule.items : ce n’est pas un objet');
108
+ return null;
109
+ }
110
+ const { field, publishAt, removeAt } = items;
111
+ const f = typeof field === 'string' ? c.fields[field] : undefined;
112
+ if (!f || f.kind !== 'array' || !f.of) {
113
+ out.push(`schedule.items.field : « ${String(field)} » n’est pas un champ tableau`);
114
+ return null;
115
+ }
116
+ const name = field;
117
+ const of = f.of;
118
+ if (publishAt === undefined && paths(removeAt).length === 0)
119
+ out.push('schedule.items : ni publishAt ni removeAt');
120
+ const sub = (key, p) => {
121
+ const kind = typeof p === 'string' ? of[p]?.kind : undefined;
122
+ if (kind === undefined || !DATED.has(kind))
123
+ out.push(`schedule.items.${key} : « ${String(p)} » n’est pas une date du tableau « ${name} »`);
124
+ };
125
+ if (publishAt !== undefined)
126
+ sub('publishAt', publishAt);
127
+ for (const p of paths(removeAt))
128
+ sub('removeAt', p);
129
+ return name;
130
+ }
98
131
  /**
99
132
  * Everything wrong with a collection's `schedule`, one French sentence each; `[]` means valid.
100
133
  *
101
134
  * The site's `zodFrom` THROWS on any of these, so a bad declaration fails the build that
102
135
  * introduced it. The portal DROPS the declaration with a warning: a list that hid nothing is a
103
136
  * better failure than a page that does not open.
137
+ *
138
+ * 2.2.0 adds `items` and `changesAt`; a 2.1.0 declaration gets exactly the sentences it got before.
104
139
  */
105
140
  export function scheduleErrors(c) {
106
141
  const s = c.schedule;
@@ -149,9 +184,30 @@ export function scheduleErrors(c) {
149
184
  out.push(`schedule.draft : « ${name} » n’est pas une case à cocher`);
150
185
  }
151
186
  }
187
+ const itemsField = itemsErrors(c, s.items, out);
188
+ if (itemsField !== null) {
189
+ for (const p of paths(s.removeAt)) {
190
+ const m = typeof p === 'string' ? PATH_RE.exec(p) : null;
191
+ if (m && m[2] !== undefined && m[1] === itemsField) {
192
+ out.push(`schedule.removeAt : « ${p} » lit le tableau déjà déclaré dans items`);
193
+ }
194
+ }
195
+ }
196
+ if (s.changesAt !== undefined) {
197
+ if (!Array.isArray(s.changesAt))
198
+ out.push('schedule.changesAt : ce n’est pas une liste');
199
+ else {
200
+ for (const name of s.changesAt) {
201
+ const f = typeof name === 'string' ? c.fields[name] : undefined;
202
+ if (!f || !DATED.has(f.kind))
203
+ out.push(`schedule.changesAt : « ${String(name)} » n’est pas une date`);
204
+ }
205
+ }
206
+ }
152
207
  return out;
153
208
  }
154
- /** The day `removeAt` resolves to: the first path that yields one; an array path yields its latest. */
209
+ /** The day `removeAt` resolves to, and the top-level field that gave it: the first path that
210
+ * yields one; an array path yields its latest. */
155
211
  function removeDay(data, c) {
156
212
  for (const p of paths(c.schedule?.removeAt)) {
157
213
  const m = typeof p === 'string' ? PATH_RE.exec(p) : null;
@@ -164,7 +220,7 @@ function removeDay(data, c) {
164
220
  if (sub === undefined) {
165
221
  const d = quebecDay(data[top], f.kind);
166
222
  if (d)
167
- return d;
223
+ return { day: d, field: top };
168
224
  continue;
169
225
  }
170
226
  const kind = f.of?.[sub]?.kind ?? '';
@@ -178,7 +234,7 @@ function removeDay(data, c) {
178
234
  latest = d;
179
235
  }
180
236
  if (latest)
181
- return latest;
237
+ return { day: latest, field: top };
182
238
  }
183
239
  return null;
184
240
  }
@@ -197,6 +253,95 @@ function isScheduled(data, c, now) {
197
253
  const d = quebecDay(data[key], f.kind);
198
254
  return d !== null && d > quebecDayOf(now.getTime());
199
255
  }
256
+ /** When a value that STARTS something takes effect: a `date` at the start of its Quebec day, a
257
+ * `datetime` at its instant. Null when empty or unreadable. */
258
+ function startInstant(value, kind) {
259
+ if (kind === 'datetime')
260
+ return instant(value);
261
+ const d = quebecDay(value, kind);
262
+ const ms = d === null ? Number.NaN : quebecMidnight(d);
263
+ return Number.isFinite(ms) ? ms : null;
264
+ }
265
+ /** The start of the Quebec day after `day`: when something whose LAST day is `day` is gone. */
266
+ function dayAfterStart(day) {
267
+ const next = nextQuebecDay(day);
268
+ const ms = next === null ? Number.NaN : quebecMidnight(next);
269
+ return Number.isFinite(ms) ? ms : null;
270
+ }
271
+ /** The declared `items`, or null. Read only after `scheduleErrors` said the declaration is valid. */
272
+ function itemsDecl(c) {
273
+ const i = c.schedule?.items;
274
+ return typeof i === 'object' && i !== null && !Array.isArray(i) && typeof i.field === 'string'
275
+ ? i : null;
276
+ }
277
+ /** The array `schedule.items` publishes item by item, or null when the collection declares none. */
278
+ export function itemsField(c) {
279
+ return itemsDecl(c)?.field ?? null;
280
+ }
281
+ /** The first of `subs` that yields a Quebec day on this item, and which one. */
282
+ function firstItemDay(item, subs, of) {
283
+ for (const p of paths(subs)) {
284
+ if (typeof p !== 'string')
285
+ continue;
286
+ const d = quebecDay(item[p], of[p]?.kind ?? '');
287
+ if (d)
288
+ return { day: d, field: p };
289
+ }
290
+ return null;
291
+ }
292
+ /**
293
+ * Where ONE item of `schedule.items.field` stands at `now`: the entry's rules on its sub-fields.
294
+ * `scheduled` before its `publishAt`, `removed` once the Quebec day is past its `removeAt`,
295
+ * `scheduled` beats `removed`. No item-level draft. A non-object item, and any item of an invalid
296
+ * declaration, is `live`: never hide what cannot be read.
297
+ */
298
+ export function itemState(item, c, now) {
299
+ const items = itemsDecl(c);
300
+ if (!items || scheduleErrors(c).length > 0)
301
+ return 'live';
302
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
303
+ return 'live';
304
+ const it = item;
305
+ const of = c.fields[items.field]?.of ?? {};
306
+ if (typeof items.publishAt === 'string') {
307
+ const start = startInstant(it[items.publishAt], of[items.publishAt]?.kind ?? '');
308
+ if (start !== null && now.getTime() < start)
309
+ return 'scheduled';
310
+ }
311
+ const r = firstItemDay(it, items.removeAt, of);
312
+ return r !== null && quebecDayOf(now.getTime()) > r.day ? 'removed' : 'live';
313
+ }
314
+ /** Every item's state, by index; `[]` when the array is absent; null when no `items` is declared. */
315
+ export function itemStates(data, c, now) {
316
+ const items = itemsDecl(c);
317
+ if (!items)
318
+ return null;
319
+ const arr = data[items.field];
320
+ return Array.isArray(arr) ? arr.map((it) => itemState(it, c, now)) : [];
321
+ }
322
+ /** When a `changesAt` value takes effect: a `date` at the start of the Quebec day AFTER it (the
323
+ * date is the last day the old content is right), a `datetime` at its instant. */
324
+ function changeInstant(value, kind) {
325
+ if (kind === 'datetime')
326
+ return instant(value);
327
+ const d = quebecDay(value, kind);
328
+ return d === null ? null : dayAfterStart(d);
329
+ }
330
+ /**
331
+ * Whether the change a `changesAt` field announces has happened at `now`.
332
+ *
333
+ * THROWS when `field` is not in `schedule.changesAt`: a page that compares the clock to a date its
334
+ * `/schedule.json` does not announce is a page the scheduler never rebuilds, and a throw fails the
335
+ * build that introduced it, as `zodFrom` does for a bad declaration.
336
+ */
337
+ export function changePassed(data, c, field, now) {
338
+ const list = c.schedule?.changesAt;
339
+ if (!Array.isArray(list) || !list.includes(field)) {
340
+ throw new Error(`changePassed : « ${field} » n’est pas déclaré dans schedule.changesAt`);
341
+ }
342
+ const at = changeInstant(data[field], c.fields[field]?.kind ?? '');
343
+ return at !== null && now.getTime() >= at;
344
+ }
200
345
  /**
201
346
  * Where an entry stands at `now`.
202
347
  *
@@ -206,6 +351,9 @@ function isScheduled(data, c, now) {
206
351
  * mis-entered, and « Programmé » is the state that makes someone look at it; « Retiré » would
207
352
  * bury it behind the portal's filter. An invalid declaration answers `live`: it must never hide
208
353
  * anything.
354
+ *
355
+ * WITH `items`, the entry-level state wins when it is not `live`; otherwise the entry is live
356
+ * while any item is (`itemStates`).
209
357
  */
210
358
  export function scheduleState(data, c, now) {
211
359
  if (!c.schedule || scheduleErrors(c).length > 0)
@@ -216,7 +364,15 @@ export function scheduleState(data, c, now) {
216
364
  if (isScheduled(data, c, now))
217
365
  return 'scheduled';
218
366
  const d = removeDay(data, c);
219
- return d !== null && quebecDayOf(now.getTime()) > d ? 'removed' : 'live';
367
+ if (d !== null && quebecDayOf(now.getTime()) > d.day)
368
+ return 'removed';
369
+ // 2.2.0: live at entry level, so the items decide -- live while any item is, else scheduled
370
+ // while one is still to come, else removed. An empty array is live (an empty array path never
371
+ // removed in 2.1.0 either).
372
+ const states = itemStates(data, c, now);
373
+ if (states === null || states.length === 0 || states.includes('live'))
374
+ return 'live';
375
+ return states.includes('scheduled') ? 'scheduled' : 'removed';
220
376
  }
221
377
  /**
222
378
  * The collections of a site's `content.schema.json` that declare a VALID `schedule`, in
@@ -230,53 +386,73 @@ export function scheduledCollections(schema) {
230
386
  .filter(([, c]) => c.schedule !== undefined && scheduleErrors(c).length === 0)
231
387
  .map(([name]) => name);
232
388
  }
233
- /** The instant `publishAt` takes effect: a `date` at the start of its Quebec day, a `datetime` at
234
- * its instant. The mirror of `isScheduled`: scheduled before it, not scheduled from it on. */
235
- function publishInstant(data, c) {
236
- const key = c.schedule?.publishAt;
389
+ function pushPublish(out, rec, key, fields, item) {
237
390
  if (typeof key !== 'string')
238
391
  return null;
239
- const f = c.fields[key];
240
- if (!f)
392
+ const kind = fields[key]?.kind ?? '';
393
+ const at = startInstant(rec[key], kind);
394
+ const day = quebecDay(rec[key], kind);
395
+ if (at === null || day === null)
241
396
  return null;
242
- if (f.kind === 'datetime')
243
- return instant(data[key]);
244
- const d = quebecDay(data[key], f.kind);
245
- const ms = d === null ? Number.NaN : quebecMidnight(d);
246
- return Number.isFinite(ms) ? ms : null;
397
+ out.push({ kind: 'publish', at, day, timed: kind === 'datetime', field: key, item });
398
+ return at;
399
+ }
400
+ function pushRemove(out, r, publish, item) {
401
+ if (r === null)
402
+ return;
403
+ const at = dayAfterStart(r.day);
404
+ // A removal at or before the publication is no change of its own: `scheduled` beats `removed`,
405
+ // so the entry (or item) goes straight from scheduled to removed AT the publication.
406
+ if (at === null || (publish !== null && at <= publish))
407
+ return;
408
+ out.push({ kind: 'remove', at, day: r.day, timed: false, field: r.field, item });
247
409
  }
248
410
  /**
249
- * Every instant at which `scheduleState` of this entry changes, and only those, in time order.
250
- *
251
- * THE SAME RULES AS `scheduleState`, read from the same helpers, which is why this lives here:
252
- * publication is `publishInstant`; removal is the start of the Quebec day AFTER `removeDay`. A
253
- * `draft` has none: it is hidden by a rule that has nothing to do with the clock. A removal that
254
- * falls at or before the publication is not a change of its own (`scheduled` beats `removed`, so
255
- * the entry goes straight from `scheduled` to `removed` at the publication), and is left out.
256
- *
257
- * A site's `/schedule.json` is the earliest of these across its entries (`nextChangeAt`), and the
258
- * portal's hourly scheduler rebuilds the site once that instant has passed. A boundary missing
259
- * here is a site that silently stops aging.
260
- *
261
- * Only what the DECLARATION says. A site that filters by time on fields its `schedule` does not
262
- * name (jessyoga's per-date `publierLe`, its `prixFin`) must add those instants itself.
411
+ * Every moment this entry's page changes, in time order -- THE ONE LIST both a site's
412
+ * `/schedule.json` (through `entryBoundaries`) and the portal's calendar read, so they cannot
413
+ * disagree. The entry's publication and removal (`scheduleState`'s rules), each item's
414
+ * (`itemState`'s), each `changesAt` field (`changePassed`'s). A draft has none, unless
415
+ * `ignoreDraft` (the calendar's « prévu si publié »).
263
416
  */
264
- export function entryBoundaries(data, c) {
417
+ export function entryEvents(data, c, opts = {}) {
265
418
  if (!c.schedule || scheduleErrors(c).length > 0)
266
419
  return [];
420
+ const d = data;
267
421
  const draftKey = c.schedule.draft;
268
- if (typeof draftKey === 'string' && data[draftKey] === true)
422
+ if (!opts.ignoreDraft && typeof draftKey === 'string' && d[draftKey] === true)
269
423
  return [];
270
424
  const out = [];
271
- const publish = publishInstant(data, c);
272
- if (publish !== null)
273
- out.push(publish);
274
- const removal = removeDay(data, c);
275
- const after = removal === null ? null : nextQuebecDay(removal);
276
- const remove = after === null ? Number.NaN : quebecMidnight(after);
277
- if (Number.isFinite(remove) && (publish === null || remove > publish))
278
- out.push(remove);
279
- return out;
425
+ const publish = pushPublish(out, d, c.schedule.publishAt, c.fields, null);
426
+ pushRemove(out, removeDay(d, c), publish, null);
427
+ const items = itemsDecl(c);
428
+ if (items) {
429
+ const of = c.fields[items.field]?.of ?? {};
430
+ const arr = Array.isArray(d[items.field]) ? d[items.field] : [];
431
+ arr.forEach((raw, i) => {
432
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
433
+ return;
434
+ const it = raw;
435
+ const p = pushPublish(out, it, items.publishAt, of, i);
436
+ pushRemove(out, firstItemDay(it, items.removeAt, of), p, i);
437
+ });
438
+ }
439
+ for (const f of Array.isArray(c.schedule.changesAt) ? c.schedule.changesAt : []) {
440
+ const kind = c.fields[f]?.kind ?? '';
441
+ const at = changeInstant(d[f], kind);
442
+ const day = quebecDay(d[f], kind);
443
+ if (at !== null && day !== null)
444
+ out.push({ kind: 'change', at, day, timed: kind === 'datetime', field: f, item: null });
445
+ }
446
+ return out.sort((a, b) => a.at - b.at);
447
+ }
448
+ /**
449
+ * Every instant at which this entry's page changes -- `entryEvents`' instants. For a 2.1.0
450
+ * declaration the same list, in the same order, as 2.1.0 returned. A site's `/schedule.json` is
451
+ * the earliest across its entries (`nextChangeAt`); a boundary missing here is a site that
452
+ * silently stops aging.
453
+ */
454
+ export function entryBoundaries(data, c) {
455
+ return entryEvents(data, c).map((e) => e.at);
280
456
  }
281
457
  /** The last instant `toISOString` writes with a four-digit year, the only shape the portal reads. */
282
458
  const LAST_ISO = Date.parse('9999-12-31T23:59:59.999Z');
package/dist/types.d.ts CHANGED
@@ -92,6 +92,24 @@ export interface ScheduleDecl {
92
92
  * is not a draft (booleans stay false-safe). May be declared alone.
93
93
  */
94
94
  draft?: string;
95
+ /**
96
+ * An array whose ITEMS go on and come off the site one by one (2.2.0): jessyoga's class dates.
97
+ * `field` names a top-level `array`; `publishAt` one `date`/`datetime` sub-field; `removeAt`
98
+ * one sub-field or several, THE FIRST THAT YIELDS WINS, read as a Quebec day. At least one of
99
+ * the two. The entry is then live while any item is: see `scheduleState`. NESTED, not flat, so
100
+ * an older reader (which ignores an unknown key) cannot mistake `publishAt` for a top-level one.
101
+ */
102
+ items?: {
103
+ field: string;
104
+ publishAt?: string;
105
+ removeAt?: string | string[];
106
+ };
107
+ /**
108
+ * Top-level `date`/`datetime` fields whose passing CHANGES THE PAGE without changing whether the
109
+ * entry is on it (2.2.0): jessyoga's `prixFin`, after which « Prévente » is no longer written. A
110
+ * site reads them with `changePassed`, which throws on a field not listed here.
111
+ */
112
+ changesAt?: string[];
95
113
  }
96
114
  export interface CollectionDecl {
97
115
  label: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmeweb/content",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "PmeWeb content contract: content.schema.json types, zodFrom, field checks, scheduling. Read by every site's build and by the client portal.",
5
5
  "type": "module",
6
6
  "license": "MIT",