@glw907/cairn-cms 0.56.2 → 0.57.1

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 (173) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/dist/components/AdminLayout.svelte +3 -0
  3. package/dist/components/CairnAdmin.svelte +8 -1
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  5. package/dist/components/CairnMediaLibrary.svelte +949 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +348 -7
  8. package/dist/components/EditPage.svelte.d.ts +2 -0
  9. package/dist/components/MarkdownEditor.svelte +283 -1
  10. package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
  11. package/dist/components/MediaCaptureCard.svelte +135 -0
  12. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  13. package/dist/components/MediaFigureControl.svelte +247 -0
  14. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  15. package/dist/components/MediaHeroField.svelte +578 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +75 -0
  17. package/dist/components/MediaInsertPopover.svelte +449 -0
  18. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  19. package/dist/components/MediaPicker.svelte +257 -0
  20. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  21. package/dist/components/admin-icons.d.ts +12 -0
  22. package/dist/components/admin-icons.js +12 -0
  23. package/dist/components/cairn-admin.css +901 -9
  24. package/dist/components/client-ingest.d.ts +142 -0
  25. package/dist/components/client-ingest.js +297 -0
  26. package/dist/components/editor-media.d.ts +11 -0
  27. package/dist/components/editor-media.js +206 -0
  28. package/dist/components/editor-placeholder.d.ts +26 -0
  29. package/dist/components/editor-placeholder.js +166 -0
  30. package/dist/components/index.d.ts +1 -0
  31. package/dist/components/index.js +1 -0
  32. package/dist/components/markdown-directives.d.ts +12 -0
  33. package/dist/components/markdown-directives.js +42 -0
  34. package/dist/components/markdown-format.d.ts +89 -0
  35. package/dist/components/markdown-format.js +255 -0
  36. package/dist/components/media-upload-outcome.d.ts +52 -0
  37. package/dist/components/media-upload-outcome.js +48 -0
  38. package/dist/content/compose.js +3 -0
  39. package/dist/content/frontmatter.js +22 -0
  40. package/dist/content/manifest.d.ts +4 -0
  41. package/dist/content/manifest.js +41 -1
  42. package/dist/content/media-refs.d.ts +7 -0
  43. package/dist/content/media-refs.js +52 -0
  44. package/dist/content/schema.d.ts +5 -2
  45. package/dist/content/schema.js +17 -0
  46. package/dist/content/types.d.ts +64 -11
  47. package/dist/content/validate.js +31 -0
  48. package/dist/delivery/public-routes.d.ts +16 -0
  49. package/dist/delivery/public-routes.js +46 -3
  50. package/dist/delivery/seo-fields.js +7 -1
  51. package/dist/delivery/seo.d.ts +2 -0
  52. package/dist/delivery/seo.js +3 -0
  53. package/dist/doctor/checks-local.d.ts +1 -0
  54. package/dist/doctor/checks-local.js +21 -0
  55. package/dist/doctor/index.d.ts +3 -1
  56. package/dist/doctor/index.js +11 -2
  57. package/dist/doctor/types.d.ts +3 -0
  58. package/dist/doctor/wrangler-config.d.ts +3 -0
  59. package/dist/doctor/wrangler-config.js +20 -0
  60. package/dist/env.d.ts +19 -0
  61. package/dist/env.js +26 -0
  62. package/dist/index.d.ts +1 -1
  63. package/dist/log/events.d.ts +1 -1
  64. package/dist/media/config.d.ts +24 -0
  65. package/dist/media/config.js +69 -0
  66. package/dist/media/delivery-bucket.d.ts +34 -0
  67. package/dist/media/delivery-bucket.js +10 -0
  68. package/dist/media/index.d.ts +6 -0
  69. package/dist/media/index.js +13 -0
  70. package/dist/media/library-entry.d.ts +30 -0
  71. package/dist/media/library-entry.js +17 -0
  72. package/dist/media/manifest.d.ts +44 -0
  73. package/dist/media/manifest.js +105 -0
  74. package/dist/media/naming.d.ts +18 -0
  75. package/dist/media/naming.js +112 -0
  76. package/dist/media/reconcile.d.ts +36 -0
  77. package/dist/media/reconcile.js +45 -0
  78. package/dist/media/reference.d.ts +12 -0
  79. package/dist/media/reference.js +33 -0
  80. package/dist/media/sniff.d.ts +18 -0
  81. package/dist/media/sniff.js +106 -0
  82. package/dist/media/store.d.ts +25 -0
  83. package/dist/media/store.js +16 -0
  84. package/dist/media/transform-url.d.ts +26 -0
  85. package/dist/media/transform-url.js +38 -0
  86. package/dist/media/usage.d.ts +48 -0
  87. package/dist/media/usage.js +90 -0
  88. package/dist/render/pipeline.d.ts +2 -0
  89. package/dist/render/pipeline.js +13 -2
  90. package/dist/render/registry.js +3 -0
  91. package/dist/render/remark-figure.d.ts +4 -0
  92. package/dist/render/remark-figure.js +103 -0
  93. package/dist/render/resolve-media.d.ts +34 -0
  94. package/dist/render/resolve-media.js +78 -0
  95. package/dist/render/sanitize-schema.d.ts +4 -2
  96. package/dist/render/sanitize-schema.js +5 -3
  97. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  98. package/dist/sveltekit/admin-dispatch.js +5 -0
  99. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  100. package/dist/sveltekit/cairn-admin.js +10 -2
  101. package/dist/sveltekit/content-routes.d.ts +77 -2
  102. package/dist/sveltekit/content-routes.js +470 -10
  103. package/dist/sveltekit/csrf.d.ts +16 -0
  104. package/dist/sveltekit/csrf.js +18 -0
  105. package/dist/sveltekit/guard.js +10 -3
  106. package/dist/sveltekit/index.d.ts +2 -1
  107. package/dist/sveltekit/index.js +1 -0
  108. package/dist/sveltekit/media-route.d.ts +12 -0
  109. package/dist/sveltekit/media-route.js +137 -0
  110. package/dist/vite/index.d.ts +3 -0
  111. package/dist/vite/index.js +7 -2
  112. package/package.json +7 -1
  113. package/src/lib/components/AdminLayout.svelte +3 -0
  114. package/src/lib/components/CairnAdmin.svelte +8 -1
  115. package/src/lib/components/CairnMediaLibrary.svelte +949 -0
  116. package/src/lib/components/EditPage.svelte +348 -7
  117. package/src/lib/components/MarkdownEditor.svelte +283 -1
  118. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  119. package/src/lib/components/MediaFigureControl.svelte +247 -0
  120. package/src/lib/components/MediaHeroField.svelte +578 -0
  121. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  122. package/src/lib/components/MediaPicker.svelte +257 -0
  123. package/src/lib/components/admin-icons.ts +12 -0
  124. package/src/lib/components/cairn-admin.css +37 -0
  125. package/src/lib/components/client-ingest.ts +380 -0
  126. package/src/lib/components/editor-media.ts +248 -0
  127. package/src/lib/components/editor-placeholder.ts +213 -0
  128. package/src/lib/components/index.ts +1 -0
  129. package/src/lib/components/markdown-directives.ts +46 -0
  130. package/src/lib/components/markdown-format.ts +307 -1
  131. package/src/lib/components/media-upload-outcome.ts +83 -0
  132. package/src/lib/content/compose.ts +3 -0
  133. package/src/lib/content/frontmatter.ts +20 -1
  134. package/src/lib/content/manifest.ts +44 -1
  135. package/src/lib/content/media-refs.ts +58 -0
  136. package/src/lib/content/schema.ts +31 -7
  137. package/src/lib/content/types.ts +80 -13
  138. package/src/lib/content/validate.ts +29 -1
  139. package/src/lib/delivery/public-routes.ts +52 -3
  140. package/src/lib/delivery/seo-fields.ts +6 -1
  141. package/src/lib/delivery/seo.ts +5 -0
  142. package/src/lib/doctor/checks-local.ts +22 -0
  143. package/src/lib/doctor/index.ts +21 -3
  144. package/src/lib/doctor/types.ts +3 -0
  145. package/src/lib/doctor/wrangler-config.ts +23 -0
  146. package/src/lib/env.ts +28 -0
  147. package/src/lib/index.ts +2 -0
  148. package/src/lib/log/events.ts +8 -1
  149. package/src/lib/media/config.ts +103 -0
  150. package/src/lib/media/delivery-bucket.ts +41 -0
  151. package/src/lib/media/index.ts +22 -0
  152. package/src/lib/media/library-entry.ts +58 -0
  153. package/src/lib/media/manifest.ts +122 -0
  154. package/src/lib/media/naming.ts +130 -0
  155. package/src/lib/media/reconcile.ts +79 -0
  156. package/src/lib/media/reference.ts +40 -0
  157. package/src/lib/media/sniff.ts +114 -0
  158. package/src/lib/media/store.ts +57 -0
  159. package/src/lib/media/transform-url.ts +58 -0
  160. package/src/lib/media/usage.ts +152 -0
  161. package/src/lib/render/pipeline.ts +17 -3
  162. package/src/lib/render/registry.ts +5 -0
  163. package/src/lib/render/remark-figure.ts +132 -0
  164. package/src/lib/render/resolve-media.ts +96 -0
  165. package/src/lib/render/sanitize-schema.ts +5 -3
  166. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  167. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  168. package/src/lib/sveltekit/content-routes.ts +589 -12
  169. package/src/lib/sveltekit/csrf.ts +18 -0
  170. package/src/lib/sveltekit/guard.ts +12 -3
  171. package/src/lib/sveltekit/index.ts +6 -0
  172. package/src/lib/sveltekit/media-route.ts +158 -0
  173. package/src/lib/vite/index.ts +9 -2
