@glw907/cairn-cms 0.68.0 → 0.76.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 (177) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  4. package/dist/components/ComponentForm.svelte +44 -27
  5. package/dist/components/ComponentInsertDialog.svelte +5 -5
  6. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  7. package/dist/components/EditPage.svelte +29 -107
  8. package/dist/components/EditPage.svelte.d.ts +2 -7
  9. package/dist/components/EntryPicker.svelte +117 -0
  10. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  11. package/dist/components/FieldInput.svelte +218 -0
  12. package/dist/components/FieldInput.svelte.d.ts +51 -0
  13. package/dist/components/IconPicker.svelte +2 -2
  14. package/dist/components/IconPicker.svelte.d.ts +2 -0
  15. package/dist/components/LinkPicker.svelte +8 -75
  16. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  17. package/dist/components/MediaHeroField.svelte +8 -5
  18. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  19. package/dist/components/ObjectGroupField.svelte +54 -0
  20. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  21. package/dist/components/ReferenceField.svelte +94 -0
  22. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  23. package/dist/components/RepeatableField.svelte +221 -0
  24. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  25. package/dist/components/cairn-admin.css +4 -0
  26. package/dist/components/preview-doc.js +5 -1
  27. package/dist/components/tidy-validate.js +1 -1
  28. package/dist/content/adapter.js +18 -0
  29. package/dist/content/advisories.d.ts +2 -2
  30. package/dist/content/advisories.js +3 -5
  31. package/dist/content/compose.d.ts +7 -6
  32. package/dist/content/compose.js +26 -20
  33. package/dist/content/concepts.d.ts +21 -15
  34. package/dist/content/concepts.js +55 -32
  35. package/dist/content/field-rules.js +3 -4
  36. package/dist/content/fields.d.ts +49 -1
  37. package/dist/content/fields.js +11 -0
  38. package/dist/content/fieldset.d.ts +31 -10
  39. package/dist/content/fieldset.js +262 -109
  40. package/dist/content/frontmatter-region.d.ts +38 -0
  41. package/dist/content/frontmatter-region.js +75 -0
  42. package/dist/content/frontmatter.d.ts +35 -2
  43. package/dist/content/frontmatter.js +232 -11
  44. package/dist/content/manifest.d.ts +34 -0
  45. package/dist/content/manifest.js +80 -4
  46. package/dist/content/media-refs.d.ts +2 -2
  47. package/dist/content/media-rewrite.js +1 -69
  48. package/dist/content/reference-index.d.ts +56 -0
  49. package/dist/content/reference-index.js +95 -0
  50. package/dist/content/references.d.ts +40 -0
  51. package/dist/content/references.js +0 -0
  52. package/dist/content/standard-schema.d.ts +30 -0
  53. package/dist/content/standard-schema.js +4 -0
  54. package/dist/content/types.d.ts +127 -178
  55. package/dist/delivery/data.d.ts +2 -2
  56. package/dist/delivery/data.js +1 -1
  57. package/dist/delivery/public-routes.d.ts +2 -5
  58. package/dist/delivery/public-routes.js +15 -1
  59. package/dist/delivery/site-descriptors.d.ts +5 -1
  60. package/dist/delivery/site-descriptors.js +8 -3
  61. package/dist/delivery/site-indexes.d.ts +2 -2
  62. package/dist/delivery/site-resolver.d.ts +25 -0
  63. package/dist/delivery/site-resolver.js +49 -0
  64. package/dist/doctor/checks-local.js +6 -11
  65. package/dist/github/backend.d.ts +83 -0
  66. package/dist/github/backend.js +76 -0
  67. package/dist/github/credentials.d.ts +11 -5
  68. package/dist/github/credentials.js +3 -3
  69. package/dist/github/repo.d.ts +8 -19
  70. package/dist/github/repo.js +69 -80
  71. package/dist/github/types.d.ts +1 -1
  72. package/dist/github/types.js +4 -4
  73. package/dist/index.d.ts +16 -12
  74. package/dist/index.js +7 -8
  75. package/dist/islands/index.d.ts +12 -0
  76. package/dist/islands/index.js +83 -0
  77. package/dist/islands/types.d.ts +7 -0
  78. package/dist/islands/types.js +1 -0
  79. package/dist/media/rewrite-plan.d.ts +2 -3
  80. package/dist/media/rewrite-plan.js +2 -3
  81. package/dist/media/usage.d.ts +2 -2
  82. package/dist/media/usage.js +3 -5
  83. package/dist/nav/site-config.d.ts +0 -6
  84. package/dist/nav/site-config.js +6 -4
  85. package/dist/render/component-grammar.js +11 -11
  86. package/dist/render/component-reference.js +5 -3
  87. package/dist/render/component-validate.d.ts +4 -1
  88. package/dist/render/component-validate.js +10 -35
  89. package/dist/render/pipeline.d.ts +0 -6
  90. package/dist/render/pipeline.js +1 -1
  91. package/dist/render/registry.d.ts +34 -34
  92. package/dist/render/registry.js +26 -5
  93. package/dist/render/rehype-dispatch.d.ts +4 -4
  94. package/dist/render/rehype-dispatch.js +36 -11
  95. package/dist/render/remark-directives.js +4 -5
  96. package/dist/render/sanitize-schema.js +1 -1
  97. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  98. package/dist/sveltekit/cairn-admin.js +3 -4
  99. package/dist/sveltekit/content-routes.d.ts +10 -8
  100. package/dist/sveltekit/content-routes.js +269 -181
  101. package/dist/sveltekit/health.d.ts +7 -3
  102. package/dist/sveltekit/health.js +9 -3
  103. package/dist/sveltekit/index.d.ts +1 -1
  104. package/dist/sveltekit/nav-routes.d.ts +6 -5
  105. package/dist/sveltekit/nav-routes.js +22 -20
  106. package/dist/sveltekit/types.d.ts +2 -0
  107. package/dist/vite/index.d.ts +3 -3
  108. package/dist/vite/index.js +17 -8
  109. package/package.json +5 -1
  110. package/src/lib/ambient.ts +7 -0
  111. package/src/lib/components/CairnAdmin.svelte +2 -6
  112. package/src/lib/components/ComponentForm.svelte +48 -27
  113. package/src/lib/components/ComponentInsertDialog.svelte +9 -8
  114. package/src/lib/components/EditPage.svelte +43 -119
  115. package/src/lib/components/EntryPicker.svelte +154 -0
  116. package/src/lib/components/FieldInput.svelte +262 -0
  117. package/src/lib/components/IconPicker.svelte +4 -2
  118. package/src/lib/components/LinkPicker.svelte +10 -81
  119. package/src/lib/components/MediaHeroField.svelte +12 -5
  120. package/src/lib/components/ObjectGroupField.svelte +97 -0
  121. package/src/lib/components/ReferenceField.svelte +126 -0
  122. package/src/lib/components/RepeatableField.svelte +310 -0
  123. package/src/lib/components/preview-doc.ts +5 -1
  124. package/src/lib/components/tidy-validate.ts +1 -1
  125. package/src/lib/content/adapter.ts +21 -0
  126. package/src/lib/content/advisories.ts +4 -7
  127. package/src/lib/content/compose.ts +30 -23
  128. package/src/lib/content/concepts.ts +68 -40
  129. package/src/lib/content/field-rules.ts +3 -4
  130. package/src/lib/content/fields.ts +52 -1
  131. package/src/lib/content/fieldset.ts +291 -128
  132. package/src/lib/content/frontmatter-region.ts +90 -0
  133. package/src/lib/content/frontmatter.ts +231 -15
  134. package/src/lib/content/manifest.ts +101 -4
  135. package/src/lib/content/media-refs.ts +2 -2
  136. package/src/lib/content/media-rewrite.ts +7 -80
  137. package/src/lib/content/reference-index.ts +159 -0
  138. package/src/lib/content/references.ts +0 -0
  139. package/src/lib/content/standard-schema.ts +25 -0
  140. package/src/lib/content/types.ts +128 -195
  141. package/src/lib/delivery/data.ts +2 -2
  142. package/src/lib/delivery/public-routes.ts +17 -3
  143. package/src/lib/delivery/site-descriptors.ts +8 -3
  144. package/src/lib/delivery/site-indexes.ts +2 -2
  145. package/src/lib/delivery/site-resolver.ts +64 -0
  146. package/src/lib/doctor/checks-local.ts +6 -14
  147. package/src/lib/github/backend.ts +161 -0
  148. package/src/lib/github/credentials.ts +10 -7
  149. package/src/lib/github/repo.ts +79 -83
  150. package/src/lib/github/types.ts +5 -5
  151. package/src/lib/index.ts +38 -23
  152. package/src/lib/islands/index.ts +84 -0
  153. package/src/lib/islands/types.ts +11 -0
  154. package/src/lib/media/rewrite-plan.ts +4 -6
  155. package/src/lib/media/usage.ts +4 -7
  156. package/src/lib/nav/site-config.ts +8 -9
  157. package/src/lib/render/component-grammar.ts +10 -10
  158. package/src/lib/render/component-reference.ts +4 -3
  159. package/src/lib/render/component-validate.ts +10 -35
  160. package/src/lib/render/pipeline.ts +1 -7
  161. package/src/lib/render/registry.ts +58 -39
  162. package/src/lib/render/rehype-dispatch.ts +45 -10
  163. package/src/lib/render/remark-directives.ts +4 -5
  164. package/src/lib/render/sanitize-schema.ts +1 -1
  165. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  166. package/src/lib/sveltekit/content-routes.ts +330 -221
  167. package/src/lib/sveltekit/health.ts +13 -6
  168. package/src/lib/sveltekit/index.ts +2 -2
  169. package/src/lib/sveltekit/nav-routes.ts +33 -29
  170. package/src/lib/sveltekit/types.ts +5 -1
  171. package/src/lib/vite/index.ts +20 -11
  172. package/dist/content/schema.d.ts +0 -87
  173. package/dist/content/schema.js +0 -85
  174. package/dist/content/validate.d.ts +0 -17
  175. package/dist/content/validate.js +0 -93
  176. package/src/lib/content/schema.ts +0 -163
  177. package/src/lib/content/validate.ts +0 -90
