@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 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
+ ```
@@ -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;
@@ -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
+ }
@@ -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>;
@@ -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
+ }