@glw907/cairn-cms 0.10.0 → 0.14.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 (95) hide show
  1. package/dist/components/ComponentForm.svelte +33 -10
  2. package/dist/components/ComponentForm.svelte.d.ts.map +1 -1
  3. package/dist/components/IconPicker.svelte +53 -7
  4. package/dist/components/IconPicker.svelte.d.ts +7 -3
  5. package/dist/components/IconPicker.svelte.d.ts.map +1 -1
  6. package/dist/content/adapter.d.ts +4 -0
  7. package/dist/content/adapter.d.ts.map +1 -0
  8. package/dist/content/adapter.js +4 -0
  9. package/dist/content/concepts.js +2 -2
  10. package/dist/content/schema.d.ts +75 -0
  11. package/dist/content/schema.d.ts.map +1 -0
  12. package/dist/content/schema.js +72 -0
  13. package/dist/content/types.d.ts +30 -7
  14. package/dist/content/types.d.ts.map +1 -1
  15. package/dist/content/validate.d.ts +5 -3
  16. package/dist/content/validate.d.ts.map +1 -1
  17. package/dist/content/validate.js +14 -7
  18. package/dist/delivery/CairnHead.svelte +36 -0
  19. package/dist/delivery/CairnHead.svelte.d.ts +15 -0
  20. package/dist/delivery/CairnHead.svelte.d.ts.map +1 -0
  21. package/dist/delivery/content-index.d.ts +16 -6
  22. package/dist/delivery/content-index.d.ts.map +1 -1
  23. package/dist/delivery/content-index.js +17 -8
  24. package/dist/delivery/index.d.ts +26 -0
  25. package/dist/delivery/index.d.ts.map +1 -0
  26. package/dist/delivery/index.js +21 -0
  27. package/dist/delivery/json-ld.d.ts +2 -0
  28. package/dist/delivery/json-ld.d.ts.map +1 -0
  29. package/dist/delivery/json-ld.js +16 -0
  30. package/dist/delivery/responses.d.ts +14 -0
  31. package/dist/delivery/responses.d.ts.map +1 -0
  32. package/dist/delivery/responses.js +30 -0
  33. package/dist/delivery/seo-fields.d.ts +22 -0
  34. package/dist/delivery/seo-fields.d.ts.map +1 -0
  35. package/dist/delivery/seo-fields.js +32 -0
  36. package/dist/delivery/seo.d.ts +4 -0
  37. package/dist/delivery/seo.d.ts.map +1 -1
  38. package/dist/delivery/seo.js +11 -0
  39. package/dist/delivery/site-descriptors.d.ts +5 -0
  40. package/dist/delivery/site-descriptors.d.ts.map +1 -0
  41. package/dist/delivery/site-descriptors.js +9 -0
  42. package/dist/delivery/site-index.d.ts +8 -2
  43. package/dist/delivery/site-index.d.ts.map +1 -1
  44. package/dist/delivery/site-index.js +26 -2
  45. package/dist/delivery/site-indexes.d.ts +26 -0
  46. package/dist/delivery/site-indexes.d.ts.map +1 -0
  47. package/dist/delivery/site-indexes.js +22 -0
  48. package/dist/index.d.ts +9 -3
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +5 -2
  51. package/dist/render/component-grammar.d.ts +7 -0
  52. package/dist/render/component-grammar.d.ts.map +1 -1
  53. package/dist/render/component-grammar.js +27 -8
  54. package/dist/render/component-validate.js +3 -3
  55. package/dist/render/glyph.d.ts +4 -1
  56. package/dist/render/glyph.d.ts.map +1 -1
  57. package/dist/render/glyph.js +6 -2
  58. package/dist/render/registry.d.ts +23 -5
  59. package/dist/render/registry.d.ts.map +1 -1
  60. package/dist/render/registry.js +6 -0
  61. package/dist/render/rehype-dispatch.d.ts +1 -5
  62. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  63. package/dist/render/rehype-dispatch.js +71 -19
  64. package/dist/render/remark-directives.d.ts +1 -1
  65. package/dist/render/remark-directives.d.ts.map +1 -1
  66. package/dist/render/remark-directives.js +37 -0
  67. package/dist/sveltekit/public-routes.d.ts +14 -0
  68. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  69. package/dist/sveltekit/public-routes.js +22 -2
  70. package/package.json +6 -1
  71. package/src/lib/components/ComponentForm.svelte +33 -10
  72. package/src/lib/components/IconPicker.svelte +53 -7
  73. package/src/lib/content/adapter.ts +10 -0
  74. package/src/lib/content/concepts.ts +2 -2
  75. package/src/lib/content/schema.ts +133 -0
  76. package/src/lib/content/types.ts +30 -7
  77. package/src/lib/content/validate.ts +10 -7
  78. package/src/lib/delivery/CairnHead.svelte +36 -0
  79. package/src/lib/delivery/content-index.ts +39 -17
  80. package/src/lib/delivery/index.ts +36 -0
  81. package/src/lib/delivery/json-ld.ts +16 -0
  82. package/src/lib/delivery/responses.ts +34 -0
  83. package/src/lib/delivery/seo-fields.ts +43 -0
  84. package/src/lib/delivery/seo.ts +13 -0
  85. package/src/lib/delivery/site-descriptors.ts +12 -0
  86. package/src/lib/delivery/site-index.ts +26 -2
  87. package/src/lib/delivery/site-indexes.ts +52 -0
  88. package/src/lib/index.ts +8 -2
  89. package/src/lib/render/component-grammar.ts +34 -10
  90. package/src/lib/render/component-validate.ts +3 -3
  91. package/src/lib/render/glyph.ts +6 -2
  92. package/src/lib/render/registry.ts +27 -5
  93. package/src/lib/render/rehype-dispatch.ts +67 -20
  94. package/src/lib/render/remark-directives.ts +39 -1
  95. package/src/lib/sveltekit/public-routes.ts +33 -2