package/CHANGELOG.md CHANGED
@@ -2,6 +2,88 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.76.0
6
+
7
+ <!-- release-size: minor -->
8
+
9
+ The Contract v2 rollup, plus content islands, published as one release. cairn is still `0.x` and the
10
+ contract may change again before a stable 1.0. This consolidates the unpublished `0.69.0`–`0.75.0`
11
+ development minors plus islands into one published `0.76.0` release. The last published release was
12
+ `0.68.0`, so a consumer crosses the whole window in a single jump and applies the "Consumers must" steps
13
+ below; the granular per-phase history lives in `docs/STATUS.md` and the plan post-mortems.
14
+
15
+ What changed. The field system unifies on the `fieldset({...})` record built from the `fields.*`
16
+ constructors, the one live field system for concepts and directive components alike, with the leaf
17
+ vocabulary (`text`, `textarea`, `number`, `select`, `multiselect`, `url`, `email`, `date`, `datetime`,
18
+ `boolean`, `image`, `icon`, `reference`) plus the `object` and `array` containers. The adapter moves from
19
+ flat keys into six subsystem groups (`content`, `backend`, `email`, `rendering`, `media`, `editor`), and a
20
+ concept owns its own URL policy through `defineConcept`. The `backend` becomes a `Backend` interface behind
21
+ a `githubApp(...)` provider, so content stays build-time over the committed manifest and no runtime database
22
+ slips in. The `render` seam becomes the entry-aware `render({ body, concept?, frontmatter?, resolve?,
23
+ resolveMedia? }) => Promise<string>`. Content islands add opt-in client interactivity over a static, no-JS
24
+ fallback. References and structured fields arrive additively.
25
+
26
+ This is breaking. Consumers must, in order:
27
+
28
+ The field system (replaces the v1 `defineFields`):
29
+
30
+ 1. Move each concept's `schema` from `defineFields([...])` (an array) to `fieldset({...})` (a record).
31
+ 2. Drop the per-field `name`; the record key is now the frontmatter key.
32
+ 3. Rename field help from `description` to `help`.
33
+ 4. Move a closed `tags` field to `fields.multiselect({ options: [...] })`, and an open `freetags` field to
34
+ `fields.multiselect({ creatable: true })` (its `placeholder` is preserved).
35
+ 5. Preserve each field's frontmatter key, especially `tags`, or tag pages and feeds read empty.
36
+ 6. Extract a frontmatter type with `InferFieldset`, and drop imports of the removed `defineFields`,
37
+ `ConceptSchema`, `Infer`, `InferFields`, `DefineFieldsOptions`, `FrontmatterField`, `TagsField`, and
38
+ `FreeTagsField`.
39
+
40
+ The adapter and concepts:
41
+
42
+ 7. Regroup the adapter into `content`/`backend`/`email`/`rendering`/`media`/`editor` (`sender` to `email`,
43
+ `render`/`registry`/`icons` to `rendering.{render,components,icons}`, `assets` to `media`,
44
+ `navMenu`/`preview`/`supportContact` to `editor.{nav,preview,supportContact}`).
45
+ 8. Rename each concept's `schema:` to `fields:` and declare it through `defineConcept`.
46
+ 9. Move `permalink` and `datePrefix` from the YAML `content:` block onto the concept via `defineConcept`,
47
+ and declare each concept's routing with the routing shorthand. A leftover YAML `content:` block now
48
+ throws at `parseSiteConfig`.
49
+ 10. Move `siteName` out of the adapter into the YAML site-config.
50
+
51
+ Directive components:
52
+
53
+ 11. Declare each component's `attributes` as a `fields.*` record (was an `AttributeField[]` array), a
54
+ repeatable slot's `itemFields` the same way, and wrap each component in `defineComponent({ ... })`.
55
+ 12. Move any cross-field attribute `validate` into the component's `behavior` table with the
56
+ `validate(value, siblings)` signature, reading `siblings.min` rather than `all.attributes.min`.
57
+ 13. Replace a `pattern: { source, message }` attribute with `fields.text({ pattern })` plus a
58
+ `behavior.validate` for a custom message, and drop imports of `AttributeField` and `FieldType`.
59
+ Attribute validation now format-checks every value, so a directive that previously saved a malformed
60
+ value now fails `validateComponent`.
61
+
62
+ The backend:
63
+
64
+ 14. Change the adapter's `backend` from a `{ owner, repo, branch, appId, installationId }` object literal to
65
+ `backend: githubApp({ ... })`, importing `githubApp` from `@glw907/cairn-cms`. Drop imports of the
66
+ removed `BackendConfig`, `RepoRef`, and `AppCredentials`, and replace `GithubKeyEnv` (from the
67
+ `/sveltekit` subpath) with `BackendEnv`.
68
+
69
+ The render seam:
70
+
71
+ 15. Change the adapter `render` from `(md, opts) => ...` to
72
+ `({ body, resolve, resolveMedia }) => ...`, read the markdown from `body`, and return a
73
+ `Promise<string>` (a typical body is `renderMarkdown(body, { resolve, resolveMedia })`). Drop any
74
+ `stagger` option; `data-rise` is now always emitted and is inert without `[data-rise]` CSS. The
75
+ attribute now appears in all rendered output, including the syndication feeds and prerendered pages,
76
+ so a consumer that snapshots rendered HTML sees it.
77
+
78
+ Additive in this window, with no action required to keep working: reference fields (`fields.reference` and
79
+ `fields.array(fields.reference(...))`), structured fields (`fields.object` and the generalized
80
+ `fields.array`), and content islands (`hydrate` on a component, `rendering.islands`, and the `./islands`
81
+ runtime). Adopt them through their guides: [references](docs/guides/link-content-with-references.md),
82
+ [structured fields](docs/guides/structured-fields.md), and [islands](docs/guides/add-an-island.md).
83
+
84
+ ecxc-ski and 907-life stay pinned to the prior version range until they cut over. See [Upgrading
85
+ cairn](docs/guides/upgrade-cairn.md) for the per-change actions.
86
+
5
87
  ## 0.68.0
