@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 +41 -3
- package/dist/collection.d.ts +2 -0
- package/dist/published.d.ts +7 -0
- package/dist/published.js +16 -1
- package/dist/schedule.d.ts +90 -0
- package/dist/schedule.js +287 -7
- package/dist/types.d.ts +18 -0
- package/package.json +1 -1
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
|
|
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:
|
package/dist/collection.d.ts
CHANGED
package/dist/published.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/schedule.d.ts
CHANGED
|
@@ -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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|