@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
@@ -9,107 +9,14 @@
9
9
  // boundary to the editor form.
10
10
  import type { ComponentRegistry } from '../render/registry.js';
11
11
  import type { IconSet } from '../render/glyph.js';
12
+ import type { IslandRegistry } from '../islands/types.js';
13
+ import type { BackendProvider } from '../github/backend.js';
12
14
  import type { DatePrefix } from './ids.js';
13
- import type { ConceptSchema } from './schema.js';
15
+ import type { Fieldset } from './fieldset.js';
16
+ import type { FieldDescriptor } from './fields.js';
14
17
  import type { LinkResolve } from './links.js';
15
18
  import type { VariantSpec } from '../media/transform-url.js';
16
19
 
17
- /** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
18
- interface FieldBase {
19
- /** Frontmatter key and form input name. */
20
- name: string;
21
- /** Form label. */
22
- label: string;
23
- /** A required field fails validation when empty (spec §7.4). */
24
- required?: boolean;
25
- /**
26
- * One author-facing sentence shown under the field in the editor, in plain end-user language.
27
- * Optional; render nothing when absent. Not a validation rule.
28
- */
29
- description?: string;
30
- }
31
-
32
- /** A single-line text input. */
33
- export interface TextField extends FieldBase {
34
- type: 'text';
35
- /** Minimum character length of a non-empty value. */
36
- min?: number;
37
- /** Maximum character length. */
38
- max?: number;
39
- /** Exact required character length. */
40
- length?: number;
41
- /**
42
- * A regular-expression source string the value must match. Stored as a string so the field
43
- * list stays plain serializable data; the validator compiles it.
44
- */
45
- pattern?: string;
46
- }
47
- /** A multi-line text input. */
48
- export interface TextareaField extends FieldBase {
49
- type: 'textarea';
50
- /** Visible rows; the editor picks a default when omitted. */
51
- rows?: number;
52
- /** Minimum character length of a non-empty value. */
53
- min?: number;
54
- /** Maximum character length. */
55
- max?: number;
56
- /** Exact required character length. */
57
- length?: number;
58
- /** A regular-expression source string the value must match. */
59
- pattern?: string;
60
- }
61
- /** A `YYYY-MM-DD` date input. */
62
- export interface DateField extends FieldBase {
63
- type: 'date';
64
- /** Earliest allowed date, as `YYYY-MM-DD`. */
65
- min?: string;
66
- /** Latest allowed date, as `YYYY-MM-DD`. */
67
- max?: string;
68
- }
69
- /** A checkbox; absent means false. */
70
- export interface BooleanField extends FieldBase {
71
- type: 'boolean';
72
- }
73
- /** A closed-vocabulary tag set, rendered as checkboxes (ecnordic). */
74
- export interface TagsField extends FieldBase {
75
- type: 'tags';
76
- /** The controlled vocabulary. */
77
- options: readonly string[];
78
- }
79
- /** Free-form tags, edited as one comma-separated input (907). */
80
- export interface FreeTagsField extends FieldBase {
81
- type: 'freetags';
82
- placeholder?: string;
83
- }
84
- /**
85
- * A hero image set in frontmatter. The stored value is the nested object
86
- * `{ src: string; alt: string; caption?: string }`, where `src` is a 2b `media:` reference, `alt`
87
- * is the screen-reader description, and `caption` is an optional line the site template may show.
88
- * One image serves two jobs: the template's lead image and the social-card image. The field feeding
89
- * the social card is the `seo`-flagged one, defaulting to the field named `image`; a concept declares
90
- * at most one SEO image field.
91
- */
92
- export interface ImageField extends FieldBase {
93
- type: 'image';
94
- /** Whether this field feeds the social-card image. The field named `image` defaults to true. */
95
- seo?: boolean;
96
- }
97
-
98
- /**
99
- * The discriminated union the per-concept frontmatter form is generated from. A scalar field type
100
- * is one variant here plus one decode arm in `frontmatterFromForm` and one in `validateFields`. The
101
- * structured `image` field additionally needs a read-back arm in `formValues` and a type-inference
102
- * arm in `schema.ts`, since its value is a nested object rather than a single string.
103
- */
104
- export type FrontmatterField =
105
- | TextField
106
- | TextareaField
107
- | DateField
108
- | BooleanField
109
- | TagsField
110
- | FreeTagsField
111
- | ImageField;
112
-
113
20
  /**
114
21
  * The stored value of an `image` field: a `media:` reference, a screen-reader description, and an
115
22
  * optional caption.
@@ -122,59 +29,75 @@ export interface ImageValue {
122
29
  decorative?: boolean;
123
30
  }
124
31
 
32
+ /** One validation failure located by a path: a top-level key, then a row index and/or a leaf sub-key. */
33
+ export interface ValidationIssue {
34
+ /** The path to the failing field, e.g. ['faq', 0, 'question'] or ['address', 'city'] or ['title']. */
35
+ path: (string | number)[];
36
+ /** The author-facing message, naming the field's label. */
37
+ message: string;
38
+ }
39
+
125
40
  /**
126
- * A validator's verdict. On success it carries the normalized frontmatter to commit; on
127
- * failure it carries field-keyed error messages (the empty key is a form-level error).
128
- * Invalid input bounces to the form and never reaches git (spec §7.4).
41
+ * A validator's verdict. On success it carries the normalized frontmatter to commit; on failure it
42
+ * carries field-keyed error messages (the empty key is a form-level error) and, additively, the
43
+ * located `issues` with multi-segment paths so the form can route a nested-container error to the
44
+ * right input. Invalid input bounces to the form and never reaches git (spec §7.4).
129
45
  */
