@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
@@ -3,6 +3,78 @@
3
3
  // on-disk write/read pair. Kept as one seam so a site owns its serialization contract
4
4
  // (quoting, key order) without the save endpoint reaching for gray-matter directly.
5
5
  import matter from 'gray-matter';
6
+ /**
7
+ * True when a multiselect field is a closed checkbox group: it declares an options vocabulary and is
8
+ * not author-extendable. The save decoder and the editor render arm both call this, so the
9
+ * closed-versus-open multiselect decision can never drift between decode and display.
10
+ */
11
+ export function isClosedMultiselect(field) {
12
+ return field.type === 'multiselect' && !!field.options && !field.creatable;
13
+ }
14
+ // Decode one field addressed by `name`, for NESTED use (object leaves, array rows). Returns undefined
15
+ // when empty so the caller omits the key; this nested contract differs from the top-level arms, which
16
+ // preserve '' / [] for back-compat. Recurses one level for an object item.
17
+ function decodeField(name, field, form) {
18
+ switch (field.type) {
19
+ case 'boolean':
20
+ return form.get(name) === 'on' ? true : undefined;
21
+ case 'multiselect': {
22
+ const list = isClosedMultiselect(field)
23
+ ? form.getAll(name).map(String)
24
+ : [...new Set(String(form.get(name) ?? '').split(',').map((t) => t.trim()).filter(Boolean))];
25
+ return list.length > 0 ? list : undefined;
26
+ }
27
+ case 'image': {
28
+ const src = String(form.get(`${name}.src`) ?? '').trim();
29
+ if (src === '')
30
+ return undefined;
31
+ const value = { src, alt: String(form.get(`${name}.alt`) ?? '') };
32
+ const caption = String(form.get(`${name}.caption`) ?? '').trim();
33
+ if (caption !== '')
34
+ value.caption = caption;
35
+ if (String(form.get(`${name}.decorative`) ?? '') === 'true')
36
+ value.decorative = true;
37
+ return value;
38
+ }
39
+ case 'object': {
40
+ const obj = {};
41
+ for (const [leafKey, leaf] of Object.entries(field.fields)) {
42
+ const v = decodeField(`${name}.${leafKey}`, leaf, form);
43
+ if (v !== undefined)
44
+ obj[leafKey] = v;
45
+ }
46
+ return Object.keys(obj).length > 0 ? obj : undefined;
47
+ }
48
+ default: {
49
+ // text, textarea, number-as-string, url, email, date, datetime: a trimmed non-empty string.
50
+ const s = String(form.get(name) ?? '').trim();
51
+ return s === '' ? undefined : s;
52
+ }
53
+ }
54
+ }
55
+ // Enumerate array rows by any present name.<i>.* key, decode each item, and drop a row that decodes to
56
+ // empty (minimal-frontmatter). A row with any non-default content emits at least one key (a text leaf
57
+ // submits even when empty; a checked boolean submits its key), so a present-but-meaningful row is always
58
+ // seen; a fully all-default row carries no data and is correctly pruned. Output order follows ascending
59
+ // index, which the editor's keyed rows keep in sync.
60
+ function decodeRows(name, item, form) {
61
+ const indices = new Set();
62
+ const prefix = `${name}.`;
63
+ for (const k of form.keys()) {
64
+ if (!k.startsWith(prefix))
65
+ continue;
66
+ const n = Number(k.slice(prefix.length).split('.')[0]);
67
+ if (Number.isInteger(n))
68
+ indices.add(n);
69
+ }
70
+ const rows = [];
71
+ for (const i of [...indices].sort((a, b) => a - b)) {
72
+ const v = decodeField(`${name}.${i}`, item, form);
73
+ if (v !== undefined)
74
+ rows.push(v);
75
+ }
76
+ return rows;
77
+ }
6
78
  /** Decode submitted form data into raw frontmatter, one rule per field type. */