@@ -0,0 +1,83 @@
1
+ // The pure upload-envelope to outcome mapper. The insert popover's optimistic loop posts the bytes
2
+ // and reads back a SvelteKit form-action result (or a manual-redirect Response shape); this turns
3
+ // that raw shape into the one decision the popover acts on: insert the reference, show a typed
4
+ // failure card, or treat the session as expired. Keeping it pure lets the branch logic unit-test
5
+ // without a browser or a real fetch.
6
+ //
7
+ // Node-safe (no @codemirror, no DOM): it imports only the MediaEntry and UploadResult types, which
8
+ // are erased at build time.
9
+ import type { MediaEntry } from '../media/manifest.js';
10
+ import type { UploadResult } from '../sveltekit/content-routes.js';
11
+ import type { IngestFailureKind } from './client-ingest.js';
12
+ import { mediaToken } from '../media/reference.js';
13
+
14
+ /** A failure the card surfaces. The ingest taxonomy plus a `generic` catch-all for a refuse reason
15
+ * with no specific author-facing card (a binding-missing, a length-required, a parse miss). */
16
+ export type UploadFailureKind = IngestFailureKind | 'generic';
17
+
18
+ /** The outcome the popover acts on. `inserted` swaps the placeholder for the reference and records
19
+ * the entry; `failed` cancels the placeholder and shows the typed card; `session-expired` cancels
20
+ * the placeholder and tells the author to sign in again. */
21
+ export type UploadOutcome =
22
+ | { kind: 'inserted'; reference: string; record: MediaEntry; reused: boolean }
23
+ | { kind: 'failed'; failure: UploadFailureKind }
24
+ | { kind: 'session-expired' };
25
+
26
+ /** The shape the popover hands in: either a parsed SvelteKit action result (success or failure) or a
27
+ * bare response signal for the redirect and network-error cases. The popover deserializes the body
28
+ * for the success and failure cases and passes the raw `response.type`/`response.status` for the
29
+ * redirect case, so this one mapper covers every branch. */
30
+ export type UploadEnvelope =
31
+ | { type: 'success'; status?: number; data: UploadResult }
32
+ | { type: 'failure'; status?: number; data?: { error?: string } }
33
+ | { type: 'redirect'; status?: number }
34
+ | { type: 'error'; status?: number }
35
+ | { type: 'opaqueredirect'; status?: number };
36
+
37
+ // The server refuse reasons mapped to a card kind. `too-large` keeps its own card; an unsupported
38
+ // type reads as a decode failure to the author (the bytes the browser sent are a type the server
39
+ // will not store); `session-expired` is its own outcome. Every other reason (binding-missing,
40
+ // media-disabled, csrf, length-required, hash-collision) is an operational refusal with no
41
+ // author-actionable specifics, so it collapses to the generic card.
42
+ const REFUSE_TO_FAILURE: Record<string, UploadFailureKind | 'session-expired'> = {
43
+ 'too-large': 'too-large',
44
+ 'unsupported-type': 'decode-unsupported',
45
+ 'session-expired': 'session-expired',
46
+ };
47
+
48
+ /**
49
+ * Map a parsed upload envelope to the single outcome the popover acts on. A success envelope yields
50
+ * an `inserted` outcome carrying the reference, the record, and the dedup flag. A failure envelope
51
+ * maps its refuse reason to a typed card, with `session-expired` lifted to its own outcome. An
52
+ * opaque or status-0 response (the guard's `redirect: 'manual'` 303) is a session-expired signal, as
53
+ * is any redirect-typed result. An error-typed result with a real status is a generic failure.
54
+ */
55
+ export function uploadOutcome(envelope: UploadEnvelope): UploadOutcome {
56
+ switch (envelope.type) {
57
+ case 'success':
58
+ return {
59
+ kind: 'inserted',
60
+ // Re-derive the reference from the validated record fields rather than trusting the loose
61
+ // server `reference` string: the token is inserted unescaped into the markdown URL slot, so
62
+ // the insert depends only on grammar-constrained fields (a 16-hex hash, a slugified slug)
63
+ // instead of an arbitrary server string. Defense in depth, in case a future server path
64
+ // returns a reference that does not match the record.
65
+ reference: mediaToken({ slug: envelope.data.record.slug, hash: envelope.data.record.hash }),
66
+ record: envelope.data.record,
67
+ reused: envelope.data.reused,
68
+ };
69
+ case 'failure': {
70
+ const reason = envelope.data?.error ?? '';
71
+ const mapped = REFUSE_TO_FAILURE[reason];
72
+ if (mapped === 'session-expired') return { kind: 'session-expired' };
73
+ return { kind: 'failed', failure: mapped ?? 'generic' };
74
+ }
75
+ case 'redirect':
76
+ case 'opaqueredirect':
77
+ return { kind: 'session-expired' };
78
+ case 'error':
79
+ // A manual-redirect Response surfaces as type 'opaqueredirect' or status 0; a status-0 error
80
+ // is that same expired-session signal. A real error status is a genuine transport failure.
81
+ return (envelope.status ?? 0) === 0 ? { kind: 'session-expired' } : { kind: 'failed', failure: 'generic' };
82
+ }
83
+ }
@@ -5,6 +5,7 @@
5
5
  // is additive later.
