@pmeweb/content 1.0.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 +43 -0
- package/dist/fields.d.ts +15 -0
- package/dist/fields.js +42 -0
- package/dist/schedule.d.ts +53 -0
- package/dist/schedule.js +183 -0
- package/dist/types.d.ts +110 -0
- package/dist/types.js +8 -0
- package/dist/zodFrom.d.ts +48 -0
- package/dist/zodFrom.js +149 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @pmeweb/content
|
|
2
|
+
|
|
3
|
+
The content contract every PmeWeb site and the client portal share. A site declares its
|
|
4
|
+
collections in `src/content.schema.json`; this package is what reads that declaration.
|
|
5
|
+
|
|
6
|
+
| Entry point | What | Who imports it |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `@pmeweb/content/types` | `CollectionDecl`, `Field`, `ScheduleDecl`, `ContentSchema` | sites, portal |
|
|
9
|
+
| `@pmeweb/content/zodFrom` | declaration -> Astro zod schema (needs `astro`) | sites |
|
|
10
|
+
| `@pmeweb/content/fields` | `isDate`, `isDateTime`, `isTime`, `isDayDate`, `TIME_RE` | portal tier 1, `zodFrom` |
|
|
11
|
+
| `@pmeweb/content/schedule` | `scheduleState`, `visibleOnSite`, `scheduleErrors`, `jourQuebec`, `quebecDay` | sites, portal |
|
|
12
|
+
|
|
13
|
+
## Dated kinds
|
|
14
|
+
|
|
15
|
+
- `date`: `2026-10-14`, a calendar day and nothing else.
|
|
16
|
+
- `datetime`: `2026-10-14T13:00:00.000Z`, an instant with its zone. The portal edits it in Quebec time.
|
|
17
|
+
- `time`: `09:05`, a time of day on 24 hours.
|
|
18
|
+
|
|
19
|
+
## Scheduling
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
"evenements": { "schedule": { "publishAt": "publierLe", "removeAt": ["fin", "debut"] } }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `publishAt`: one `date`/`datetime` field. A `date` publishes at the start of its Quebec day,
|
|
26
|
+
a `datetime` at its instant.
|
|
27
|
+
- `removeAt`: one path or several; the first that yields a value wins. `dates[].debut` takes the
|
|
28
|
+
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.
|
|
30
|
+
|
|
31
|
+
## Versioning
|
|
32
|
+
|
|
33
|
+
Sites and the portal pin an EXACT version. What `scheduleState` returns and what the field checks
|
|
34
|
+
accept is a frozen contract: changing either is a major version. The portal must never accept a
|
|
35
|
+
value a site's `zodFrom` refuses.
|
|
36
|
+
|
|
37
|
+
## Develop
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install # .npmrc sets legacy-peer-deps (npm 10.9 arborist crash)
|
|
41
|
+
npm test
|
|
42
|
+
npm run build # dist/ (JS + .d.ts)
|
|
43
|
+
```
|
package/dist/fields.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const TIME_RE: RegExp;
|
|
2
|
+
/** « HH:MM », 00:00 to 23:59. */
|
|
3
|
+
export declare function isTime(value: unknown): value is string;
|
|
4
|
+
/**
|
|
5
|
+
* « YYYY-MM-DD », and a day that exists. The round trip is what refuses `2026-02-30`, which
|
|
6
|
+
* `Date` would silently roll over to March 2nd.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isDate(value: unknown): value is string;
|
|
9
|
+
/**
|
|
10
|
+
* An ISO instant WITH ITS ZONE. A wall clock with no `Z` and no offset is refused: `Date.parse`
|
|
11
|
+
* would read it in the machine's zone, so its meaning would depend on where it was built.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isDateTime(value: unknown): value is string;
|
|
14
|
+
/** A `Date` that is a day as the site's loader produces one: exactly UTC midnight. */
|
|
15
|
+
export declare function isDayDate(value: unknown): value is Date;
|
package/dist/fields.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The stored shape of each dated kind, as one definition read by every check.
|
|
2
|
+
//
|
|
3
|
+
// THE SITE'S BUILD AND THE PORTAL'S TIER 1 BOTH CALL THESE, which is the reason they live in a
|
|
4
|
+
// package: « a draft the portal accepts is a draft the site accepts » used to hold because two
|
|
5
|
+
// copies of a regex agreed, and now it holds because there is one.
|
|
6
|
+
//
|
|
7
|
+
// date `2026-10-14` a calendar day, nothing else (strict since 2026-09-27)
|
|
8
|
+
// datetime `2026-10-14T13:00:00.000Z` an instant, with Z or an offset
|
|
9
|
+
// time `09:05` a time of day on 24 hours
|
|
10
|
+
//
|
|
11
|
+
// A STRING IS WHAT THE PORTAL HOLDS; A `Date` IS WHAT A SITE MAY HOLD. The portal reads YAML with
|
|
12
|
+
// `yaml` v2, which keeps `2026-10-14` a string. Astro's loader can hand zod a `Date` for the same
|
|
13
|
+
// text (a YAML timestamp). `isDayDate` is the site-side reading of « this is a day »: the loader
|
|
14
|
+
// turns a day into UTC midnight, so a `Date` anywhere else carried a time.
|
|
15
|
+
export const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
16
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
17
|
+
const DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
18
|
+
/** « HH:MM », 00:00 to 23:59. */
|
|
19
|
+
export function isTime(value) {
|
|
20
|
+
return typeof value === 'string' && TIME_RE.test(value);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* « YYYY-MM-DD », and a day that exists. The round trip is what refuses `2026-02-30`, which
|
|
24
|
+
* `Date` would silently roll over to March 2nd.
|
|
25
|
+
*/
|
|
26
|
+
export function isDate(value) {
|
|
27
|
+
if (typeof value !== 'string' || !DATE_RE.test(value))
|
|
28
|
+
return false;
|
|
29
|
+
const d = new Date(`${value}T00:00:00.000Z`);
|
|
30
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* An ISO instant WITH ITS ZONE. A wall clock with no `Z` and no offset is refused: `Date.parse`
|
|
34
|
+
* would read it in the machine's zone, so its meaning would depend on where it was built.
|
|
35
|
+
*/
|
|
36
|
+
export function isDateTime(value) {
|
|
37
|
+
return typeof value === 'string' && DATETIME_RE.test(value) && !Number.isNaN(Date.parse(value));
|
|
38
|
+
}
|
|
39
|
+
/** A `Date` that is a day as the site's loader produces one: exactly UTC midnight. */
|
|
40
|
+
export function isDayDate(value) {
|
|
41
|
+
return value instanceof Date && !Number.isNaN(value.getTime()) && value.getTime() % 86_400_000 === 0;
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The zone every PmeWeb client lives in. One constant, named where it is used. */
|
|
2
|
+
export declare const QUEBEC = "America/Toronto";
|
|
3
|
+
export type ScheduleState = 'scheduled' | 'live' | 'removed';
|
|
4
|
+
/**
|
|
5
|
+
* The slice of a declaration this module reads. STRUCTURAL AND LOOSE on purpose: a site passes
|
|
6
|
+
* its imported `content.schema.json` (whose `kind`s TypeScript widens to `string`) and the portal
|
|
7
|
+
* its own parsed schema, and neither should need a cast to ask when an entry is on.
|
|
8
|
+
*/
|
|
9
|
+
export interface FieldLike {
|
|
10
|
+
kind: string;
|
|
11
|
+
of?: Record<string, FieldLike>;
|
|
12
|
+
}
|
|
13
|
+
export interface SchedulableLike {
|
|
14
|
+
fields: Record<string, FieldLike>;
|
|
15
|
+
schedule?: {
|
|
16
|
+
publishAt?: unknown;
|
|
17
|
+
removeAt?: unknown;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The Quebec calendar day `now` falls on, as UTC midnight of that day -- the same shape a `date`
|
|
22
|
+
* field has once a site's zod has read it, so the two compare directly (`fin >= jourQuebec(now)`).
|
|
23
|
+
*/
|
|
24
|
+
export declare function jourQuebec(now: Date): Date;
|
|
25
|
+
/**
|
|
26
|
+
* A stored value's Quebec day, read by its field's kind; null when empty or unreadable.
|
|
27
|
+
*
|
|
28
|
+
* A `date` IS its day, whichever spelling it arrives in (the portal's string, a site's UTC-midnight
|
|
29
|
+
* `Date`). A `datetime` is converted to Quebec FIRST and then truncated, so an event ending at
|
|
30
|
+
* 21:00 on the 14th is the 14th, not the 15th it already is in UTC.
|
|
31
|
+
*/
|
|
32
|
+
export declare function quebecDay(value: unknown, kind: string): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Everything wrong with a collection's `schedule`, one French sentence each; `[]` means valid.
|
|
35
|
+
*
|
|
36
|
+
* The site's `zodFrom` THROWS on any of these, so a bad declaration fails the build that
|
|
37
|
+
* introduced it. The portal DROPS the declaration with a warning: a list that hid nothing is a
|
|
38
|
+
* better failure than a page that does not open.
|
|
39
|
+
*/
|
|
40
|
+
export declare function scheduleErrors(c: SchedulableLike): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Where an entry stands at `now`.
|
|
43
|
+
*
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
export declare function scheduleState(data: Record<string, unknown>, c: SchedulableLike, now: Date): ScheduleState;
|
|
49
|
+
/**
|
|
50
|
+
* What a site builds. The portal's PRIVATE preview (`PMEWEB_PORTAL_PREVIEW === '1'`) shows a
|
|
51
|
+
* scheduled entry early so the client can check it; it never brings back a removed one.
|
|
52
|
+
*/
|
|
53
|
+
export declare function visibleOnSite(state: ScheduleState, portalPreview: boolean): boolean;
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// When an entry is on the site: a collection's `schedule` (types.ts), read the same way by the
|
|
2
|
+
// site's build and by the portal's list.
|
|
3
|
+
//
|
|
4
|
+
// ONE FUNCTION, TWO READERS, AND THAT IS THE POINT. jessyoga and yoga-unique each carried
|
|
5
|
+
// `fin ?? debut` and a `publierLe` rule by hand, and the portal knew neither -- so its list kept
|
|
6
|
+
// past events forever and never said an entry was waiting. A site that builds with
|
|
7
|
+
// `scheduleState` and a portal that badges with it cannot disagree about an entry.
|
|
8
|
+
//
|
|
9
|
+
// THE UNIT IS THE QUEBEC CALENDAR DAY, not the UTC one. A day stored as `2026-11-01` parses as UTC
|
|
10
|
+
// midnight, which is 20:00 the evening before in Quebec: compared in UTC, an event disappears
|
|
11
|
+
// during the evening it describes. So `now` is turned into the day it is in `America/Toronto`,
|
|
12
|
+
// with Intl doing the clock changes, and compared as a `YYYY-MM-DD` string.
|
|
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.
|
|
17
|
+
import { isDate, isDateTime } from './fields.js';
|
|
18
|
+
/** The zone every PmeWeb client lives in. One constant, named where it is used. */
|
|
19
|
+
export const QUEBEC = 'America/Toronto';
|
|
20
|
+
const DAY = new Intl.DateTimeFormat('en-CA', {
|
|
21
|
+
timeZone: QUEBEC, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
22
|
+
});
|
|
23
|
+
/** « YYYY-MM-DD » of this instant in Quebec. */
|
|
24
|
+
function quebecDayOf(ms) {
|
|
25
|
+
const p = {};
|
|
26
|
+
for (const part of DAY.formatToParts(new Date(ms)))
|
|
27
|
+
p[part.type] = part.value;
|
|
28
|
+
return `${p.year}-${p.month}-${p.day}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The Quebec calendar day `now` falls on, as UTC midnight of that day -- the same shape a `date`
|
|
32
|
+
* field has once a site's zod has read it, so the two compare directly (`fin >= jourQuebec(now)`).
|
|
33
|
+
*/
|
|
34
|
+
export function jourQuebec(now) {
|
|
35
|
+
return new Date(`${quebecDayOf(now.getTime())}T00:00:00.000Z`);
|
|
36
|
+
}
|
|
37
|
+
function instant(value) {
|
|
38
|
+
if (value instanceof Date)
|
|
39
|
+
return Number.isNaN(value.getTime()) ? null : value.getTime();
|
|
40
|
+
if (isDateTime(value))
|
|
41
|
+
return Date.parse(value);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A stored value's Quebec day, read by its field's kind; null when empty or unreadable.
|
|
46
|
+
*
|
|
47
|
+
* A `date` IS its day, whichever spelling it arrives in (the portal's string, a site's UTC-midnight
|
|
48
|
+
* `Date`). A `datetime` is converted to Quebec FIRST and then truncated, so an event ending at
|
|
49
|
+
* 21:00 on the 14th is the 14th, not the 15th it already is in UTC.
|
|
50
|
+
*/
|
|
51
|
+
export function quebecDay(value, kind) {
|
|
52
|
+
if (kind === 'date') {
|
|
53
|
+
if (isDate(value))
|
|
54
|
+
return value;
|
|
55
|
+
if (value instanceof Date && !Number.isNaN(value.getTime()))
|
|
56
|
+
return value.toISOString().slice(0, 10);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (kind === 'datetime') {
|
|
60
|
+
const ms = instant(value);
|
|
61
|
+
return ms === null ? null : quebecDayOf(ms);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const PATH_RE = /^([A-Za-z_$][\w$]*)(?:\[\]\.([A-Za-z_$][\w$]*))?$/;
|
|
66
|
+
const DATED = new Set(['date', 'datetime']);
|
|
67
|
+
function paths(v) {
|
|
68
|
+
if (v === undefined)
|
|
69
|
+
return [];
|
|
70
|
+
return Array.isArray(v) ? v : [v];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Everything wrong with a collection's `schedule`, one French sentence each; `[]` means valid.
|
|
74
|
+
*
|
|
75
|
+
* The site's `zodFrom` THROWS on any of these, so a bad declaration fails the build that
|
|
76
|
+
* introduced it. The portal DROPS the declaration with a warning: a list that hid nothing is a
|
|
77
|
+
* better failure than a page that does not open.
|
|
78
|
+
*/
|
|
79
|
+
export function scheduleErrors(c) {
|
|
80
|
+
const s = c.schedule;
|
|
81
|
+
if (s === undefined)
|
|
82
|
+
return [];
|
|
83
|
+
if (typeof s !== 'object' || s === null || Array.isArray(s))
|
|
84
|
+
return ['schedule : ce n’est pas un objet'];
|
|
85
|
+
const out = [];
|
|
86
|
+
const check = (key, p, allowArray) => {
|
|
87
|
+
const m = typeof p === 'string' ? PATH_RE.exec(p) : null;
|
|
88
|
+
if (!m) {
|
|
89
|
+
out.push(`schedule.${key} : « ${String(p)} » n’est pas un chemin de champ`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const [, top, sub] = m;
|
|
93
|
+
const f = c.fields[top];
|
|
94
|
+
if (!f) {
|
|
95
|
+
out.push(`schedule.${key} : « ${top} » n’est pas un champ`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (sub !== undefined) {
|
|
99
|
+
if (!allowArray)
|
|
100
|
+
out.push(`schedule.${key} : « ${p} », un tableau n’est permis que pour removeAt`);
|
|
101
|
+
else if (f.kind !== 'array' || !f.of?.[sub])
|
|
102
|
+
out.push(`schedule.${key} : « ${p} » n’est pas un champ de tableau`);
|
|
103
|
+
else if (!DATED.has(f.of[sub].kind))
|
|
104
|
+
out.push(`schedule.${key} : « ${p} » n’est pas une date`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!DATED.has(f.kind))
|
|
108
|
+
out.push(`schedule.${key} : « ${top} » n’est pas une date`);
|
|
109
|
+
};
|
|
110
|
+
if (s.publishAt !== undefined)
|
|
111
|
+
check('publishAt', s.publishAt, false);
|
|
112
|
+
for (const p of paths(s.removeAt))
|
|
113
|
+
check('removeAt', p, true);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/** The day `removeAt` resolves to: the first path that yields one; an array path yields its latest. */
|
|
117
|
+
function removeDay(data, c) {
|
|
118
|
+
for (const p of paths(c.schedule?.removeAt)) {
|
|
119
|
+
const m = typeof p === 'string' ? PATH_RE.exec(p) : null;
|
|
120
|
+
if (!m)
|
|
121
|
+
continue;
|
|
122
|
+
const [, top, sub] = m;
|
|
123
|
+
const f = c.fields[top];
|
|
124
|
+
if (!f)
|
|
125
|
+
continue;
|
|
126
|
+
if (sub === undefined) {
|
|
127
|
+
const d = quebecDay(data[top], f.kind);
|
|
128
|
+
if (d)
|
|
129
|
+
return d;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const kind = f.of?.[sub]?.kind ?? '';
|
|
133
|
+
const items = Array.isArray(data[top]) ? data[top] : [];
|
|
134
|
+
let latest = null;
|
|
135
|
+
for (const item of items) {
|
|
136
|
+
if (typeof item !== 'object' || item === null)
|
|
137
|
+
continue;
|
|
138
|
+
const d = quebecDay(item[sub], kind);
|
|
139
|
+
if (d && (latest === null || d > latest))
|
|
140
|
+
latest = d;
|
|
141
|
+
}
|
|
142
|
+
if (latest)
|
|
143
|
+
return latest;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
/** A `date` publishes at the start of its Quebec day; a `datetime` at its instant. */
|
|
148
|
+
function isScheduled(data, c, now) {
|
|
149
|
+
const key = c.schedule?.publishAt;
|
|
150
|
+
if (typeof key !== 'string')
|
|
151
|
+
return false;
|
|
152
|
+
const f = c.fields[key];
|
|
153
|
+
if (!f)
|
|
154
|
+
return false;
|
|
155
|
+
if (f.kind === 'datetime') {
|
|
156
|
+
const ms = instant(data[key]);
|
|
157
|
+
return ms !== null && ms > now.getTime();
|
|
158
|
+
}
|
|
159
|
+
const d = quebecDay(data[key], f.kind);
|
|
160
|
+
return d !== null && d > quebecDayOf(now.getTime());
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Where an entry stands at `now`.
|
|
164
|
+
*
|
|
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.
|
|
168
|
+
*/
|
|
169
|
+
export function scheduleState(data, c, now) {
|
|
170
|
+
if (!c.schedule || scheduleErrors(c).length > 0)
|
|
171
|
+
return 'live';
|
|
172
|
+
if (isScheduled(data, c, now))
|
|
173
|
+
return 'scheduled';
|
|
174
|
+
const d = removeDay(data, c);
|
|
175
|
+
return d !== null && quebecDayOf(now.getTime()) > d ? 'removed' : 'live';
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* What a site builds. The portal's PRIVATE preview (`PMEWEB_PORTAL_PREVIEW === '1'`) shows a
|
|
179
|
+
* scheduled entry early so the client can check it; it never brings back a removed one.
|
|
180
|
+
*/
|
|
181
|
+
export function visibleOnSite(state, portalPreview) {
|
|
182
|
+
return state === 'live' || (state === 'scheduled' && portalPreview);
|
|
183
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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';
|
|
4
|
+
/** Where an enum's vocabulary lives. A POINTER at a data file, never a copy -- the portal's
|
|
5
|
+
* blog pipeline reads AND WRITES that file (design § 4). */
|
|
6
|
+
export interface OptionsRef {
|
|
7
|
+
from: string;
|
|
8
|
+
}
|
|
9
|
+
/** What every field carries, whatever its kind. NOT a member of the union and deliberately
|
|
10
|
+
* carries no `kind`: see the note under `Field`. */
|
|
11
|
+
export interface FieldCommon {
|
|
12
|
+
/** French, client-facing. The one thing that cannot be inferred. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Help text under the input. */
|
|
15
|
+
description?: string;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
/** Declared and generated, but never rendered for the client. `draft` is the case. */
|
|
18
|
+
editable?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Clearing this field REMOVES the key rather than writing `""`. Defaults to true for
|
|
21
|
+
* optional strings. `ogImageAlt` is why it is named: `ogImageAlt: ''` tells a screen
|
|
22
|
+
* reader the image is decorative, which is a different claim from « no alt was given »,
|
|
23
|
+
* and it stops the template's fallback to the post title from firing. Read by the
|
|
24
|
+
* portal's writer; `zodFrom` ignores it.
|
|
25
|
+
*/
|
|
26
|
+
absentSafe?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The value on disk is a map from language code to what this field would otherwise hold.
|
|
29
|
+
*
|
|
30
|
+
* ORTHOGONAL TO `kind`, not a kind of its own: a localized `line` or `paragraph` both work,
|
|
31
|
+
* and each language's value is still held to the field's own rules. THE LANGUAGE SET IS NOT
|
|
32
|
+
* DECLARED HERE -- it is the site's own, read from `pmeweb.json`'s `content.dictionaries`,
|
|
33
|
+
* because a site's languages are a property of the site and a second declaration is a second
|
|
34
|
+
* place for the same fact to be wrong.
|
|
35
|
+
*/
|
|
36
|
+
localized?: boolean;
|
|
37
|
+
max?: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A field, as a DISCRIMINATED UNION on `kind`.
|
|
41
|
+
*
|
|
42
|
+
* The discriminant has to live on each member rather than on a shared base: a base declaring
|
|
43
|
+
* `kind: FieldKind` makes every member's `kind` the full union, so `switch (f.kind)` narrows
|
|
44
|
+
* nothing and `zodFrom` needs a cast in every branch -- which is precisely where a wrong one
|
|
45
|
+
* would go unnoticed. Written this way, `case 'reference':` gives TypeScript `collection`
|
|
46
|
+
* for free and a missing case is a compile error.
|
|
47
|
+
*/
|
|
48
|
+
export type Field = (FieldCommon & {
|
|
49
|
+
kind: 'line' | 'paragraph' | 'markdown' | 'slug' | 'url' | 'time' | 'date' | 'datetime' | 'boolean' | 'image';
|
|
50
|
+
}) | (FieldCommon & {
|
|
51
|
+
kind: 'number';
|
|
52
|
+
int?: boolean;
|
|
53
|
+
}) | (FieldCommon & {
|
|
54
|
+
kind: 'select' | 'multiselect';
|
|
55
|
+
options: OptionsRef;
|
|
56
|
+
}) | (FieldCommon & {
|
|
57
|
+
kind: 'reference';
|
|
58
|
+
collection: string;
|
|
59
|
+
}) | (FieldCommon & {
|
|
60
|
+
kind: 'array';
|
|
61
|
+
of: Record<string, Field>;
|
|
62
|
+
itemLabel?: string;
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* Which existing fields say when an entry goes on and comes off the site. OPT-IN: a collection
|
|
66
|
+
* that declares none is simply always on. Checked by `scheduleErrors` (schedule.ts), read by
|
|
67
|
+
* `scheduleState` -- on the site's build and in the portal's list alike.
|
|
68
|
+
*
|
|
69
|
+
* Both point at fields the collection already has, so no date is ever typed twice: a blog's
|
|
70
|
+
* `date` IS its publication, an event's end IS its removal.
|
|
71
|
+
*/
|
|
72
|
+
export interface ScheduleDecl {
|
|
73
|
+
/** One top-level `date` or `datetime` field. Before it, the entry is `scheduled`. */
|
|
74
|
+
publishAt?: string;
|
|
75
|
+
/**
|
|
76
|
+
* One path or several, THE FIRST THAT YIELDS A VALUE WINS (`["fin", "debut"]`: the end, or
|
|
77
|
+
* else the start). A path is `field` or `arrayField[].subField`; the array form yields the
|
|
78
|
+
* latest filled value. Read as a Quebec calendar day: visible through that whole day.
|
|
79
|
+
*/
|
|
80
|
+
removeAt?: string | string[];
|
|
81
|
+
}
|
|
82
|
+
export interface CollectionDecl {
|
|
83
|
+
label: string;
|
|
84
|
+
/** Directory holding the entries, no trailing slash. */
|
|
85
|
+
path: string;
|
|
86
|
+
/** The file inside each entry's directory. `index.md` for blog, `index.yaml` for authors. */
|
|
87
|
+
entryFile: string;
|
|
88
|
+
/** The field the slug is derived from AT CREATION. Never re-derived on edit (design § 6). */
|
|
89
|
+
slugField: string;
|
|
90
|
+
/** Which field is the Markdown body. ABSENT on a collection that has no body -- `authors`
|
|
91
|
+
* is frontmatter only. Excluded from the generated zod: Astro's body is not a field. */
|
|
92
|
+
contentField?: string;
|
|
93
|
+
/** The list view. */
|
|
94
|
+
columns: string[];
|
|
95
|
+
fields: Record<string, Field>;
|
|
96
|
+
/**
|
|
97
|
+
* The order and grouping of the portal's form. READ BY THE PORTAL ONLY -- `zodFrom` ignores
|
|
98
|
+
* it, since it changes where a field is drawn and nothing about what it accepts. `type` says
|
|
99
|
+
* what a group IS; the portal decides where each type goes.
|
|
100
|
+
*/
|
|
101
|
+
groups?: Array<{
|
|
102
|
+
key: string;
|
|
103
|
+
label: string;
|
|
104
|
+
type?: 'content' | 'metadata' | 'media' | 'seo';
|
|
105
|
+
collapsed?: boolean;
|
|
106
|
+
fields: string[];
|
|
107
|
+
}>;
|
|
108
|
+
schedule?: ScheduleDecl;
|
|
109
|
+
}
|
|
110
|
+
export type ContentSchema = Record<string, CollectionDecl>;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @pmeweb/content/types
|
|
2
|
+
// The shape of a site's src/content.schema.json -- the one place a site declares what its
|
|
3
|
+
// collections are. Types only: the generator is zodFrom.ts and the data is the JSON.
|
|
4
|
+
//
|
|
5
|
+
// WHY THE JSON IS TYPED AT ALL. A mistyped `kind` is otherwise a runtime throw during
|
|
6
|
+
// `astro build`, in a file with no line number that a reader associates with content. With
|
|
7
|
+
// this, `npm run check` names the field.
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'astro/zod';
|
|
2
|
+
import type { CollectionDecl } from './types.js';
|
|
3
|
+
export interface ZodFromContext {
|
|
4
|
+
/** Astro's SchemaContext `image`. */
|
|
5
|
+
image: () => z.ZodTypeAny;
|
|
6
|
+
/** Astro's `reference`, passed in from content.config.ts. */
|
|
7
|
+
reference: (collection: string) => z.ZodTypeAny;
|
|
8
|
+
/** Enum vocabularies by the path their `options.from` names. Static imports, because
|
|
9
|
+
* `import(someString)` does not bundle -- Vite needs the specifier at build time. */
|
|
10
|
+
vocabularies: Record<string, ReadonlyArray<{
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
}>>;
|
|
14
|
+
/**
|
|
15
|
+
* The site's languages, in the order it declares them -- what a `localized` field is keyed by.
|
|
16
|
+
*
|
|
17
|
+
* PASSED IN, never derived here, and read from ONE declaration: `pmeweb.json`'s
|
|
18
|
+
* `content.dictionaries`, which is also where the client portal reads it. A second list would
|
|
19
|
+
* be a second place for the same fact to be wrong, and the failure would be silent in the
|
|
20
|
+
* worst direction: a language missing from it is a language whose copy the build stops
|
|
21
|
+
* validating and every template stops finding.
|
|
22
|
+
*
|
|
23
|
+
* Empty means « this site declares none », and a localized field is then generated as an
|
|
24
|
+
* ordinary one -- there is no language to key it by, so refusing would fail a build over a
|
|
25
|
+
* declaration that simply does not apply here.
|
|
26
|
+
*/
|
|
27
|
+
locales: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build one collection's schema.
|
|
31
|
+
*
|
|
32
|
+
* `contentField` is EXCLUDED. It names the Markdown body, which the portal renders as a box
|
|
33
|
+
* and which Astro does not treat as a schema field at all -- declaring it would make every
|
|
34
|
+
* entry fail for a missing `body` key that lives below the frontmatter.
|
|
35
|
+
*
|
|
36
|
+
* `T` IS THE ONE THING THE DECLARATION CANNOT CARRY, and the cast below is where it is
|
|
37
|
+
* admitted rather than hidden. TypeScript widens every string in an imported `.json` to
|
|
38
|
+
* `string` -- probed 2026-09-21: `typeof schema.blog.fields.title.kind` accepts
|
|
39
|
+
* `'pas-line'` -- so `kind` is not a literal in the type system and no conditional type can
|
|
40
|
+
* recover the shape from the file. Without `T`, `post.data` is `Record<string, unknown>`
|
|
41
|
+
* and `post.data.date.getTime()` stops compiling across the whole site.
|
|
42
|
+
*
|
|
43
|
+
* So the caller states the parsed shape once, beside its loader, and
|
|
44
|
+
* tests/contentSchemaDeclaration.test.ts holds that statement to the JSON's field names --
|
|
45
|
+
* the one drift a reader would not otherwise see (a field added to the declaration and not
|
|
46
|
+
* to the type is silently unreachable in every template).
|
|
47
|
+
*/
|
|
48
|
+
export declare function zodFrom<T = Record<string, unknown>>(decl: CollectionDecl, ctx: ZodFromContext): z.ZodType<T>;
|
package/dist/zodFrom.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @pmeweb/content/zodFrom
|
|
2
|
+
// A collection's declaration (src/content.schema.json) -> the z.ZodObject that Astro's
|
|
3
|
+
// defineCollection requires. THE ONLY PLACE `z.` APPEARS IN A SITE.
|
|
4
|
+
//
|
|
5
|
+
// A PACKAGE SINCE 2026-09-27. It was a byte-identical copy in the boilerplate and four sites, and
|
|
6
|
+
// the portal's tier 1 held its rules a third time; the field checks (`fields.ts`) and the
|
|
7
|
+
// schedule check (`schedule.ts`) are now one definition both sides import.
|
|
8
|
+
//
|
|
9
|
+
// WHY ZOD AT ALL, since the rules are now data: `schema?: S extends zCore.$ZodType` is
|
|
10
|
+
// Astro's signature (node_modules/astro/dist/content/config.d.ts), and `image()` itself
|
|
11
|
+
// returns a ZodObject -- so anything holding one has to be one. And the schema does not only
|
|
12
|
+
// validate, it TRANSFORMS: `z.coerce.date()` is why `post.data.date.toISOString()` works and
|
|
13
|
+
// `image()` is why `<Image src={post.data.ogImage}/>` gets ImageMetadata rather than a string.
|
|
14
|
+
// Remove it and the site renders wrong, twice out of three times in silence.
|
|
15
|
+
//
|
|
16
|
+
// `image` AND `reference` ARE ARGUMENTS, NOT IMPORTS. `reference` lives in 'astro:content', a
|
|
17
|
+
// virtual module that resolves only inside an Astro build; importing it here would make this
|
|
18
|
+
// file untestable -- content.config.ts cannot be imported by a test for exactly that
|
|
19
|
+
// reason, which is why the generator lives here and takes what it needs as arguments.
|
|
20
|
+
import { z } from 'astro/zod';
|
|
21
|
+
import { TIME_RE, isDate, isDateTime, isDayDate } from './fields.js';
|
|
22
|
+
import { scheduleErrors } from './schedule.js';
|
|
23
|
+
function values(from, ctx) {
|
|
24
|
+
const vocab = ctx.vocabularies[from];
|
|
25
|
+
// THROWS rather than falling back to z.string(). A silent fallback accepts any value, and
|
|
26
|
+
// the only writer that would notice is a person reading a published page.
|
|
27
|
+
if (!vocab || vocab.length === 0) {
|
|
28
|
+
throw new Error(`zodFrom: no vocabulary passed for "${from}". Add a static import for it in `
|
|
29
|
+
+ 'src/content.config.ts and pass it in `vocabularies`.');
|
|
30
|
+
}
|
|
31
|
+
const list = vocab.map((v) => v.value);
|
|
32
|
+
return [list[0], ...list.slice(1)];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A localized field: an object of the field's own schema, keyed by the site's languages.
|
|
36
|
+
*
|
|
37
|
+
* `required` STAYS AT THE FIELD LEVEL, which is the whole of the shape. A required localized
|
|
38
|
+
* field is a required map; an optional one is an optional map -- `.optional()`, which accepts
|
|
39
|
+
* the key being ABSENT and refuses `null`, and that is why the migration omits an empty field
|
|
40
|
+
* rather than writing one. There is no third state where the map exists holding nothing.
|
|
41
|
+
*
|
|
42
|
+
* EACH LANGUAGE INSIDE IT IS REQUIRED, whatever the field says, because a present map is a
|
|
43
|
+
* claim that the site says this -- and a site that says it in one language only is the exact
|
|
44
|
+
* defect this shape exists to close. « Not filled » is spelled by the map being absent, once,
|
|
45
|
+
* rather than by each language being separately allowed to be empty.
|
|
46
|
+
*/
|
|
47
|
+
function localizedSchema(f, ctx) {
|
|
48
|
+
const inner = fieldSchema({ ...f, localized: false, required: true }, ctx);
|
|
49
|
+
const map = z.object(Object.fromEntries(ctx.locales.map((lang) => [lang, inner])));
|
|
50
|
+
return f.required === true ? map : map.optional();
|
|
51
|
+
}
|
|
52
|
+
function fieldSchema(f, ctx) {
|
|
53
|
+
if (f.localized === true && ctx.locales.length > 0)
|
|
54
|
+
return localizedSchema(f, ctx);
|
|
55
|
+
const req = f.required === true;
|
|
56
|
+
switch (f.kind) {
|
|
57
|
+
case 'line':
|
|
58
|
+
case 'paragraph':
|
|
59
|
+
case 'markdown':
|
|
60
|
+
case 'slug': {
|
|
61
|
+
const base = req ? z.string().min(1) : z.string();
|
|
62
|
+
const capped = typeof f.max === 'number' ? base.max(f.max) : base;
|
|
63
|
+
return req ? capped : capped.optional();
|
|
64
|
+
}
|
|
65
|
+
case 'url':
|
|
66
|
+
// `z.url()`, not `z.string().url()`, which zod v4 deprecates.
|
|
67
|
+
return req ? z.url() : z.url().optional();
|
|
68
|
+
case 'date': {
|
|
69
|
+
// A DAY AND NOTHING ELSE (strict since 2026-09-27). Astro's loader may already have made
|
|
70
|
+
// the YAML a Date, which for a day is UTC midnight -- a Date anywhere else carried a time,
|
|
71
|
+
// and a time belongs in a `datetime`. Either spelling comes out as that UTC-midnight Date.
|
|
72
|
+
const d = z
|
|
73
|
+
.custom((v) => isDate(v) || isDayDate(v), { message: 'Date attendue (AAAA-MM-JJ).' })
|
|
74
|
+
.transform((v) => new Date(typeof v === 'string' ? `${v}T00:00:00.000Z` : v.getTime()));
|
|
75
|
+
return req ? d : d.optional();
|
|
76
|
+
}
|
|
77
|
+
case 'datetime': {
|
|
78
|
+
// An instant with its zone (`fields.ts`). The portal edits it in Quebec time and stores UTC.
|
|
79
|
+
const d = z
|
|
80
|
+
.custom((v) => isDateTime(v) || (v instanceof Date && !Number.isNaN(v.getTime())), { message: 'Date et heure attendues (ISO, avec fuseau).' })
|
|
81
|
+
.transform((v) => new Date(typeof v === 'string' ? Date.parse(v) : v.getTime()));
|
|
82
|
+
return req ? d : d.optional();
|
|
83
|
+
}
|
|
84
|
+
case 'time': {
|
|
85
|
+
// « HH:MM » on 24 hours, 00:00 to 23:59 -- the portal's `<input type="time">` writes
|
|
86
|
+
// exactly this, and its tier 1 reads the same `TIME_RE`. The site formats it for display.
|
|
87
|
+
const t = z.string().regex(TIME_RE);
|
|
88
|
+
return req ? t : t.optional();
|
|
89
|
+
}
|
|
90
|
+
case 'boolean':
|
|
91
|
+
return z.boolean().default(false);
|
|
92
|
+
case 'number': {
|
|
93
|
+
const n = f.int === true ? z.number().int() : z.number();
|
|
94
|
+
return req ? n : n.optional();
|
|
95
|
+
}
|
|
96
|
+
case 'image':
|
|
97
|
+
// Always optional: an image cannot be required while upload is Phase 4 (design § 8).
|
|
98
|
+
return ctx.image().optional();
|
|
99
|
+
case 'reference': {
|
|
100
|
+
const r = ctx.reference(f.collection);
|
|
101
|
+
return req ? r : r.optional();
|
|
102
|
+
}
|
|
103
|
+
case 'select': {
|
|
104
|
+
const e = z.enum(values(f.options.from, ctx));
|
|
105
|
+
return req ? e : e.optional();
|
|
106
|
+
}
|
|
107
|
+
case 'multiselect':
|
|
108
|
+
return z.array(z.enum(values(f.options.from, ctx))).default([]);
|
|
109
|
+
case 'array':
|
|
110
|
+
return z.array(objectSchema(f.of, ctx)).default([]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// The shape is built MUTABLY and typed as a plain record, not as `z.ZodRawShape`: zod v4
|
|
114
|
+
// declares that alias `Readonly<...>`, so assigning into it is a compile error. `z.object`
|
|
115
|
+
// accepts the record either way.
|
|
116
|
+
function objectSchema(fields, ctx) {
|
|
117
|
+
const shape = {};
|
|
118
|
+
for (const [key, f] of Object.entries(fields))
|
|
119
|
+
shape[key] = fieldSchema(f, ctx);
|
|
120
|
+
return z.object(shape);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Build one collection's schema.
|
|
124
|
+
*
|
|
125
|
+
* `contentField` is EXCLUDED. It names the Markdown body, which the portal renders as a box
|
|
126
|
+
* and which Astro does not treat as a schema field at all -- declaring it would make every
|
|
127
|
+
* entry fail for a missing `body` key that lives below the frontmatter.
|
|
128
|
+
*
|
|
129
|
+
* `T` IS THE ONE THING THE DECLARATION CANNOT CARRY, and the cast below is where it is
|
|
130
|
+
* admitted rather than hidden. TypeScript widens every string in an imported `.json` to
|
|
131
|
+
* `string` -- probed 2026-09-21: `typeof schema.blog.fields.title.kind` accepts
|
|
132
|
+
* `'pas-line'` -- so `kind` is not a literal in the type system and no conditional type can
|
|
133
|
+
* recover the shape from the file. Without `T`, `post.data` is `Record<string, unknown>`
|
|
134
|
+
* and `post.data.date.getTime()` stops compiling across the whole site.
|
|
135
|
+
*
|
|
136
|
+
* So the caller states the parsed shape once, beside its loader, and
|
|
137
|
+
* tests/contentSchemaDeclaration.test.ts holds that statement to the JSON's field names --
|
|
138
|
+
* the one drift a reader would not otherwise see (a field added to the declaration and not
|
|
139
|
+
* to the type is silently unreachable in every template).
|
|
140
|
+
*/
|
|
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);
|
|
145
|
+
if (errors.length > 0)
|
|
146
|
+
throw new Error(`zodFrom: ${errors.join(' ; ')}`);
|
|
147
|
+
const fields = Object.fromEntries(Object.entries(decl.fields).filter(([key]) => key !== decl.contentField));
|
|
148
|
+
return objectSchema(fields, ctx);
|
|
149
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pmeweb/content",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
"./types": {
|
|
15
|
+
"types": "./dist/types.d.ts",
|
|
16
|
+
"default": "./dist/types.js"
|
|
17
|
+
},
|
|
18
|
+
"./fields": {
|
|
19
|
+
"types": "./dist/fields.d.ts",
|
|
20
|
+
"default": "./dist/fields.js"
|
|
21
|
+
},
|
|
22
|
+
"./schedule": {
|
|
23
|
+
"types": "./dist/schedule.d.ts",
|
|
24
|
+
"default": "./dist/schedule.js"
|
|
25
|
+
},
|
|
26
|
+
"./zodFrom": {
|
|
27
|
+
"types": "./dist/zodFrom.d.ts",
|
|
28
|
+
"default": "./dist/zodFrom.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
35
|
+
"prepublishOnly": "npm run build && vitest run"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"astro": "7.2.9",
|
|
39
|
+
"typescript": "6.0.3",
|
|
40
|
+
"vite": "8.3.0",
|
|
41
|
+
"vitest": "4.1.11"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22.12"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"astro": ">=7"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"astro": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|