@pmeweb/content 2.0.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` | 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
@@ -29,7 +29,21 @@ collections in `src/content.schema.json`; this package is what reads that declar
29
29
  a `datetime` at its instant.
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
- and comes off at the next day's rebuild. A site that declares a schedule needs a daily rebuild.
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.
42
+
43
+ A site that declares a schedule serves `/schedule.json` (`{ next }`, built with
44
+ `scheduledCollections`, `entryBoundaries` and `nextChangeAt`; the boilerplate ships the endpoint),
45
+ and the portal's hourly scheduler rebuilds it once `next` has passed (its deploy hook goes in the
46
+ admin's « Publication planifiée » card).
33
47
 
34
48
  ## The portal preview
35
49
 
@@ -40,6 +54,30 @@ reads both once, `const APERCU = portalPreview(process.env)`, and filters with
40
54
  drafts in the preview, removed ones only in a preview that asked for them. Only the exact
41
55
  string `'1'` counts, and the past flag means nothing without the preview flag.
42
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
+
69
+ ## 2.1.0
70
+
71
+ Additive, for `/schedule.json`:
72
+
73
+ - `scheduledCollections(schema)`: the collections that declare a valid `schedule`, in order.
74
+ - `entryBoundaries(data, decl)`: every instant at which `scheduleState` of that entry changes, by
75
+ the same rules (none for a draft; a removal at or before the publication is not a change).
76
+ - `nextChangeAt(candidates, now)`: the earliest one strictly after `now`, as `toISOString()`, or
77
+ `null`. `Schedule` is the `{ next }` shape the endpoint serves.
78
+ - `quebecMidnight(day)` (the instant a Quebec day starts) and `nextQuebecDay(day)`, for a site
79
+ that adds instants its declaration does not name (jessyoga).
80
+
43
81
  ## 2.0.0
44
82
 
45
83
  Four declaration keys:
@@ -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,8 +16,22 @@ 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
  }
23
+ /**
24
+ * The instant a Quebec calendar day starts (00:00 in America/Toronto), as epoch ms; NaN for
25
+ * anything that is not a real `YYYY-MM-DD`.
26
+ *
27
+ * Toronto changes its clock at 02:00 local, so midnight always exists, once, at UTC-4 (daylight)
28
+ * or UTC-5 (standard). 04:00Z is that midnight when the day starts on daylight time; when it starts
29
+ * on standard time, 04:00Z is still 23:00 the evening before, so 05:00Z is the first candidate
30
+ * that falls on `day`. The earlier candidate that falls on the day is its start.
31
+ */
32
+ export declare function quebecMidnight(day: string): number;
33
+ /** The calendar day after `day`, or null when `day` is not one. */
34
+ export declare function nextQuebecDay(day: string): string | null;
21
35
  /**
22
36
  * The Quebec calendar day `now` falls on, as UTC midnight of that day -- the same shape a `date`
23
37
  * field has once a site's zod has read it, so the two compare directly (`fin >= jourQuebec(now)`).
@@ -37,8 +51,30 @@ export declare function quebecDay(value: unknown, kind: string): string | null;
37
51
  * The site's `zodFrom` THROWS on any of these, so a bad declaration fails the build that
38
52
  * introduced it. The portal DROPS the declaration with a warning: a list that hid nothing is a
39
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.
40
56
  */
41
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;
42
78
  /**
43
79
  * Where an entry stands at `now`.
44
80
  *
@@ -48,8 +84,62 @@ export declare function scheduleErrors(c: SchedulableLike): string[];
48
84
  * mis-entered, and « Programmé » is the state that makes someone look at it; « Retiré » would
49
85
  * bury it behind the portal's filter. An invalid declaration answers `live`: it must never hide
50
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`).
51
90
  */
52
91
  export declare function scheduleState(data: Record<string, unknown>, c: SchedulableLike, now: Date): ScheduleState;