6
6
  import type { AdminPanel, CairnAdapter, CairnExtension, CairnRuntime, ConceptConfig, FieldTypeDef } from './types.js';
7
7
  import { resolveConcepts } from './concepts.js';
8
+ import { normalizeAssets } from '../media/config.js';
8
9
  import type { SiteConfig } from '../nav/site-config.js';
9
10
 
10
11
  /** The input to {@link composeRuntime}. `siteConfig` is required so the per-concept URL policy is
@@ -46,6 +47,8 @@ export function composeRuntime({ adapter, siteConfig, extensions = [] }: Compose
46
47
  navMenu: adapter.navMenu,
47
48
  preview: adapter.preview,
48
49
  assets: adapter.assets,
50
+ resolvedAssets: normalizeAssets(adapter.assets),
51
+ mediaManifestPath: adapter.mediaManifestPath ?? 'src/content/.cairn/media.json',
49
52
  adminPanels,
50
53
  fieldTypes,
51
54
  };
@@ -3,7 +3,7 @@
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
- import type { FrontmatterField } from './types.js';
6
+ import type { FrontmatterField, ImageValue } from './types.js';
7
7
 
8
8
  /** Decode submitted form data into raw frontmatter, one rule per field type. */
9
9
  export function frontmatterFromForm(
@@ -30,6 +30,25 @@ export function frontmatterFromForm(
30
30
  ),
31
31
  ];
32
32
  break;
33
+ case 'image': {
34
+ // The hero submits three sub-fields under one key. An empty src means no hero, so omit the
35
+ // whole key. Alt is stored verbatim (it is not markdown, so no escaping). A blank caption
36
+ // is dropped so committed frontmatter stays minimal.
37
+ const src = String(form.get(`${field.name}.src`) ?? '').trim();
38
+ if (src === '') break;
39
+ const value: ImageValue = {
40
+ src,
41
+ alt: String(form.get(`${field.name}.alt`) ?? ''),
42
+ };
43
+ const caption = String(form.get(`${field.name}.caption`) ?? '').trim();
44
+ if (caption !== '') value.caption = caption;
45
+ // An explicit decorative choice persists so a reload tells it apart from a left-blank alt.
46
+ // The key is dropped otherwise to keep committed frontmatter minimal.
47
+ const decorative = String(form.get(`${field.name}.decorative`) ?? '');
48
+ if (decorative === 'true') value.decorative = true;
49
+ data[field.name] = value;
50
+ break;
51
+ }
33
52
  default:
34
53
  // FormData.get returns null for an absent field; normalize to an empty string so
35
54
  // a caller reading a text value never gets null.
@@ -7,6 +7,7 @@ import { parseMarkdown } from './frontmatter.js';
7
7
  import { deriveExcerpt } from './excerpt.js';
