@pmeweb/content 1.1.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 CHANGED
@@ -8,7 +8,10 @@ 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
+ | `@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 |
14
+ | `@pmeweb/content/prose.css` | the structure of a row of images in a rich body | sites' `Prose.astro` |
12
15
 
13
16
  ## Dated kinds
14
17
 
@@ -26,17 +29,55 @@ collections in `src/content.schema.json`; this package is what reads that declar
26
29
  a `datetime` at its instant.
27
30
  - `removeAt`: one path or several; the first that yields a value wins. `dates[].debut` takes the
28
31
  latest value in the array. Always read as a Quebec day: the entry stays through that whole day
29
- 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
+
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).
30
38
 
31
39
  ## The portal preview
32
40
 
33
41
  The builder sets `PMEWEB_PORTAL_PREVIEW=1` on the portal's preview builds, and
34
42
  `PMEWEB_PORTAL_PREVIEW_PAST=1` too when the client ticked « Afficher les entrées passées ». A site
35
43
  reads both once, `const APERCU = portalPreview(process.env)`, and filters with
36
- `visibleOnSite(scheduleState(data, decl.<collection>, now), APERCU)`: live entries always,
37
- scheduled ones in the preview, removed ones only in a preview that asked for them. Only the exact
44
+ `getPublished(entries, decl.<collection>, now, APERCU)`: live entries always, scheduled ones and
45
+ drafts in the preview, removed ones only in a preview that asked for them. Only the exact
38
46
  string `'1'` counts, and the past flag means nothing without the preview flag.
39
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
+
60
+ ## 2.0.0
61
+
62
+ Four declaration keys:
63
+
64
+ - `titleField` (REQUIRED): the entry's human title, a top-level `line`. Lists, reference dropdowns
65
+ and confirmations show it. May name the same field as `slugField`, which only builds the address.
66
+ - `thumbnail` (optional): a top-level `image` shown as the entry's thumbnail in the portal's list.
67
+ A collection with a `rich` body takes its first body image instead.
68
+ - `rich: true` on a `markdown` field: the portal's visual editor. Only on the `contentField`.
69
+ - `schedule.draft`: a top-level `boolean`. Exactly `true` makes the entry a `draft`, whatever its
70
+ dates: never on the live site, shown in the portal's private preview. May be declared alone.
71
+
72
+ Every site filters through `getPublished(entries, decl, now, portalPreview(process.env))` from
73
+ `@pmeweb/content/published`: live entries always, scheduled ones and drafts in the preview,
74
+ removed ones only when asked for. The `collection` helpers check the keys above, one function per
75
+ key so the portal can drop the faulty one alone. `prose.css`: import it in the site's
76
+ `Prose.astro`; it holds the structure of a row of images, the look stays per site.
77
+
78
+ Why it is major: `scheduleState` can return `draft`, and `zodFrom` refuses a collection without
79
+ `titleField` (or with any other error `collectionErrors` reports).
80
+
40
81
  ## Versioning
41
82
 
42
83
  Sites and the portal pin an EXACT version. What `scheduleState` returns and what the field checks
@@ -0,0 +1,26 @@
1
+ import { type FieldLike } from './schedule.js';
2
+ export interface CollectionLike {
3
+ titleField?: unknown;
4
+ thumbnail?: unknown;
5
+ contentField?: string;
6
+ fields: Record<string, FieldLike & {
7
+ rich?: unknown;
8
+ }>;
9
+ schedule?: {
10
+ publishAt?: unknown;
11
+ removeAt?: unknown;
12
+ draft?: unknown;
13
+ };
14
+ }
15
+ export declare function titleFieldError(c: CollectionLike): string | null;
16
+ export declare function thumbnailError(c: CollectionLike): string | null;
17
+ /** `rich: true` only on the `contentField`, and only on a `markdown` one. `false` means nothing. */
18
+ export declare function richErrors(c: CollectionLike): string[];
19
+ /** Every error of one collection; `[]` means valid. */
20
+ export declare function collectionErrors(c: CollectionLike): string[];
21
+ /** Every error of a whole `content.schema.json`, each prefixed with its collection's key. */
22
+ export declare function schemaErrors(schema: Record<string, CollectionLike>): string[];
23
+ /** The field that gets the visual editor, or null. */
24
+ export declare function richBodyField(c: CollectionLike): string | null;
25
+ /** Whether the portal's list draws thumbnails for this collection at all. */
26
+ export declare function hasThumbnails(c: CollectionLike): boolean;
@@ -0,0 +1,74 @@
1
+ // @pmeweb/content/collection
2
+ // The collection-level keys `schedule.ts` does not own: which field is the title, which is the
3
+ // thumbnail, which body is rich. Checked here once, read the same way by a site's build
4
+ // (`zodFrom` throws on any error) and by the portal (which DROPS the faulty key with a warning,
5
+ // key by key -- hence one function per key, not just the total).
6
+ //
7
+ // STRUCTURAL AND LOOSE, like `SchedulableLike`: a site passes its imported JSON (every `kind`
8
+ // widened to `string`) and the portal its own parsed schema; neither should need a cast.
9
+ import { scheduleErrors } from './schedule.js';
10
+ export function titleFieldError(c) {
11
+ const name = c.titleField;
12
+ if (name === undefined)
13
+ return 'titleField : absent (le champ qui sert de titre)';
14
+ if (typeof name !== 'string' || !c.fields[name])
15
+ return `titleField : « ${String(name)} » n’est pas un champ`;
16
+ if (c.fields[name].kind !== 'line')
17
+ return `titleField : « ${name} » n’est pas une ligne de texte`;
18
+ return null;
19
+ }
20
+ export function thumbnailError(c) {
21
+ const name = c.thumbnail;
22
+ if (name === undefined)
23
+ return null;
24
+ if (typeof name !== 'string' || !c.fields[name])
25
+ return `thumbnail : « ${String(name)} » n’est pas un champ`;
26
+ if (c.fields[name].kind !== 'image')
27
+ return `thumbnail : « ${name} » n’est pas une image`;
28
+ return null;
29
+ }
30
+ /** `rich: true` only on the `contentField`, and only on a `markdown` one. `false` means nothing. */
31
+ export function richErrors(c) {
32
+ const out = [];
33
+ for (const [key, f] of Object.entries(c.fields)) {
34
+ if (f.rich === undefined || f.rich === false)
35
+ continue;
36
+ if (f.rich !== true)
37
+ out.push(`fields.${key}.rich : seule la valeur true est permise`);
38
+ else if (f.kind !== 'markdown')
39
+ out.push(`fields.${key}.rich : « ${key} » n’est pas un champ markdown`);
40
+ else if (c.contentField !== key)
41
+ out.push(`fields.${key}.rich : « ${key} » n’est pas le contentField`);
42
+ }
43
+ return out;
44
+ }
45
+ /** Every error of one collection; `[]` means valid. */
46
+ export function collectionErrors(c) {
47
+ const out = [];
48
+ const title = titleFieldError(c);
49
+ if (title)
50
+ out.push(title);
51
+ const thumb = thumbnailError(c);
52
+ if (thumb)
53
+ out.push(thumb);
54
+ out.push(...richErrors(c), ...scheduleErrors(c));
55
+ return out;
56
+ }
57
+ /** Every error of a whole `content.schema.json`, each prefixed with its collection's key. */
58
+ export function schemaErrors(schema) {
59
+ return Object.entries(schema).flatMap(([key, c]) => collectionErrors(c).map((e) => `${key}.${e}`));
60
+ }
61
+ /** The field that gets the visual editor, or null. */
62
+ export function richBodyField(c) {
63
+ const key = c.contentField;
64
+ if (!key)
65
+ return null;
66
+ const f = c.fields[key];
67
+ return f && f.kind === 'markdown' && f.rich === true ? key : null;
68
+ }
69
+ /** Whether the portal's list draws thumbnails for this collection at all. */
70
+ export function hasThumbnails(c) {
71
+ if (typeof c.thumbnail === 'string' && c.fields[c.thumbnail]?.kind === 'image')
72
+ return true;
73
+ return richBodyField(c) !== null;
74
+ }
@@ -0,0 +1,4 @@
1
+ import { type PortalPreview, type SchedulableLike } from './schedule.js';
2
+ export declare function getPublished<E extends {
3
+ data: object;
4
+ }>(entries: readonly E[], decl: SchedulableLike, now: Date, preview: PortalPreview): E[];
@@ -0,0 +1,14 @@
1
+ // @pmeweb/content/published
2
+ // THE ONE FILTER every site's pages go through: an entry is built when its declared schedule
3
+ // says it is on the site. It replaces the hand-written `isVisible` the blog sites carried and the
4
+ // inline `!draft` checks elsewhere -- one function, the same one the portal badges with.
5
+ //
6
+ // NO ASTRO IMPORT. The site passes `getCollection(...)`'s result, so `astro` stays an optional
7
+ // peer and this file stays testable.
8
+ import { scheduleState, visibleOnSite } from './schedule.js';
9
+ // `data: object`, NOT `Record<string, unknown>`: every site types its entries with an
10
+ // `interface` (`BlogData`, `ArticleData`), and an interface has no implicit index signature, so
11
+ // the narrower bound would refuse exactly the call this function exists for.
12
+ export function getPublished(entries, decl, now, preview) {
13
+ return entries.filter((e) => visibleOnSite(scheduleState(e.data, decl, now), preview));
14
+ }
@@ -1,6 +1,6 @@
1
1
  /** The zone every PmeWeb client lives in. One constant, named where it is used. */
2
2
  export declare const QUEBEC = "America/Toronto";
3
- export type ScheduleState = 'scheduled' | 'live' | 'removed';
3
+ export type ScheduleState = 'draft' | 'scheduled' | 'live' | 'removed';
4
4
  /**
5
5
  * The slice of a declaration this module reads. STRUCTURAL AND LOOSE on purpose: a site passes
6
6
  * its imported `content.schema.json` (whose `kind`s TypeScript widens to `string`) and the portal
@@ -15,8 +15,21 @@ export interface SchedulableLike {
15
15
  schedule?: {
16
16
  publishAt?: unknown;
17
17
  removeAt?: unknown;
18
+ draft?: unknown;
18
19
  };
19
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;
20
33
  /**
21
34
  * The Quebec calendar day `now` falls on, as UTC midnight of that day -- the same shape a `date`
22
35
  * field has once a site's zod has read it, so the two compare directly (`fin >= jourQuebec(now)`).
@@ -41,11 +54,51 @@ export declare function scheduleErrors(c: SchedulableLike): string[];
41
54
  /**
42
55
  * Where an entry stands at `now`.
43
56
  *
44
- * `scheduled` BEATS `removed`. An entry whose publication is after its removal was mis-entered,
45
- * and « Programmé » is the state that makes someone look at it; « Retiré » would bury it behind
46
- * the portal's filter. An invalid declaration answers `live`: it must never hide anything.
57
+ * `draft` BEATS EVERYTHING: an unfinished entry is unfinished whatever its dates say, and only
58
+ * the exact value `true` makes one (a string « true » is a typo, and a typo must not hide a
59
+ * post). `scheduled` BEATS `removed`. An entry whose publication is after its removal was
60
+ * mis-entered, and « Programmé » is the state that makes someone look at it; « Retiré » would
61
+ * bury it behind the portal's filter. An invalid declaration answers `live`: it must never hide
62
+ * anything.
47
63
  */
48
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
+ }
49
102
  /**
50
103
  * What kind of build this is, read from the two variables the portal's builder sets.
51
104
  *
@@ -64,5 +117,6 @@ export interface PortalPreview {
64
117
  showPast: boolean;
65
118
  }
66
119
  export declare function portalPreview(env: Record<string, string | undefined>): PortalPreview;
67
- /** What a site builds: live always; scheduled in the preview; removed only when asked for. */
120
+ /** What a site builds: live always; a draft or a scheduled entry in the preview; removed only
121
+ * when asked for. */
68
122
  export declare function visibleOnSite(state: ScheduleState, preview: PortalPreview): boolean;
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 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.
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)`).
@@ -111,6 +137,18 @@ export function scheduleErrors(c) {
111
137
  check('publishAt', s.publishAt, false);
112
138
  for (const p of paths(s.removeAt))
113
139
  check('removeAt', p, true);
140
+ if (s.draft !== undefined) {
141
+ const name = s.draft;
142
+ if (typeof name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(name)) {
143
+ out.push(`schedule.draft : « ${String(name)} » n’est pas un nom de champ`);
144
+ }
145
+ else if (!c.fields[name]) {
146
+ out.push(`schedule.draft : « ${name} » n’est pas un champ`);
147
+ }
148
+ else if (c.fields[name].kind !== 'boolean') {
149
+ out.push(`schedule.draft : « ${name} » n’est pas une case à cocher`);
150
+ }
151
+ }
114
152
  return out;
115
153
  }