92
+ /**
93
+ * The collections of a site's `content.schema.json` that declare a VALID `schedule`, in
94
+ * declaration order. A site's `/schedule.json` walks exactly these, so a collection that gains a
95
+ * `schedule` later is covered without anybody remembering to add it -- the silent failure of a
96
+ * hand-kept list is a site that quietly stops aging. An invalid declaration is left out because
97
+ * `scheduleState` answers `live` for it at every instant: it has no boundary to report.
98
+ */
99
+ export declare function scheduledCollections(schema: Record<string, SchedulableLike>): string[];
100
+ /**
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.
129
+ */
130
+ export declare function entryBoundaries(data: object, c: SchedulableLike): number[];
131
+ /**
132
+ * The earliest candidate strictly after `now`, as ISO (`toISOString`), or null when nothing is
133
+ * pending. STRICTLY after: an instant that has arrived was already rendered by the build computing
134
+ * this. A non-finite candidate is dropped, not allowed to make the answer « nothing », and so is
135
+ * one past year 9999, whose ISO form (`+010000-...`) the portal would refuse as unreadable.
136
+ */
137
+ export declare function nextChangeAt(candidates: Iterable<number>, now: number): string | null;
138
+ /** What `/schedule.json` serves. One field, so a reader cannot pick the wrong one. */
139
+ export interface Schedule {
140
+ /** An ISO instant, or null when nothing is pending. */
141
+ next: string | null;
142
+ }
53
143
  /**
54
144
  * What kind of build this is, read from the two variables the portal's builder sets.
55
145
  *
package/dist/schedule.js CHANGED
@@ -11,9 +11,11 @@
11
11
  // during the evening it describes. So `now` is turned into the day it is in `America/Toronto`,
12
12
  // with Intl doing the clock changes, and compared as a `YYYY-MM-DD` string.
13
13
  //
14
- // A STATIC SITE APPLIES THIS WHEN IT BUILDS. `now` is build time; the daily rebuild (06:00
15
- // Quebec) is what makes an entry come off « the morning after ». A collection that declares a
16
- // schedule on a site with no daily rebuild keeps showing it until someone publishes.
14
+ // A STATIC SITE APPLIES THIS WHEN IT BUILDS. `now` is build time; the site serves the next instant
15
+ // its output changes at `/schedule.json` (`scheduledCollections` + `entryBoundaries` +
16
+ // `nextChangeAt`), and the portal's hourly scheduler (pmeweb-portail/src/server/schedule/) rebuilds
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.
17
19
  import { isDate, isDateTime } from './fields.js';
18
20
  /** The zone every PmeWeb client lives in. One constant, named where it is used. */
19
21
  export const QUEBEC = 'America/Toronto';
@@ -27,6 +29,31 @@ function quebecDayOf(ms) {
27
29
  p[part.type] = part.value;
28
30
  return `${p.year}-${p.month}-${p.day}`;
29
31
  }
32
+ /**
33
+ * The instant a Quebec calendar day starts (00:00 in America/Toronto), as epoch ms; NaN for
34
+ * anything that is not a real `YYYY-MM-DD`.
35
+ *
36
+ * Toronto changes its clock at 02:00 local, so midnight always exists, once, at UTC-4 (daylight)
37
+ * or UTC-5 (standard). 04:00Z is that midnight when the day starts on daylight time; when it starts
38
+ * on standard time, 04:00Z is still 23:00 the evening before, so 05:00Z is the first candidate
39
+ * that falls on `day`. The earlier candidate that falls on the day is its start.
40
+ */
41
+ export function quebecMidnight(day) {
42
+ if (!isDate(day))
43
+ return Number.NaN;
44
+ for (const h of [4, 5]) {
45
+ const ms = Date.parse(`${day}T0${h}:00:00.000Z`);
46
+ if (quebecDayOf(ms) === day)
47
+ return ms;
48
+ }
49
+ return Number.NaN;
50
+ }
51
+ /** The calendar day after `day`, or null when `day` is not one. */
52
+ export function nextQuebecDay(day) {
53
+ if (!isDate(day))
54
+ return null;
55
+ return new Date(Date.parse(`${day}T00:00:00.000Z`) + 86_400_000).toISOString().slice(0, 10);
56
+ }
30
57
  /**
31
58
  * The Quebec calendar day `now` falls on, as UTC midnight of that day -- the same shape a `date`
32
59
  * field has once a site's zod has read it, so the two compare directly (`fin >= jourQuebec(now)`).
@@ -69,12 +96,46 @@ function paths(v) {
69
96
  return [];
70
97
  return Array.isArray(v) ? v : [v];
71
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
+ }
72
131
  /**
73
132
  * Everything wrong with a collection's `schedule`, one French sentence each; `[]` means valid.
74
133
  *
75
134
  * The site's `zodFrom` THROWS on any of these, so a bad declaration fails the build that
76
135
  * introduced it. The portal DROPS the declaration with a warning: a list that hid nothing is a
77
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.
78
139
  */