6
88
 
7
89
  <!-- release-size: minor -->
package/dist/ambient.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { Editor } from './auth/types.js';
2
+ import type { Backend } from './github/backend.js';
2
3
  declare global {
3
4
  namespace App {
4
5
  interface Locals {
5
6
  editor?: Editor | null;
7
+ backend?: Backend;
6
8
  }
7
9
  }
8
10
  }
@@ -2,8 +2,7 @@ import type { AdminData } from '../sveltekit/cairn-admin.js';
2
2
  import type { ContentFormFailure } from '../sveltekit/content-routes.js';
3
3
  import type { ComponentRegistry } from '../render/registry.js';
4
4
  import type { IconSet } from '../render/glyph.js';
5
- import type { LinkResolve } from '../content/links.js';
6
- import type { MediaResolve } from '../render/resolve-media.js';
5
+ import type { SiteRender } from '../content/types.js';
7
6
  interface Props {
8
7
  /** The discriminated view data from `createCairnAdmin`'s load. */
9
8
  data: AdminData;
@@ -16,11 +15,7 @@ interface Props {
16
15
  ok?: boolean;
17
16
  }) | null;
18
17
  /** The site's design-accurate render pipeline, for the edit view's preview pane. */
19
- render?: (md: string, opts?: {
20
- stagger?: boolean;
21
- resolve?: LinkResolve;
22
- resolveMedia?: MediaResolve;
23
- }) => string | Promise<string>;
18
+ render?: SiteRender;
24
19
  /** The site's component registry, for the edit view's insert palette. */