7
79
  export function frontmatterFromForm(fields, form) {
8
80
  const data = {};
@@ -11,17 +83,20 @@ export function frontmatterFromForm(fields, form) {
11
83
  case 'boolean':
12
84
  data[field.name] = form.get(field.name) === 'on';
13
85
  break;
14
- case 'tags':
15
- data[field.name] = form.getAll(field.name).map(String);
16
- break;
17
- case 'freetags':
18
- // One comma-separated input to trimmed, de-duplicated, non-empty tags.
19
- data[field.name] = [
20
- ...new Set(String(form.get(field.name) ?? '')
21
- .split(',')
22
- .map((tag) => tag.trim())
23
- .filter(Boolean)),
24
- ];
86
+ case 'multiselect':
87
+ if (isClosedMultiselect(field)) {
88
+ // A closed vocabulary submits one form value per checked box.
89
+ data[field.name] = form.getAll(field.name).map(String);
90
+ }
91
+ else {
92
+ // An open or creatable set is one comma-separated input to trimmed, de-duplicated tags.
93
+ data[field.name] = [
94
+ ...new Set(String(form.get(field.name) ?? '')
95
+ .split(',')
96
+ .map((tag) => tag.trim())
97
+ .filter(Boolean)),
98
+ ];
99
+ }
25
100
  break;
26
101
  case 'image': {
27
102
  // The hero submits three sub-fields under one key. An empty src means no hero, so omit the
@@ -45,6 +120,34 @@ export function frontmatterFromForm(fields, form) {
45
120
  data[field.name] = value;
46
121
  break;
47
122
  }
123
+ case 'reference': {
124
+ // One submitted id. An empty value means no edge, so omit the key (like the image arm)
125
+ // rather than committing a blank scalar the extractor would have to skip.
126
+ const id = String(form.get(field.name) ?? '').trim();
127
+ if (id !== '')
128
+ data[field.name] = id;
129
+ break;
130
+ }
131
+ case 'array': {
132
+ if (field.item.type === 'reference') {
133
+ // One submitted id per selected element. Drop empties and omit the key on an empty list,
134
+ // so a cleared array leaves no dead key in committed frontmatter.
135
+ const ids = form.getAll(field.name).map(String).map((id) => id.trim()).filter(Boolean);
136
+ if (ids.length > 0)
137
+ data[field.name] = ids;
138
+ break;
139
+ }
140
+ const rows = decodeRows(field.name, field.item, form);
141
+ if (rows.length > 0)
142
+ data[field.name] = rows;
143
+ break;
144
+ }
145
+ case 'object': {
146
+ const obj = decodeField(field.name, field, form);
147
+ if (obj !== undefined)
148
+ data[field.name] = obj;
149
+ break;
150
+ }
48
151
  default:
49
152
  // FormData.get returns null for an absent field; normalize to an empty string so
50
153
  // a caller reading a text value never gets null.
@@ -69,6 +172,30 @@ export function dateInputValue(value) {
69
172
  }
70
173
  return '';
71
174
  }
175
+ /**
176
+ * Coerce a frontmatter datetime value to the naive-local, minute-precision `YYYY-MM-DDTHH:mm` an
177
+ * `<input type="datetime-local">` wants. A datetime is round-tripped as TEXT, so a stored value is
178
+ * already this string; the `Date` branch is the fallback for a value gray-matter parsed into a JS
179
+ * `Date` from an unquoted full-ISO scalar. UTC getters read the value back as it was written,
180
+ * avoiding a local-timezone shift.
181
+ */
182
+ export function datetimeInputValue(value) {
183
+ if (value instanceof Date) {
184
+ if (Number.isNaN(value.getTime()))
185
+ return '';
186
+ const yyyy = value.getUTCFullYear().toString().padStart(4, '0');
187
+ const mm = (value.getUTCMonth() + 1).toString().padStart(2, '0');
188
+ const dd = value.getUTCDate().toString().padStart(2, '0');
189
+ const hh = value.getUTCHours().toString().padStart(2, '0');
190
+ const min = value.getUTCMinutes().toString().padStart(2, '0');
191
+ return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
192
+ }
193
+ if (typeof value === 'string') {
194
+ const match = value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/);
195
+ return match ? match[0] : '';
196
+ }
197
+ return '';
198
+ }
72
199
  /**
73
200
  * True when `s` is a canonical zero-padded `YYYY-MM-DD` string naming a real calendar date.
74
201
  * Rejects a wrong format, an impossible month or day, and a JS date-rollover such as
@@ -88,6 +215,100 @@ export function isCalendarDate(s) {
88
215
  date.getUTCMonth() === month - 1 &&
89
216
  date.getUTCDate() === day);
90
217
  }
218
+ /**
219
+ * Canonicalize one raw reference value to its target id. A Date (a YAML-parsed unquoted date-shaped id)
220
+ * becomes its UTC-sliced `YYYY-MM-DD` string, so a stored id never depends on the reader's timezone; a
221
+ * string is trimmed; anything else is the empty string. `validate`, `extractReferenceEdges`, and
222
+ * `formValues` all read through this, so a hand-edited or rewriter-emitted value canonicalizes identically.
223
+ */
224
+ export function referenceIdFromValue(value) {
225
+ if (value instanceof Date)
226
+ return dateInputValue(value);
227
+ if (typeof value === 'string')
228
+ return value.trim();
229
+ return '';
230
+ }
231
+ /**
232
+ * Canonicalize a raw `array(reference)` value to its list of target ids. An array maps each element
233
+ * through `referenceIdFromValue`; a lone non-empty scalar is one element (a single id a YAML scalar
234
+ * carries, never dropped to an empty list); anything else is empty. Empty ids are dropped.
235
+ */
236
+ export function referenceIdsFromValue(value) {
237
+ const list = Array.isArray(value)
238
+ ? value.map(referenceIdFromValue)
239
+ : typeof value === 'string' && value.trim() !== ''
240
+ ? [referenceIdFromValue(value)]
241
+ : [];
242
+ return list.filter((id) => id !== '');
243
+ }
244
+ /** The NamedField[] shape formValues iterates, derived from a container's keyed sub-field record. */
245
+ function namedLeaves(record) {
246
+ return Object.entries(record).map(([name, f]) => ({ ...f, name }));
247
+ }
248
+ /** The form value for one leaf array element, reusing the per-type rules so dates/images/etc. coerce identically. */
249
+ function oneLeafFormValue(field, value) {
250
+ return formValues([{ ...field, name: '_' }], { _: value })._;
251
+ }
252
+ /**
253
+ * Coerce a raw multiselect value to the string[] the editor's tag/checkbox inputs read. An array maps
254
+ * each element through String; a lone non-empty scalar (a single tag a YAML scalar carries) is one
255
+ * element rather than dropping to []; anything else is the empty list.
256
+ */
257
+ function multiselectFormValue(value) {
258
+ if (Array.isArray(value))
259
+ return value.map(String);
260
+ if (typeof value === 'string' && value.trim() !== '')
261
+ return [value.trim()];
262
+ return [];
263
+ }
264
+ /** Coerce parsed frontmatter to the form-ready values the editor inputs expect, one rule per field type. */
265
+ export function formValues(fields, frontmatter) {
266
+ const out = {};
267
+ for (const field of fields) {
268
+ const value = frontmatter[field.name];
269
+ if (field.type === 'date')
270
+ out[field.name] = dateInputValue(value);
271
+ // A datetime round-trips as text; a value gray-matter parsed into a Date reformats to the
272
+ // naive-local minute-precision string the datetime-local input wants.
273
+ else if (field.type === 'datetime')
274
+ out[field.name] = datetimeInputValue(value);
275
+ else if (field.type === 'boolean')
276
+ out[field.name] = value === true;
277
+ else if (field.type === 'multiselect')
278
+ out[field.name] = multiselectFormValue(value);
279
+ // A hero is a nested object; the default String() arm would corrupt it to '[object Object]'.
280
+ // Hand the stored object back as-is so the editor reads .src/.alt/.caption on open.
281
+ else if (field.type === 'image')
282
+ out[field.name] = value !== null && typeof value === 'object' ? value : undefined;
283
+ // A reference canonicalizes through the shared coercer: a YAML-parsed Date becomes its UTC-sliced
284
+ // id, never String(Date) timezone garbage.
285
+ else if (field.type === 'reference')
286
+ out[field.name] = referenceIdFromValue(value);
287
+ // An object recurses one level into a nested form-ready record.
288
+ else if (field.type === 'object') {
289
+ const raw = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
290
+ out[field.name] = formValues(namedLeaves(field.fields), raw);
291
+ }
292
+ else if (field.type === 'array') {
293
+ // An array(reference) canonicalizes through the shared coercer: a lone scalar becomes a
294
+ // single-element list rather than dropping to [], and each element is UTC-sliced.
295
+ if (field.item.type === 'reference')
296
+ out[field.name] = referenceIdsFromValue(value);
297
+ else {
298
+ const elements = Array.isArray(value) ? value : [];
299
+ const item = field.item;
300
+ out[field.name] = elements.map((el) => item.type === 'object'
301
+ ? formValues(namedLeaves(item.fields), el !== null && typeof el === 'object' ? el : {})
302
+ : oneLeafFormValue(item, el));
303
+ }
304
+ }
305
+ // Every other type is a plain string input: a nullish value reads as empty, anything else
306
+ // stringifies (a string passes through unchanged).
307
+ else
308
+ out[field.name] = value == null ? '' : String(value);
309
+ }
310
+ return out;
311
+ }
91
312
  /** Reassemble a markdown file from frontmatter and body for committing. */
92
313
  export function serializeMarkdown(frontmatter, body) {
93
314
  return matter.stringify(body, frontmatter);
@@ -1,4 +1,5 @@
1
1
  import { type CairnRef, type LinkResolve } from './links.js';
2
+ import { type ReferenceEdge } from './references.js';
2
3
  import type { ConceptDescriptor } from './types.js';
3
4
  /** One entry's projection: its identity, routing, draft flag, and outbound cairn: edges. */
4
5
  export interface ManifestEntry {
@@ -16,6 +17,13 @@ export interface ManifestEntry {
16
17
  * the key, and a manifest committed before this field still parses (absent reads as no refs).
17
18
  */
18
19
  mediaRefs?: string[];
20
+ /**
21
+ * The typed frontmatter reference edges this entry declares (`{ field, concept, id }` each). The
22
+ * main side of the cross-branch reference index and the reverse `inboundReferences` reader.
23
+ * Additive and optional: an entry with no reference fields omits the key, and a manifest committed
24
+ * before this field still parses (absent reads as no edges).
25
+ */
26
+ references?: ReferenceEdge[];
19
27
  }
20
28
  /** The whole corpus as one committed file. `version` guards a future shape migration. */
21
29
  export interface Manifest {
@@ -79,6 +87,15 @@ export declare function diffManifests(built: Manifest, committed: Manifest): Man
79
87
  * committed manifest stale fails the build loudly with what drifted.
80
88
  */
81
89
  export declare function verifyManifest(built: Manifest, committedRaw: string): void;
90
+ /**
91
+ * Throw if any entry's reference edge points at a target absent from the corpus. The match is the
92
+ * `(concept, id)` pair, never id alone, since ids are unique only within a concept. The error names
93
+ * the source entry, the field the edge was declared on, and the missing target, so a build failure
94
+ * reads as a content fix. References have no prerender backstop the way body links do, so this build
95
+ * gate is the only integrity authority; it runs inside the generated virtual-module source (where the
96
+ * built manifest is in scope), beside `verifyManifest`.
97
+ */
98
+ export declare function verifyReferences(manifest: Manifest): void;
82
99
  /**
83
100
  * Replace the entry with the same concept and id, or add it. Order does not matter, since
84
101
  * serializeManifest sorts. This is the save path's incremental patch.
@@ -99,6 +116,23 @@ export interface InboundLink {
99
116
  * manifest, so the request-time delete path and a unit test call it the same way.
100
117
  */
101
118
  export declare function inboundLinks(manifest: Manifest, concept: string, id: string): InboundLink[];
119
+ /** One inbound referencer: its identity plus the distinct fields through which it references the target. */
120
+ export interface InboundReference {
121
+ concept: string;
122
+ id: string;
123
+ title: string;
124
+ permalink: string;
125
+ /** The distinct fields whose reference edges point at the target, in first-seen order. */
126
+ fields: string[];
127
+ }
128
+ /**
129
+ * Every entry holding a reference edge at the target, excluding the target itself. The match is the
130
+ * `(concept, id)` pair, never id alone, since ids are unique only within a concept (the same keyOf
131
+ * identity upsertEntry and removeEntry use). Each referencer carries the distinct fields through
132
+ * which it points at the target, for the rename repoint and the delete refusal. Pure over the
133
+ * manifest, so the request-time paths and a unit test call it the same way.
134
+ */
135
+ export declare function inboundReferences(manifest: Manifest, concept: string, id: string): InboundReference[];
102
136
  /**
103
137
  * A resolver backed by manifest targets, for the admin preview. A miss returns undefined, so the
104
138
  * render step marks the link broken rather than throwing. The build resolver throws instead.
@@ -8,6 +8,7 @@ import { deriveExcerpt } from './excerpt.js';
8
8
  import { entryIdentity, asString } from './identity.js';
9
9
  import { extractCairnLinks } from './links.js';
10
10
  import { extractMediaRefs } from './media-refs.js';
11
+ import { extractReferenceEdges } from './references.js';
11
12
  /**
12
13
  * Build one manifest entry from a content file. Drafts are included and flagged. The id, date, and
13
14
  * permalink come from entryIdentity, the same source content-index uses, so a cairn: link resolves to
@@ -19,6 +20,9 @@ export function manifestEntryFromFile(descriptor, file) {
19
20
  // Set mediaRefs only when non-empty, so an image-free entry's row stays byte-identical to before
20
21
  // (matching the optional-spread for date and summary).
21
22
  const mediaRefs = extractMediaRefs(frontmatter, body, descriptor.fields);
23
+ // Set references only when non-empty, mirroring mediaRefs, so a reference-free entry's row stays
24
+ // byte-identical to a manifest committed before this field.
25
+ const references = extractReferenceEdges(frontmatter, descriptor.fields);
22
26
  return {
23
27
  id,
24
28
  concept: descriptor.id,
@@ -31,6 +35,7 @@ export function manifestEntryFromFile(descriptor, file) {
31
35
  draft: frontmatter.draft === true,
32
36
  links: extractCairnLinks(body),
33
37
  ...(mediaRefs.length ? { mediaRefs } : {}),
38
+ ...(references.length ? { references } : {}),
34
39
  };
35
40
  }
36
41
  /** An empty manifest, the starting point when no committed file exists yet. */
@@ -40,6 +45,9 @@ export function emptyManifest() {
40
45
  function compareRef(a, b) {
41
46
  return a.concept.localeCompare(b.concept) || a.id.localeCompare(b.id);
42
47
  }
48
+ function compareEdge(a, b) {
49
+ return a.field.localeCompare(b.field) || a.concept.localeCompare(b.concept) || a.id.localeCompare(b.id);
50
+ }
43
51
  /**
44
52
  * Serialize canonically: entries sorted by concept then id, links sorted and deduped, a fixed key
45
53
  * order, two-space pretty, and a trailing newline, so the committed file diffs cleanly in a PR.
@@ -55,6 +63,9 @@ export function serializeManifest(manifest) {
55
63
  draft: e.draft,
56
64
  links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
57
65
  ...(e.mediaRefs && e.mediaRefs.length ? { mediaRefs: [...e.mediaRefs].sort() } : {}),
66
+ ...(e.references && e.references.length
67
+ ? { references: [...e.references].sort(compareEdge).map((r) => ({ field: r.field, concept: r.concept, id: r.id })) }
68
+ : {}),
58
69
  }));
59
70
  return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
60
71
  }
@@ -87,6 +98,7 @@ export function parseManifest(raw) {
87
98
  (e.date === undefined || typeof e.date === 'string') &&
88
99
  (e.summary === undefined || typeof e.summary === 'string') &&
89
100
  (e.mediaRefs === undefined || Array.isArray(e.mediaRefs)) &&
101
+ (e.references === undefined || Array.isArray(e.references)) &&
90
102
  Array.isArray(e.links);
91
103
  if (!ok) {
92
104
  throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
@@ -101,6 +113,18 @@ export function parseManifest(raw) {
101
113
  }
102
114
  }
103
115
  }
116
+ // references is additive and optional: an entry without it parses (the field reads as absent), so
117
+ // a manifest committed before this field still builds. When present, validate each edge's shape,
118
+ // mirroring the link-element validation, so a hand-edited file fails loudly rather than dropping a
119
+ // malformed edge to undefined.
120
+ if (e.references !== undefined) {
121
+ for (const edge of e.references) {
122
+ const r = edge;
123
+ if (!r || typeof r !== 'object' || typeof r.field !== 'string' || typeof r.concept !== 'string' || typeof r.id !== 'string') {
124
+ throw new Error(`content manifest: malformed reference ${JSON.stringify(edge)} in entry ${JSON.stringify(e)}`);
125
+ }
126
+ }
127
+ }
104
128
  // Validate each link element's shape, not just that links is an array. inboundLinks and the
105
129
  // delete guard read l.concept and l.id, so a string, null, or id-less element would read as
106
130
  // undefined and silently drop a real inbound linker. Reject it here instead.
@@ -172,11 +196,20 @@ export function verifyManifest(built, committedRaw) {
172
196
  version: 1,
173
197
  entries: built.entries.map((b) => {
174
198
  const c = committedByKey.get(keyOf(b));
175
- if (b.mediaRefs && c && c.mediaRefs === undefined) {
176
- const { mediaRefs: _dropped, ...rest } = b;
177
- return rest;
199
+ let entry = b;
200
+ if (entry.mediaRefs && c && c.mediaRefs === undefined) {
201
+ const { mediaRefs: _dropped, ...rest } = entry;
202
+ entry = rest;
203
+ }
204
+ // references is additive: a site whose committed manifest predates the field must still build,
205
+ // even when its content carries reference edges. Drop the built entry's references only when the
206
+ // committed counterpart omits the key, so an un-regenerated site matches while a regenerated one
207
+ // (committed carries references) still detects real drift in that field.
208
+ if (entry.references && c && c.references === undefined) {
209
+ const { references: _dropped, ...rest } = entry;
210
+ entry = rest;
178
211
  }
179
- return b;
212
+ return entry;
180
213
  }),
181
214
  };
182
215
  const normalizedRaw = serializeManifest(normalized);
@@ -191,6 +224,25 @@ export function verifyManifest(built, committedRaw) {
191
224
  formatDiff(diff) +
192
225
  '\nRegenerate it (npm run cairn:manifest) and commit the result.');
193
226
  }
227
+ /**
228
+ * Throw if any entry's reference edge points at a target absent from the corpus. The match is the
229
+ * `(concept, id)` pair, never id alone, since ids are unique only within a concept. The error names
230
+ * the source entry, the field the edge was declared on, and the missing target, so a build failure
231
+ * reads as a content fix. References have no prerender backstop the way body links do, so this build
232
+ * gate is the only integrity authority; it runs inside the generated virtual-module source (where the
233
+ * built manifest is in scope), beside `verifyManifest`.
234
+ */
235
+ export function verifyReferences(manifest) {
236
+ const present = new Set(manifest.entries.map(keyOf));
237
+ for (const entry of manifest.entries) {
238
+ for (const edge of entry.references ?? []) {
239
+ const target = `${edge.concept}/${edge.id}`;
240
+ if (!present.has(target)) {
241
+ throw new Error(`content reference is dangling: ${entry.concept}/${entry.id} field "${edge.field}" points at ${target}, which does not exist.`);
242
+ }
243
+ }
244
+ }
245
+ }
194
246
  /**
195
247
  * Replace the entry with the same concept and id, or add it. Order does not matter, since
196
248
  * serializeManifest sorts. This is the save path's incremental patch.
@@ -215,6 +267,30 @@ export function inboundLinks(manifest, concept, id) {
215
267
  .filter((e) => e.links.some((l) => l.concept === concept && l.id === id))
216
268
  .map((e) => ({ concept: e.concept, id: e.id, title: e.title, permalink: e.permalink }));
217
269
  }
270
+ /**
271
+ * Every entry holding a reference edge at the target, excluding the target itself. The match is the
272
+ * `(concept, id)` pair, never id alone, since ids are unique only within a concept (the same keyOf
273
+ * identity upsertEntry and removeEntry use). Each referencer carries the distinct fields through
274
+ * which it points at the target, for the rename repoint and the delete refusal. Pure over the
275
+ * manifest, so the request-time paths and a unit test call it the same way.
276
+ */
277
+ export function inboundReferences(manifest, concept, id) {
278
+ const out = [];
279
+ for (const e of manifest.entries) {
280
+ if (e.concept === concept && e.id === id)
281
+ continue;
282
+ const fields = [];
283
+ for (const edge of e.references ?? []) {
284
+ if (edge.concept === concept && edge.id === id && !fields.includes(edge.field)) {
285
+ fields.push(edge.field);
286
+ }
287
+ }
288
+ if (fields.length > 0) {
289
+ out.push({ concept: e.concept, id: e.id, title: e.title, permalink: e.permalink, fields });
290
+ }
291
+ }
292
+ return out;
293
+ }
218
294
  /**
219
295
  * A resolver backed by manifest targets, for the admin preview. A miss returns undefined, so the
220
296
  * render step marks the link broken rather than throwing. The build resolver throws instead.
@@ -1,4 +1,4 @@
1
- import type { FrontmatterField } from './types.js';
1
+ import type { NamedField } from './types.js';
2
2
  /**
3
3
  * The content hashes one entry references, in first-occurrence order, deduped by hash. Reads the
4
4
  * frontmatter hero `image.src` for each `image`-typed field plus every body image node. A
@@ -6,4 +6,4 @@ import type { FrontmatterField } from './types.js';
6
6
  * the manifest build. The body is parsed as mdast, so a `media:` token inside a code span or fence
7
7
  * is never matched.
8
8
  */
9
- export declare function extractMediaRefs(frontmatter: Record<string, unknown>, body: string, fields: FrontmatterField[]): string[];
9
+ export declare function extractMediaRefs(frontmatter: Record<string, unknown>, body: string, fields: NamedField[]): string[];
@@ -23,6 +23,7 @@ import remarkDirective from 'remark-directive';
23
23
  import { visit } from 'unist-util-visit';
24
24
  import { parseMediaToken } from '../media/reference.js';
25
25
  import { escapeLinkText } from './links.js';
26
+ import { splitFrontmatter, fmLines, frontmatterKeyRange, escapeForRegExp, } from './frontmatter-region.js';
26
27
  /**
27
28
  * Drop any span that overlaps a span already kept, in source order. A final safety net so two
28
29
  * splices can never target the same or overlapping bytes and clobber each other into a corrupt
@@ -46,17 +47,6 @@ function dropOverlappingEdits(edits) {
46
47
  * destination ends the candidate.
47
48
  */
48
49
  const MEDIA_TOKEN_SCAN = /media:[A-Za-z0-9._-]+/g;
49
- /**
50
- * Split a leading frontmatter block off the markdown. `fmBlock` is the `---` fenced block including
51
- * both fences and the trailing newline (empty when there is none); `body` is everything after it.
52
- * The block leads the document, so a frontmatter offset is already absolute and a body offset needs
53
- * `fmBlock.length` added. Shared by every arm so they agree on the boundary.
54
- */
55
- function splitFrontmatter(markdown) {
56
- const m = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
57
- const fmBlock = m ? m[0] : '';
58
- return { fmBlock, body: markdown.slice(fmBlock.length) };
59
- }
60
50
  /**
61
51
  * Parse a doc with the figure-aware pipeline, so the body arm agrees with what remarkFigure renders
62
52
  * and can see the enclosing `:::figure` container. Mirrors parseFigureDoc in markdown-format.ts.
@@ -81,57 +71,6 @@ function inFigure(tree, target) {
81
71
  });
82
72
  return found;
83
73
  }
84
- /**
85
- * Split fmBlock into lines once, so the locator helpers walk a shared structure instead of
86
- * re-scanning the block per call.
87
- */
88
- function fmLines(fmBlock) {
89
- const lines = [];
90
- let pos = 0;
91
- while (pos <= fmBlock.length) {
92
- const nl = fmBlock.indexOf('\n', pos);
93
- const end = nl === -1 ? fmBlock.length : nl;
94
- lines.push({ start: pos, end });
95
- if (nl === -1)
96
- break;
97
- pos = nl + 1;
98
- }
99
- return lines;
100
- }
101
- /**
102
- * The inclusive line-index range `[lo, hi]` of the block-style mapping a top-level key opens: the
103
- * line `^<key>:` at indent 0 through the last line before the next top-level key (or the document
104
- * end). A flow-style value (`key: { ... }` all on one line) yields a single-line range. Returns null
105
- * when the key has no top-level line, which a malformed or non-canonical block can cause. Scoping the
106
- * per-key search to this range is what lets two image fields that share one hash, or an image field
107
- * whose hash also appears in a sibling text value, resolve to distinct, correct spans.
108
- */
109
- function frontmatterKeyRange(lines, fmBlock, key) {
110
- const opener = new RegExp(`^${escapeForRegExp(key)}:`);
111
- const topLevelKey = /^[^\s#][^:]*:/;
112
- const isBoundary = (i) => {
113
- const text = fmBlock.slice(lines[i].start, lines[i].end);
114
- // A new top-level key or the closing `---` fence ends the current key's block.
115
- return topLevelKey.test(text) || text === '---';
116
- };
117
- let lo = -1;
118
- for (let i = 1; i < lines.length - 1; i += 1) {
119
- // Skip the leading `---` fence (line 0) and the trailing empty line after the closing fence.
120
- if (opener.test(fmBlock.slice(lines[i].start, lines[i].end))) {
121
- lo = i;
122
- break;
123
- }
124
- }
125
- if (lo === -1)
126
- return null;
127
- let hi = lo;
128
- for (let i = lo + 1; i < lines.length - 1; i += 1) {
129
- if (isBoundary(i))
130
- break;
131
- hi = i;
132
- }
133
- return [lo, hi];
134
- }
135
74
  /**
136
75
  * Find the block-style `src:` line within `[lo, hi]` whose value token parses to `hash`. The token
137
76
  * is located by the broad scan and validated through parseMediaToken (matching on hash), so a
@@ -365,13 +304,6 @@ function bodyAltEdits(body, blockLength, hash, defaultAlt, overwrite) {
365
304
  }
366
305
  return edits;
367
306
  }
368
- /**
369
- * Escape a literal string for safe interpolation into a RegExp source. A key name or an indent is
370
- * matched literally, so its characters must not act as metacharacters.
371
- */
372
- function escapeForRegExp(literal) {
373
- return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
374
- }
375
307
  /**
376
308
  * Find a sibling key line (`alt:` or `decorative:`) at exactly `indent` within the inclusive
377
309
  * line-index range `[lo, hi]` of one mapping. The range is the mapping's own block, so the search
@@ -0,0 +1,56 @@
1
+ import type { ConceptDescriptor } from './types.js';
2
+ import type { Backend } from '../github/backend.js';
3
+ import type { Manifest } from './manifest.js';
4
+ /**
5
+ * Where a reference lives: the published corpus on main, or a named open edit branch. Re-declared here
6
+ * (rather than imported from media/usage.ts) so the content layer does not depend on the media layer.
7
+ */
8
+ export type UsageOrigin = {
9
+ kind: 'published';
10
+ } | {
11
+ kind: 'branch';
12
+ branch: string;
13
+ };
14
+ /** One entry that references a target, in a shape the rename and delete gates name and group by. */
15
+ export interface ReferenceUsageEntry {
16
+ /** The referencing (source) entry's concept id, e.g. "posts". */
17
+ concept: string;
18
+ /** The referencing (source) entry's id (its filename stem). */
19
+ id: string;
20
+ /** The referencing entry's title for display, from the manifest (published) or frontmatter (branch). */
21
+ title: string;
22
+ /** The referencing entry's public permalink, present for a published entry (carried from the manifest). */
23
+ permalink?: string;
24
+ /** The frontmatter field the edge was declared on. */
25
+ field: string;
26
+ /** Published vs the cairn/* branch the edit lives on. */
27
+ origin: UsageOrigin;
28
+ }
29
+ /**
30
+ * The target's `${concept}/${id}` pair to the distinct entries that reference it. A pair with no row is
31
+ * not referenced anywhere the index could read (main plus the listed open branches).
32
+ */
33
+ export type ReferenceIndex = Map<string, ReferenceUsageEntry[]>;
34
+ /**
35
+ * Build options. `branches` lets a caller that already listed the open cairn/* branches pass them in so
36
+ * the index does not list them a second time. `strict` flips the per-branch read from degrade-and-skip
37
+ * to fail-closed: a delete or rename gate must not treat a transient branch-read failure as an absent
38
+ * reference, so it rethrows instead.
39
+ */
40
+ export interface BuildReferenceOptions {
41
+ /** The open cairn/* branch names, already listed. When present the index skips its own listing. */
42
+ branches?: string[];
43
+ /** When true a branch read that throws rejects the whole build, so the caller can fail closed. */
44
+ strict?: boolean;
45
+ }
46
+ /**
47
+ * Build the pair-keyed reference index over main (from the manifest's per-entry references) plus every
48
+ * open cairn/* branch (parsed from its edited markdown).
49
+ *
50
+ * By default a single branch read that throws degrades that one branch and is skipped, the way the
51
+ * admin loaders degrade a failed read. That tolerance is wrong for the rename and delete gates: a
52
+ * transient branch-read failure would make a still-referenced target look free. Pass `strict: true` to
53
+ * rethrow a branch failure so the caller fails closed. Pass `branches` to reuse a branch list the
54
+ * caller already has rather than listing them a second time.
55
+ */
56
+ export declare function buildReferenceIndex(backend: Backend, concepts: ConceptDescriptor[], manifest: Manifest, opts?: BuildReferenceOptions): Promise<ReferenceIndex>;