@@ -38,6 +38,32 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
38
38
  return Array.isArray(v) ? v : [];
39
39
  }
40
40
 
41
+ // Stable per-item ids run parallel to each repeatable slot's value array, so the {#each} keys by
42
+ // identity instead of index. A mid-list removal then drops the right DOM node and the focused
43
+ // item follows the data. Ids come from a monotonic module-local counter, never Math.random or
44
+ // Date.now. The value arrays in values.slots stay the canonical string lists serializeComponent
45
+ // reads, so the emitted markdown is unchanged. emptyValues seeds every repeatable slot to [], so
46
+ // the id lists start empty and stay in lockstep with the values through addItem/removeItem.
47
+ let nextId = 0;
48
+ const itemIds = $state<Record<string, number[]>>(
49
+ untrack(() => Object.fromEntries((def.slots ?? []).filter((s) => s.kind === 'repeatable').map((s) => [s.name, []]))),
50
+ );
51
+
52
+ // emptyValues and the itemIds seed both cover every repeatable slot, so this read always hits.
53
+ function slotIds(name: string): number[] {
54
+ return itemIds[name] ?? [];
55
+ }
56
+
57
+ function addItem(name: string): void {
58
+ slotItems(name).push('');
59
+ slotIds(name).push(nextId++);
60
+ }
61
+
62
+ function removeItem(name: string, index: number): void {
63
+ slotItems(name).splice(index, 1);
64
+ slotIds(name).splice(index, 1);
65
+ }
66
+
41
67
  // Typed accessors over the unions so explicit value targets stay sound.
