@glw907/cairn-cms 0.62.1 → 0.68.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/dist/auth/types.d.ts +7 -0
  3. package/dist/components/ComponentInsertDialog.svelte +17 -6
  4. package/dist/components/ConceptList.svelte +25 -4
  5. package/dist/components/cairn-admin.css +175 -2
  6. package/dist/content/advisories.d.ts +5 -0
  7. package/dist/content/advisories.js +17 -9
  8. package/dist/content/field-rules.d.ts +15 -0
  9. package/dist/content/field-rules.js +39 -0
  10. package/dist/content/fields.d.ts +121 -0
  11. package/dist/content/fields.js +30 -0
  12. package/dist/content/fieldset.d.ts +86 -0
  13. package/dist/content/fieldset.js +233 -0
  14. package/dist/content/schema.js +16 -20
  15. package/dist/delivery/public-routes.d.ts +8 -0
  16. package/dist/delivery/public-routes.js +10 -1
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.js +5 -0
  19. package/dist/log/events.d.ts +1 -1
  20. package/dist/media/index.d.ts +1 -1
  21. package/dist/media/index.js +1 -1
  22. package/dist/media/manifest.d.ts +11 -0
  23. package/dist/media/manifest.js +13 -0
  24. package/dist/render/highlight.d.ts +9 -0
  25. package/dist/render/highlight.js +206 -0
  26. package/dist/render/pipeline.js +12 -1
  27. package/dist/render/registry.d.ts +10 -2
  28. package/dist/render/registry.js +21 -1
  29. package/dist/render/rehype-dispatch.d.ts +2 -6
  30. package/dist/render/rehype-dispatch.js +2 -6
  31. package/dist/render/sanitize-schema.d.ts +10 -0
  32. package/dist/render/sanitize-schema.js +29 -0
  33. package/dist/sveltekit/content-routes.js +9 -7
  34. package/dist/sveltekit/guard.js +10 -0
  35. package/package.json +13 -2
  36. package/src/lib/auth/types.ts +7 -0
  37. package/src/lib/components/ComponentInsertDialog.svelte +17 -6
  38. package/src/lib/components/ConceptList.svelte +41 -4
  39. package/src/lib/content/advisories.ts +24 -15
  40. package/src/lib/content/field-rules.ts +40 -0
  41. package/src/lib/content/fields.ts +127 -0
  42. package/src/lib/content/fieldset.ts +307 -0
  43. package/src/lib/content/schema.ts +9 -13
  44. package/src/lib/delivery/public-routes.ts +19 -1
  45. package/src/lib/index.ts +7 -0
  46. package/src/lib/log/events.ts +1 -0
  47. package/src/lib/media/index.ts +1 -0
  48. package/src/lib/media/manifest.ts +14 -0
  49. package/src/lib/render/highlight.ts +259 -0
  50. package/src/lib/render/pipeline.ts +12 -1
  51. package/src/lib/render/registry.ts +30 -3
  52. package/src/lib/render/rehype-dispatch.ts +2 -6
  53. package/src/lib/render/sanitize-schema.ts +31 -0
  54. package/src/lib/sveltekit/content-routes.ts +9 -7
  55. package/src/lib/sveltekit/guard.ts +15 -0
@@ -317,11 +317,16 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
317
317
 
318
318
  {#if defs.length > 0}
319
319
  <dialog class="modal" aria-labelledby="cairn-insert-dialog-title" bind:this={dialog} onclose={onClose} oncancel={onCancel}>
320
- <div class="modal-box {twoPane ? 'max-w-3xl' : ''}">
320
+ <!-- The box caps at 85vh and is a flex column so its header holds while only the body scrolls,
321
+ per the design system's dialog-sizing recipe. The cap rides Tailwind utilities (the utilities
322
+ layer) so it beats DaisyUI's `.modal-box` max-height: 100vh; a components-layer rule loses the
323
+ cascade. overflow-hidden keeps the box from being a second scroll container. Matches TidyReview. -->
324
+ <div class="modal-box flex max-h-[85vh] flex-col overflow-hidden {twoPane ? 'max-w-3xl' : ''}">
321
325
  <!-- The shared header: at the configure step it carries the Back control and the
322
326
  "Insert > group" eyebrow breadcrumb above the component label; while browsing it is the
