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