8
8
  import { entryIdentity, asString } from './identity.js';
9
9
  import { extractCairnLinks, type CairnRef, type LinkResolve } from './links.js';
10
+ import { extractMediaRefs } from './media-refs.js';
10
11
  import type { ConceptDescriptor } from './types.js';
11
12
 
12
13
  /** One entry's projection: its identity, routing, draft flag, and outbound cairn: edges. */
@@ -19,6 +20,10 @@ export interface ManifestEntry {
19
20
  summary?: string;
20
21
  draft: boolean;
21
22
  links: CairnRef[];
23
+ /** The content hashes of the media this entry references (its hero plus its body images). The
24
+ * main side of the media where-used index. Additive and optional: an entry with no media omits
25
+ * the key, and a manifest committed before this field still parses (absent reads as no refs). */
26
+ mediaRefs?: string[];
22
27
  }
23
28
 
24
29
  /** The whole corpus as one committed file. `version` guards a future shape migration. */
@@ -43,6 +48,9 @@ export interface LinkTarget {
43
48
  export function manifestEntryFromFile(descriptor: ConceptDescriptor, file: { path: string; raw: string }): ManifestEntry {
44
49
  const { frontmatter, body } = parseMarkdown(file.raw);
45
50
  const { id, date, permalink } = entryIdentity(descriptor, file.path, frontmatter);
51
+ // Set mediaRefs only when non-empty, so an image-free entry's row stays byte-identical to before
52
+ // (matching the optional-spread for date and summary).
53
+ const mediaRefs = extractMediaRefs(frontmatter, body, descriptor.fields);
46
54
  return {
47
55
  id,
48
56
  concept: descriptor.id,
@@ -54,6 +62,7 @@ export function manifestEntryFromFile(descriptor: ConceptDescriptor, file: { pat
54
62
  summary: deriveExcerpt(body, { description: asString(frontmatter.description) }) || undefined,
55
63
  draft: frontmatter.draft === true,
56
64
  links: extractCairnLinks(body),
65
+ ...(mediaRefs.length ? { mediaRefs } : {}),
57
66
  };
58
67
  }
59
68
 
@@ -78,6 +87,7 @@ export function serializeManifest(manifest: Manifest): string {
78
87
  ...(e.summary ? { summary: e.summary } : {}),
79
88
  draft: e.draft,
80
89
  links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
90
+ ...(e.mediaRefs && e.mediaRefs.length ? { mediaRefs: [...e.mediaRefs].sort() } : {}),
81
91
  }));
82
92
  return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
83
93
  }