130
46
  export type ValidationResult =
131
47
  | { ok: true; data: Record<string, unknown> }
132
- | { ok: false; errors: Record<string, string> };
48
+ | { ok: false; errors: Record<string, string>; issues?: ValidationIssue[] };
49
+
50
+ /**
51
+ * A field descriptor with its frontmatter key re-attached as `name`. This is the normalized form
52
+ * `ConceptDescriptor.fields` carries: `normalizeConcepts` derives it from a concept's `fieldset`
53
+ * record so every consumer (the editor form, the form decoder, the media extractor) iterates an
54
+ * array and reads `name` rather than the keyed record.
55
+ */
56
+ export type NamedField = FieldDescriptor & { name: string };
133
57
 
134
58
  /**
135
- * Per-site configuration for one content concept (spec §8). One `schema`, built with
136
- * `defineFields`, is the single source of truth for the editor form, the validator, and the
137
- * inferred frontmatter type. Generic over the schema so a concept's concrete type survives for
138
- * typed reads. Concept-fixed behavior such as routability is not here; it lives in the engine's
139
- * routing table (`CONCEPT_ROUTING`).
59
+ * Per-site configuration for one content concept (spec §8). One `fields` fieldset, built with
60
+ * `fieldset`, is the single source of truth for the editor form, the validator, and the
61
+ * inferred frontmatter type. Generic over the fieldset so a concept's concrete type survives for
62
+ * typed reads. A concept also declares its own routing and URL policy here (`routing`, `permalink`,
63
+ * `datePrefix`), resolved by `normalizeConcepts`.
140
64
  */