42
68
  function asString(key: string): string {
43
69
  const v = values.attributes[key];
@@ -79,7 +105,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
79
105
  <input
80
106
  class="checkbox checkbox-sm"
81
107
  type="checkbox"
82
- aria-label={field.label}
83
108
  aria-invalid={Boolean(errors[field.key])}
84
109
  aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
85
110
  checked={asBool(field.key)}
@@ -92,7 +117,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
92
117
  <span class="text-sm font-medium">{field.label}</span>
93
118
  <select
94
119
  class="select"
95
- aria-label={field.label}
96
120
  aria-invalid={Boolean(errors[field.key])}
97
121
  aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
98
122
  value={asString(field.key)}
@@ -107,6 +131,7 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
107
131
  <span class="text-sm font-medium">{field.label}</span>
108
132
  <IconPicker
109
133
  {icons}
134
+ label={field.label}
110
135
  value={asString(field.key)}
111
136
  required={field.required ?? false}
112
137
  onChange={(name) => (values.attributes[field.key] = name)}
@@ -117,7 +142,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
117
142
  <span class="text-sm font-medium">{field.label}</span>
118
143
  <input
119
144
  class="input"
120
- aria-label={field.label}
121
145
  aria-invalid={Boolean(errors[field.key])}
122
146
  aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
123
147
  value={asString(field.key)}
@@ -134,7 +158,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
134
158
  <span class="text-sm font-medium">{slot.label}</span>
135
159
  <textarea
136
160
  class="textarea"
137
- aria-label={slot.label}
138
161
  aria-invalid={Boolean(errors[slot.name])}
139
162
  aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
140
163
  rows={3}
@@ -147,7 +170,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
147
170
  <span class="text-sm font-medium">{slot.label}</span>
148
171
  <input
149
172
  class="input"
150
- aria-label={slot.label}
151
173
  aria-invalid={Boolean(errors[slot.name])}
152
174
  aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
153
175
  value={slotString(slot.name)}
@@ -160,16 +182,17 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
160
182
 
161
183
  {#each repeatableSlots as slot (slot.name)}
162
184
  {@const items = slotItems(slot.name)}
185
+ {@const ids = slotIds(slot.name)}
163
186
  <fieldset class="rounded-box border border-base-300 flex flex-col gap-2 p-2">
164
187
  <legend class="text-sm font-medium">{slot.label}</legend>
165
- <!-- Index key is deliberate: items are bare strings with no stable id, so the value bindings and splice/push are index-based by design. -->
166
- {#each items as _, i (i)}
188
+ <!-- Keyed by the parallel stable id so a mid-list removal drops the right node and focus follows the data; the value still binds to the canonical items[i] string the serializer reads. -->
189
+ {#each ids as id, i (id)}
167
190
  <div class="flex items-center gap-2">
168
- <input class="input input-sm flex-1" aria-label={`${slot.label} item`} bind:value={items[i]} />
169
- <button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() => items.splice(i, 1)}>✕</button>
191
+ <input class="input input-sm flex-1" aria-label={`${slot.label} ${i + 1}`} bind:value={items[i]} />
192
+ <button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() => removeItem(slot.name, i)}>✕</button>
170
193
  </div>
171
194
  {/each}
172
- <button type="button" class="btn btn-sm self-start" onclick={() => items.push('')}>Add item</button>
195
+ <button type="button" class="btn btn-sm self-start" onclick={() => addItem(slot.name)}>Add item</button>
173
196
  {#if errors[slot.name]}<span id={`err-${slot.name}`} role="alert" class="text-error text-xs">{errors[slot.name]}</span>{/if}
174
197
  </fieldset>
175
198
  {/each}
@@ -1 +1 @@
1
- {"version":3,"file":"ComponentForm.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ComponentForm.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,EAAe,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAEvE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAIhD,UAAU,KAAK;IACb,GAAG,EAAE,YAAY,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mEAAmE;IACnE,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,4BAA4B;IAC5B,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AA8HH;;;;;GAKG;AACH,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"ComponentForm.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ComponentForm.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,EAAe,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAEvE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAIhD,UAAU,KAAK;IACb,GAAG,EAAE,YAAY,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mEAAmE;IACnE,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,4BAA4B;IAC5B,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AAyJH;;;;;GAKG;AACH,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -1,10 +1,13 @@
1
1
  <!--
2
2
  @component
3
- A visual icon choice over the site's IconSet. Each glyph is a toggle button; the selected one carries
4
- aria-pressed. When the field is optional, a None button clears the value. The glyph renders inline from
5
- the IconSet path data, matching the renderer's 256-unit viewBox.
3
+ A visual icon choice over the site's IconSet. The choices form a radiogroup; each glyph is a radio
4
+ button carrying aria-checked, and the selected one carries btn-primary for the visible state. When the
5
+ field is optional, a None radio clears the value. A roving tabindex keeps a single tab stop and arrow
6
+ keys move the selection, the standard radiogroup keyboard model. The glyph renders inline from the
7
+ IconSet path data, matching the renderer's 256-unit viewBox.
6
8
  -->
7
9
  <script lang="ts">
10
+ import { tick } from 'svelte';
8
11
  import type { IconSet } from '../render/glyph.js';
9
12
 
10
13
  interface Props {
@@ -16,20 +19,60 @@ the IconSet path data, matching the renderer's 256-unit viewBox.
16
19
  required: boolean;
17
20
  /** Called with the new glyph name (or '' for none). */
18
21
  onChange: (name: string) => void;
22
+ /** The group's accessible name, threaded from the field label. Defaults to Icon. */
23
+ label?: string;
19
24
  }
20
25
 
21
- let { icons, value, required, onChange }: Props = $props();
26
+ let { icons, value, required, onChange, label = 'Icon' }: Props = $props();
27
+
28
+ // The radiogroup container, used to move focus with the selection per the ARIA radiogroup pattern.
29
+ let group: HTMLDivElement;
22
30
 
23
31
  const names = $derived(Object.keys(icons));
32
+ // The selectable keys in DOM order: the optional None choice ('') first, then each glyph name.
33
+ // Arrow-key navigation walks this list, and the roving tabindex marks the selected key (or the
34
+ // first key when nothing is selected) as the single tab stop.
35
+ const choices = $derived(required ? names : ['', ...names]);
36
+ const tabStop = $derived(choices.includes(value) ? value : choices[0]);
37
+
38
+ function move(delta: number): void {
39
+ // Navigate relative to the focused element (the current tab stop), not the bound value. In a
40
+ // required group with no value, tabStop is the first radio while value is '', so a value-based
41
+ // origin would skip the first step.
42
+ const from = Math.max(0, choices.indexOf(tabStop));
43
+ const next = (from + delta + choices.length) % choices.length;
44
+ onChange(choices[next]);
45
+ // The roving tabindex updates reactively, so wait for the DOM then move focus onto the new tab
46
+ // stop. The keydown handler runs only when focus is already inside the group, so this never
47
+ // steals focus on mount.
48
+ void tick().then(() => {
49
+ const target = group?.querySelector<HTMLElement>('[tabindex="0"]');
50
+ target?.focus();
51
+ });
52
+ }
53
+
54
+ function onKeydown(e: KeyboardEvent): void {
55
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
56
+ e.preventDefault();
57
+ move(1);
58
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
59
+ e.preventDefault();
60
+ move(-1);
61
+ }
62
+ }
24
63
  </script>
25
64
 
26
- <div class="flex flex-wrap gap-2" role="group" aria-label="Icon">
65
+ <div class="flex flex-wrap gap-2" role="radiogroup" aria-label={label} bind:this={group}>
27
66
  {#if !required}
28
67
  <button
29
68
  type="button"
30
69
  class="btn btn-sm"
31
70
  class:btn-primary={value === ''}
32
- aria-pressed={value === ''}
71
+ role="radio"
72
+ aria-checked={value === ''}
73
+ aria-label="None"
74
+ tabindex={tabStop === '' ? 0 : -1}
75
+ onkeydown={onKeydown}
33
76
  onclick={() => onChange('')}
34
77
  >None</button>
35
78
  {/if}
@@ -38,8 +81,11 @@ the IconSet path data, matching the renderer's 256-unit viewBox.
38
81
  type="button"
39
82
  class="btn btn-sm gap-1"
40
83
  class:btn-primary={value === name}
41
- aria-pressed={value === name}
84
+ role="radio"
85
+ aria-checked={value === name}
42
86
  aria-label={name}
87
+ tabindex={tabStop === name ? 0 : -1}
88
+ onkeydown={onKeydown}
43
89
  onclick={() => onChange(name)}
44
90
  >
45
91
  <svg class="ec-glyph" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true" width="16" height="16">
@@ -8,11 +8,15 @@ interface Props {
8
8
  required: boolean;
9
9
  /** Called with the new glyph name (or '' for none). */
10
10
  onChange: (name: string) => void;
11
+ /** The group's accessible name, threaded from the field label. Defaults to Icon. */
12
+ label?: string;
11
13
  }
12
14
  /**
13
- * A visual icon choice over the site's IconSet. Each glyph is a toggle button; the selected one carries
14
- * aria-pressed. When the field is optional, a None button clears the value. The glyph renders inline from
15
- * the IconSet path data, matching the renderer's 256-unit viewBox.
15
+ * A visual icon choice over the site's IconSet. The choices form a radiogroup; each glyph is a radio
16
+ * button carrying aria-checked, and the selected one carries btn-primary for the visible state. When the
17
+ * field is optional, a None radio clears the value. A roving tabindex keeps a single tab stop and arrow
18
+ * keys move the selection, the standard radiogroup keyboard model. The glyph renders inline from the
19
+ * IconSet path data, matching the renderer's 256-unit viewBox.
16
20
  */
17
21
  declare const IconPicker: import("svelte").Component<Props, {}, "">;
18
22
  type IconPicker = ReturnType<typeof IconPicker>;
@@ -1 +1 @@
1
- {"version":3,"file":"IconPicker.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/IconPicker.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGhD,UAAU,KAAK;IACb,kDAAkD;IAClD,KAAK,EAAE,OAAO,CAAC;IACf,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,4CAA4C;IAC5C,QAAQ,EAAE,OAAO,CAAC;IAClB,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC;AA2BH;;;;GAIG;AACH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"IconPicker.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/IconPicker.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGhD,UAAU,KAAK;IACb,kDAAkD;IAClD,KAAK,EAAE,OAAO,CAAC;IACf,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,4CAA4C;IAC5C,QAAQ,EAAE,OAAO,CAAC;IAClB,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA8DH;;;;;;GAMG;AACH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { CairnAdapter } from './types.js';
2
+ /** Declare a site's adapter while preserving each concept's concrete schema type for typed reads. */
3
+ export declare function defineAdapter<const A extends CairnAdapter>(adapter: A): A;
4
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/lib/content/adapter.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,qGAAqG;AACrG,wBAAgB,aAAa,CAAC,KAAK,CAAC,CAAC,SAAS,YAAY,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,CAEzE"}
@@ -0,0 +1,4 @@
1
+ /** Declare a site's adapter while preserving each concept's concrete schema type for typed reads. */
2
+ export function defineAdapter(adapter) {
3
+ return adapter;
4
+ }
@@ -37,8 +37,8 @@ export function normalizeConcepts(content, urlPolicy = {}, routing = CONCEPT_ROU
37
37
  routing: routing[id] ?? DEFAULT_ROUTING,
38
38
  permalink: policy.permalink ?? defaultPermalink(id),
39
39
  datePrefix: policy.datePrefix ?? 'day',
40
- fields: config.fields,
41
- validate: config.validate,
40
+ fields: config.schema.fields,
41
+ validate: config.schema.validate,
42
42
  });
43
43
  }
44
44
  return descriptors;
@@ -0,0 +1,75 @@
1
+ import type { FrontmatterField, ValidationResult } from './types.js';
2
+ /** The validate input the cairn adapter takes: the raw frontmatter and the body. */
3
+ export interface StandardInput {
4
+ frontmatter: Record<string, unknown>;
5
+ body: string;
6
+ }
7
+ /** A minimal local copy of the Standard Schema v1 interface (https://standardschema.dev), so the
8
+ * schema is a drop-in where the ecosystem accepts a validator, with no runtime dependency. */
9
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
10
+ readonly '~standard': {
11
+ readonly version: 1;
12
+ readonly vendor: string;
13
+ readonly validate: (value: unknown) => StandardResult<Output>;
14
+ readonly types?: {
15
+ readonly input: Input;
16
+ readonly output: Output;
17
+ };
18
+ };
19
+ }
20
+ type StandardResult<Output> = {
21
+ readonly value: Output;
22
+ readonly issues?: undefined;
23
+ } | {
24
+ readonly issues: ReadonlyArray<{
25
+ readonly message: string;
26
+ readonly path?: ReadonlyArray<PropertyKey>;
27
+ }>;
28
+ };
29
+ /** Map one field descriptor to the TS type of its normalized value. text, textarea, and date
30
+ * normalize to a string; a closed-vocabulary `tags` field to the option-union array. */
31
+ type FieldValue<K extends FrontmatterField> = K extends {
32
+ type: 'boolean';
33
+ } ? boolean : K extends {
34
+ type: 'tags';
35
+ options: readonly (infer O extends string)[];
36
+ } ? O[] : K extends {
37
+ type: 'tags' | 'freetags';
38
+ } ? string[] : string;
39
+ /** Flatten an intersection into a single readable object type. */
40
+ type Prettify<T> = {
41
+ [K in keyof T]: T[K];
42
+ } & {};
43
+ /** The normalized frontmatter type inferred from a field tuple. A field declared
44
+ * `required: true` is a required key; every other field is optional. */
45
+ export type InferFields<F extends readonly FrontmatterField[]> = Prettify<{
46
+ [K in F[number] as K extends {
47
+ required: true;
48
+ } ? K['name'] : never]: FieldValue<K>;
49
+ } & {
50
+ [K in F[number] as K extends {
51
+ required: true;
52
+ } ? never : K['name']]?: FieldValue<K>;
53
+ }>;
54
+ /** A concept's schema: the plain-data field projection, the generated validator, and the
55
+ * Standard Schema conformance property. */
56
+ export interface ConceptSchema<F extends readonly FrontmatterField[] = readonly FrontmatterField[]> {
57
+ /** The declared fields as plain serializable data, for the editor form. */
58
+ readonly fields: FrontmatterField[];
59
+ /** Validate raw frontmatter, returning field-keyed errors or the normalized data. */
60
+ validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
61
+ /** Standard Schema v1 conformance, for ecosystem interop. A thin adapter over `validate`. */
62
+ readonly '~standard': StandardSchemaV1<StandardInput, InferFields<F>>['~standard'];
63
+ }
64
+ /** Extract the inferred frontmatter type from a `ConceptSchema`. */
65
+ export type Infer<S> = S extends ConceptSchema<infer F> ? InferFields<F> : never;
66
+ /** Options for `defineFields`. `refine` runs after the per-field rules pass, for cross-field and
67
+ * body-dependent checks. It is validation-only: it returns field-keyed errors to merge, or
68
+ * nothing, and never transforms the data. */
69
+ export interface DefineFieldsOptions<F extends readonly FrontmatterField[]> {
70
+ refine?: (data: InferFields<F>, body: string) => Record<string, string> | undefined;
71
+ }
72
+ /** Declare a concept's fields once. Returns the schema's faces derived from that one declaration. */
73
+ export declare function defineFields<const F extends readonly FrontmatterField[]>(fields: F, options?: DefineFieldsOptions<F>): ConceptSchema<F>;
74
+ export {};
75
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/lib/content/schema.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,72 @@
1
+ import { validateFields } from './validate.js';
2
+ // Enforce the declarative per-field rules on an already-coerced value. Rules run only on a
3
+ // present, non-empty string value, so an absent optional field is never flagged. The first
4
+ // failing rule per field wins, so the author sees one clear message at a time.
5
+ function applyRules(field, value, errors, patterns) {
6
+ if (typeof value !== 'string' || value === '')
7
+ return;
8
+ if (field.type === 'text' || field.type === 'textarea') {
9
+ if (field.min != null && value.length < field.min)
10
+ errors[field.name] = `${field.label} must be at least ${field.min} characters`;
11
+ else if (field.max != null && value.length > field.max)
12
+ errors[field.name] = `${field.label} must be at most ${field.max} characters`;
13
+ else if (field.length != null && value.length !== field.length)
14
+ errors[field.name] = `${field.label} must be exactly ${field.length} characters`;
15
+ else if (field.pattern != null) {
16
+ const re = patterns.get(field.name);
17
+ if (re && !re.test(value))
18
+ errors[field.name] = `${field.label} is not in the expected format`;
19
+ }
20
+ }
21
+ else if (field.type === 'date') {
22
+ if (field.min != null && value < field.min)
23
+ errors[field.name] = `${field.label} must be on or after ${field.min}`;
24
+ else if (field.max != null && value > field.max)
25
+ errors[field.name] = `${field.label} must be on or before ${field.max}`;
26
+ }
27
+ }
28
+ // Compile each declared text/textarea pattern once, so a malformed pattern fails loudly at
29
+ // declaration (a site config error) instead of throwing from inside validate() on every save.
30
+ function compilePatterns(fields) {
31
+ const compiled = new Map();
32
+ for (const field of fields) {
33
+ if ((field.type === 'text' || field.type === 'textarea') && field.pattern != null) {
34
+ try {
35
+ compiled.set(field.name, new RegExp(field.pattern));
36
+ }
37
+ catch (cause) {
38
+ throw new Error(`cairn: field "${field.name}" has an invalid pattern: ${field.pattern}`, { cause });
39
+ }
40
+ }
41
+ }
42
+ return compiled;
43
+ }
44
+ /** Declare a concept's fields once. Returns the schema's faces derived from that one declaration. */
45
+ export function defineFields(fields, options = {}) {
46
+ const list = [...fields];
47
+ const patterns = compilePatterns(list);
48
+ const validate = (frontmatter, body) => {
49
+ const base = validateFields(list, frontmatter);
50
+ if (!base.ok)
51
+ return base;
52
+ const errors = {};
53
+ for (const field of list)
54
+ applyRules(field, base.data[field.name], errors, patterns);
55
+ if (Object.keys(errors).length > 0)
56
+ return { ok: false, errors };
57
+ const refined = options.refine?.(base.data, body);
58
+ return refined && Object.keys(refined).length > 0 ? { ok: false, errors: refined } : base;
59
+ };
60
+ const standard = {
61
+ version: 1,
62
+ vendor: 'cairn',
63
+ validate: (value) => {
64
+ const { frontmatter = {}, body = '' } = (value ?? {});
65
+ const result = validate(frontmatter ?? {}, body ?? '');
66
+ return result.ok
67
+ ? { value: result.data }
68
+ : { issues: Object.entries(result.errors).map(([field, message]) => ({ message, path: [field] })) };
69
+ },
70
+ };
71
+ return { fields: list, validate, '~standard': standard };
72
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ComponentRegistry } from '../render/registry.js';
2
2
  import type { IconSet } from '../render/glyph.js';
3
3
  import type { DatePrefix } from './ids.js';
4
+ import type { ConceptSchema } from './schema.js';
4
5
  /** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
5
6
  interface FieldBase {
6
7
  /** Frontmatter key and form input name. */
@@ -13,16 +14,37 @@ interface FieldBase {
13
14
  /** A single-line text input. */
14
15
  export interface TextField extends FieldBase {
15
16
  type: 'text';
17
+ /** Minimum character length of a non-empty value. */
18
+ min?: number;
19
+ /** Maximum character length. */
20
+ max?: number;
21
+ /** Exact required character length. */
22
+ length?: number;
23
+ /** A regular-expression source string the value must match. Stored as a string so the field
24
+ * list stays plain serializable data; the validator compiles it. */
25
+ pattern?: string;
16
26
  }
17
27
  /** A multi-line text input. */
18
28
  export interface TextareaField extends FieldBase {
19
29
  type: 'textarea';
20
30
  /** Visible rows; the editor picks a default when omitted. */
21
31
  rows?: number;
32
+ /** Minimum character length of a non-empty value. */
33
+ min?: number;
34
+ /** Maximum character length. */
35
+ max?: number;
36
+ /** Exact required character length. */
37
+ length?: number;
38
+ /** A regular-expression source string the value must match. */
39
+ pattern?: string;
22
40
  }
23
41
  /** A `YYYY-MM-DD` date input. */
24
42
  export interface DateField extends FieldBase {
25
43
  type: 'date';
44
+ /** Earliest allowed date, as `YYYY-MM-DD`. */
45
+ min?: string;
46
+ /** Latest allowed date, as `YYYY-MM-DD`. */
47
+ max?: string;
26
48
  }
27
49
  /** A checkbox; absent means false. */
28
50
  export interface BooleanField extends FieldBase {
@@ -58,18 +80,19 @@ export type ValidationResult = {
58
80
  errors: Record<string, string>;
59
81
  };
60
82
  /**
61
- * Per-site configuration for one content concept (spec §8). Concept-fixed behavior such as
62
- * routability is not here; it lives in the engine's routing table (`CONCEPT_ROUTING`).
83
+ * Per-site configuration for one content concept (spec §8). One `schema`, built with
84
+ * `defineFields`, is the single source of truth for the editor form, the validator, and the
85
+ * inferred frontmatter type. Generic over the schema so a concept's concrete type survives for
86
+ * typed reads. Concept-fixed behavior such as routability is not here; it lives in the engine's
87
+ * routing table (`CONCEPT_ROUTING`).
63
88
  */
64
- export interface ConceptConfig {
89
+ export interface ConceptConfig<S extends ConceptSchema = ConceptSchema> {
65
90
  /** Repo-relative content directory, e.g. "src/content/posts". */
66
91
  dir: string;
67
92
  /** Sidebar label; defaults from the concept id when omitted. */
68
93
  label?: string;
69
- /** Drives the per-concept frontmatter form, in order. */
70
- fields: FrontmatterField[];
71
- /** Validate submitted frontmatter before any commit. */
72
- validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
94
+ /** The concept's schema: the form projection, the generated validator, and the inferred type. */
95
+ schema: S;
73
96
  }
74
97
  /**
75
98
  * A concept's URL policy, set per concept in the YAML site-config (not the adapter). `permalink` is
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/content/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/content/types.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,11 @@
1
1
  import type { FrontmatterField, ValidationResult } from './types.js';
2
2
  /**
3
3
  * Validate raw frontmatter against a field list. Required text and date fields must be
4
- * non-empty; required tag fields must be non-empty lists. Booleans coerce to `true`/`false`
5
- * and tag fields to string arrays. Returns the normalized data, or field-keyed errors when
6
- * any required field is empty.
4
+ * non-empty; required tag fields must be non-empty lists. A present boolean coerces to `true`
5
+ * and an unchecked one is omitted; a present tag field coerces to a string array and an empty
6
+ * one is omitted; an empty optional text or date field is omitted, so the normalized data
7
+ * carries only meaningful values and committed frontmatter stays minimal. Returns the
8
+ * normalized data, or field-keyed errors when any required field is empty.
7
9
  *
8
10
  * Frontmatter may arrive from the edit form (all string values) or from `parseMarkdown`,
9
11
  * where gray-matter turns an unquoted YAML date into a JS `Date`. The `date` case coerces a
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/lib/content/validate.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAGrE;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,gBAAgB,EAAE,EAC1B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,gBAAgB,CA8BlB"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/lib/content/validate.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAGrE;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,gBAAgB,EAAE,EAC1B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,gBAAgB,CA+BlB"}
@@ -1,9 +1,11 @@
1
1
  import { dateInputValue } from './frontmatter.js';
2
2
  /**
3
3
  * Validate raw frontmatter against a field list. Required text and date fields must be
4
- * non-empty; required tag fields must be non-empty lists. Booleans coerce to `true`/`false`
5
- * and tag fields to string arrays. Returns the normalized data, or field-keyed errors when
6
- * any required field is empty.
4
+ * non-empty; required tag fields must be non-empty lists. A present boolean coerces to `true`
5
+ * and an unchecked one is omitted; a present tag field coerces to a string array and an empty
6
+ * one is omitted; an empty optional text or date field is omitted, so the normalized data
7
+ * carries only meaningful values and committed frontmatter stays minimal. Returns the
8
+ * normalized data, or field-keyed errors when any required field is empty.
7
9
  *
8
10
  * Frontmatter may arrive from the edit form (all string values) or from `parseMarkdown`,
9
11
  * where gray-matter turns an unquoted YAML date into a JS `Date`. The `date` case coerces a
@@ -16,28 +18,33 @@ export function validateFields(fields, frontmatter) {
16
18
  const value = frontmatter[field.name];
17
19
  switch (field.type) {
18
20
  case 'boolean':
19
- data[field.name] = value === true;
21
+ // Absent or unchecked means false; omit it so a published file carries no draft: false noise.
22
+ if (value === true)
23
+ data[field.name] = true;
20
24
  break;
21
25
  case 'tags':
22
26
  case 'freetags': {
23
27
  const list = Array.isArray(value) ? value.map(String) : [];
24
28
  if (field.required && list.length === 0)
25
29
  errors[field.name] = `${field.label} is required`;
26
- data[field.name] = list;
30
+ if (list.length > 0)
31
+ data[field.name] = list;
27
32
  break;
28
33
  }
29
34
  case 'date': {
30
35
  const text = value instanceof Date ? dateInputValue(value) : typeof value === 'string' ? value.trim() : '';
31
36
  if (field.required && text === '')
32
37
  errors[field.name] = `${field.label} is required`;
33
- data[field.name] = text;
38
+ if (text !== '')
39
+ data[field.name] = text;
34
40
  break;
35
41
  }
36
42
  default: {
37
43
  const text = typeof value === 'string' ? value.trim() : '';
38
44
  if (field.required && text === '')
39
45
  errors[field.name] = `${field.label} is required`;
40
- data[field.name] = text;
46
+ if (text !== '')
47
+ data[field.name] = text;
41
48
  }
42
49
  }
43
50
  }
@@ -0,0 +1,36 @@
1
+ <!--
2
+ @component
3
+ Renders a page's SEO head from a SeoMeta object into <svelte:head>: a title, meta tags, link
4
+ tags, and one escaped JSON-LD script. The title renders from seo.title by default; title={false}
5
+ lets the site own the <title>, and a string overrides it. It carries no CSS, so it pulls in no
6
+ admin styles.
7
+ -->
8
+ <script lang="ts">
9
+ import type { SeoMeta } from './seo.js';
10
+ import { jsonLdScript } from './json-ld.js';
11
+
12
+ let {
13
+ /** The plain-data head to render. */
14
+ seo,
15
+ /** Title override: a string replaces seo.title, false lets the site own <title>. */
16
+ title,
17
+ }: { seo: SeoMeta; title?: string | false } = $props();
18
+ const titleText = $derived(title === undefined ? seo.title : title);
19
+ </script>
20
+
21
+ <svelte:head>
22
+ {#if titleText !== false}
23
+ <title>{titleText}</title>
24
+ {/if}
25
+ {#each seo.meta as m}
26
+ {#if m.name}
27
+ <meta name={m.name} content={m.content} />
28
+ {:else if m.property}
29
+ <meta property={m.property} content={m.content} />
30
+ {/if}
31
+ {/each}
32
+ {#each seo.links as l}
33
+ <link rel={l.rel} type={l.type} href={l.href} title={l.title} />
34
+ {/each}
35
+ {@html jsonLdScript(seo.jsonLd)}
36
+ </svelte:head>