323
- plain "Insert a component" title. -->
324
- <div class="mb-3 flex items-center gap-3">
327
+ plain "Insert a component" title. It holds (flex-none) while the body scrolls, per the
328
+ design system's dialog-sizing recipe. -->
329
+ <div class="mb-3 flex flex-none items-center gap-3">
325
330
  {#if picked && !editing}
326
331
  <button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Back to components" onclick={back}>
327
332
  <svg class="h-4 w-4" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M165.7 202.3a8 8 0 0 1-11.4 11.4l-80-80a8 8 0 0 1 0-11.4l80-80a8 8 0 0 1 11.4 11.4L91.3 128Z" /></svg>
@@ -339,6 +344,9 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
339
344
  </div>
340
345
 
341
346
  {#if picked}
347
+ <!-- The configure body is the box's scroll container (flex-1, min-h-0): the shared header
348
+ above holds while the form scrolls within the 85vh cap. -->
349
+ <div class="-mr-1 flex min-h-0 flex-1 flex-col overflow-y-auto pr-1">
342
350
  {#key picked}
343
351
  {#if twoPane}
344
352
  <!-- Two panes: the form on the left, the live preview on the right. Below the breakpoint
@@ -395,9 +403,10 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
395
403
  {@render configureForm(picked)}
396
404
  {/if}
397
405
  {/key}
406
+ </div>
398
407
  {:else}
399
408
  {#if showSearch}
400
- <div class="mb-3 flex items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
409
+ <div class="mb-3 flex flex-none items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
401
410
  <svg class="ec-glyph h-4 w-4 text-[var(--color-muted)]" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M229.7 218.3 179.6 168.2A92.2 92.2 0 1 0 168.2 179.6l50.1 50.1a8 8 0 0 0 11.4-11.4ZM40 112a72 72 0 1 1 72 72 72.1 72.1 0 0 1-72-72Z" /></svg>
402
411
  <input
403
412
  type="search"
@@ -421,8 +430,10 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
421
430
  <button type="button" class="text-[0.8125rem] font-medium text-primary underline [text-underline-offset:2px]" onclick={() => (query = '')}>Clear search</button>
422
431
  </div>
423
432
  {:else}
424
- <!-- One scroll region holds every group, so the arrow keys roam the whole catalog. -->
425
- <div data-cairn-pk-list>
433
+ <!-- One scroll region holds every group, so the arrow keys roam the whole catalog. It
434
+ is the box's scroll container (flex-1, min-h-0): the header above holds while the
435
+ list scrolls within the 85vh cap. -->
436
+ <div data-cairn-pk-list class="-mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
426
437
  {#each groups as group (group.heading)}
427
438
  <div class="mt-3 first:mt-0">
428
439
  {#if group.heading}
@@ -200,6 +200,38 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
200
200
  ? `Published ${data.publishedAll} ${data.publishedAll === 1 ? 'entry' : 'entries'}.`
201
201
  : '',
202
202
  );
203
+
204
+ // The one lifecycle error to announce (the visible alerts below keep their own styling). A blocked
205
+ // delete leads, then a form error, then a load error, since the refusal is the most recent and most
206
+ // actionable outcome of the last submit. The refusal announcement carries the blocker count, so a
207
+ // screen reader hears the magnitude (matching the visible banner) before navigating to the list.
208
+ const lifecycleError = $derived(
209
+ deleteRefused
210
+ ? `This ${data.label.toLowerCase()} could not be deleted. ${deleteRefused.inboundLinks.length} ${deleteRefused.inboundLinks.length === 1 ? 'page links' : 'pages link'} to it.`
211
+ : (data.formError ?? data.error ?? ''),
212
+ );
213
+
214
+ // The polite live region's text re-announces only when it changes, so a repeated identical error
215
+ // (a second submit failing the same way) would go silent. An invisible nonce flips on every fresh
216
+ // error so the region text always mutates and the screen reader speaks again (the MediaPicker
217
+ // discipline). The nonce is a zero-width space, never voiced, so the heard sentence is unchanged.
218
+ let announceNonce = $state(0);
219
+ function nonce(): string {
220
+ return announceNonce % 2 === 0 ? '' : '​';
221
+ }
222
+ // Each submit hands a fresh `form` (or `data` on a load) object, so the nonce bumps once per submit,
223
+ // keying the re-announce to the submit rather than to a string change the live region would swallow.
224
+ // The guard reads a plain non-reactive `lastSubmit`, so the bump fires only when the submit identity
225
+ // changes, never on the re-render the bump itself causes; that is what keeps the effect from looping.
226
+ let lastSubmit: unknown;
227
+ $effect(() => {
228
+ const submit = form ?? data;
229
+ if (submit !== lastSubmit) {
230
+ lastSubmit = submit;
231
+ if (lifecycleError) announceNonce++;
232
+ }
233
+ });
234
+ const liveError = $derived(lifecycleError ? `${lifecycleError}${nonce()}` : '');
203
235
  </script>
204
236
 
205
237
  <!-- The non-color selected cue for the triage controls (WCAG 1.4.1): a small check glyph that
@@ -225,20 +257,25 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
225
257
  {#if}-gated role element inserted fresh is announced inconsistently, so the visible alert
226
258
  below keeps its styling without a role and the message is announced once. -->
227
259
  <div class="sr-only" aria-live="polite">{publishedAllMessage}</div>
260
+ <!-- One persistent polite region announces the lifecycle errors, re-announcing a repeat through the
261
+ nonce. The visible alerts below keep their styling and drop the live `role` (a fresh-inserted
262
+ role element announces inconsistently and clobbers a repeat), so the message is announced once. -->
263
+ <div class="sr-only" aria-live="polite">{liveError}</div>
228
264
  {#if publishedAllMessage}
229
265
  <div class="alert alert-success mb-4 text-sm">{publishedAllMessage}</div>
230
266
  {/if}
231
267
  {#if data.formError}
232
- <div role="alert" class="alert alert-error mb-4 text-sm">{data.formError}</div>
268
+ <div class="alert alert-error mb-4 text-sm">{data.formError}</div>
233
269
  {/if}
234
270
  {#if data.error}
235
- <div role="alert" class="alert alert-warning mb-4 text-sm">{data.error}</div>
271
+ <div class="alert alert-warning mb-4 text-sm">{data.error}</div>
236
272
  {/if}
237
273
 
238
274
  {#if deleteRefused}
239
275
  <!-- A `?/delete` was refused: name the blockers up front, matching the editor's refusal banner,
240
- so the author sees why without re-opening a dialog. -->
241
- <div role="alert" aria-label="This {data.label.toLowerCase()} could not be deleted" class="alert alert-error mb-4 flex-col items-start text-sm">
276
+ so the author sees why without re-opening a dialog. The polite region above announces it, so
277
+ the box itself carries no role or label (a bare div with an aria-label gets no accessible name). -->
278
+ <div class="alert alert-error mb-4 flex-col items-start text-sm">
242
279
  <p class="font-medium">This {data.label.toLowerCase()} could not be deleted.</p>
243
280
  <p>{deleteRefused.inboundLinks.length} {deleteRefused.inboundLinks.length === 1 ? 'page links' : 'pages link'} to it. Remove or repoint the {deleteRefused.inboundLinks.length === 1 ? 'link' : 'links'} listed below, then delete again.</p>
244
281
  <ul class="mt-1 w-full">
@@ -1,7 +1,7 @@
1
1
  // cairn-cms: the entry editor's internal advisory channel (editor-help pass 3). An advisory is a
2
2
  // non-blocking, serializable notice that rides EditData across the SSR boundary, so it carries data
3
- // only and never a callback. Today's one notice is the cross-branch address collision: a warning,
4
- // not a gate, that another entry already resolves to the same public address (last-write-wins).
3
+ // only and never a callback. Today's one notice is the address collision: a warning, not a gate,
4
+ // that another entry already resolves to the same public address (last-write-wins).
5
5
  //
6
6
  // The address index mirrors buildUsageIndex (src/lib/media/usage.ts): a main arm that reads each
7
7
  // manifest entry's resolved permalink with no per-file read, and a branch arm that lists every open
@@ -9,7 +9,8 @@
9
9
  // and resolves its permalink. The map is keyed by permalink, so every entry that resolves to a given
10
10
  // address shares one bucket. The build fails open: a branch read that throws, or a dated entry whose
11
11
  // permalink cannot resolve, is skipped rather than thrown, so a transient failure degrades to no
12
- // notice and never blocks the editor or the publish.
12
+ // notice and never blocks the editor or the publish. The scope splits by call site: the main arm at
13
+ // edit-load (synchronous, no extra GitHub read per open) and the full cross-branch check at publish.
13
14
  import type { ConceptDescriptor } from './types.js';
14
15
  import type { RepoRef } from '../github/types.js';
15
16
  import type { Manifest } from './manifest.js';
@@ -63,6 +64,23 @@ function push(index: AddressIndex, permalink: string, entry: AddressEntry): void
63
64
  else index.set(permalink, [entry]);
64
65
  }
65
66
 
67
+ /**
68
+ * The address index over main only: a synchronous reverse map of each manifest entry's resolved
69
+ * permalink. No backend read, so an edit-load can build it for free from the manifest it already holds.
70
+ */
71
+ export function mainAddressIndex(manifest: Manifest): AddressIndex {
72
+ const index: AddressIndex = new Map();
73
+ for (const entry of manifest.entries) {
74
+ push(index, entry.permalink, {
75
+ concept: entry.concept,
76
+ id: entry.id,
77
+ title: entry.title,
78
+ source: 'main',
79
+ });
80
+ }
81
+ return index;
82
+ }
83
+
66
84
  /**
67
85
  * Build the permalink-keyed address index over main (from each manifest entry's resolved permalink)
68
86
  * plus every open cairn/* branch (resolved from its edited markdown).
@@ -77,18 +95,9 @@ export async function buildAddressIndex(
77
95
  concepts: ConceptDescriptor[],
78
96
  manifest: Manifest,
79
97
  ): Promise<AddressIndex> {
80
- const index: AddressIndex = new Map();
81
-
82
- // The main arm: the manifest already carries each entry's resolved permalink, so this is a pure
83
- // reverse map with no per-file read.
84
- for (const entry of manifest.entries) {
85
- push(index, entry.permalink, {
86
- concept: entry.concept,
87
- id: entry.id,
88
- title: entry.title,
89
- source: 'main',
90
- });
91
- }
98
+ // The main arm: the manifest already carries each entry's resolved permalink, so seed from the
99
+ // synchronous main-only index and union the branch arm on top.
100
+ const index = mainAddressIndex(manifest);
92
101
 
93
102
  // The branch arm: read each open cairn/* branch's one edited file and resolve its permalink. The
94
103
  // path is derivable from the branch name, so no tree-listing is needed.
@@ -0,0 +1,40 @@
1
+ // cairn-cms: the shared field constraint rules. Both the v1 `defineFields` validator and the v2
2
+ // `fieldset` validator call these pure helpers, so the two validators cannot drift on the
3
+ // constraint wording or the first-failing-rule-wins order. No I/O and no clock reads, so the
4
+ // rules stay deterministic on Workers.
5
+
6
+ /** Compile a field pattern once, throwing a labeled error when the source is not a valid regex. */
7
+ export function compilePattern(source: string, label: string): RegExp {
8
+ try {
9
+ return new RegExp(source);
10
+ } catch (cause) {
11
+ throw new Error(`cairn: field "${label}" has an invalid pattern: ${source}`, { cause });
12
+ }
13
+ }
14
+
15
+ /** Return the first string-length violation message, or null when the value satisfies the bounds. */
16
+ export function stringLengthError(
17
+ value: string,
18
+ constraints: { min?: number; max?: number; length?: number },
19
+ label: string,
20
+ ): string | null {
21
+ const { min, max, length } = constraints;
22
+ if (min != null && value.length < min) return `${label} must be at least ${min} characters`;
23
+ if (max != null && value.length > max) return `${label} must be at most ${max} characters`;
24
+ if (length != null && value.length !== length) return `${label} must be exactly ${length} characters`;
25
+ return null;
26
+ }
27
+
28
+ /** Return the format violation message when a compiled pattern rejects the value, else null. */
29
+ export function patternError(value: string, compiled: RegExp | undefined, label: string): string | null {
30
+ if (compiled && !compiled.test(value)) return `${label} is not in the expected format`;
31
+ return null;
32
+ }
33
+
34
+ /** Return the first date-bounds violation message, or null when the value is within the bounds. */
35
+ export function dateBoundsError(value: string, constraints: { min?: string; max?: string }, label: string): string | null {
36
+ const { min, max } = constraints;
37
+ if (min != null && value < min) return `${label} must be on or after ${min}`;
38
+ if (max != null && value > max) return `${label} must be on or before ${max}`;
39
+ return null;
40
+ }
@@ -0,0 +1,127 @@
1
+ /** The stored value of an image field; re-exported so this module owns the image shape too. */
2
+ export type { ImageValue } from './types.js';
3
+
4
+ /** Common to every field descriptor: the form label and the universal options. */
5
+ export interface FieldBase {
6
+ /** Form label. */
7
+ label: string;
8
+ /** One author-facing sentence shown under the field. */
9
+ help?: string;
10
+ /** A required field fails validation when empty. */
11
+ required?: boolean;
12
+ /** Form-render-time initial value; a sentinel like "today" resolves at render (Task 9). */
13
+ default?: string | boolean;
14
+ }
15
+ /** A single-line text input. */
16
+ export interface TextField extends FieldBase {
17
+ type: 'text';
18
+ min?: number; max?: number; length?: number;
19
+ /** A regular-expression source string the value must match. */
20
+ pattern?: string;
21
+ }
22
+ /** A multi-line text input. */
23
+ export interface TextareaField extends FieldBase {
24
+ type: 'textarea';
25
+ rows?: number; min?: number; max?: number; length?: number; pattern?: string;
26
+ }
27
+ /** A numeric input. */
28
+ export interface NumberField extends FieldBase {
29
+ type: 'number';
30
+ min?: number; max?: number;
31
+ /** Constrain the value to whole numbers. */
32
+ integer?: boolean;
33
+ }
34
+ /** A single-choice input over a closed option list. */
35
+ export interface SelectField extends FieldBase {
36
+ type: 'select';
37
+ /** The closed set of allowed values. */
38
+ options: readonly string[];
39
+ }
40
+ /** A multiple-choice input. */
41
+ export interface MultiselectField extends FieldBase {
42
+ type: 'multiselect';
43
+ /** The allowed values; omitted leaves the set open. */
44
+ options?: readonly string[];
45
+ /** Allow the author to add values not in the list. */
46
+ creatable?: boolean;
47
+ /** Mark the field as a site-wide taxonomy whose values pool across entries. */
48
+ taxonomy?: boolean;
49
+ }
50
+ /** A URL input whose format the validator enforces. */
51
+ export interface UrlField extends FieldBase {
52
+ type: 'url';
53
+ }
54
+ /** An email-address input whose format the validator enforces. */
55
+ export interface EmailField extends FieldBase {
56
+ type: 'email';
57
+ }
58
+ /** A calendar-date input. */
59
+ export interface DateField extends FieldBase {
60
+ type: 'date';
61
+ /** Earliest allowed date as YYYY-MM-DD. */
62
+ min?: string;
63
+ /** Latest allowed date as YYYY-MM-DD. */
64
+ max?: string;
65
+ }
66
+ /** A date-and-time input. */
67
+ export interface DatetimeField extends FieldBase {
68
+ type: 'datetime';
69
+ /** Earliest allowed moment as an ISO string. */
70
+ min?: string;
71
+ /** Latest allowed moment as an ISO string. */
72
+ max?: string;
73
+ }
74
+ /** A checkbox; absent means false. */
75
+ export interface BooleanField extends FieldBase {
76
+ type: 'boolean';
77
+ }
78
+ /** A hero image whose stored value is the nested ImageValue object. */
79
+ export interface ImageField extends FieldBase {
80
+ type: 'image';
81
+ /** Whether this field feeds the social-card image. */
82
+ seo?: boolean;
83
+ }
84
+ /** The plain-data descriptor union the form, validator, and inference all read. Grows per task. */
85
+ export type FieldDescriptor =
86
+ | TextField
87
+ | TextareaField
88
+ | NumberField
89
+ | SelectField
90
+ | MultiselectField
91
+ | UrlField
92
+ | EmailField
93
+ | DateField
94
+ | DatetimeField
95
+ | BooleanField
96
+ | ImageField;
97
+
98
+ /**
99
+ * The constructor namespace a concept declares its fields with. Each constructor captures its
100
+ * argument with a `const` type parameter and intersects it onto the descriptor, so the call-site
101
+ * literals (`required: true`, a `select`/`multiselect` `options` union) survive into the descriptor
102
+ * type for `Infer` to read. The runtime value is unchanged: still `{ type, ...o }`.
103
+ */
104
+ export const fields = {
105
+ /** A single-line text field. */
106
+ text: <const O extends Omit<TextField, 'type'>>(o: O): TextField & O => ({ type: 'text', ...o }),
107
+ /** A multi-line text field. */
108
+ textarea: <const O extends Omit<TextareaField, 'type'>>(o: O): TextareaField & O => ({ type: 'textarea', ...o }),
109
+ /** A numeric field. */
110
+ number: <const O extends Omit<NumberField, 'type'>>(o: O): NumberField & O => ({ type: 'number', ...o }),
111
+ /** A single-choice field over a closed option list, preserving the literal option union. */
112
+ select: <const O extends Omit<SelectField, 'type'>>(o: O): SelectField & O => ({ type: 'select', ...o }),
113
+ /** A multiple-choice field, preserving the literal option union when one is given. */
114
+ multiselect: <const O extends Omit<MultiselectField, 'type'>>(o: O): MultiselectField & O => ({ type: 'multiselect', ...o }),
115
+ /** A URL field. */
116
+ url: <const O extends Omit<UrlField, 'type'>>(o: O): UrlField & O => ({ type: 'url', ...o }),
117
+ /** An email-address field. */
118
+ email: <const O extends Omit<EmailField, 'type'>>(o: O): EmailField & O => ({ type: 'email', ...o }),
119
+ /** A calendar-date field. */
120
+ date: <const O extends Omit<DateField, 'type'>>(o: O): DateField & O => ({ type: 'date', ...o }),
121
+ /** A date-and-time field. */
122
+ datetime: <const O extends Omit<DatetimeField, 'type'>>(o: O): DatetimeField & O => ({ type: 'datetime', ...o }),
123
+ /** A boolean checkbox field. */
124
+ boolean: <const O extends Omit<BooleanField, 'type'>>(o: O): BooleanField & O => ({ type: 'boolean', ...o }),
125
+ /** An image field whose value is the nested ImageValue object. */
126
+ image: <const O extends Omit<ImageField, 'type'>>(o: O): ImageField & O => ({ type: 'image', ...o }),
127
+ };
@@ -0,0 +1,307 @@
1
+ // cairn-cms: the fieldset primitive (Contract v2). A key-to-descriptor record becomes a schema
2
+ // carrying the descriptors as plain data, a server-derived validator, and the Standard Schema
3
+ // conformance property. The validator coerces per type, drops an empty optional field, and returns
4
+ // field-keyed errors or normalized data. This is the additive v2 path alongside `defineFields`; the
5
+ // inferred-type and default-resolution arms land in later tasks, and the cutover is a later plan.
6
+ import type { FieldDescriptor, ImageValue } from './fields.js';
7
+ import type { ValidationResult } from './types.js';
8
+ import type { StandardInput, StandardSchemaV1 } from './schema.js';
9
+ import { dateInputValue, isCalendarDate } from './frontmatter.js';
10
+ import { compilePattern, dateBoundsError, patternError, stringLengthError } from './field-rules.js';
11
+
12
+ /** Accept any URL using http or https with a non-empty rest, mirroring the conservative form check. */
13
+ const URL_RE = /^https?:\/\/\S+$/;
14
+ /** Accept a single address conservatively: exactly one at-sign and a dotted domain, nothing more. */
15
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
16
+
17
+ /**
18
+ * The behavior table co-bundled with a fieldset, keyed by field name. It holds function-valued
19
+ * behavior a descriptor cannot carry as plain data (a cross-field validator, an array itemLabel).
20
+ * Scalars have no behavior, so the table is empty for now and reserved for later co-bundled functions.
21
+ */
22
+ export type BehaviorTable = Record<string, never>;
23
+
24
+ /**
25
+ * Options for `fieldset`. `refine` runs after the per-field coercion and constraints pass, for
26
+ * cross-field and body-dependent checks. It is validation-only: it returns field-keyed errors to
27
+ * merge, or nothing, and never transforms the data. Server-only, since it may carry closures.
28
+ */
29
+ export interface FieldsetOptions {
30
+ refine?: (data: Record<string, unknown>, body: string) => Record<string, string> | undefined;
31
+ }
32
+
33
+ /**
34
+ * A concept's fieldset: the plain-data descriptors, the co-bundled behavior table, the server-derived
35
+ * validator, and the Standard Schema conformance property.
36
+ */
37
+ export interface Fieldset<R extends Record<string, FieldDescriptor> = Record<string, FieldDescriptor>> {
38
+ /** The declared descriptors as plain serializable data, for the editor form. */
39
+ readonly fields: R;
40
+ /** Function-valued behavior keyed by field name; empty for a scalar-only fieldset. */
41
+ readonly behavior: BehaviorTable;
42
+ /** Validate raw frontmatter, returning field-keyed errors or the normalized data. */
43
+ validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
44
+ /** Standard Schema v1 conformance, for ecosystem interop. A thin adapter over `validate`. */
45
+ readonly '~standard': StandardSchemaV1<StandardInput, Record<string, unknown>>['~standard'];
46
+ }
47
+
48
+ /**
49
+ * Map one field descriptor to the TS type of its normalized value. number is number, boolean is
50
+ * boolean, image is the nested ImageValue object; a select with a literal option list is that
51
+ * option union, a multiselect with one is that union array (else string[]); everything else is a
52
+ * string.
53
+ */
54
+ type ValueOf<D extends FieldDescriptor> = D extends { type: 'number' }
55
+ ? number
56
+ : D extends { type: 'boolean' }
57
+ ? boolean
58
+ : D extends { type: 'image' }
59
+ ? ImageValue
60
+ : D extends { type: 'select'; options: readonly (infer O extends string)[] }
61
+ ? O
62
+ : D extends { type: 'multiselect'; options: readonly (infer O extends string)[] }
63
+ ? O[]
64
+ : D extends { type: 'multiselect' }
65
+ ? string[]
66
+ : string;
67
+
68
+ /** Flatten an intersection into a single readable object type. */
69
+ type Prettify<T> = { [K in keyof T]: T[K] } & {};
70
+
71
+ /**
72
+ * The normalized frontmatter type inferred from a fieldset's descriptor record. A descriptor
73
+ * declared `required: true` is a required key; every other descriptor is optional.
74
+ */
75
+ type Infer<R extends Record<string, FieldDescriptor>> = Prettify<
76
+ { -readonly [K in keyof R as R[K] extends { required: true } ? K : never]: ValueOf<R[K]> } & {
77
+ -readonly [K in keyof R as R[K] extends { required: true } ? never : K]?: ValueOf<R[K]>;
78
+ }
79
+ >;
80
+
81
+ /** Extract the inferred frontmatter type from a `Fieldset`. */
82
+ export type InferFieldset<S> = S extends Fieldset<infer R> ? Infer<R> : never;
83
+
84
+ // Coerce one image value to the stored `{ src, alt, caption?, decorative? }` shape, ported from
85
+ // validate.ts. Default a missing alt to empty (alt is debt, never a save block), trim and drop a
86
+ // blank caption, keep decorative only when an explicit true, and drop the whole key when src is empty.
87
+ // A required image with an empty src is the one error this arm raises.
88
+ function coerceImage(
89
+ field: Extract<FieldDescriptor, { type: 'image' }>,
90
+ key: string,
91
+ value: unknown,
92
+ data: Record<string, unknown>,
93
+ errors: Record<string, string>,
94
+ ): void {
95
+ let src = '';
96
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
97
+ const obj = value as Record<string, unknown>;
98
+ src = typeof obj.src === 'string' ? obj.src.trim() : '';
99
+ if (src !== '') {
100
+ const normalized: ImageValue = {
101
+ src,
102
+ alt: typeof obj.alt === 'string' ? obj.alt : '',
103
+ };
104
+ const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
105
+ if (caption !== '') normalized.caption = caption;
106
+ if (obj.decorative === true) normalized.decorative = true;
107
+ data[key] = normalized;
108
+ }
109
+ }
110
+ if (field.required && src === '') errors[key] = `${field.label} is required`;
111
+ }
112
+
113
+ // Coerce a raw value to the trimmed string the empty check and constraints run on. A parsed value may
114
+ // arrive from parseMarkdown, not only a form string: a Date on a date or datetime field, a JS number on
115
+ // a number field. A finite 0 coerces to '0', never read as empty, since 0 is a real number a YAML scalar
116
+ // carries; a NaN or non-finite number stays '' and routes to the number error in validateField.
117
+ function coerceToText(type: FieldDescriptor['type'], value: unknown): string {
118
+ if (type === 'date' && value instanceof Date) return dateInputValue(value);
119
+ if (type === 'datetime' && value instanceof Date) return value.toISOString();
120
+ if (type === 'number' && typeof value === 'number' && Number.isFinite(value)) return String(value);
121
+ if (typeof value === 'string') return value.trim();
122
+ return '';
123
+ }
124
+
125
+ // Validate one descriptor against its raw value, writing into `data` or `errors`. Empty or absent is
126
+ // "not provided" and is read BEFORE type coercion, uniformly: a required field errors, an optional
127
+ // field drops (no key, no error). Only a non-empty value is coerced. boolean is the exception: true
128
+ // stores true, anything else omits the key. number relies on the empty-first drop so an empty optional
129
+ // number never becomes Number('') === 0.
130
+ function validateField(
131
+ key: string,
132
+ field: FieldDescriptor,
133
+ value: unknown,
134
+ data: Record<string, unknown>,
135
+ errors: Record<string, string>,
136
+ patterns: Map<string, RegExp>,
137
+ ): void {
138
+ // boolean: presence is the value; an unchecked or absent box omits the key (no draft: false noise).
139
+ if (field.type === 'boolean') {
140
+ if (value === true) data[key] = true;
141
+ return;
142
+ }
143
+
144
+ // multiselect: a string array; drop empties, reject an unknown value when options is closed. An empty
145
+ // list omits the key (a required empty errors); the array path is the one non-string coercion. A lone
146
+ // non-empty scalar (a single tag a YAML scalar carries) coerces to a single-element list, rather than
147
+ // dropping to [] and reading as "required" while present. An empty string or a non-string-non-array
148
+ // stays the empty list.
149
+ if (field.type === 'multiselect') {
150
+ let raw: string[];
151
+ if (Array.isArray(value)) raw = value.map(String);
152
+ else if (typeof value === 'string' && value.trim() !== '') raw = [value.trim()];
153
+ else raw = [];
154
+ const list = raw.map((v) => v.trim()).filter((v) => v !== '');
155
+ if (field.required && list.length === 0) {
156
+ errors[key] = `${field.label} is required`;
157
+ return;
158
+ }
159
+ const { options } = field;
160
+ if (options) {
161
+ const unknown = list.find((v) => !options.includes(v));
162
+ if (unknown !== undefined) {
163
+ errors[key] = `${field.label} contains an unknown value: ${unknown}`;
164
+ return;
165
+ }
166
+ }
167
+ if (list.length > 0) data[key] = list;
168
+ return;
169
+ }
170
+
171
+ // image: the nested object arm, dropping the key on empty src.
172
+ if (field.type === 'image') {
173
+ coerceImage(field, key, value, data, errors);
174
+ return;
175
+ }
176
+
177
+ // Every other type is "not provided when empty" first, then coerced. `coerceToText` turns a parsed
178
+ // value into its string form BEFORE the empty check, so a real parsed value (a Date on a date or
179
+ // datetime field, a number on a number field) is not read as empty.
180
+ const text = coerceToText(field.type, value);
181
+ if (text === '') {
182
+ if (field.required) errors[key] = `${field.label} is required`;
183
+ return;
184
+ }
185
+
186
+ switch (field.type) {
187
+ case 'number': {
188
+ const n = Number(text);
189
+ // Reject NaN and the non-finite values Number() yields for "Infinity"/"1e400", which an
190
+ // isNaN check alone would pass through and commit as a YAML .inf scalar.
191
+ if (!Number.isFinite(n)) errors[key] = `${field.label} must be a number`;
192
+ else if (field.integer && !Number.isInteger(n)) errors[key] = `${field.label} must be a whole number`;
193
+ else if (field.min != null && n < field.min) errors[key] = `${field.label} must be at least ${field.min}`;
194
+ else if (field.max != null && n > field.max) errors[key] = `${field.label} must be at most ${field.max}`;
195
+ else data[key] = n;
196
+ break;
197
+ }
198
+ case 'select': {
199
+ if (!field.options.includes(text)) errors[key] = `${field.label} contains an unknown value: ${text}`;
200
+ else data[key] = text;
201
+ break;
202
+ }
203
+ case 'url': {
204
+ if (!URL_RE.test(text)) errors[key] = `${field.label} is not a valid URL`;
205
+ else data[key] = text;
206
+ break;
207
+ }
208
+ case 'email': {
209
+ if (!EMAIL_RE.test(text)) errors[key] = `${field.label} is not a valid email address`;
210
+ else data[key] = text;
211
+ break;
212
+ }
213
+ case 'date': {
214
+ if (!isCalendarDate(text)) {
215
+ errors[key] = `${field.label} must be a valid date (YYYY-MM-DD)`;
216
+ break;
217
+ }
218
+ const boundsError = dateBoundsError(text, field, field.label);
219
+ if (boundsError != null) {
220
+ errors[key] = boundsError;
221
+ break;
222
+ }
223
+ data[key] = text;
224
+ break;
225
+ }
226
+ default: {
227
+ // text, textarea, datetime: a trimmed non-empty string. text and textarea also enforce the
228
+ // string-length and pattern constraints (v1 parity); datetime stays a plain string for now,
229
+ // since its bounds are out of scope this pass and v1 has no datetime equivalent to match.
230
+ if (field.type === 'text' || field.type === 'textarea') {
231
+ const lengthError = stringLengthError(text, field, field.label);
232
+ if (lengthError != null) {
233
+ errors[key] = lengthError;
234
+ break;
235
+ }
236
+ const formatError = patternError(text, patterns.get(key), field.label);
237
+ if (formatError != null) {
238
+ errors[key] = formatError;
239
+ break;
240
+ }
241
+ }
242
+ data[key] = text;
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Build a fieldset from a key-to-descriptor record. The returned schema carries the descriptors, a
249
+ * server-derived validator that coerces per type and returns field-keyed errors or normalized data,
250
+ * and the Standard Schema conformance property whose issues map each error to a single-segment path.
251
+ */
252
+ export function fieldset<const R extends Record<string, FieldDescriptor>>(
253
+ record: R,
254
+ options: FieldsetOptions = {},
255
+ ): Fieldset<R> {
256
+ // Compile each text/textarea pattern once at construction, so a malformed pattern fails loudly here
257
+ // (mirroring v1's compilePatterns) rather than on every save. Keyed by field name for validateField.
258
+ const patterns = new Map<string, RegExp>();
259
+ for (const [key, field] of Object.entries(record)) {
260
+ if ((field.type === 'text' || field.type === 'textarea') && field.pattern != null) {
261
+ patterns.set(key, compilePattern(field.pattern, field.label));
262
+ }
263
+ }
264
+ const validate = (frontmatter: Record<string, unknown>, body: string): ValidationResult => {
265
+ const data: Record<string, unknown> = {};
266
+ const errors: Record<string, string> = {};
267
+ for (const [key, field] of Object.entries(record)) {
268
+ validateField(key, field, frontmatter[key], data, errors, patterns);
269
+ }
270
+ if (Object.keys(errors).length > 0) return { ok: false, errors };
271
+ const refined = options.refine?.(data, body);
272
+ return refined && Object.keys(refined).length > 0 ? { ok: false, errors: refined } : { ok: true, data };
273
+ };
274
+ const standard: StandardSchemaV1<StandardInput, Record<string, unknown>>['~standard'] = {
275
+ version: 1,
276
+ vendor: 'cairn',
277
+ validate: (value) => {
278
+ const { frontmatter = {}, body = '' } = (value ?? {}) as Partial<StandardInput>;
279
+ const result = validate(frontmatter ?? {}, body ?? '');
280
+ return result.ok
281
+ ? { value: result.data }
282
+ : { issues: Object.entries(result.errors).map(([key, message]) => ({ message, path: [key] })) };
283
+ },
284
+ };
285
+ return { fields: record, behavior: {}, validate, '~standard': standard };
286
+ }
287
+
288
+ /**
289
+ * Resolve each descriptor's `default` to a form-initial value, so a fresh entry opens prefilled. The
290
+ * `'today'` sentinel on a date field resolves through the passed `now` to its `YYYY-MM-DD` form; an
291
+ * empty-string or `false` default is omitted, so an untouched field commits no key (the
292
+ * minimal-frontmatter invariant). With no `now`, a `'today'` default is omitted rather than read off
293
+ * a real clock, since library code must stay deterministic and Workers-safe.
294
+ */
295
+ export function initialValues(fieldset: Fieldset, now?: Date): Record<string, unknown> {
296
+ const values: Record<string, unknown> = {};
297
+ for (const [key, field] of Object.entries(fieldset.fields)) {
298
+ const value = field.default;
299
+ if (value === undefined || value === '' || value === false) continue;
300
+ if (field.type === 'date' && value === 'today') {
301
+ if (now) values[key] = now.toISOString().slice(0, 10);
302
+ continue;
303
+ }
304
+ values[key] = value;
305
+ }
306
+ return values;
307
+ }