141
- export interface ConceptConfig<S extends ConceptSchema = ConceptSchema> {
65
+ export interface ConceptConfig<S extends Fieldset = Fieldset> {
142
66
  /** Repo-relative content directory, e.g. "src/content/posts". */
143
67
  dir: string;
144
68
  /** Sidebar label; defaults from the concept id when omitted. */
145
69
  label?: string;
146
70
  /** The singular noun for the create affordances ("New post"); defaults to `label` when omitted. */
147
71
  singular?: string;
148
- /** The concept's schema: the form projection, the generated validator, and the inferred type. */
149
- schema: S;
72
+ /** The concept's fieldset: the form projection, the generated validator, and the inferred type. */
73
+ fields: S;
74
+ /**
75
+ * This concept's routing. A named shorthand (`'feed'` dated and in feeds, `'page'` a routable
76
+ * static page, `'embedded'` not routable) or an explicit rule. Omitted means `'page'`.
77
+ */
78
+ routing?: 'feed' | 'page' | 'embedded' | RoutingRule;
79
+ /** The permalink pattern, root-relative, e.g. `/blog/:year/:slug`. Defaults by concept id. */
80
+ permalink?: string;
81
+ /** Date-prefix granularity for a dated concept's id-to-slug stripping. Defaults to `day`. */
82
+ datePrefix?: DatePrefix;
150
83
  /**
151
84
  * Frontmatter keys to surface on each `ContentSummary.fields`, so a list card reads an authored
152
- * field without a per-entry detail read. Each key should also be declared in `schema`.
85
+ * field without a per-entry detail read. Each key should also be declared in `fields`.
153
86
  */
154
87
  summaryFields?: string[];
155
88
  }
156
89
 
157
90
  /**
158
- * A concept's URL policy, set per concept in the YAML site-config (not the adapter). `permalink` is
159
- * a `/`-prefixed pattern of literal segments and the tokens `:slug`, `:year`, `:month`, `:day`.
160
- * `datePrefix` is the filename date-prefix granularity for a dated concept. Both default in
161
- * `normalizeConcepts` when omitted.
91
+ * A concept's URL policy, declared on the adapter concept itself (`ConceptConfig.permalink` and
92
+ * `ConceptConfig.datePrefix`) since Contract v2. `permalink` is a `/`-prefixed pattern of literal
93
+ * segments and the tokens `:slug`, `:year`, `:month`, `:day`. `datePrefix` is the filename
94
+ * date-prefix granularity for a dated concept. Both default in `normalizeConcepts` when omitted.
162
95
  */
163
96
  export interface ConceptUrlPolicy {
164
97
  permalink?: string;
165
98
  datePrefix?: DatePrefix;
166
99
  }
167
100
 
168
- /** The GitHub App backend a site reads from and commits to (spec §8). Plain data the GitHub engine (Plan 03) consumes. */
169
- export interface BackendConfig {
170
- owner: string;
171
- repo: string;
172
- /** Commit target, e.g. "main". */
173
- branch: string;
174
- appId: string;
175
- installationId: string;
176
- }
177
-
178
101
  /** Magic-link sender identity for Cloudflare Email Sending. */
179
102
  export interface SenderConfig {
180
103
  from: string;
@@ -256,67 +179,73 @@ export interface AssetConfig {
256
179
  transformations?: boolean;
257
180
  }
258
181
 
259
- /** The single seam the engine consumes. A site implements this at `src/lib/cairn.config.ts`. */
182
+ /**
183
+ * The site's one renderer (design decision 4): the editor preview and every public page call it.
184
+ * Entry-aware so a custom renderer can vary output by concept or frontmatter; the default reads only
185
+ * `body` plus the resolvers. `resolve` rewrites cairn: links to live permalinks (the build passes a
186
+ * site-resolver-backed resolver, the preview a manifest-backed one); `resolveMedia` resolves media:
187
+ * references the same way. `concept` and `frontmatter` carry the entry's context for an entry render
188
+ * and are absent for the standalone component-insert preview.
189
+ */
190
+ export type SiteRender = (input: {
191
+ body: string;
192
+ concept?: string;
193
+ frontmatter?: Record<string, unknown>;
194
+ resolve?: LinkResolve;
195
+ resolveMedia?: import('../render/resolve-media.js').MediaResolve;
196
+ }) => Promise<string>;
197
+
198
+ /**
199
+ * The single seam the engine consumes. A site implements this at `src/lib/cairn.config.ts`, in six
200
+ * subsystem groups (spec §8): the content concepts, the commit backend, the magic-link sender, the
201
+ * render subsystem, the optional media stack, and the admin-experience knobs. The internal manifest
202
+ * and dictionary paths are not here; `composeRuntime` defaults them by convention.
203
+ */
260
204
  export interface CairnAdapter {
261
- siteName: string;
262
- /**
263
- * Which content concepts this site enables. A future `fragments?` key attaches here with
264
- * no reshape of the contract (seam 1). A site never has two of the same concept.
265
- */
266
- content: {
267
- posts?: ConceptConfig;
268
- pages?: ConceptConfig;
205
+ /** The site's concepts, keyed by id. Posts and pages are the documented defaults; a site may add more. */
206
+ content: Record<string, ConceptConfig>;
207
+ /** The commit backend provider, from `githubApp({ ... })` (the GitHub App today). */
208
+ backend: BackendProvider;
209
+ /** The magic-link sender. */
210
+ email: SenderConfig;
211
+ /** The render subsystem: the one renderer, its directive vocabulary, and its icons. */
212
+ rendering: {
213
+ /**
214
+ * The one renderer the editor preview and every public page call (design decision 4). `resolve`
215
+ * rewrites cairn: links to live permalinks; the build passes a site-resolver-backed one, the
216
+ * preview a manifest one. `resolveMedia` resolves media: references the same way.
217
+ */
218
+ render: SiteRender;
219
+ /** Directive component registry; the renderer and the insert palette derive from it (seam 3). */
220
+ components?: ComponentRegistry;
221
+ /** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
222
+ icons?: IconSet;
223
+ /**
224
+ * The live Svelte components for hydrated directives, keyed by directive name (phase 4b islands).
225
+ * Every component whose {@link ComponentDef.hydrate} is set needs an entry here, and every entry
226
+ * needs a matching `hydrate` component; `defineAdapter` checks both. Absent leaves the site
227
+ * static, and the island client runtime is never imported.
228
+ */
229
+ islands?: IslandRegistry;
230
+ };
231
+ /** R2-backed media (seam 4): the bucket binding and image variants. Absent leaves media off. */
232
+ media?: AssetConfig;
233
+ /** Admin-experience knobs: the preview frame, the nav menu, and the editor support contact. */
234
+ editor?: {
235
+ /**
236
+ * The live site's content styling for the preview frame. The admin's chrome isolation keeps
237
+ * the site's CSS out of the admin document, so the preview frame links these instead.
238
+ */
239
+ preview?: PreviewConfig;
240
+ /** Which git-committed YAML menu the nav editor manages. */
241
+ nav?: NavMenuConfig;
242
+ /**
243
+ * Optional contact a stuck editor is pointed to from the in-admin help (an email address, a URL,
244
+ * or a name and instruction). The help renders the hand-off only when this is set. Plain string,
245
+ * passed through verbatim.
246
+ */
247
+ supportContact?: string;
269
248
  };
270
- backend: BackendConfig;
271
- sender: SenderConfig;
272
- /**
273
- * Optional contact a stuck editor is pointed to from the in-admin help (an email address, a URL,
274
- * or a name and instruction). The help renders the hand-off only when this is set. Plain string,
275
- * passed through verbatim.
276
- */
277
- supportContact?: string;
278
- /**
279
- * The site's one renderer: the editor preview and every public page call it (design decision 4).
280
- * `resolve` rewrites cairn: links to live permalinks; the build passes a site-resolver-backed
281
- * one, the preview a manifest one. The trailing `resolveMedia` is additive and optional: the build
282
- * passes a site-resolver-backed media resolver, the preview a manifest-backed one.
283
- */
284
- render(
285
- md: string,
286
- opts?: {
287
- stagger?: boolean;
288
- resolve?: LinkResolve;
289
- resolveMedia?: import('../render/resolve-media.js').MediaResolve;
290
- },
291
- ): string | Promise<string>;
292
- /**
293
- * Repo-relative path to the committed content manifest. Defaults to src/content/.cairn/index.json
294
- * in composeRuntime. It sits outside any concept directory, so content enumeration never globs it.
295
- */
296
- manifestPath?: string;
297
- /**
298
- * Repo-relative path to the committed media manifest. Defaults to src/content/.cairn/media.json,
299
- * applied in composeRuntime. Sits outside any concept directory, like the content manifest.
300
- */
301
- mediaManifestPath?: string;
302
- /**
303
- * Repo-relative path to the committed personal dictionary file. Defaults to
304
- * src/content/.cairn/dictionary.txt, applied in composeRuntime: the same `.cairn/` content root the
305
- * manifests use, so the spec's `content/.cairn/dictionary.txt` resolves the same configurable way the
306
- * manifest paths do. One word per line, sorted, comment lines allowed (see site-dictionary.ts).
307
- */
308
- dictionaryPath?: string;
309
- /** Directive component registry; the renderer and the future palette derive from it (seam 3). */
310
- registry?: ComponentRegistry;
311
- /** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
312
- icons?: IconSet;
313
- navMenu?: NavMenuConfig;
314
- /**
315
- * The live site's content styling for the preview frame. The admin's chrome isolation keeps
316
- * the site's CSS out of the admin document, so the preview frame links these instead.
317
- */
318
- preview?: PreviewConfig;
319
- assets?: AssetConfig;
320
249
  }
321
250
 
322
251
  /**
@@ -351,7 +280,17 @@ export interface ConceptDescriptor {
351
280
  permalink: string;
352
281
  /** Filename date-prefix granularity for a dated concept; resolved by `normalizeConcepts`. */
353
282
  datePrefix: DatePrefix;
354
- fields: FrontmatterField[];
283
+ /**
284
+ * The concept's fields in normalized form: each descriptor with its record key re-attached as
285
+ * `name`, derived by `normalizeConcepts` from the concept's `fieldset` record. Every consumer
286
+ * (the editor form, the form decoder, the media extractor) iterates this array and reads `name`.
287
+ */
288
+ fields: NamedField[];
289
+ /**
290
+ * The concept's source fieldset, carried through so `editLoad` can resolve a create-form's
291
+ * initial values (a `default: 'today'` date) against a request-time clock via `initialValues`.
292
+ */
293
+ schema: Fieldset;
355
294
  /**
356
295
  * Frontmatter keys the index copies onto each summary's `fields` record. `normalizeConcepts`
357
296
  * resolves it to `[]` when a concept omits `summaryFields`.
@@ -412,23 +351,17 @@ export interface CairnExtension {
412
351
  export interface CairnRuntime {
413
352
  siteName: string;
414
353
  concepts: ConceptDescriptor[];
415
- backend: BackendConfig;
354
+ /** The commit backend provider, carried through from the adapter by `composeRuntime`. */
355
+ backend: BackendProvider;
416
356
  sender: SenderConfig;
417
357
  /** The support contact passed through from the adapter; the in-admin help reads it. Optional. */
418
358
  supportContact?: string;
419
359
  /**
420
360
  * The site's one renderer: the editor preview and every public page call it (design decision 4).
421
- * The trailing `resolveMedia` is additive and optional: the build passes a site-resolver-backed
422
- * media resolver, the preview a manifest-backed one.
361
+ * The build passes a site-resolver-backed `resolve`/`resolveMedia` pair, the preview manifest-backed
362
+ * ones.
423
363
  */
424
- render(
425
- md: string,
426
- opts?: {
427
- stagger?: boolean;
428
- resolve?: LinkResolve;
429
- resolveMedia?: import('../render/resolve-media.js').MediaResolve;
430
- },
431
- ): string | Promise<string>;
364
+ render: SiteRender;
432
365
  manifestPath: string;
433
366
  /** The repo-relative path to the committed media manifest, defaulted in composeRuntime. */
434
367
  mediaManifestPath: string;
@@ -3,8 +3,8 @@
3
3
  // the graph. The full ./delivery barrel re-exports this and adds the route loaders.
4
4
  export { createContentIndex, fromGlob } from './content-index.js';
5
5
  export type { RawFile, ContentSummary, ContentEntry, ContentIndex, ContentProblem } from './content-index.js';
6
- export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
7
- export type { SiteResolver, ConceptIndex } from './site-resolver.js';
6
+ export { createSiteResolver, buildLinkResolver, resolveReferences } from './site-resolver.js';
7
+ export type { SiteResolver, ConceptIndex, ResolvedReference } from './site-resolver.js';
8
8
  export { createSiteIndexes } from './site-indexes.js';
9
9
  export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
10
10
  export { siteDescriptors } from './site-descriptors.js';
@@ -10,7 +10,7 @@ import { buildSeoMeta } from './seo.js';
10
10
  import type { SeoMeta } from './seo.js';
11
11
  import { readSeoFields, resolveImageUrl } from './seo-fields.js';
12
12
  import { buildLinkResolver } from './site-resolver.js';
13
- import type { LinkResolve } from '../content/links.js';
13
+ import type { SiteRender } from '../content/types.js';
14
14
  import type { MediaResolve } from '../render/resolve-media.js';
15
15
  import { parseMediaToken } from '../media/reference.js';
16
16
  import { log } from '../log/index.js';
@@ -18,7 +18,7 @@ import { log } from '../log/index.js';
18
18
  /** Injected dependencies for the public loaders. */
19
19
  export interface PublicRoutesDeps {
20
20
  site: SiteResolver;
21
- render: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
21
+ render: SiteRender;
22
22
  origin: string;
23
23
  /** Site name for og:site_name and the SEO head. */
24
24
  siteName: string;
@@ -163,7 +163,21 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
163
163
  ...(fields.author ? { author: fields.author } : {}),
164
164
  ...(entry.date ? { feeds } : {}),
165
165
  });
166
- return { concept: entry.concept, entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older, ...(heroImage ? { heroImage } : {}) };
166
+ return {
167
+ concept: entry.concept,
168
+ entry,
169
+ html: await render({
170
+ body: entry.body,
171
+ concept: entry.concept,
172
+ frontmatter: entry.frontmatter,
173
+ resolve: buildLinkResolver(site),
174
+ }),
175
+ canonicalUrl,
176
+ seo,
177
+ newer,
178
+ older,
179
+ ...(heroImage ? { heroImage } : {}),
180
+ };
167
181
  }
168
182
 
169
183
  /** The chronological archive for one concept: every non-draft summary, newest-first. */
@@ -1,11 +1,16 @@
1
1
  // cairn-cms: the one-call descriptor helper. A delivery site needs the same per-concept descriptors
2
2
  // the admin runtime uses; this delegates to the shared resolveConcepts so the pairing is one path, not
3
- // tribal knowledge. The YAML URL policy stays the single source of truth.
3
+ // tribal knowledge. Each concept declares its own routing and URL policy, the single source of truth.
4
4
  import { resolveConcepts } from '../content/concepts.js';
5
5
  import type { CairnAdapter, ConceptDescriptor } from '../content/types.js';
6
6
  import type { SiteConfig } from '../nav/site-config.js';
7
7
 
8
- /** Per-concept descriptors for a site, from its adapter content and its parsed site config. */
8
+ /**
9
+ * Per-concept descriptors for a site, from its adapter content. The `siteConfig` parameter is retained
10
+ * for API stability and the menus and site name it still carries; the URL policy now lives on each
11
+ * concept, so it is not read here.
12
+ */
9
13
  export function siteDescriptors(adapter: CairnAdapter, siteConfig: SiteConfig): ConceptDescriptor[] {
10
- return resolveConcepts(adapter.content, siteConfig);
14
+ void siteConfig;
15
+ return resolveConcepts(adapter.content);
11
16
  }
@@ -5,7 +5,7 @@
5
5
  // lower-level escape hatch. It imports only pure content and delivery code, so the delivery
6
6
  // bundle stays backend-free.
7
7
  import type { CairnAdapter, ConceptConfig } from '../content/types.js';
8
- import type { Infer } from '../content/schema.js';
8
+ import type { InferFieldset } from '../content/fieldset.js';
9
9
  import type { SiteConfig } from '../nav/site-config.js';
10
10
  import { siteDescriptors } from './site-descriptors.js';
11
11
  import { createContentIndex, fromGlob } from './content-index.js';
@@ -24,7 +24,7 @@ export type SiteGlobs<A extends CairnAdapter> = {
24
24
  */
25
25
  export type SiteIndexes<A extends CairnAdapter> = {
26
26
  [K in keyof A['content']]: ContentIndex<
27
- NonNullable<A['content'][K]> extends ConceptConfig<infer S> ? Infer<S> : Record<string, unknown>
27
+ NonNullable<A['content'][K]> extends ConceptConfig<infer S> ? InferFieldset<S> : Record<string, unknown>
28
28
  >;
29
29
  } & { readonly site: SiteResolver };
30
30
 
@@ -6,6 +6,7 @@
6
6
  import type { ConceptDescriptor } from '../content/types.js';
7
7
  import type { ContentEntry, ContentIndex, ContentSummary } from './content-index.js';
8
8
  import type { LinkResolve } from '../content/links.js';
9
+ import { extractReferenceEdges, type ReferenceEdge } from '../content/references.js';
9
10
 
10
11
  /** One concept's descriptor paired with its built index. */
11
12
  export interface ConceptIndex {
@@ -93,6 +94,69 @@ export function createSiteResolver(concepts: ConceptIndex[], opts: { validate?:
93
94
  };
94
95
  }
95
96
 
97
+ /**
98
+ * A reference edge resolved to its target's identity, for a public route to render a linked target.
99
+ * It reuses the target entry's own summary fields rather than re-deriving them, so a linked author
100
+ * card reads the same title and permalink the target's own page does. `summary` is the target's
101
+ * excerpt when present.
102
+ */
103
+ export interface ResolvedReference {
104
+ id: string;
105
+ concept: string;
106
+ title: string;
107
+ permalink: string;
108
+ summary?: string;
109
+ }
110
+
111
+ /** Project a resolved target entry into the identity a public route renders for a reference. */
112
+ function projectReference(edge: ReferenceEdge, target: ContentSummary): ResolvedReference {
113
+ const resolved: ResolvedReference = {
114
+ id: edge.id,
115
+ concept: edge.concept,
116
+ title: target.title,
117
+ permalink: target.permalink,
118
+ };
119
+ if (target.excerpt) resolved.summary = target.excerpt;
120
+ return resolved;
121
+ }
122
+
123
+ /**
124
+ * Resolve a concept's `reference` and `array(reference)` frontmatter edges to their target identities,
125
+ * keyed by the field name, so a public route renders a reference as a link to its target's page. The
126
+ * resolution lives here because only the cross-concept resolver reaches a different concept's entries:
127
+ * a posts entry's `author` edge targets a pages entry, which the posts index alone cannot read. A
128
+ * single `reference` field resolves to one `ResolvedReference`, an `array(reference)` to a
129
+ * `ResolvedReference[]` in edge order. An id with no live target is dropped rather than thrown: the
130
+ * build's `verifyReferences` gate already fails a true dangling edge, so an unresolved id at request
131
+ * time is a mid-flight or draft target, not a hard error. Resolve per call, since the target entries
132
+ * exist only after every per-concept index is unioned into the resolver.
133
+ */
134
+ export function resolveReferences(
135
+ site: SiteResolver,
136
+ descriptor: ConceptDescriptor,
137
+ frontmatter: Record<string, unknown>,
138
+ ): Record<string, ResolvedReference | ResolvedReference[]> {
139
+ const edges = extractReferenceEdges(frontmatter, descriptor.fields);
140
+ const resolved: Record<string, ResolvedReference | ResolvedReference[]> = {};
141
+ for (const field of descriptor.fields) {
142
+ const isSingle = field.type === 'reference';
143
+ const isArray = field.type === 'array' && field.item.type === 'reference';
144
+ if (!isSingle && !isArray) continue;
145
+ const fieldEdges = edges.filter((edge) => edge.field === field.name);
146
+ const hits: ResolvedReference[] = [];
147
+ for (const edge of fieldEdges) {
148
+ const target = site.concept(edge.concept)?.byId(edge.id);
149
+ if (target) hits.push(projectReference(edge, target));
150
+ }
151
+ if (isSingle) {
152
+ if (hits.length > 0) resolved[field.name] = hits[0];
153
+ } else {
154
+ resolved[field.name] = hits;
155
+ }
156
+ }
157
+ return resolved;
158
+ }
159
+
96
160
  /**
97
161
  * A resolver backed by the site resolver, for the build. A miss throws, so a dangling cairn: token
98
162
  * fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks.
@@ -5,11 +5,8 @@ import { fail, pass, skip } from './types.js';
5
5
  import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
6
6
  import { readWranglerConfig } from './wrangler-config.js';
7
7
  import { requireOrigin } from '../env.js';
8
- import { parseSiteConfig, urlPolicyFrom } from '../nav/site-config.js';
8
+ import { parseSiteConfig } from '../nav/site-config.js';
9
9
  import type { SiteConfig } from '../nav/site-config.js';
10
- import { normalizeConcepts } from '../content/concepts.js';
11
- import { defineFields } from '../content/schema.js';
12
- import type { ConceptConfig } from '../content/types.js';
13
10
 
14
11
  const NO_WRANGLER: CheckResult = skip('no wrangler.jsonc or wrangler.toml found');
15
12
 
@@ -156,16 +153,11 @@ export const configSiteConfig: DoctorCheck = {
156
153
  const text = await readSiteConfigText(ctx);
157
154
  if (text === null) return skip(`no site.config.yaml found (looked in ${SITE_CONFIG_PATHS.join(', ')})`);
158
155
  try {
159
- const policy = urlPolicyFrom(parseSiteConfig(text));
160
- // Run the engine's own URL-policy validation by declaring a synthetic empty concept
161
- // per policy key. Routing is concept-fixed in the engine (CONCEPT_ROUTING, never the
162
- // adapter), so the dated rules apply faithfully here. What a CLI cannot check without
163
- // evaluating the adapter is whether each policy key names a concept the site declares.
164
- const synthetic = Object.fromEntries(
165
- Object.keys(policy).map((id): [string, ConceptConfig] => [id, { dir: '', schema: defineFields([]) }])
166
- );
167
- normalizeConcepts(synthetic, policy);
168
- return pass('parsed and URL policy validated (the adapter concept set is not checkable from the CLI)');
156
+ // Parse-only. parseSiteConfig validates the root shape and, since Contract v2, hard-errors on a
157
+ // stale per-concept `content:` block (URL policy moved onto defineConcept). The per-concept URL
158
+ // policy is now validated at the concept declaration, which a CLI cannot reach without the adapter.
159
+ parseSiteConfig(text);
160
+ return pass('parsed (per-concept URL policy lives on the adapter concepts, not checkable from the CLI)');
169
161
  } catch (err) {
170
162
  return fail(err instanceof Error ? err.message : String(err));
171
163
  }