79
140
  export function scheduleErrors(c) {
80
141
  const s = c.schedule;
@@ -123,9 +184,30 @@ export function scheduleErrors(c) {
123
184
  out.push(`schedule.draft : « ${name} » n’est pas une case à cocher`);
124
185
  }
125
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
+ }
126
207
  return out;
127
208
  }
128
- /** 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. */
129
211
  function removeDay(data, c) {
130
212
  for (const p of paths(c.schedule?.removeAt)) {
131
213
  const m = typeof p === 'string' ? PATH_RE.exec(p) : null;
@@ -138,7 +220,7 @@ function removeDay(data, c) {
138
220
  if (sub === undefined) {
139
221
  const d = quebecDay(data[top], f.kind);
140
222
  if (d)
141
- return d;
223
+ return { day: d, field: top };
142
224
  continue;
143
225
  }
144
226
  const kind = f.of?.[sub]?.kind ?? '';
@@ -152,7 +234,7 @@ function removeDay(data, c) {
152
234
  latest = d;
153
235
  }
154
236
  if (latest)
155
- return latest;
237
+ return { day: latest, field: top };
156
238
  }
157
239
  return null;
158
240
  }
@@ -171,6 +253,95 @@ function isScheduled(data, c, now) {
171
253
  const d = quebecDay(data[key], f.kind);
172
254
  return d !== null && d > quebecDayOf(now.getTime());
173
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
+ }
174
345
  /**
175
346
  * Where an entry stands at `now`.
176
347
  *
@@ -180,6 +351,9 @@ function isScheduled(data, c, now) {
180
351
  * mis-entered, and « Programmé » is the state that makes someone look at it; « Retiré » would
181
352
  * bury it behind the portal's filter. An invalid declaration answers `live`: it must never hide
182
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`).
183
357
  */
184
358
  export function scheduleState(data, c, now) {
185
359
  if (!c.schedule || scheduleErrors(c).length > 0)
@@ -190,7 +364,113 @@ export function scheduleState(data, c, now) {
190
364
  if (isScheduled(data, c, now))
191
365
  return 'scheduled';
192
366
  const d = removeDay(data, c);
193
- 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';
376
+ }
377
+ /**
378
+ * The collections of a site's `content.schema.json` that declare a VALID `schedule`, in
379
+ * declaration order. A site's `/schedule.json` walks exactly these, so a collection that gains a
380
+ * `schedule` later is covered without anybody remembering to add it -- the silent failure of a
381
+ * hand-kept list is a site that quietly stops aging. An invalid declaration is left out because
382
+ * `scheduleState` answers `live` for it at every instant: it has no boundary to report.
383
+ */
384
+ export function scheduledCollections(schema) {
385
+ return Object.entries(schema)
386
+ .filter(([, c]) => c.schedule !== undefined && scheduleErrors(c).length === 0)
387
+ .map(([name]) => name);
388
+ }
389
+ function pushPublish(out, rec, key, fields, item) {
390
+ if (typeof key !== 'string')
391
+ return null;
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)
396
+ return 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 });
409
+ }
410
+ /**
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é »).
416
+ */
417
+ export function entryEvents(data, c, opts = {}) {
418
+ if (!c.schedule || scheduleErrors(c).length > 0)
419
+ return [];
420
+ const d = data;
421
+ const draftKey = c.schedule.draft;
422
+ if (!opts.ignoreDraft && typeof draftKey === 'string' && d[draftKey] === true)
423
+ return [];
424
+ const 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);
456
+ }
457
+ /** The last instant `toISOString` writes with a four-digit year, the only shape the portal reads. */
458
+ const LAST_ISO = Date.parse('9999-12-31T23:59:59.999Z');
459
+ /**
460
+ * The earliest candidate strictly after `now`, as ISO (`toISOString`), or null when nothing is
461
+ * pending. STRICTLY after: an instant that has arrived was already rendered by the build computing
462
+ * this. A non-finite candidate is dropped, not allowed to make the answer « nothing », and so is
463
+ * one past year 9999, whose ISO form (`+010000-...`) the portal would refuse as unreadable.
464
+ */
465
+ export function nextChangeAt(candidates, now) {
466
+ let best = null;
467
+ for (const at of candidates) {
468
+ if (!Number.isFinite(at) || at <= now || at > LAST_ISO)
469
+ continue;
470
+ if (best === null || at < best)
471
+ best = at;
472
+ }
473
+ return best === null ? null : new Date(best).toISOString();
194
474
  }
195
475
  export function portalPreview(env) {
196
476
  const active = env.PMEWEB_PORTAL_PREVIEW === '1';
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.0.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",