@pmeweb/content 2.0.0 → 2.1.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 +19 -2
- package/dist/schedule.d.ts +49 -0
- package/dist/schedule.js +107 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ 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` | sites, portal |
|
|
12
12
|
| `@pmeweb/content/collection` | `titleFieldError`, `thumbnailError`, `richErrors`, `collectionErrors`, `schemaErrors`, `richBodyField`, `hasThumbnails` | portal, `zodFrom` |
|
|
13
13
|
| `@pmeweb/content/published` | `getPublished`, the one filter every site's pages go through | sites |
|
|
14
14
|
| `@pmeweb/content/prose.css` | the structure of a row of images in a rich body | sites' `Prose.astro` |
|
|
@@ -29,7 +29,12 @@ 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
|
+
|
|
34
|
+
A site that declares a schedule serves `/schedule.json` (`{ next }`, built with
|
|
35
|
+
`scheduledCollections`, `entryBoundaries` and `nextChangeAt`; the boilerplate ships the endpoint),
|
|
36
|
+
and the portal's hourly scheduler rebuilds it once `next` has passed (its deploy hook goes in the
|
|
37
|
+
admin's « Publication planifiée » card).
|
|
33
38
|
|
|
34
39
|
## The portal preview
|
|
35
40
|
|
|
@@ -40,6 +45,18 @@ reads both once, `const APERCU = portalPreview(process.env)`, and filters with
|
|
|
40
45
|
drafts in the preview, removed ones only in a preview that asked for them. Only the exact
|
|
41
46
|
string `'1'` counts, and the past flag means nothing without the preview flag.
|
|
42
47
|
|
|
48
|
+
## 2.1.0
|
|
49
|
+
|
|
50
|
+
Additive, for `/schedule.json`:
|
|
51
|
+
|
|
52
|
+
- `scheduledCollections(schema)`: the collections that declare a valid `schedule`, in order.
|
|
53
|
+
- `entryBoundaries(data, decl)`: every instant at which `scheduleState` of that entry changes, by
|
|
54
|
+
the same rules (none for a draft; a removal at or before the publication is not a change).
|
|
55
|
+
- `nextChangeAt(candidates, now)`: the earliest one strictly after `now`, as `toISOString()`, or
|
|
56
|
+
`null`. `Schedule` is the `{ next }` shape the endpoint serves.
|
|
57
|
+
- `quebecMidnight(day)` (the instant a Quebec day starts) and `nextQuebecDay(day)`, for a site
|
|
58
|
+
that adds instants its declaration does not name (jessyoga).
|
|
59
|
+
|
|
43
60
|
## 2.0.0
|
|
44
61
|
|
|
45
62
|
Four declaration keys:
|
package/dist/schedule.d.ts
CHANGED
|
@@ -18,6 +18,18 @@ export interface SchedulableLike {
|
|
|
18
18
|
draft?: unknown;
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The instant a Quebec calendar day starts (00:00 in America/Toronto), as epoch ms; NaN for
|
|
23
|
+
* anything that is not a real `YYYY-MM-DD`.
|
|
24
|
+
*
|
|
25
|
+
* Toronto changes its clock at 02:00 local, so midnight always exists, once, at UTC-4 (daylight)
|
|
26
|
+
* or UTC-5 (standard). 04:00Z is that midnight when the day starts on daylight time; when it starts
|
|
27
|
+
* on standard time, 04:00Z is still 23:00 the evening before, so 05:00Z is the first candidate
|
|
28
|
+
* that falls on `day`. The earlier candidate that falls on the day is its start.
|
|
29
|
+
*/
|
|
30
|
+
export declare function quebecMidnight(day: string): number;
|
|
31
|
+
/** The calendar day after `day`, or null when `day` is not one. */
|
|
32
|
+
export declare function nextQuebecDay(day: string): string | null;
|
|
21
33
|
/**
|
|
22
34
|
* The Quebec calendar day `now` falls on, as UTC midnight of that day -- the same shape a `date`
|
|
23
35
|
* field has once a site's zod has read it, so the two compare directly (`fin >= jourQuebec(now)`).
|
|
@@ -50,6 +62,43 @@ export declare function scheduleErrors(c: SchedulableLike): string[];
|
|
|
50
62
|
* anything.
|
|
51
63
|
*/
|
|
52
64
|
export declare function scheduleState(data: Record<string, unknown>, c: SchedulableLike, now: Date): ScheduleState;
|
|
65
|
+
/**
|
|
66
|
+
* The collections of a site's `content.schema.json` that declare a VALID `schedule`, in
|
|
67
|
+
* declaration order. A site's `/schedule.json` walks exactly these, so a collection that gains a
|
|
68
|
+
* `schedule` later is covered without anybody remembering to add it -- the silent failure of a
|
|
69
|
+
* hand-kept list is a site that quietly stops aging. An invalid declaration is left out because
|
|
70
|
+
* `scheduleState` answers `live` for it at every instant: it has no boundary to report.
|
|
71
|
+
*/
|
|
72
|
+
export declare function scheduledCollections(schema: Record<string, SchedulableLike>): string[];
|
|
73
|
+
/**
|
|
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.
|
|
88
|
+
*/
|
|
89
|
+
export declare function entryBoundaries(data: Record<string, unknown>, c: SchedulableLike): number[];
|
|
90
|
+
/**
|
|
91
|
+
* The earliest candidate strictly after `now`, as ISO (`toISOString`), or null when nothing is
|
|
92
|
+
* pending. STRICTLY after: an instant that has arrived was already rendered by the build computing
|
|
93
|
+
* this. A non-finite candidate is dropped, not allowed to make the answer « nothing », and so is
|
|
94
|
+
* one past year 9999, whose ISO form (`+010000-...`) the portal would refuse as unreadable.
|
|
95
|
+
*/
|
|
96
|
+
export declare function nextChangeAt(candidates: Iterable<number>, now: number): string | null;
|
|
97
|
+
/** What `/schedule.json` serves. One field, so a reader cannot pick the wrong one. */
|
|
98
|
+
export interface Schedule {
|
|
99
|
+
/** An ISO instant, or null when nothing is pending. */
|
|
100
|
+
next: string | null;
|
|
101
|
+
}
|
|
53
102
|
/**
|
|
54
103
|
* What kind of build this is, read from the two variables the portal's builder sets.
|
|
55
104
|
*
|
package/dist/schedule.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
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.
|
|
17
18
|
import { isDate, isDateTime } from './fields.js';
|
|
18
19
|
/** The zone every PmeWeb client lives in. One constant, named where it is used. */
|
|
19
20
|
export const QUEBEC = 'America/Toronto';
|
|
@@ -27,6 +28,31 @@ function quebecDayOf(ms) {
|
|
|
27
28
|
p[part.type] = part.value;
|
|
28
29
|
return `${p.year}-${p.month}-${p.day}`;
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The instant a Quebec calendar day starts (00:00 in America/Toronto), as epoch ms; NaN for
|
|
33
|
+
* anything that is not a real `YYYY-MM-DD`.
|
|
34
|
+
*
|
|
35
|
+
* Toronto changes its clock at 02:00 local, so midnight always exists, once, at UTC-4 (daylight)
|
|
36
|
+
* or UTC-5 (standard). 04:00Z is that midnight when the day starts on daylight time; when it starts
|
|
37
|
+
* on standard time, 04:00Z is still 23:00 the evening before, so 05:00Z is the first candidate
|
|
38
|
+
* that falls on `day`. The earlier candidate that falls on the day is its start.
|
|
39
|
+
*/
|
|
40
|
+
export function quebecMidnight(day) {
|
|
41
|
+
if (!isDate(day))
|
|
42
|
+
return Number.NaN;
|
|
43
|
+
for (const h of [4, 5]) {
|
|
44
|
+
const ms = Date.parse(`${day}T0${h}:00:00.000Z`);
|
|
45
|
+
if (quebecDayOf(ms) === day)
|
|
46
|
+
return ms;
|
|
47
|
+
}
|
|
48
|
+
return Number.NaN;
|
|
49
|
+
}
|
|
50
|
+
/** The calendar day after `day`, or null when `day` is not one. */
|
|
51
|
+
export function nextQuebecDay(day) {
|
|
52
|
+
if (!isDate(day))
|
|
53
|
+
return null;
|
|
54
|
+
return new Date(Date.parse(`${day}T00:00:00.000Z`) + 86_400_000).toISOString().slice(0, 10);
|
|
55
|
+
}
|
|
30
56
|
/**
|
|
31
57
|
* The Quebec calendar day `now` falls on, as UTC midnight of that day -- the same shape a `date`
|
|
32
58
|
* field has once a site's zod has read it, so the two compare directly (`fin >= jourQuebec(now)`).
|
|
@@ -192,6 +218,84 @@ export function scheduleState(data, c, now) {
|
|
|
192
218
|
const d = removeDay(data, c);
|
|
193
219
|
return d !== null && quebecDayOf(now.getTime()) > d ? 'removed' : 'live';
|
|
194
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* The collections of a site's `content.schema.json` that declare a VALID `schedule`, in
|
|
223
|
+
* declaration order. A site's `/schedule.json` walks exactly these, so a collection that gains a
|
|
224
|
+
* `schedule` later is covered without anybody remembering to add it -- the silent failure of a
|
|
225
|
+
* hand-kept list is a site that quietly stops aging. An invalid declaration is left out because
|
|
226
|
+
* `scheduleState` answers `live` for it at every instant: it has no boundary to report.
|
|
227
|
+
*/
|
|
228
|
+
export function scheduledCollections(schema) {
|
|
229
|
+
return Object.entries(schema)
|
|
230
|
+
.filter(([, c]) => c.schedule !== undefined && scheduleErrors(c).length === 0)
|
|
231
|
+
.map(([name]) => name);
|
|
232
|
+
}
|
|
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;
|
|
237
|
+
if (typeof key !== 'string')
|
|
238
|
+
return null;
|
|
239
|
+
const f = c.fields[key];
|
|
240
|
+
if (!f)
|
|
241
|
+
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;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
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.
|
|
263
|
+
*/
|
|
264
|
+
export function entryBoundaries(data, c) {
|
|
265
|
+
if (!c.schedule || scheduleErrors(c).length > 0)
|
|
266
|
+
return [];
|
|
267
|
+
const draftKey = c.schedule.draft;
|
|
268
|
+
if (typeof draftKey === 'string' && data[draftKey] === true)
|
|
269
|
+
return [];
|
|
270
|
+
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;
|
|
280
|
+
}
|
|
281
|
+
/** The last instant `toISOString` writes with a four-digit year, the only shape the portal reads. */
|
|
282
|
+
const LAST_ISO = Date.parse('9999-12-31T23:59:59.999Z');
|
|
283
|
+
/**
|
|
284
|
+
* The earliest candidate strictly after `now`, as ISO (`toISOString`), or null when nothing is
|
|
285
|
+
* pending. STRICTLY after: an instant that has arrived was already rendered by the build computing
|
|
286
|
+
* this. A non-finite candidate is dropped, not allowed to make the answer « nothing », and so is
|
|
287
|
+
* one past year 9999, whose ISO form (`+010000-...`) the portal would refuse as unreadable.
|
|
288
|
+
*/
|
|
289
|
+
export function nextChangeAt(candidates, now) {
|
|
290
|
+
let best = null;
|
|
291
|
+
for (const at of candidates) {
|
|
292
|
+
if (!Number.isFinite(at) || at <= now || at > LAST_ISO)
|
|
293
|
+
continue;
|
|
294
|
+
if (best === null || at < best)
|
|
295
|
+
best = at;
|
|
296
|
+
}
|
|
297
|
+
return best === null ? null : new Date(best).toISOString();
|
|
298
|
+
}
|
|
195
299
|
export function portalPreview(env) {
|
|
196
300
|
const active = env.PMEWEB_PORTAL_PREVIEW === '1';
|
|
197
301
|
return { active, showPast: active && env.PMEWEB_PORTAL_PREVIEW_PAST === '1' };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmeweb/content",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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",
|