116
154
  /** The day `removeAt` resolves to: the first path that yields one; an array path yields its latest. */
@@ -162,27 +200,112 @@ function isScheduled(data, c, now) {
162
200
  /**
163
201
  * Where an entry stands at `now`.
164
202
  *
165
- * `scheduled` BEATS `removed`. An entry whose publication is after its removal was mis-entered,
166
- * and « Programmé » is the state that makes someone look at it; « Retiré » would bury it behind
167
- * the portal's filter. An invalid declaration answers `live`: it must never hide anything.
203
+ * `draft` BEATS EVERYTHING: an unfinished entry is unfinished whatever its dates say, and only
204
+ * the exact value `true` makes one (a string « true » is a typo, and a typo must not hide a
205
+ * post). `scheduled` BEATS `removed`. An entry whose publication is after its removal was
206
+ * mis-entered, and « Programmé » is the state that makes someone look at it; « Retiré » would
207
+ * bury it behind the portal's filter. An invalid declaration answers `live`: it must never hide
208
+ * anything.
168
209
  */
169
210
  export function scheduleState(data, c, now) {
170
211
  if (!c.schedule || scheduleErrors(c).length > 0)
171
212
  return 'live';
213
+ const draftKey = c.schedule.draft;
214
+ if (typeof draftKey === 'string' && data[draftKey] === true)
215
+ return 'draft';
172
216
  if (isScheduled(data, c, now))
173
217
  return 'scheduled';
174
218
  const d = removeDay(data, c);
175
219
  return d !== null && quebecDayOf(now.getTime()) > d ? 'removed' : 'live';
176
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
+ }
177
299
  export function portalPreview(env) {
178
300
  const active = env.PMEWEB_PORTAL_PREVIEW === '1';
179
301
  return { active, showPast: active && env.PMEWEB_PORTAL_PREVIEW_PAST === '1' };
180
302
  }
181
- /** What a site builds: live always; scheduled in the preview; removed only when asked for. */
303
+ /** What a site builds: live always; a draft or a scheduled entry in the preview; removed only
304
+ * when asked for. */
182
305
  export function visibleOnSite(state, preview) {
183
306
  if (state === 'live')
184
307
  return true;
185
308
  if (!preview.active)
186
309
  return false;
187
- return state === 'scheduled' || preview.showPast;
310
+ return state === 'scheduled' || state === 'draft' || preview.showPast;
188
311
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Every field kind, and the whole vocabulary a site may use. Widening this is a spec
2
- * decision, not a convenience: see § 4 of the design. */
3
- export type FieldKind = 'line' | 'paragraph' | 'markdown' | 'slug' | 'image' | 'date' | 'datetime' | 'select' | 'multiselect' | 'number' | 'boolean' | 'array' | 'reference' | 'url' | 'time';
2
+ * decision, not a convenience: see § 4 of the design. A RUNTIME LIST so the portal's
3
+ * defensive parser checks against this one instead of a copy. */
4
+ export declare const FIELD_KINDS: readonly ["line", "paragraph", "markdown", "slug", "image", "date", "datetime", "select", "multiselect", "number", "boolean", "array", "reference", "url", "time"];
5
+ export type FieldKind = (typeof FIELD_KINDS)[number];
4
6
  /** Where an enum's vocabulary lives. A POINTER at a data file, never a copy -- the portal's
5
7
  * blog pipeline reads AND WRITES that file (design § 4). */
6
8
  export interface OptionsRef {
@@ -46,7 +48,13 @@ export interface FieldCommon {
46
48
  * for free and a missing case is a compile error.
47
49
  */
48
50
  export type Field = (FieldCommon & {
49
- kind: 'line' | 'paragraph' | 'markdown' | 'slug' | 'url' | 'time' | 'date' | 'datetime' | 'boolean' | 'image';
51
+ kind: 'line' | 'paragraph' | 'slug' | 'url' | 'time' | 'date' | 'datetime' | 'boolean' | 'image';
52
+ })
53
+ /** `rich: true` gives the body the portal's visual editor. Valid ONLY on the collection's
54
+ * `contentField` (`richErrors`, collection.ts). Replaces the portal's hardcoded `blog`. */
55
+ | (FieldCommon & {
56
+ kind: 'markdown';
57
+ rich?: boolean;
50
58
  }) | (FieldCommon & {
51
59
  kind: 'number';
52
60
  int?: boolean;
@@ -78,6 +86,12 @@ export interface ScheduleDecl {
78
86
  * latest filled value. Read as a Quebec calendar day: visible through that whole day.
79
87
  */
80
88
  removeAt?: string | string[];
89
+ /**
90
+ * One top-level `boolean` field. When it holds exactly `true` the entry is a `draft`, whatever
91
+ * its dates: never on the live site, shown in the portal's private preview. Absent or `false`
92
+ * is not a draft (booleans stay false-safe). May be declared alone.
93
+ */
94
+ draft?: string;
81
95
  }
82
96
  export interface CollectionDecl {
83
97
  label: string;
@@ -87,6 +101,12 @@ export interface CollectionDecl {
87
101
  entryFile: string;
88
102
  /** The field the slug is derived from AT CREATION. Never re-derived on edit (design § 6). */
89
103
  slugField: string;
104
+ /** The entry's human title: lists, reference dropdowns, confirmations. A top-level `line`.
105
+ * REQUIRED. May name the same field as `slugField`, which only builds the address. */
106
+ titleField: string;
107
+ /** A top-level `image` field shown as the entry's thumbnail in the portal's list. Optional:
108
+ * a collection with a `rich` body takes its first body image instead (`hasThumbnails`). */
109
+ thumbnail?: string;
90
110
  /** Which field is the Markdown body. ABSENT on a collection that has no body -- `authors`
91
111
  * is frontmatter only. Excluded from the generated zod: Astro's body is not a field. */
92
112
  contentField?: string;
package/dist/types.js CHANGED
@@ -5,4 +5,10 @@
5
5
  // WHY THE JSON IS TYPED AT ALL. A mistyped `kind` is otherwise a runtime throw during
6
6
  // `astro build`, in a file with no line number that a reader associates with content. With
7
7
  // this, `npm run check` names the field.
8
- export {};
8
+ /** Every field kind, and the whole vocabulary a site may use. Widening this is a spec
9
+ * decision, not a convenience: see § 4 of the design. A RUNTIME LIST so the portal's
10
+ * defensive parser checks against this one instead of a copy. */
11
+ export const FIELD_KINDS = [
12
+ 'line', 'paragraph', 'markdown', 'slug', 'image', 'date', 'datetime',
13
+ 'select', 'multiselect', 'number', 'boolean', 'array', 'reference', 'url', 'time',
14
+ ];
package/dist/zodFrom.js CHANGED
@@ -19,7 +19,7 @@
19
19
  // reason, which is why the generator lives here and takes what it needs as arguments.
20
20
  import { z } from 'astro/zod';
21
21
  import { TIME_RE, isDate, isDateTime, isDayDate } from './fields.js';
22
- import { scheduleErrors } from './schedule.js';
22
+ import { collectionErrors } from './collection.js';
23
23
  function values(from, ctx) {
24
24
  const vocab = ctx.vocabularies[from];
25
25
  // THROWS rather than falling back to z.string(). A silent fallback accepts any value, and
@@ -139,9 +139,10 @@ function objectSchema(fields, ctx) {
139
139
  * to the type is silently unreachable in every template).
140
140
  */
141
141
  export function zodFrom(decl, ctx) {
142
- // A schedule pointing at a field that is not a date fails the build that introduced it. The
143
- // portal drops the same declaration with a warning instead (`scheduleErrors`).
144
- const errors = scheduleErrors(decl);
142
+ // A declaration the package refuses -- a title that is not a line, a rich flag off the body, a
143
+ // schedule pointing at a non-date -- fails the build that introduced it. The portal drops the
144
+ // same key with a warning instead (`collection.ts`, `schedule.ts`).
145
+ const errors = collectionErrors(decl);
145
146
  if (errors.length > 0)
146
147
  throw new Error(`zodFrom: ${errors.join(' ; ')}`);
147
148
  const fields = Object.fromEntries(Object.entries(decl.fields).filter(([key]) => key !== decl.contentField));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmeweb/content",
3
- "version": "1.1.0",
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",
@@ -8,7 +8,8 @@
8
8
  "access": "public"
9
9
  },
10
10
  "files": [
11
- "dist"
11
+ "dist",
12
+ "prose.css"
12
13
  ],
13
14
  "exports": {
14
15
  "./types": {
@@ -26,7 +27,16 @@
26
27
  "./zodFrom": {
27
28
  "types": "./dist/zodFrom.d.ts",
28
29
  "default": "./dist/zodFrom.js"
29
- }
30
+ },
31
+ "./collection": {
32
+ "types": "./dist/collection.d.ts",
33
+ "default": "./dist/collection.js"
34
+ },
35
+ "./published": {
36
+ "types": "./dist/published.d.ts",
37
+ "default": "./dist/published.js"
38
+ },
39
+ "./prose.css": "./prose.css"
30
40
  },
31
41
  "scripts": {
32
42
  "build": "rm -rf dist && tsc -p tsconfig.json",
package/prose.css ADDED
@@ -0,0 +1,17 @@
1
+ /* @pmeweb/content/prose.css -- the STRUCTURE a row of images needs, and nothing else.
2
+ * A paragraph holding two or more images (consecutive lines, no blank line) is a row; a blank
3
+ * line keeps them stacked. The portal's editor writes rows (chunk 9), so every site whose body is
4
+ * `rich` must show one: a copied rule is one a site forgets, and the client's click then does
5
+ * nothing with no error anywhere. The LOOK (border, radius, the row's margin) stays per site.
6
+ * Global on purpose: import it from the site's Prose component. */
7
+ .prose p:has(img + img) {
8
+ display: flex;
9
+ flex-wrap: wrap;
10
+ align-items: flex-start;
11
+ gap: var(--prose-row-gap, 14px);
12
+ }
13
+ .prose p:has(img + img) img {
14
+ flex: 1 1 260px;
15
+ min-width: 0;
16
+ margin: 0;
17
+ }