@@ -109,10 +119,21 @@ export function parseManifest(raw: string): Manifest {
109
119
  typeof e.draft === 'boolean' &&
110
120
  (e.date === undefined || typeof e.date === 'string') &&
111
121
  (e.summary === undefined || typeof e.summary === 'string') &&
122
+ (e.mediaRefs === undefined || Array.isArray(e.mediaRefs)) &&
112
123
  Array.isArray(e.links);
113
124
  if (!ok) {
114
125
  throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
115
126
  }
127
+ // mediaRefs is additive and optional: an entry without it parses (the field reads as absent),
128
+ // so a manifest committed before this field still builds. When present, validate each element
129
+ // is a string, mirroring the link-element validation, so a hand-edited file fails loudly.
130
+ if (e.mediaRefs !== undefined) {
131
+ for (const hash of e.mediaRefs as unknown[]) {
132
+ if (typeof hash !== 'string') {
133
+ throw new Error(`content manifest: malformed mediaRefs element ${JSON.stringify(hash)} in entry ${JSON.stringify(e)}`);
134
+ }
135
+ }
136
+ }
116
137
  // Validate each link element's shape, not just that links is an array. inboundLinks and the
117
138
  // delete guard read l.concept and l.id, so a string, null, or id-less element would read as
118
139
  // undefined and silently drop a real inbound linker. Reject it here instead.
@@ -181,11 +202,33 @@ function formatDiff(d: ManifestDiff): string {
181
202
  export function verifyManifest(built: Manifest, committedRaw: string): void {
182
203
  const builtRaw = serializeManifest(built);
183
204
  if (committedRaw === builtRaw) return;
205
+ // mediaRefs is additive: a site whose committed manifest predates the field must still build,
206
+ // even when its content references media (open risk 3, the migration landmine). Before diffing,
207
+ // normalize the built manifest against the committed one: for any built entry whose committed
208
+ // counterpart carries no mediaRefs key, drop mediaRefs from the built entry. An un-regenerated
209
+ // site (committed omits mediaRefs) then matches; a regenerated site (committed carries mediaRefs)
210
+ // still detects real drift in that field. The normalization is per entry and per missing key, so
211
+ // it never masks drift in any other field or in an entry the committed manifest already tracks.
212
+ const committed = parseManifest(committedRaw);
213
+ const committedByKey = new Map(committed.entries.map((e) => [keyOf(e), e]));
214
+ const normalized: Manifest = {
215
+ version: 1,
216
+ entries: built.entries.map((b) => {
217
+ const c = committedByKey.get(keyOf(b));
218
+ if (b.mediaRefs && c && c.mediaRefs === undefined) {
219
+ const { mediaRefs: _dropped, ...rest } = b;
220
+ return rest;
221
+ }
222
+ return b;
223
+ }),
224
+ };
225
+ const normalizedRaw = serializeManifest(normalized);
226
+ if (committedRaw === normalizedRaw) return;
184
227
  // Diff the canonical built form, not the raw one. serializeManifest sorts each entry's links, so a
185
228
  // build whose links are in extraction order would otherwise report a false (links) drift for an
186
229
  // entry whose link set is identical and only the order differs. Reuse the serialized form so both
187
230
  // sides are canonical.
188
- const diff = diffManifests(parseManifest(builtRaw), parseManifest(committedRaw));
231
+ const diff = diffManifests(parseManifest(normalizedRaw), committed);
189
232
  throw new Error(
190
233
  'content manifest is stale: the committed file does not match the corpus.\n' +
191
234
  formatDiff(diff) +
@@ -0,0 +1,58 @@
1
+ // cairn-cms: the media-reference extractor. Given one entry's parsed frontmatter and body, it
2
+ // returns the deduped content hashes the entry references. This is the main side of the media
3
+ // where-used index: manifestEntryFromFile records the result per entry, and the usage-index
4
+ // builder runs it directly over each open branch's edited markdown. It mirrors extractCairnLinks
5
+ // (the same remark pipeline, the same first-occurrence dedup) but visits image nodes and the
6
+ // frontmatter hero rather than link nodes.
7
+ //
8
+ // A media reference lives in two places, and both are load-bearing. Body image nodes carry the
9
+ // inline `![](media:...)` placements (a 3a :::figure also lands here, since the figure directive
10
+ // wraps a real image node). The frontmatter hero is the other site: a hero is `image: { src }` in
11
+ // frontmatter, outside the markdown body, so an extractor that visited only body nodes would read
12
+ // every in-use hero as orphaned and let safe-delete remove an in-use image.
13
+ //
14
+ // Every match is keyed by the parsed hash, the immutable truth, never the cosmetic slug, so a bare
15
+ // `media:<hash>` and a `media:<slug>.<hash>` for the same bytes collapse to one.
16
+ import { unified } from 'unified';
17
+ import remarkParse from 'remark-parse';
18
+ import remarkGfm from 'remark-gfm';
19
+ import { visit } from 'unist-util-visit';
20
+ import { parseMediaToken } from '../media/reference.js';
21
+ import type { FrontmatterField, ImageValue } from './types.js';
22
+
23
+ /** The content hashes one entry references, in first-occurrence order, deduped by hash. Reads the
24
+ * frontmatter hero `image.src` for each `image`-typed field plus every body image node. A
25
+ * non-media or malformed token is skipped, never thrown, so a stray `![](/x.png)` does not break
26
+ * the manifest build. The body is parsed as mdast, so a `media:` token inside a code span or fence
27
+ * is never matched. */
28
+ export function extractMediaRefs(
29
+ frontmatter: Record<string, unknown>,
30
+ body: string,
31
+ fields: FrontmatterField[],
32
+ ): string[] {
33
+ const seen = new Set<string>();
34
+ const hashes: string[] = [];
35
+ const add = (href: string) => {
36
+ const ref = parseMediaToken(href);
37
+ if (!ref || seen.has(ref.hash)) return;
38
+ seen.add(ref.hash);
39
+ hashes.push(ref.hash);
40
+ };
41
+
42
+ // The frontmatter hero arm: each `image`-typed field stores an ImageValue, so read its `.src`.
43
+ for (const field of fields) {
44
+ if (field.type !== 'image') continue;
45
+ const value = frontmatter[field.name];
46
+ if (value && typeof value === 'object' && typeof (value as ImageValue).src === 'string') {
47
+ add((value as ImageValue).src);
48
+ }
49
+ }
50
+
51
+ // The body arm: every image node's url. A 3a figure's inner image is a real image node.
52
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
53
+ visit(tree, 'image', (node: { url?: string }) => {
54
+ if (node.url) add(node.url);
55
+ });
56
+
57
+ return hashes;
58
+ }
@@ -2,7 +2,7 @@
2
2
  // declaration yields a plain-data field projection for the editor form, a generated validator,
3
3
  // and an inferred frontmatter type. Plan 1 builds the additive primitive; the adapter-contract
4
4
  // cutover and the typed reads are Plan 2.
5
- import type { FrontmatterField, ValidationResult } from './types.js';
5
+ import type { FrontmatterField, ImageValue, ValidationResult } from './types.js';
6
6
  import { validateFields } from './validate.js';
7
7
 
8
8
  /** The validate input the cairn adapter takes: the raw frontmatter and the body. */
@@ -26,14 +26,17 @@ type StandardResult<Output> =
26
26
  | { readonly issues: ReadonlyArray<{ readonly message: string; readonly path?: ReadonlyArray<PropertyKey> }> };
27
27
 
28
28
  /** Map one field descriptor to the TS type of its normalized value. text, textarea, and date
29
- * normalize to a string; a closed-vocabulary `tags` field to the option-union array. */
29
+ * normalize to a string; a closed-vocabulary `tags` field to the option-union array; an `image`
30
+ * field to its nested object. */
30
31
  type FieldValue<K extends FrontmatterField> = K extends { type: 'boolean' }
31
32
  ? boolean
32
- : K extends { type: 'tags'; options: readonly (infer O extends string)[] }
33
- ? O[]
34
- : K extends { type: 'tags' | 'freetags' }
35
- ? string[]
36
- : string;
33
+ : K extends { type: 'image' }
34
+ ? ImageValue
35
+ : K extends { type: 'tags'; options: readonly (infer O extends string)[] }
36
+ ? O[]
37
+ : K extends { type: 'tags' | 'freetags' }
38
+ ? string[]
39
+ : string;
37
40
 
38
41
  /** Flatten an intersection into a single readable object type. */
39
42
  type Prettify<T> = { [K in keyof T]: T[K] } & {};
@@ -102,6 +105,26 @@ function compilePatterns(fields: FrontmatterField[]): Map<string, RegExp> {
102
105
  return compiled;
103
106
  }
104
107
 
108
+ // True when an image field feeds the social card: an explicit `seo: true`, or the back-compat
109
+ // default that the field named `image` is the SEO image. The SEO unify (Task 4) reads this flag.
110
+ function isSeoImage(field: FrontmatterField): boolean {
111
+ return field.type === 'image' && (field.seo === true || (field.seo === undefined && field.name === 'image'));
112
+ }
113
+
114
+ // A concept declares at most one SEO image field, so the social card is unambiguous. More than one
115
+ // is a site config error: a hero named `cover` plus an explicit `seo` on another, or two explicit
116
+ // `seo` fields. Fail loudly at declaration rather than emit a silent or wrong og:image.
117
+ function checkSeoImageFields(fields: FrontmatterField[]): void {
118
+ const seo = fields.filter(isSeoImage);
119
+ if (seo.length > 1) {
120
+ const names = seo.map((field) => `"${field.name}"`).join(', ');
121
+ throw new Error(
122
+ `cairn: a concept declares at most one SEO image field, but found ${seo.length} (${names}). ` +
123
+ 'Set seo: false on all but one, or rename the extra image fields so only one feeds the social card.',
124
+ );
125
+ }
126
+ }
127
+
105
128
  /** Declare a concept's fields once. Returns the schema's faces derived from that one declaration. */
106
129
  export function defineFields<const F extends readonly FrontmatterField[]>(
107
130
  fields: F,
@@ -109,6 +132,7 @@ export function defineFields<const F extends readonly FrontmatterField[]>(
109
132
  ): ConceptSchema<F> {
110
133
  const list = [...fields] as FrontmatterField[];
111
134
  const patterns = compilePatterns(list);
135
+ checkSeoImageFields(list);
112
136
  const validate = (frontmatter: Record<string, unknown>, body: string): ValidationResult => {
113
137
  const base = validateFields(list, frontmatter);
114
138
  if (!base.ok) return base;
@@ -12,6 +12,7 @@ import type { IconSet } from '../render/glyph.js';
12
12
  import type { DatePrefix } from './ids.js';
13
13
  import type { ConceptSchema } from './schema.js';
14
14
  import type { LinkResolve } from './links.js';
15
+ import type { VariantSpec } from '../media/transform-url.js';
15
16
 
16
17
  /** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
17
18
  interface FieldBase {
@@ -73,11 +74,25 @@ export interface FreeTagsField extends FieldBase {
73
74
  type: 'freetags';
74
75
  placeholder?: string;
75
76
  }
77
+ /**
78
+ * A hero image set in frontmatter. The stored value is the nested object
79
+ * `{ src: string; alt: string; caption?: string }`, where `src` is a 2b `media:` reference, `alt`
80
+ * is the screen-reader description, and `caption` is an optional line the site template may show.
81
+ * One image serves two jobs: the template's lead image and the social-card image. The field feeding
82
+ * the social card is the `seo`-flagged one, defaulting to the field named `image`; a concept declares
83
+ * at most one SEO image field.
84
+ */
85
+ export interface ImageField extends FieldBase {
86
+ type: 'image';
87
+ /** Whether this field feeds the social-card image. The field named `image` defaults to true. */
88
+ seo?: boolean;
89
+ }
76
90
 
77
91
  /**
78
- * The discriminated union the per-concept frontmatter form is generated from. Adding a
79
- * field type is one variant here plus one decode arm in `frontmatterFromForm` and one in
80
- * `validateFields`.
92
+ * The discriminated union the per-concept frontmatter form is generated from. A scalar field type
93
+ * is one variant here plus one decode arm in `frontmatterFromForm` and one in `validateFields`. The
94
+ * structured `image` field additionally needs a read-back arm in `formValues` and a type-inference
95
+ * arm in `schema.ts`, since its value is a nested object rather than a single string.
81
96
  */
82
97
  export type FrontmatterField =
83
98
  | TextField
@@ -85,7 +100,18 @@ export type FrontmatterField =
85
100
  | DateField
86
101
  | BooleanField
87
102
  | TagsField
88
- | FreeTagsField;
103
+ | FreeTagsField
104
+ | ImageField;
105
+
106
+ /** The stored value of an `image` field: a `media:` reference, a screen-reader description, and an
107
+ * optional caption. */
108
+ export interface ImageValue {
109
+ src: string;
110
+ alt: string;
111
+ caption?: string;
112
+ /** An explicit decorative choice: an empty alt that is not debt. Omitted unless true. */
113
+ decorative?: boolean;
114
+ }
89
115
 
90
116
  /**
91
117
  * A validator's verdict. On success it carries the normalized frontmatter to commit; on
@@ -183,12 +209,28 @@ export interface PreviewConfig {
183
209
  * values with the entry's concept override applied, and no `byConcept` map. */
184
210
  export type ResolvedPreview = Omit<PreviewConfig, 'byConcept'>;
185
211
 
186
- /** Reserved asset slot (seam 4). Typed and unused in the rebuild; R7/R9 read it later with no contract change. */
212
+ /** A site's media configuration (seam 4). A site sets this to turn on R2-backed media: uploads,
213
+ * content-addressed storage, and Cloudflare Images variants. Omitting it leaves media off. The
214
+ * engine normalizes this into a `ResolvedAssetConfig` and merges the named variants over the
215
+ * built-in thumb, inline, card, and hero presets. */
187
216
  export interface AssetConfig {
188
- /** Repo-relative asset roots, e.g. ["static/images"]. */
189
- roots: string[];
190
- /** Public URL base, e.g. "/images". */
191
- publicBase: string;
217
+ /** The R2 bucket binding name on the Worker, e.g. "MEDIA_BUCKET". Required when a site declares media. */
218
+ bucketBinding: string;
219
+ /** The delivery base path. Defaults to "/media". */
220
+ publicBase?: string;
221
+ /** Whether the public URL carries the slug ("slug") or stays opaque ("opaque"). Defaults to "slug". */
222
+ urlForm?: 'slug' | 'opaque';
223
+ /** The maximum accepted upload size in bytes. Defaults to 25 MB. */
224
+ maxUploadBytes?: number;
225
+ /** The accepted upload MIME types. Defaults to the common web image types. */
226
+ allowedTypes?: string[];
227
+ /** Named transform presets, merged over the built-in thumb/inline/card/hero presets. */
228
+ variants?: Record<string, VariantSpec>;
229
+ /** Whether Cloudflare Image Transformations are enabled for the zone (default false). The feature
230
+ * is a per-zone setting that the dashboard or API turns on; it cannot be flipped from a Worker. With
231
+ * it off, the media resolver serves the bare full-size delivery path and ignores any preset, so
232
+ * thumbnails stay correct (full-size-but-correct) rather than pointing at a dead /cdn-cgi/image URL. */
233
+ transformations?: boolean;
192
234
  }
193
235
 
194
236
  /** The single seam the engine consumes. A site implements this at `src/lib/cairn.config.ts`. */
@@ -206,11 +248,22 @@ export interface CairnAdapter {
206
248
  sender: SenderConfig;
207
249
  /** The site's one renderer: the editor preview and every public page call it (design decision 4).
208
250
  * `resolve` rewrites cairn: links to live permalinks; the build passes a site-resolver-backed
209
- * one, the preview a manifest one. */
210
- render(md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }): string | Promise<string>;
251
+ * one, the preview a manifest one. The trailing `resolveMedia` is additive and optional: the build
252
+ * passes a site-resolver-backed media resolver, the preview a manifest-backed one. */
253
+ render(
254
+ md: string,
255
+ opts?: {
256
+ stagger?: boolean;
257
+ resolve?: LinkResolve;
258
+ resolveMedia?: import('../render/resolve-media.js').MediaResolve;
259
+ },
260
+ ): string | Promise<string>;
211
261
  /** Repo-relative path to the committed content manifest. Defaults to src/content/.cairn/index.json
212
262
  * in composeRuntime. It sits outside any concept directory, so content enumeration never globs it. */
213
263
  manifestPath?: string;
264
+ /** Repo-relative path to the committed media manifest. Defaults to src/content/.cairn/media.json,
265
+ * applied in composeRuntime. Sits outside any concept directory, like the content manifest. */
266
+ mediaManifestPath?: string;
214
267
  /** Directive component registry; the renderer and the future palette derive from it (seam 3). */
215
268
  registry?: ComponentRegistry;
216
269
  /** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
@@ -313,9 +366,23 @@ export interface CairnRuntime {
313
366
  concepts: ConceptDescriptor[];
314
367
  backend: BackendConfig;
315
368
  sender: SenderConfig;
316
- /** The site's one renderer: the editor preview and every public page call it (design decision 4). */
317
- render(md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }): string | Promise<string>;
369
+ /** The site's one renderer: the editor preview and every public page call it (design decision 4).
370
+ * The trailing `resolveMedia` is additive and optional: the build passes a site-resolver-backed
371
+ * media resolver, the preview a manifest-backed one. */
372
+ render(
373
+ md: string,
374
+ opts?: {
375
+ stagger?: boolean;
376
+ resolve?: LinkResolve;
377
+ resolveMedia?: import('../render/resolve-media.js').MediaResolve;
378
+ },
379
+ ): string | Promise<string>;
318
380
  manifestPath: string;
381
+ /** The repo-relative path to the committed media manifest, defaulted in composeRuntime. */
382
+ mediaManifestPath: string;
383
+ /** The adapter's asset config resolved once at compose: `{ enabled: false }` for a no-media site,
384
+ * otherwise the filled config the upload, storage, delivery, and resolver paths read. */
385
+ resolvedAssets: import('../media/config.js').ResolvedAssetConfig;
319
386
  registry?: ComponentRegistry;
320
387
  /** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
321
388
  icons?: IconSet;
@@ -2,7 +2,7 @@
2
2
  // required-and-coerce baseline, then layers any bespoke rules on top, so the per-site
3
3
  // validator stays thin (engine-fat rule). Saving runs the concept's validator on the
4
4
  // server before any commit; invalid input bounces to the form (spec §7.4).
5
- import type { FrontmatterField, ValidationResult } from './types.js';
5
+ import type { FrontmatterField, ImageValue, ValidationResult } from './types.js';
6
6
  import { dateInputValue, isCalendarDate } from './frontmatter.js';
7
7
 
8
8
  /**
@@ -44,6 +44,34 @@ export function validateFields(
44
44
  if (list.length > 0) data[field.name] = list;
45
45
  break;
46
46
  }
47
+ case 'image': {
48
+ // A hero is the nested object { src, alt, caption }. Normalize a well-formed value (default
49
+ // a missing alt to empty, since alt is debt and never a save block), and drop the key when
50
+ // src is empty or absent. A malformed value (a string, or an object without a string src)
51
+ // drops the key rather than throwing, so a hand-edit never breaks a save.
52
+ let src = '';
53
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
54
+ const obj = value as Record<string, unknown>;
55
+ src = typeof obj.src === 'string' ? obj.src.trim() : '';
56
+ if (src !== '') {
57
+ const normalized: ImageValue = {
58
+ src,
59
+ alt: typeof obj.alt === 'string' ? obj.alt : '',
60
+ };
61
+ const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
62
+ if (caption !== '') normalized.caption = caption;
63
+ // An explicit decorative choice carries through; it is never required and never a save
64
+ // block. A missing or non-boolean value drops the key, like a blank caption.
65
+ if (obj.decorative === true) normalized.decorative = true;
66
+ data[field.name] = normalized;
67
+ }
68
+ }
69
+ // A required image needs a src (the presence check), like the other arms; alt is never
70
+ // required, since alt is debt. The inferred type makes a required image non-optional, so the
71
+ // validator must enforce it or a save could omit it against the type.
72
+ if (field.required && src === '') errors[field.name] = `${field.label} is required`;
73
+ break;
74
+ }
47
75
  case 'date': {
48
76
  const text = value instanceof Date ? dateInputValue(value) : typeof value === 'string' ? value.trim() : '';
49
77
  if (field.required && text === '') errors[field.name] = `${field.label} is required`;
@@ -11,6 +11,8 @@ import type { SeoMeta } from './seo.js';
11
11
  import { readSeoFields, resolveImageUrl } from './seo-fields.js';
12
12
  import { buildLinkResolver } from './site-resolver.js';
13
13
  import type { LinkResolve } from '../content/links.js';
14
+ import type { MediaResolve } from '../render/resolve-media.js';
15
+ import { parseMediaToken } from '../media/reference.js';
14
16
 
15
17
  /** Injected dependencies for the public loaders. */
16
18
  export interface PublicRoutesDeps {
@@ -26,6 +28,10 @@ export interface PublicRoutesDeps {
26
28
  /** A site-wide default OG image, used when an entry declares none. Resolved to absolute like the
27
29
  * canonical URL, so a relative path such as "/og/default.png" works. */
28
30
  defaultImage?: string;
31
+ /** Resolve a frontmatter `media:` hero reference to its delivery path. The site builds this from its
32
+ * committed `media.json` exactly as it builds the body resolver (`makeMediaResolver`). When absent,
33
+ * media is off and no `heroImage` projection is derived. */
34
+ resolveMedia?: MediaResolve;
29
35
  }
30
36
 
31
37
  /** The archive and tag list data: summaries the template renders. */
@@ -52,11 +58,48 @@ export interface EntryData {
52
58
  seo: SeoMeta;
53
59
  newer?: ContentSummary;
54
60
  older?: ContentSummary;
61
+ /** The resolved hero image, a derived projection of the frontmatter `image` field. `url` is the
62
+ * root-relative delivery path for an `<img>`, `absoluteUrl` the origin-anchored form for the
63
+ * og:image, and `alt`/`caption` carry from the stored object. The canonical token is untouched:
64
+ * `entry.frontmatter.image.src` stays the `media:` token. Undefined when no hero is set, media is
65
+ * off, the reference does not parse, or the resolver finds no asset. */
66
+ heroImage?: { url: string; absoluteUrl?: string; alt: string; caption?: string };
55
67
  }
56
68
 
57
69
  /** Build the public loaders for a site's unified index. */
58
70
  export function createPublicRoutes(deps: PublicRoutesDeps) {
59
- const { site, render, origin, siteName, description, feeds, defaultImage } = deps;
71
+ const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia } = deps;
72
+
73
+ /** Derive the hero projection from an entry's frontmatter, without mutating it (locked decision 5).
74
+ * The hero lives at the conventional `image` key as the validated nested object `{ src, alt, caption }`;
75
+ * only an image field's validate arm produces an object-with-string-`src` shape, so detecting that
76
+ * structure is enough (a text field stores a string, a tags field an array). Returns undefined when
77
+ * media is off, no hero is set, the token does not parse, or the resolver finds no asset.
78
+ *
79
+ * Scope: this resolves the `image` key, which is the back-compat SEO default the schema's `seo`
80
+ * flag also defaults to. A concept that renames its hero (e.g. `cover`) with `seo: true` validates
81
+ * and renders in the editor, but its delivery resolution is not wired here yet, since the field
82
+ * declarations are not reachable in the delivery read path. Honoring a renamed `seo`-flagged field
83
+ * (and a second image field per concept) at delivery is a carried follow-up; every consumer today
84
+ * uses `image`. */
85
+ function deriveHeroImage(frontmatter: Record<string, unknown>): EntryData['heroImage'] {
86
+ if (!resolveMedia) return undefined;
87
+ const value = frontmatter.image;
88
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
89
+ const obj = value as { src?: unknown; alt?: unknown; caption?: unknown };
90
+ if (typeof obj.src !== 'string' || obj.src === '') return undefined;
91
+ const ref = parseMediaToken(obj.src);
92
+ if (!ref) return undefined;
93
+ const path = resolveMedia(ref);
94
+ if (!path) return undefined;
95
+ const hero: NonNullable<EntryData['heroImage']> = {
96
+ url: path,
97
+ absoluteUrl: resolveImageUrl(path, origin),
98
+ alt: typeof obj.alt === 'string' ? obj.alt : '',
99
+ };
100
+ if (typeof obj.caption === 'string' && obj.caption !== '') hero.caption = obj.caption;
101
+ return hero;
102
+ }
60
103
 
61
104
  /** Resolve one concept's index by id, or a 404 (the route names an unconfigured concept). */
62
105
  function indexOf(conceptId: string) {
@@ -72,8 +115,13 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
72
115
  const { newer, older } = site.adjacent(entry);
73
116
  const canonicalUrl = origin + entry.permalink;
74
117
  const fields = readSeoFields(entry.frontmatter);
118
+ const heroImage = deriveHeroImage(entry.frontmatter);
119
+ // The SEO unify (locked decision 3): a resolved structured hero is the social card and wins over
120
+ // the back-compat string `image` field and the site default. A bare-string `image` keeps its
121
+ // origin-anchored behavior. An empty hero alt emits no twitter:image:alt.
75
122
  const rawImage = fields.image ?? defaultImage;
76
- const image = rawImage ? resolveImageUrl(rawImage, origin) : undefined;
123
+ const image = heroImage?.absoluteUrl ?? (rawImage ? resolveImageUrl(rawImage, origin) : undefined);
124
+ const imageAlt = heroImage?.alt && heroImage.alt.trim() !== '' ? heroImage.alt : undefined;
77
125
  // A dated entry is an article; an undated one (a page) is a website.
78
126
  const seo = buildSeoMeta({
79
127
  title: entry.title,
@@ -84,11 +132,12 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
84
132
  ...(entry.date ? { published: entry.date } : {}),
85
133
  ...(entry.updated ? { modified: entry.updated } : {}),
86
134
  ...(image ? { image } : {}),
135
+ ...(imageAlt ? { imageAlt } : {}),
87
136
  ...(fields.robots ? { robots: fields.robots } : {}),
88
137
  ...(fields.author ? { author: fields.author } : {}),
89
138
  ...(entry.date ? { feeds } : {}),
90
139
  });
91
- return { concept: entry.concept, entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
140
+ return { concept: entry.concept, entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older, ...(heroImage ? { heroImage } : {}) };
92
141
  }
93
142
 
94
143
  /** The chronological archive for one concept: every non-draft summary, newest-first. */