@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 +45 -4
- package/dist/collection.d.ts +26 -0
- package/dist/collection.js +74 -0
- package/dist/published.d.ts +4 -0
- package/dist/published.js +14 -0
- package/dist/schedule.d.ts +59 -5
- package/dist/schedule.js +131 -8
- package/dist/types.d.ts +23 -3
- package/dist/types.js +7 -1
- package/dist/zodFrom.js +5 -4
- package/package.json +13 -3
- package/prose.css +17 -0
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
|
|
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
|
-
`
|
|
37
|
-
|
|
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,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
|
+
}
|
package/dist/schedule.d.ts
CHANGED
|
@@ -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
|
-
* `
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
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
|
|
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)`).
|
|
@@ -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
|
-
* `
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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
|
|
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
|
-
|
|
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' | '
|
|
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
|
-
|
|
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 {
|
|
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
|
|
143
|
-
//
|
|
144
|
-
|
|
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": "
|
|
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
|
+
}
|