25
20
  registry?: ComponentRegistry;
26
21
  /** The site's icon set, for the edit view's guided form fields. */
@@ -26,7 +26,7 @@ let working = $state(untrack(() => initial ?? previewValues(def)));
26
26
  $effect(() => {
27
27
  values = working;
28
28
  });
29
- const attributes = $derived(def.attributes ?? []);
29
+ const attributes = $derived(Object.entries(def.attributes ?? {}));
30
30
  const flatSlots = $derived((def.slots ?? []).filter((s) => s.kind !== "repeatable"));
31
31
  const repeatableSlots = $derived((def.slots ?? []).filter((s) => s.kind === "repeatable"));
32
32
  function slotItems(name) {
@@ -59,7 +59,7 @@ function removeItem(name, index) {
59
59
  function rowLabel(slot, value, index) {
60
60
  const fallback = `${slot.label} ${index + 1}`;
61
61
  if (!slot.itemLabel) return fallback;
62
- const key = slot.itemFields?.[0]?.key ?? "text";
62
+ const key = Object.keys(slot.itemFields ?? {})[0] ?? "text";
63
63
  const derived = slot.itemLabel({ [key]: value }, index);
64
64
  return derived && derived.trim() ? derived : fallback;
65
65
  }
@@ -74,10 +74,26 @@ function slotString(name) {
74
74
  const v = working.slots[name];
75
75
  return typeof v === "string" ? v : "";
76
76
  }
77
+ function inputType(type) {
78
+ switch (type) {
79
+ case "number":
80
+ return "number";
81
+ case "date":
82
+ return "date";
83
+ case "datetime":
84
+ return "datetime-local";
85
+ case "url":
86
+ return "url";
87
+ case "email":
88
+ return "email";
89
+ default:
90
+ return "text";
91
+ }
92
+ }
77
93
  const incompleteState = $derived.by(() => {
78
- for (const field of attributes) {
94
+ for (const [name, field] of attributes) {
79
95
  if (!field.required || field.type === "boolean") continue;
80
- if (asString(field.key) === "") return true;
96
+ if (asString(name) === "") return true;
81
97
  }
82
98
  for (const slot of def.slots ?? []) {
83
99
  if (!slot.required) continue;
@@ -97,9 +113,9 @@ function markTouched(key) {
97
113
  }
98
114
  const errors = $derived.by(() => {
99
115
  const out = {};
100
- for (const field of attributes) {
101
- if (field.required && field.type !== "boolean" && touched[field.key] && asString(field.key) === "") {
102
- out[field.key] = `${field.label} is required.`;
116
+ for (const [name, field] of attributes) {
117
+ if (field.required && field.type !== "boolean" && touched[name] && asString(name) === "") {
118
+ out[name] = `${field.label} is required.`;
103
119
  }
104
120
  }
105
121
  for (const slot of def.slots ?? []) {
@@ -127,16 +143,16 @@ async function submit() {
127
143
  </script>
128
144
 
129
145
  <div class="flex flex-col gap-3" bind:this={formEl}>
130
- {#each attributes as field (field.key)}
146
+ {#each attributes as [name, field] (name)}
131
147
  {#if field.type === 'boolean'}
132
148
  <label class="label cursor-pointer justify-start gap-2">
133
149
  <input
134
150
  class="checkbox checkbox-sm"
135
151
  type="checkbox"
136
- aria-invalid={Boolean(errors[field.key])}
137
- aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
138
- checked={asBool(field.key)}
139
- onchange={(e) => (working.attributes[field.key] = e.currentTarget.checked)}
152
+ aria-invalid={Boolean(errors[name])}
153
+ aria-describedby={errors[name] ? `err-${name}` : undefined}
154
+ checked={asBool(name)}
155
+ onchange={(e) => (working.attributes[name] = e.currentTarget.checked)}
140
156
  />
141
157
  <span class="text-sm">{field.label}</span>
142
158
  </label>
@@ -146,14 +162,14 @@ async function submit() {
146
162
  <select
147
163
  class="select"
148
164
  aria-required={field.required ? 'true' : undefined}
149
- aria-invalid={Boolean(errors[field.key])}
150
- aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
151
- value={asString(field.key)}
165
+ aria-invalid={Boolean(errors[name])}
166
+ aria-describedby={errors[name] ? `err-${name}` : undefined}
167
+ value={asString(name)}
152
168
  onchange={(e) => {
153
- working.attributes[field.key] = e.currentTarget.value;
154
- markTouched(field.key);
169
+ working.attributes[name] = e.currentTarget.value;
170
+ markTouched(name);
155
171
  }}
156
- onblur={() => markTouched(field.key)}
172
+ onblur={() => markTouched(name)}
157
173
  >
158
174
  {#if !field.required}<option value="">—</option>{/if}
159
175
  {#each field.options ?? [] as opt (opt)}<option value={opt}>{opt}</option>{/each}
@@ -165,9 +181,9 @@ async function submit() {
165
181
  <IconPicker
166
182
  {icons}
167
183
  label={field.label}
168
- value={asString(field.key)}
184
+ value={asString(name)}
169
185
  required={field.required ?? false}
170
- onChange={(name) => (working.attributes[field.key] = name)}
186
+ onChange={(glyph) => (working.attributes[name] = glyph)}
171
187
  />
172
188
  </div>
173
189
  {:else}
@@ -175,19 +191,20 @@ async function submit() {
175
191
  <span class="text-sm font-medium">{field.label}{#if field.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
176
192
  <input
177
193
  class="input"
194
+ type={inputType(field.type)}
178
195
  aria-required={field.required ? 'true' : undefined}
179
- aria-invalid={Boolean(errors[field.key])}
180
- aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
181
- value={asString(field.key)}
196
+ aria-invalid={Boolean(errors[name])}
197
+ aria-describedby={errors[name] ? `err-${name}` : undefined}
198
+ value={asString(name)}
182
199
  oninput={(e) => {
183
- working.attributes[field.key] = e.currentTarget.value;
184
- markTouched(field.key);
200
+ working.attributes[name] = e.currentTarget.value;
201
+ markTouched(name);
185
202
  }}
186
- onblur={() => markTouched(field.key)}
203
+ onblur={() => markTouched(name)}
187
204
  />
188
205
  </label>
189
206
  {/if}
190
- {#if errors[field.key]}<span id={`err-${field.key}`} role="alert" class="text-error text-xs">{errors[field.key]}</span>{/if}
207
+ {#if errors[name]}<span id={`err-${name}`} role="alert" class="text-error text-xs">{errors[name]}</span>{/if}
191
208
  {/each}
192
209
 
193
210
  {#each flatSlots as slot (slot.name)}
@@ -7,7 +7,7 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
7
7
  -->
8
8
  <script module lang="ts">const SEARCH_THRESHOLD = 8;
9
9
  export function hasSchema(def) {
10
- return (def.attributes?.length ?? 0) > 0 || (def.slots?.length ?? 0) > 0;
10
+ return Object.keys(def.attributes ?? {}).length > 0 || (def.slots?.length ?? 0) > 0;
11
11
  }
12
12
  export function insertableDefs(registry) {
13
13
  return (registry?.defs ?? []).filter(
@@ -49,10 +49,10 @@ let previewDoc = $state("");
49
49
  const emptyRequired = $derived.by(() => {
50
50
  if (!picked || !formValues) return [];
51
51
  const out = [];
52
- for (const field of picked.attributes ?? []) {
52
+ for (const [name, field] of Object.entries(picked.attributes ?? {})) {
53
53
  if (!field.required || field.type === "boolean") continue;
54
- const v = formValues.attributes[field.key];
55
- if (typeof v !== "string" || v === "") out.push(field.label);
54
+ const v = formValues.attributes[name];
55
+ if (typeof v !== "string" || v === "") out.push(field.label ?? name);
56
56
  }
57
57
  for (const slot of picked.slots ?? []) {
58
58
  if (!slot.required) continue;
@@ -75,7 +75,7 @@ $effect(() => {
75
75
  previewState = "settling";
76
76
  const handle = setTimeout(async () => {
77
77
  try {
78
- const html = await render(md);
78
+ const html = await render({ body: md });
79
79
  if (run === previewRun) {
80
80
  previewDoc = buildPreviewDoc(html, preview);
81
81
  previewState = "settled";
@@ -10,8 +10,7 @@ export declare function hasSchema(def: ComponentDef): boolean;
10
10
  export declare function insertableDefs(registry?: ComponentRegistry): ComponentDef[];
11
11
  import type { IconSet } from '../render/glyph.js';
12
12
  import type { ComponentValues } from '../render/registry.js';
13
- import type { ResolvedPreview } from '../content/types.js';
14
- import type { LinkResolve } from '../content/links.js';
13
+ import type { ResolvedPreview, SiteRender } from '../content/types.js';
15
14
  interface Props {
16
15
  /** The site's component registry. */
17
16
  registry?: ComponentRegistry;
@@ -29,10 +28,7 @@ interface Props {
29
28
  * `preview`, the configure step splits to two panes and renders the configured directive
30
29
  * through this into a sandboxed iframe (the same path EditPage's preview uses). Optional: a
31
30
  * host that passes none simply gets no preview pane. */
32
- render?: (md: string, opts?: {
33
- stagger?: boolean;
34
- resolve?: LinkResolve;
35
- }) => string | Promise<string>;
31
+ render?: SiteRender;
36
32
  /** The adapter's resolved preview knob (stylesheets and container class), threaded to
37
33
  * buildPreviewDoc so the preview frame links the site's own CSS, the same as EditPage. */
38
34
  preview?: ResolvedPreview | null;
@@ -34,7 +34,7 @@ import ComponentInsertDialog, { insertableDefs, hasSchema } from "./ComponentIns
34
34
  import LinkPicker from "./LinkPicker.svelte";
35
35
  import WebLinkDialog from "./WebLinkDialog.svelte";
36
36
  import MediaInsertPopover from "./MediaInsertPopover.svelte";
37
- import MediaHeroField from "./MediaHeroField.svelte";
37
+ import FieldInput from "./FieldInput.svelte";
38
38
  import MediaFigureControl from "./MediaFigureControl.svelte";
39
39
  import DeleteDialog from "./DeleteDialog.svelte";
40
40
  import RenameDialog from "./RenameDialog.svelte";
@@ -614,6 +614,10 @@ const draftWarning = $derived.by(() => {
614
614
  const drafts = page.url.searchParams.get("drafts");
615
615
  return drafts ? drafts.split(",").filter(Boolean).join(", ") : "";
616
616
  });
617
+ const referenceWarning = $derived.by(() => {
618
+ const refs = page.url.searchParams.get("refs");
619
+ return refs ? refs.split(",").filter(Boolean).join(", ") : "";
620
+ });
617
621
  const flash = $derived.by(() => {
618
622
  if (data.saved && !draftWarning)
619
623
  return "Saved. Your site keeps showing the published version until you publish.";
@@ -622,9 +626,11 @@ const flash = $derived.by(() => {
622
626
  if (data.renamed) return `The URL is now ${data.slug}.`;
623
627
  return "";
624
628
  });
625
- const politeMessage = $derived(
626
- draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash
627
- );
629
+ const politeMessage = $derived.by(() => {
630
+ if (draftWarning) return `Saved. This page links to unpublished pages: ${draftWarning}.`;
631
+ if (referenceWarning) return `Saved. This page references unpublished entries: ${referenceWarning}.`;
632
+ return flash;
633
+ });
628
634
  const assertiveMessage = $derived.by(() => {
629
635
  if (data.error) return data.error;
630
636
  if (formError) return formError;
@@ -718,7 +724,7 @@ $effect(() => {
718
724
  const run = ++previewRun;
719
725
  const handle = setTimeout(async () => {
720
726
  try {
721
- const html = await render(md, { resolve, resolveMedia: resolveMediaRef });
727
+ const html = await render({ body: md, concept: data.conceptId, frontmatter: data.frontmatter, resolve, resolveMedia: resolveMediaRef });
722
728
  if (run === previewRun) {
723
729
  previewHtml = html;
724
730
  previewFailed = false;
@@ -742,7 +748,6 @@ const eyebrowClass = "mb-2 text-[0.6875rem] font-semibold uppercase tracking-[0.
742
748
  const titleField = $derived(data.fields.find((f) => f.name === "title"));
743
749
  const draftField = $derived(data.fields.find((f) => f.type === "boolean" && f.name === "draft"));
744
750
  const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
745
- const DATE_PUBLISH_HINT = "Sets the date for this post. Publishing is a separate step you choose.";
746
751
  </script>
747
752
 
748
753
  <!-- The desk controls live in the one header band: AdminLayout renders this snippet through the
@@ -869,17 +874,6 @@ const DATE_PUBLISH_HINT = "Sets the date for this post. Publishing is a separate
869
874
  </div>
870
875
  {/snippet}
871
876
 
872
- <!-- The author-facing hint under a Details field. The id pairs with the input's aria-describedby
873
- (`<name>-hint`); its uniqueness rests on schema field names being unique within a concept, which
874
- is also the loop key. So assistive tech announces the sentence without bloating the accessible
875
- name. Each field branch decides whether and where to render it; this snippet holds the one shape.
876
- The `fld-hint` class is a styling hook with no rule today; the Tailwind utilities do the work. -->
877
- {#snippet fieldHint(name: string, text: string)}
878
- <p id={`${name}-hint`} class="fld-hint mt-1 text-sm text-[var(--color-muted)]">
879
- {text}
880
- </p>
881
- {/snippet}
882
-
883
877
  <!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
884
878
  reset above); script-level state and the beforeNavigate registration sit outside the block,
885
879
  so only the template rebuilds. -->
@@ -987,6 +981,11 @@ const DATE_PUBLISH_HINT = "Sets the date for this post. Publishing is a separate
987
981
  Saved. Note: this page links to unpublished {draftWarning.includes(',') ? 'pages' : 'a page'} ({draftWarning}), which will 404 until published.
988
982
  </div>
989
983
  {/if}
984
+ {#if referenceWarning}
985
+ <div class="alert alert-warning mb-4 text-sm">
986
+ Saved. Note: this page references {referenceWarning.includes(',') ? 'entries' : 'an entry'} ({referenceWarning}) not yet published, which the build will flag until published.
987
+ </div>
988
+ {/if}
990
989
 
991
990
  <form
992
991
  method="POST"
@@ -1368,96 +1367,19 @@ const DATE_PUBLISH_HINT = "Sets the date for this post. Publishing is a separate
1368
1367
  the screen-reader grouping but hides visually, the way the mockup carries it once. -->
1369
1368
  <legend class="sr-only">Details</legend>
1370
1369
  {#each detailFields as field (field.name)}
1371
- {#if field.type === 'textarea'}
1372
- {@const f = field as TextareaField}
1373
- <label class="flex flex-col gap-1">
1374
- <span class="text-sm font-medium">{f.label}</span>
1375
- <textarea class="textarea textarea-sm" name={f.name} aria-label={f.label} aria-describedby={f.description ? `${f.name}-hint` : undefined} rows={f.rows ?? 3}>{str(data.frontmatter[f.name])}</textarea>
1376
- {#if f.description}
1377
- {@render fieldHint(f.name, f.description)}
1378
- {/if}
1379
- </label>
1380
- {:else if field.type === 'date'}
1381
- <label class="flex flex-col gap-1">
1382
- <span class="text-sm font-medium">{field.label}</span>
1383
- <!-- A date field always carries a hint: the adapter's description when set, else the
1384
- built-in publish-clarity default. So aria-describedby always points at the paragraph. -->
1385
- <input class="input input-sm" type="date" name={field.name} aria-label={field.label} aria-describedby={`${field.name}-hint`} value={str(data.frontmatter[field.name])} />
1386
- {@render fieldHint(field.name, field.description || DATE_PUBLISH_HINT)}
1387
- </label>
1388
- {:else if field.type === 'boolean'}
1389
- <div class="flex flex-col gap-1">
1390
- <label class="label cursor-pointer justify-start gap-2">
1391
- <input class="checkbox checkbox-sm" type="checkbox" name={field.name} aria-label={field.label} aria-describedby={field.description ? `${field.name}-hint` : undefined} checked={data.frontmatter[field.name] === true} />
1392
- <span class="text-sm">{field.label}</span>
1393
- </label>
1394
- {#if field.description}
1395
- {@render fieldHint(field.name, field.description)}
1396
- {/if}
1397
- </div>
1398
- {:else if field.type === 'tags'}
1399
- {@const f = field as TagsField}
1400
- {@const selected = (data.frontmatter[f.name] ?? []) as string[]}
1401
- <fieldset class="fieldset" aria-describedby={f.description ? `${f.name}-hint` : undefined}>
1402
- <legend class="fieldset-legend">{f.label}</legend>
1403
- {#if f.description}
1404
- {@render fieldHint(f.name, f.description)}
1405
- {/if}
1406
- <div class="flex flex-wrap gap-2">
1407
- {#each f.options as option (option)}
1408
- <label class="label cursor-pointer justify-start gap-2">
1409
- <input
1410
- class="checkbox checkbox-sm"
1411
- type="checkbox"
1412
- name={f.name}
1413
- value={option}
1414
- checked={selected.includes(option)}
1415
- />
1416
- <span class="text-sm">{option}</span>
1417
- </label>
1418
- {/each}
1419
- </div>
1420
- </fieldset>
1421
- {:else if field.type === 'freetags'}
1422
- {@const f = field as FreeTagsField}
1423
- {@const tagValue = ((data.frontmatter[f.name] ?? []) as string[]).join(', ')}
1424
- <label class="flex flex-col gap-1">
1425
- <span class="text-sm font-medium">{f.label}</span>
1426
- <input
1427
- class="input input-sm"
1428
- name={f.name}
1429
- aria-label={f.label}
1430
- aria-describedby={f.description ? `${f.name}-hint` : undefined}
1431
- placeholder={f.placeholder}
1432
- value={tagValue}
1433
- />
1434
- {#if f.description}
1435
- {@render fieldHint(f.name, f.description)}
1436
- {/if}
1437
- </label>
1438
- {:else if field.type === 'image'}
1439
- {@const heroValue = data.frontmatter[field.name] as ImageValue | undefined}
1440
- <MediaHeroField
1441
- bind:this={heroFieldRefs[field.name]}
1442
- field={{ name: field.name, label: field.label }}
1443
- value={heroValue}
1444
- decorative={heroValue?.decorative ?? false}
1445
- mediaLibrary={mediaLibrary}
1446
- conceptId={data.conceptId}
1447
- id={data.id}
1448
- onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
1449
- ondirty={markFieldsDirty}
1450
- onneedsaltchange={(n) => (heroNeedsAlt = { ...heroNeedsAlt, [field.name]: n })}
1451
- />
1452
- {:else}
1453
- <label class="flex flex-col gap-1">
1454
- <span class="text-sm font-medium">{field.label}</span>
1455
- <input class="input input-sm" name={field.name} aria-label={field.label} aria-describedby={field.description ? `${field.name}-hint` : undefined} value={str(data.frontmatter[field.name])} required={field.required} />
1456
- {#if field.description}
1457
- {@render fieldHint(field.name, field.description)}
1458
- {/if}
1459
- </label>
1460
- {/if}
1370
+ <FieldInput
1371
+ {field}
1372
+ frontmatter={data.frontmatter}
1373
+ targets={data.linkTargets}
1374
+ markFieldsDirty={markFieldsDirty}
1375
+ mediaLibrary={mediaLibrary}
1376
+ conceptId={data.conceptId}
1377
+ id={data.id}
1378
+ heroFieldRefs={heroFieldRefs}
1379
+ onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
1380
+ onheroneedsalt={(name, n) => (heroNeedsAlt = { ...heroNeedsAlt, [name]: n })}
1381
+ {icons}
1382
+ />
1461
1383
  {/each}
1462
1384
  </fieldset>
1463
1385
  {/if}
@@ -1,8 +1,7 @@
1
1
  import type { ComponentRegistry } from '../render/registry.js';
2
2
  import type { IconSet } from '../render/glyph.js';
3
3
  import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
4
- import type { LinkResolve } from '../content/links.js';
5
- import type { MediaResolve } from '../render/resolve-media.js';
4
+ import type { SiteRender } from '../content/types.js';
6
5
  interface Props {
7
6
  /** The edit load's data, plus the site name for the heading. */
8
7
  data: EditData & {
@@ -11,11 +10,7 @@ interface Props {
11
10
  /** The site's component registry, for the insert palette. */
12
11
  registry?: ComponentRegistry;
13
12
  /** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
14
- render?: (md: string, opts?: {
15
- stagger?: boolean;
16
- resolve?: LinkResolve;
17
- resolveMedia?: MediaResolve;
18
- }) => string | Promise<string>;
13
+ render?: SiteRender;
19
14
  /** The site's icon set, for the guided form's icon fields. */
20
15
  icons?: IconSet;
21
16
  /** The last content action's failure: the save guard's broken links, the delete guard's
@@ -0,0 +1,117 @@
1
+ <!--
2
+ @component
3
+ The search + concept-grouped target list shared by the editor's "Link to page" control and the
4
+ reference field picker. It lists link targets from the committed manifest, grouped by concept with
5
+ Pages first then Posts then any other concept, each post showing its date and each draft marked, and
6
+ fires choose() with the picked target. It knows nothing about cairn: tokens or the editor cursor; the
7
+ host decides what a chosen target means. An optional conceptFilter narrows the list to one concept,
8
+ and selectedIds marks rows the host already holds. Built on a native <dialog>, following the component
9
+ dialog's a11y conventions.
10
+ -->
11
+ <script lang="ts">let {
12
+ targets,
13
+ choose,
14
+ conceptFilter,
15
+ selectedIds = [],
16
+ trigger = true,
17
+ heading: dialogHeading = "Link to a page",
18
+ searchLabel = "Search pages and posts",
19
+ emptyText = "No pages or posts to link to."
20
+ } = $props();
21
+ let dialog = $state(null);
22
+ let searchInput = $state(null);
23
+ let query = $state("");
24
+ const ORDER = { pages: 0, posts: 1 };
25
+ function rank(concept) {
26
+ return ORDER[concept] ?? 2;
27
+ }
28
+ function heading(concept) {
29
+ if (concept === "pages") return "Pages";
30
+ if (concept === "posts") return "Posts";
31
+ return concept.charAt(0).toUpperCase() + concept.slice(1);
32
+ }
33
+ const groups = $derived.by(() => {
34
+ const q = query.trim().toLowerCase();
35
+ const scoped = conceptFilter ? targets.filter((t) => t.concept === conceptFilter) : targets;
36
+ const matched = q ? scoped.filter((t) => t.title.toLowerCase().includes(q)) : scoped;
37
+ const byConcept = /* @__PURE__ */ new Map();
38
+ for (const t of matched) {
39
+ const list = byConcept.get(t.concept) ?? [];
40
+ list.push(t);
41
+ byConcept.set(t.concept, list);
42
+ }
43
+ return [...byConcept.entries()].map(([concept, items]) => ({ concept, heading: heading(concept), items })).sort((a, b) => rank(a.concept) - rank(b.concept) || a.heading.localeCompare(b.heading));
44
+ });
45
+ function isSelected(target) {
46
+ return selectedIds.includes(target.id);
47
+ }
48
+ export function open() {
49
+ query = "";
50
+ dialog?.showModal();
51
+ queueMicrotask(() => searchInput?.focus());
52
+ }
53
+ function close() {
54
+ dialog?.close();
55
+ }
56
+ function pick(target) {
57
+ if (isSelected(target)) return;
58
+ choose(target);
59
+ close();
60
+ }
61
+ </script>
62
+
63
+ {#if trigger}
64
+ <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Link to page" onclick={open}>
65
+ Link to page
66
+ </button>
67
+ {/if}
68
+
69
+ <dialog class="modal" aria-labelledby="cairn-entry-picker-title" bind:this={dialog}>
70
+ <div class="modal-box">
71
+ <div class="mb-3 flex items-center justify-between">
72
+ <h2 id="cairn-entry-picker-title" class="text-base font-semibold">{dialogHeading}</h2>
73
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
74
+ </div>
75
+
76
+ <input
77
+ type="search"
78
+ class="input input-bordered mb-3 w-full"
79
+ placeholder="Search by title"
80
+ aria-label={searchLabel}
81
+ bind:this={searchInput}
82
+ bind:value={query}
83
+ />
84
+
85
+ {#if groups.length === 0}
86
+ <p class="text-sm text-[var(--color-muted)]">{emptyText}</p>
87
+ {:else}
88
+ {#each groups as group (group.concept)}
89
+ <h3 class="mt-2 mb-1 text-xs font-semibold tracking-wide text-[var(--color-muted)] uppercase">{group.heading}</h3>
90
+ <ul class="menu w-full">
91
+ {#each group.items as target (`${target.concept}/${target.id}`)}
92
+ <li>
93
+ <button
94
+ type="button"
95
+ aria-disabled={isSelected(target)}
96
+ aria-label={isSelected(target) ? `${target.title} (already selected)` : target.title}
97
+ onclick={() => pick(target)}
98
+ >
99
+ <span class="flex flex-col items-start">
100
+ <span class="font-medium">{target.title}</span>
101
+ <span class="text-xs text-[var(--color-muted)]">
102
+ {#if isSelected(target)}<span class="badge badge-ghost badge-sm mr-1">Selected</span>{/if}
103
+ {#if target.draft}<span class="badge badge-ghost badge-sm mr-1">Draft</span>{/if}
104
+ {#if target.date}{target.date}{/if}
105
+ </span>
106
+ </span>
107
+ </button>
108
+ </li>
109
+ {/each}
110
+ </ul>
111
+ {/each}
112
+ {/if}
113
+ </div>
114
+ <form method="dialog" class="modal-backdrop">
115
+ <button tabindex="-1" aria-label="Close">close</button>
116
+ </form>
117
+ </dialog>