@glw907/cairn-cms 0.56.1 → 0.57.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 (186) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -4
  3. package/dist/components/AdminLayout.svelte +3 -0
  4. package/dist/components/CairnAdmin.svelte +8 -1
  5. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  6. package/dist/components/CairnMediaLibrary.svelte +929 -0
  7. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  8. package/dist/components/ComponentForm.svelte +175 -46
  9. package/dist/components/ComponentForm.svelte.d.ts +22 -8
  10. package/dist/components/ComponentInsertDialog.svelte +379 -26
  11. package/dist/components/ComponentInsertDialog.svelte.d.ts +31 -2
  12. package/dist/components/EditPage.svelte +477 -15
  13. package/dist/components/EditPage.svelte.d.ts +2 -0
  14. package/dist/components/MarkdownEditor.svelte +358 -1
  15. package/dist/components/MarkdownEditor.svelte.d.ts +51 -1
  16. package/dist/components/MediaCaptureCard.svelte +135 -0
  17. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  18. package/dist/components/MediaFigureControl.svelte +247 -0
  19. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  20. package/dist/components/MediaHeroField.svelte +569 -0
  21. package/dist/components/MediaHeroField.svelte.d.ts +67 -0
  22. package/dist/components/MediaInsertPopover.svelte +449 -0
  23. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  24. package/dist/components/MediaPicker.svelte +257 -0
  25. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  26. package/dist/components/admin-icons.d.ts +12 -0
  27. package/dist/components/admin-icons.js +12 -0
  28. package/dist/components/cairn-admin.css +1045 -28
  29. package/dist/components/client-ingest.d.ts +142 -0
  30. package/dist/components/client-ingest.js +297 -0
  31. package/dist/components/editor-media.d.ts +11 -0
  32. package/dist/components/editor-media.js +206 -0
  33. package/dist/components/editor-placeholder.d.ts +26 -0
  34. package/dist/components/editor-placeholder.js +166 -0
  35. package/dist/components/index.d.ts +1 -0
  36. package/dist/components/index.js +1 -0
  37. package/dist/components/markdown-directives.d.ts +19 -0
  38. package/dist/components/markdown-directives.js +52 -0
  39. package/dist/components/markdown-format.d.ts +89 -0
  40. package/dist/components/markdown-format.js +255 -0
  41. package/dist/components/media-upload-outcome.d.ts +52 -0
  42. package/dist/components/media-upload-outcome.js +48 -0
  43. package/dist/content/compose.js +3 -0
  44. package/dist/content/frontmatter.js +17 -0
  45. package/dist/content/manifest.d.ts +4 -0
  46. package/dist/content/manifest.js +41 -1
  47. package/dist/content/media-refs.d.ts +7 -0
  48. package/dist/content/media-refs.js +52 -0
  49. package/dist/content/schema.d.ts +5 -2
  50. package/dist/content/schema.js +17 -0
  51. package/dist/content/types.d.ts +62 -11
  52. package/dist/content/validate.js +27 -0
  53. package/dist/delivery/public-routes.d.ts +16 -0
  54. package/dist/delivery/public-routes.js +46 -3
  55. package/dist/delivery/seo-fields.js +7 -1
  56. package/dist/delivery/seo.d.ts +2 -0
  57. package/dist/delivery/seo.js +3 -0
  58. package/dist/doctor/checks-local.d.ts +1 -0
  59. package/dist/doctor/checks-local.js +21 -0
  60. package/dist/doctor/index.d.ts +3 -1
  61. package/dist/doctor/index.js +11 -2
  62. package/dist/doctor/types.d.ts +3 -0
  63. package/dist/doctor/wrangler-config.d.ts +3 -0
  64. package/dist/doctor/wrangler-config.js +20 -0
  65. package/dist/env.d.ts +19 -0
  66. package/dist/env.js +26 -0
  67. package/dist/index.d.ts +1 -1
  68. package/dist/log/events.d.ts +1 -1
  69. package/dist/media/config.d.ts +24 -0
  70. package/dist/media/config.js +69 -0
  71. package/dist/media/delivery-bucket.d.ts +34 -0
  72. package/dist/media/delivery-bucket.js +10 -0
  73. package/dist/media/index.d.ts +6 -0
  74. package/dist/media/index.js +13 -0
  75. package/dist/media/library-entry.d.ts +30 -0
  76. package/dist/media/library-entry.js +17 -0
  77. package/dist/media/manifest.d.ts +44 -0
  78. package/dist/media/manifest.js +105 -0
  79. package/dist/media/naming.d.ts +18 -0
  80. package/dist/media/naming.js +112 -0
  81. package/dist/media/reconcile.d.ts +36 -0
  82. package/dist/media/reconcile.js +45 -0
  83. package/dist/media/reference.d.ts +12 -0
  84. package/dist/media/reference.js +33 -0
  85. package/dist/media/sniff.d.ts +18 -0
  86. package/dist/media/sniff.js +106 -0
  87. package/dist/media/store.d.ts +25 -0
  88. package/dist/media/store.js +16 -0
  89. package/dist/media/transform-url.d.ts +26 -0
  90. package/dist/media/transform-url.js +38 -0
  91. package/dist/media/usage.d.ts +48 -0
  92. package/dist/media/usage.js +90 -0
  93. package/dist/render/component-grammar.d.ts +20 -0
  94. package/dist/render/component-grammar.js +47 -3
  95. package/dist/render/component-validate.js +22 -0
  96. package/dist/render/pipeline.d.ts +2 -0
  97. package/dist/render/pipeline.js +13 -2
  98. package/dist/render/registry.d.ts +28 -0
  99. package/dist/render/registry.js +15 -0
  100. package/dist/render/remark-figure.d.ts +4 -0
  101. package/dist/render/remark-figure.js +103 -0
  102. package/dist/render/resolve-media.d.ts +34 -0
  103. package/dist/render/resolve-media.js +78 -0
  104. package/dist/render/sanitize-schema.d.ts +4 -2
  105. package/dist/render/sanitize-schema.js +5 -3
  106. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  107. package/dist/sveltekit/admin-dispatch.js +5 -0
  108. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  109. package/dist/sveltekit/cairn-admin.js +10 -2
  110. package/dist/sveltekit/content-routes.d.ts +68 -2
  111. package/dist/sveltekit/content-routes.js +461 -10
  112. package/dist/sveltekit/csrf.d.ts +16 -0
  113. package/dist/sveltekit/csrf.js +18 -0
  114. package/dist/sveltekit/guard.js +10 -3
  115. package/dist/sveltekit/index.d.ts +2 -1
  116. package/dist/sveltekit/index.js +1 -0
  117. package/dist/sveltekit/media-route.d.ts +12 -0
  118. package/dist/sveltekit/media-route.js +137 -0
  119. package/dist/vite/index.d.ts +3 -0
  120. package/dist/vite/index.js +7 -2
  121. package/package.json +8 -1
  122. package/src/lib/components/AdminLayout.svelte +3 -0
  123. package/src/lib/components/CairnAdmin.svelte +8 -1
  124. package/src/lib/components/CairnMediaLibrary.svelte +929 -0
  125. package/src/lib/components/ComponentForm.svelte +175 -46
  126. package/src/lib/components/ComponentInsertDialog.svelte +379 -26
  127. package/src/lib/components/EditPage.svelte +477 -15
  128. package/src/lib/components/MarkdownEditor.svelte +358 -1
  129. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  130. package/src/lib/components/MediaFigureControl.svelte +247 -0
  131. package/src/lib/components/MediaHeroField.svelte +569 -0
  132. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  133. package/src/lib/components/MediaPicker.svelte +257 -0
  134. package/src/lib/components/admin-icons.ts +12 -0
  135. package/src/lib/components/cairn-admin.css +37 -0
  136. package/src/lib/components/client-ingest.ts +380 -0
  137. package/src/lib/components/editor-media.ts +248 -0
  138. package/src/lib/components/editor-placeholder.ts +213 -0
  139. package/src/lib/components/index.ts +1 -0
  140. package/src/lib/components/markdown-directives.ts +57 -0
  141. package/src/lib/components/markdown-format.ts +307 -1
  142. package/src/lib/components/media-upload-outcome.ts +83 -0
  143. package/src/lib/content/compose.ts +3 -0
  144. package/src/lib/content/frontmatter.ts +16 -1
  145. package/src/lib/content/manifest.ts +44 -1
  146. package/src/lib/content/media-refs.ts +58 -0
  147. package/src/lib/content/schema.ts +31 -7
  148. package/src/lib/content/types.ts +78 -13
  149. package/src/lib/content/validate.ts +26 -1
  150. package/src/lib/delivery/public-routes.ts +52 -3
  151. package/src/lib/delivery/seo-fields.ts +6 -1
  152. package/src/lib/delivery/seo.ts +5 -0
  153. package/src/lib/doctor/checks-local.ts +22 -0
  154. package/src/lib/doctor/index.ts +21 -3
  155. package/src/lib/doctor/types.ts +3 -0
  156. package/src/lib/doctor/wrangler-config.ts +23 -0
  157. package/src/lib/env.ts +28 -0
  158. package/src/lib/index.ts +2 -0
  159. package/src/lib/log/events.ts +8 -1
  160. package/src/lib/media/config.ts +103 -0
  161. package/src/lib/media/delivery-bucket.ts +41 -0
  162. package/src/lib/media/index.ts +22 -0
  163. package/src/lib/media/library-entry.ts +58 -0
  164. package/src/lib/media/manifest.ts +122 -0
  165. package/src/lib/media/naming.ts +130 -0
  166. package/src/lib/media/reconcile.ts +79 -0
  167. package/src/lib/media/reference.ts +40 -0
  168. package/src/lib/media/sniff.ts +114 -0
  169. package/src/lib/media/store.ts +57 -0
  170. package/src/lib/media/transform-url.ts +58 -0
  171. package/src/lib/media/usage.ts +152 -0
  172. package/src/lib/render/component-grammar.ts +59 -3
  173. package/src/lib/render/component-validate.ts +22 -1
  174. package/src/lib/render/pipeline.ts +17 -3
  175. package/src/lib/render/registry.ts +38 -0
  176. package/src/lib/render/remark-figure.ts +132 -0
  177. package/src/lib/render/resolve-media.ts +96 -0
  178. package/src/lib/render/sanitize-schema.ts +5 -3
  179. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  180. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  181. package/src/lib/sveltekit/content-routes.ts +573 -12
  182. package/src/lib/sveltekit/csrf.ts +18 -0
  183. package/src/lib/sveltekit/guard.ts +12 -3
  184. package/src/lib/sveltekit/index.ts +6 -0
  185. package/src/lib/sveltekit/media-route.ts +158 -0
  186. package/src/lib/vite/index.ts +9 -2
@@ -3,6 +3,7 @@ import type { IconSet } from '../render/glyph.js';
3
3
  import type { DatePrefix } from './ids.js';
4
4
  import type { ConceptSchema } from './schema.js';
5
5
  import type { LinkResolve } from './links.js';
6
+ import type { VariantSpec } from '../media/transform-url.js';
6
7
  /** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
7
8
  interface FieldBase {
8
9
  /** Frontmatter key and form input name. */
@@ -63,11 +64,32 @@ export interface FreeTagsField extends FieldBase {
63
64
  placeholder?: string;
64
65
  }
65
66
  /**
66
- * The discriminated union the per-concept frontmatter form is generated from. Adding a
67
- * field type is one variant here plus one decode arm in `frontmatterFromForm` and one in
68
- * `validateFields`.
67
+ * A hero image set in frontmatter. The stored value is the nested object
68
+ * `{ src: string; alt: string; caption?: string }`, where `src` is a 2b `media:` reference, `alt`
69
+ * is the screen-reader description, and `caption` is an optional line the site template may show.
70
+ * One image serves two jobs: the template's lead image and the social-card image. The field feeding
71
+ * the social card is the `seo`-flagged one, defaulting to the field named `image`; a concept declares
72
+ * at most one SEO image field.
69
73
  */
70
- export type FrontmatterField = TextField | TextareaField | DateField | BooleanField | TagsField | FreeTagsField;
74
+ export interface ImageField extends FieldBase {
75
+ type: 'image';
76
+ /** Whether this field feeds the social-card image. The field named `image` defaults to true. */
77
+ seo?: boolean;
78
+ }
79
+ /**
80
+ * The discriminated union the per-concept frontmatter form is generated from. A scalar field type
81
+ * is one variant here plus one decode arm in `frontmatterFromForm` and one in `validateFields`. The
82
+ * structured `image` field additionally needs a read-back arm in `formValues` and a type-inference
83
+ * arm in `schema.ts`, since its value is a nested object rather than a single string.
84
+ */
85
+ export type FrontmatterField = TextField | TextareaField | DateField | BooleanField | TagsField | FreeTagsField | ImageField;
86
+ /** The stored value of an `image` field: a `media:` reference, a screen-reader description, and an
87
+ * optional caption. */
88
+ export interface ImageValue {
89
+ src: string;
90
+ alt: string;
91
+ caption?: string;
92
+ }
71
93
  /**
72
94
  * A validator's verdict. On success it carries the normalized frontmatter to commit; on
73
95
  * failure it carries field-keyed error messages (the empty key is a form-level error).
@@ -163,12 +185,28 @@ export interface PreviewConfig {
163
185
  /** The flat preview shape `editLoad` ships to the edit page: the top-level `PreviewConfig`
164
186
  * values with the entry's concept override applied, and no `byConcept` map. */
165
187
  export type ResolvedPreview = Omit<PreviewConfig, 'byConcept'>;
166
- /** Reserved asset slot (seam 4). Typed and unused in the rebuild; R7/R9 read it later with no contract change. */
188
+ /** A site's media configuration (seam 4). A site sets this to turn on R2-backed media: uploads,
189
+ * content-addressed storage, and Cloudflare Images variants. Omitting it leaves media off. The
190
+ * engine normalizes this into a `ResolvedAssetConfig` and merges the named variants over the
191
+ * built-in thumb, inline, card, and hero presets. */
167
192
  export interface AssetConfig {
168
- /** Repo-relative asset roots, e.g. ["static/images"]. */
169
- roots: string[];
170
- /** Public URL base, e.g. "/images". */
171
- publicBase: string;
193
+ /** The R2 bucket binding name on the Worker, e.g. "MEDIA_BUCKET". Required when a site declares media. */
194
+ bucketBinding: string;
195
+ /** The delivery base path. Defaults to "/media". */
196
+ publicBase?: string;
197
+ /** Whether the public URL carries the slug ("slug") or stays opaque ("opaque"). Defaults to "slug". */
198
+ urlForm?: 'slug' | 'opaque';
199
+ /** The maximum accepted upload size in bytes. Defaults to 25 MB. */
200
+ maxUploadBytes?: number;
201
+ /** The accepted upload MIME types. Defaults to the common web image types. */
202
+ allowedTypes?: string[];
203
+ /** Named transform presets, merged over the built-in thumb/inline/card/hero presets. */
204
+ variants?: Record<string, VariantSpec>;
205
+ /** Whether Cloudflare Image Transformations are enabled for the zone (default false). The feature
206
+ * is a per-zone setting that the dashboard or API turns on; it cannot be flipped from a Worker. With
207
+ * it off, the media resolver serves the bare full-size delivery path and ignores any preset, so
208
+ * thumbnails stay correct (full-size-but-correct) rather than pointing at a dead /cdn-cgi/image URL. */
209
+ transformations?: boolean;
172
210
  }
173
211
  /** The single seam the engine consumes. A site implements this at `src/lib/cairn.config.ts`. */
174
212
  export interface CairnAdapter {
@@ -185,14 +223,19 @@ export interface CairnAdapter {
185
223
  sender: SenderConfig;
186
224
  /** The site's one renderer: the editor preview and every public page call it (design decision 4).
187
225
  * `resolve` rewrites cairn: links to live permalinks; the build passes a site-resolver-backed
188
- * one, the preview a manifest one. */
226
+ * one, the preview a manifest one. The trailing `resolveMedia` is additive and optional: the build
227
+ * passes a site-resolver-backed media resolver, the preview a manifest-backed one. */
189
228
  render(md: string, opts?: {
190
229
  stagger?: boolean;
191
230
  resolve?: LinkResolve;
231
+ resolveMedia?: import('../render/resolve-media.js').MediaResolve;
192
232
  }): string | Promise<string>;
193
233
  /** Repo-relative path to the committed content manifest. Defaults to src/content/.cairn/index.json
194
234
  * in composeRuntime. It sits outside any concept directory, so content enumeration never globs it. */
195
235
  manifestPath?: string;
236
+ /** Repo-relative path to the committed media manifest. Defaults to src/content/.cairn/media.json,
237
+ * applied in composeRuntime. Sits outside any concept directory, like the content manifest. */
238
+ mediaManifestPath?: string;
196
239
  /** Directive component registry; the renderer and the future palette derive from it (seam 3). */
197
240
  registry?: ComponentRegistry;
198
241
  /** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
@@ -289,12 +332,20 @@ export interface CairnRuntime {
289
332
  concepts: ConceptDescriptor[];
290
333
  backend: BackendConfig;
291
334
  sender: SenderConfig;
292
- /** The site's one renderer: the editor preview and every public page call it (design decision 4). */
335
+ /** The site's one renderer: the editor preview and every public page call it (design decision 4).
336
+ * The trailing `resolveMedia` is additive and optional: the build passes a site-resolver-backed
337
+ * media resolver, the preview a manifest-backed one. */
293
338
  render(md: string, opts?: {
294
339
  stagger?: boolean;
295
340
  resolve?: LinkResolve;
341
+ resolveMedia?: import('../render/resolve-media.js').MediaResolve;
296
342
  }): string | Promise<string>;
297
343
  manifestPath: string;
344
+ /** The repo-relative path to the committed media manifest, defaulted in composeRuntime. */
345
+ mediaManifestPath: string;
346
+ /** The adapter's asset config resolved once at compose: `{ enabled: false }` for a no-media site,
347
+ * otherwise the filled config the upload, storage, delivery, and resolver paths read. */
348
+ resolvedAssets: import('../media/config.js').ResolvedAssetConfig;
298
349
  registry?: ComponentRegistry;
299
350
  /** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
300
351
  icons?: IconSet;
@@ -39,6 +39,33 @@ export function validateFields(fields, frontmatter) {
39
39
  data[field.name] = list;
40
40
  break;
41
41
  }
42
+ case 'image': {
43
+ // A hero is the nested object { src, alt, caption }. Normalize a well-formed value (default
44
+ // a missing alt to empty, since alt is debt and never a save block), and drop the key when
45
+ // src is empty or absent. A malformed value (a string, or an object without a string src)
46
+ // drops the key rather than throwing, so a hand-edit never breaks a save.
47
+ let src = '';
48
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
49
+ const obj = value;
50
+ src = typeof obj.src === 'string' ? obj.src.trim() : '';
51
+ if (src !== '') {
52
+ const normalized = {
53
+ src,
54
+ alt: typeof obj.alt === 'string' ? obj.alt : '',
55
+ };
56
+ const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
57
+ if (caption !== '')
58
+ normalized.caption = caption;
59
+ data[field.name] = normalized;
60
+ }
61
+ }
62
+ // A required image needs a src (the presence check), like the other arms; alt is never
63
+ // required, since alt is debt. The inferred type makes a required image non-optional, so the
64
+ // validator must enforce it or a save could omit it against the type.
65
+ if (field.required && src === '')
66
+ errors[field.name] = `${field.label} is required`;
67
+ break;
68
+ }
42
69
  case 'date': {
43
70
  const text = value instanceof Date ? dateInputValue(value) : typeof value === 'string' ? value.trim() : '';
44
71
  if (field.required && text === '')
@@ -2,6 +2,7 @@ import type { ContentSummary, ContentEntry } from './content-index.js';
2
2
  import type { SiteResolver } from './site-resolver.js';
3
3
  import type { SeoMeta } from './seo.js';
4
4
  import type { LinkResolve } from '../content/links.js';
5
+ import type { MediaResolve } from '../render/resolve-media.js';
5
6
  /** Injected dependencies for the public loaders. */
6
7
  export interface PublicRoutesDeps {
7
8
  site: SiteResolver;
@@ -22,6 +23,10 @@ export interface PublicRoutesDeps {
22
23
  /** A site-wide default OG image, used when an entry declares none. Resolved to absolute like the
23
24
  * canonical URL, so a relative path such as "/og/default.png" works. */
24
25
  defaultImage?: string;
26
+ /** Resolve a frontmatter `media:` hero reference to its delivery path. The site builds this from its
27
+ * committed `media.json` exactly as it builds the body resolver (`makeMediaResolver`). When absent,
28
+ * media is off and no `heroImage` projection is derived. */
29
+ resolveMedia?: MediaResolve;
25
30
  }
26
31
  /** The archive and tag list data: summaries the template renders. */
27
32
  export interface ListData {
@@ -47,6 +52,17 @@ export interface EntryData {
47
52
  seo: SeoMeta;
48
53
  newer?: ContentSummary;
49
54
  older?: ContentSummary;
55
+ /** The resolved hero image, a derived projection of the frontmatter `image` field. `url` is the
56
+ * root-relative delivery path for an `<img>`, `absoluteUrl` the origin-anchored form for the
57
+ * og:image, and `alt`/`caption` carry from the stored object. The canonical token is untouched:
58
+ * `entry.frontmatter.image.src` stays the `media:` token. Undefined when no hero is set, media is
59
+ * off, the reference does not parse, or the resolver finds no asset. */
60
+ heroImage?: {
61
+ url: string;
62
+ absoluteUrl?: string;
63
+ alt: string;
64
+ caption?: string;
65
+ };
50
66
  }
51
67
  /** Build the public loaders for a site's unified index. */
52
68
  export declare function createPublicRoutes(deps: PublicRoutesDeps): {
@@ -7,9 +7,46 @@ import { error } from '@sveltejs/kit';
7
7
  import { buildSeoMeta } from './seo.js';
8
8
  import { readSeoFields, resolveImageUrl } from './seo-fields.js';
9
9
  import { buildLinkResolver } from './site-resolver.js';
10
+ import { parseMediaToken } from '../media/reference.js';
10
11
  /** Build the public loaders for a site's unified index. */
11
12
  export function createPublicRoutes(deps) {
12
- const { site, render, origin, siteName, description, feeds, defaultImage } = deps;
13
+ const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia } = deps;
14
+ /** Derive the hero projection from an entry's frontmatter, without mutating it (locked decision 5).
15
+ * The hero lives at the conventional `image` key as the validated nested object `{ src, alt, caption }`;
16
+ * only an image field's validate arm produces an object-with-string-`src` shape, so detecting that
17
+ * structure is enough (a text field stores a string, a tags field an array). Returns undefined when
18
+ * media is off, no hero is set, the token does not parse, or the resolver finds no asset.
19
+ *
20
+ * Scope: this resolves the `image` key, which is the back-compat SEO default the schema's `seo`
21
+ * flag also defaults to. A concept that renames its hero (e.g. `cover`) with `seo: true` validates
22
+ * and renders in the editor, but its delivery resolution is not wired here yet, since the field
23
+ * declarations are not reachable in the delivery read path. Honoring a renamed `seo`-flagged field
24
+ * (and a second image field per concept) at delivery is a carried follow-up; every consumer today
25
+ * uses `image`. */
26
+ function deriveHeroImage(frontmatter) {
27
+ if (!resolveMedia)
28
+ return undefined;
29
+ const value = frontmatter.image;
30
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
31
+ return undefined;
32
+ const obj = value;
33
+ if (typeof obj.src !== 'string' || obj.src === '')
34
+ return undefined;
35
+ const ref = parseMediaToken(obj.src);
36
+ if (!ref)
37
+ return undefined;
38
+ const path = resolveMedia(ref);
39
+ if (!path)
40
+ return undefined;
41
+ const hero = {
42
+ url: path,
43
+ absoluteUrl: resolveImageUrl(path, origin),
44
+ alt: typeof obj.alt === 'string' ? obj.alt : '',
45
+ };
46
+ if (typeof obj.caption === 'string' && obj.caption !== '')
47
+ hero.caption = obj.caption;
48
+ return hero;
49
+ }
13
50
  /** Resolve one concept's index by id, or a 404 (the route names an unconfigured concept). */
14
51
  function indexOf(conceptId) {
15
52
  const index = site.concept(conceptId);
@@ -25,8 +62,13 @@ export function createPublicRoutes(deps) {
25
62
  const { newer, older } = site.adjacent(entry);
26
63
  const canonicalUrl = origin + entry.permalink;
27
64
  const fields = readSeoFields(entry.frontmatter);
65
+ const heroImage = deriveHeroImage(entry.frontmatter);
66
+ // The SEO unify (locked decision 3): a resolved structured hero is the social card and wins over
67
+ // the back-compat string `image` field and the site default. A bare-string `image` keeps its
68
+ // origin-anchored behavior. An empty hero alt emits no twitter:image:alt.
28
69
  const rawImage = fields.image ?? defaultImage;
29
- const image = rawImage ? resolveImageUrl(rawImage, origin) : undefined;
70
+ const image = heroImage?.absoluteUrl ?? (rawImage ? resolveImageUrl(rawImage, origin) : undefined);
71
+ const imageAlt = heroImage?.alt && heroImage.alt.trim() !== '' ? heroImage.alt : undefined;
30
72
  // A dated entry is an article; an undated one (a page) is a website.
31
73
  const seo = buildSeoMeta({
32
74
  title: entry.title,
@@ -37,11 +79,12 @@ export function createPublicRoutes(deps) {
37
79
  ...(entry.date ? { published: entry.date } : {}),
38
80
  ...(entry.updated ? { modified: entry.updated } : {}),
39
81
  ...(image ? { image } : {}),
82
+ ...(imageAlt ? { imageAlt } : {}),
40
83
  ...(fields.robots ? { robots: fields.robots } : {}),
41
84
  ...(fields.author ? { author: fields.author } : {}),
42
85
  ...(entry.date ? { feeds } : {}),
43
86
  });
44
- return { concept: entry.concept, entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
87
+ return { concept: entry.concept, entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older, ...(heroImage ? { heroImage } : {}) };
45
88
  }
46
89
  /** The chronological archive for one concept: every non-draft summary, newest-first. */
47
90
  function archiveLoad(conceptId) {
@@ -24,7 +24,13 @@ export function readSeoFields(frontmatter) {
24
24
  * that path, per the WHATWG URL rules. */
25
25
  export function resolveImageUrl(image, origin) {
26
26
  try {
27
- return new URL(image, origin).href;
27
+ const url = new URL(image, origin);
28
+ // Guard the unresolved-`media:`-token failure mode: `media:photo.<hash>` is a valid URL scheme,
29
+ // so `new URL(...).href` returns the token verbatim and it would otherwise ship as the og:image.
30
+ // Only an http or https result is a real social-card URL; anything else degrades to no image.
31
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
32
+ return undefined;
33
+ return url.href;
28
34
  }
29
35
  catch {
30
36
  return undefined;
@@ -12,6 +12,8 @@ export interface SeoInput {
12
12
  json?: string;
13
13
  };
14
14
  image?: string;
15
+ /** The social image's alt text, emitted as twitter:image:alt. Used only when image is also set. */
16
+ imageAlt?: string;
15
17
  /** A robots meta directive, e.g. "noindex, nofollow". Omitted from the head when absent. */
16
18
  robots?: string;
17
19
  /** Author name, emitted as article:author for the article type. */
@@ -16,6 +16,9 @@ export function buildSeoMeta(input) {
16
16
  if (input.image) {
17
17
  meta.push({ property: 'og:image', content: input.image });
18
18
  meta.push({ name: 'twitter:image', content: input.image });
19
+ if (input.imageAlt) {
20
+ meta.push({ name: 'twitter:image:alt', content: input.imageAlt });
21
+ }
19
22
  }
20
23
  if (input.robots) {
21
24
  meta.push({ name: 'robots', content: input.robots });
@@ -1,5 +1,6 @@
1
1
  import type { DoctorCheck } from './types.js';
2
2
  export declare const configBindings: DoctorCheck;
3
+ export declare const configMediaBucket: DoctorCheck;
3
4
  export declare const configObservability: DoctorCheck;
4
5
  export declare const configCsrfDisable: DoctorCheck;
5
6
  export declare const configPublicOrigin: DoctorCheck;
@@ -26,6 +26,27 @@ export const configBindings = {
26
26
  return pass('EMAIL and AUTH_DB are declared');
27
27
  },
28
28
  };
29
+ // The R2 media bucket is never added to the hard config.bindings check, so a no-media site never
30
+ // fails on a missing media binding (decision 9). This conditional runs only when the adapter
31
+ // declares assets, matching the adapter's bucketBinding against wrangler's r2_buckets. It reuses the
32
+ // config.bindings-missing condition rather than registering a new one, so the readiness count holds.
33
+ export const configMediaBucket = {
34
+ id: 'config.media-bucket',
35
+ conditionId: 'config.bindings-missing',
36
+ title: 'Media bucket binding',
37
+ async run(ctx) {
38
+ const binding = ctx.mediaBucketBinding;
39
+ if (binding === undefined)
40
+ return skip('no media assets configured');
41
+ const facts = await readWranglerConfig(ctx.readFile);
42
+ if (facts === null)
43
+ return NO_WRANGLER;
44
+ if (!facts.r2Buckets.includes(binding)) {
45
+ return fail(`adapter declares media bucket ${binding} but no matching r2_buckets binding is in wrangler`);
46
+ }
47
+ return pass(`media bucket ${binding} is declared`);
48
+ },
49
+ };
29
50
  export const configObservability = {
30
51
  id: 'config.observability',
31
52
  conditionId: 'config.observability-off',
@@ -22,11 +22,13 @@ export declare function contextFromEnv(env: Record<string, string | undefined>,
22
22
  * Vite resolution and the wrangler config's account_id. Each runs only when an input it feeds
23
23
  * is still missing, so a doctor run with full flags touches neither. */
24
24
  export interface DerivationSources {
25
- /** Returns { owner, repo, from } off the adapter, or null when nothing is derivable. */
25
+ /** Returns { owner, repo, from, mediaBucketBinding } off the adapter, or null when nothing is
26
+ * derivable. */
26
27
  adapterFacts: () => Promise<{
27
28
  owner?: string;
28
29
  repo?: string;
29
30
  from?: string;
31
+ mediaBucketBinding?: string;
30
32
  } | null>;
31
33
  /** Returns the wrangler config's account_id, or undefined when none is declared. */
32
34
  wranglerAccountId: () => Promise<string | undefined>;
@@ -1,4 +1,4 @@
1
- import { configBindings, configObservability, configCsrfDisable, configSiteConfig, configPublicOrigin, } from './checks-local.js';
1
+ import { configBindings, configMediaBucket, configObservability, configCsrfDisable, configSiteConfig, configPublicOrigin, } from './checks-local.js';
2
2
  import { configDependencyFloors } from './check-floors.js';
3
3
  import { emailSenderOnboarded, edgeHttpsForced, edgeHsts, authStore } from './checks-cloudflare.js';
4
4
  import { githubApp } from './checks-github.js';
@@ -69,7 +69,12 @@ export function contextFromEnv(env, args, cwd) {
69
69
  */
70
70
  export async function deriveMissingInputs(ctx, sources) {
71
71
  const out = { ...ctx };
72
- if (out.from === undefined || out.repo === undefined) {
72
+ // The adapter read also carries the media bucket binding, which has no env source, so it runs
73
+ // when from, repo, or the media binding is still missing. A failure leaves each input absent so
74
+ // its check skips with the usual remediation rather than the doctor crashing.
75
+ if (out.from === undefined ||
76
+ out.repo === undefined ||
77
+ out.mediaBucketBinding === undefined) {
73
78
  const facts = await sources.adapterFacts().catch(() => null);
74
79
  if (out.from === undefined && typeof facts?.from === 'string') {
75
80
  out.from = facts.from;
@@ -79,6 +84,9 @@ export async function deriveMissingInputs(ctx, sources) {
79
84
  typeof facts?.repo === 'string') {
80
85
  out.repo = `${facts.owner}/${facts.repo}`;
81
86
  }
87
+ if (out.mediaBucketBinding === undefined && typeof facts?.mediaBucketBinding === 'string') {
88
+ out.mediaBucketBinding = facts.mediaBucketBinding;
89
+ }
82
90
  }
83
91
  if (out.cfAccountId === undefined) {
84
92
  const accountId = await sources.wranglerAccountId().catch(() => undefined);
@@ -95,6 +103,7 @@ export async function deriveMissingInputs(ctx, sources) {
95
103
  export function defaultChecks() {
96
104
  return [
97
105
  configBindings,
106
+ configMediaBucket,
98
107
  configObservability,
99
108
  configCsrfDisable,
100
109
  configSiteConfig,
@@ -30,6 +30,9 @@ export interface DoctorContext {
30
30
  cfAccountId?: string;
31
31
  /** PUBLIC_ORIGIN, the env fallback when the wrangler vars carry none. */
32
32
  publicOrigin?: string;
33
+ /** The adapter's media bucket binding (cairn.assets.bucketBinding), derived off the adapter.
34
+ * Undefined when the site declares no media assets; the media-bucket check skips in that case. */
35
+ mediaBucketBinding?: string;
33
36
  /** GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID / GITHUB_APP_PRIVATE_KEY_B64. */
34
37
  github?: {
35
38
  appId: string;
@@ -12,5 +12,8 @@ export interface WranglerFacts {
12
12
  publicOrigin?: string;
13
13
  /** The top-level account_id, when declared; a fallback for CLOUDFLARE_ACCOUNT_ID. */
14
14
  accountId?: string;
15
+ /** The declared r2_buckets binding names; the conditional media check matches the adapter's
16
+ * bucketBinding against this. Not part of the hard config.bindings check (decision 9). */
17
+ r2Buckets: string[];
15
18
  }
16
19
  export declare function readWranglerConfig(readFile: DoctorContext['readFile']): Promise<WranglerFacts | null>;
@@ -64,10 +64,17 @@ function factsFromJsonc(text) {
64
64
  const databases = Array.isArray(config.d1_databases) ? config.d1_databases : [];
65
65
  const authDb = databases.find((entry) => typeof entry === 'object' && entry !== null && entry.binding === 'AUTH_DB');
66
66
  const observability = config.observability;
67
+ const r2 = Array.isArray(config.r2_buckets) ? config.r2_buckets : [];
68
+ const r2Buckets = r2
69
+ .map((entry) => typeof entry === 'object' && entry !== null && typeof entry.binding === 'string'
70
+ ? entry.binding
71
+ : undefined)
72
+ .filter((binding) => binding !== undefined);
67
73
  const facts = {
68
74
  hasEmailBinding,
69
75
  hasAuthDb: authDb !== undefined,
70
76
  observabilityEnabled: observability?.enabled === true,
77
+ r2Buckets,
71
78
  };
72
79
  if (typeof authDb?.database_id === 'string')
73
80
  facts.authDbId = authDb.database_id;
@@ -87,10 +94,12 @@ function factsFromToml(text) {
87
94
  hasEmailBinding: false,
88
95
  hasAuthDb: false,
89
96
  observabilityEnabled: false,
97
+ r2Buckets: [],
90
98
  };
91
99
  let section = '';
92
100
  let d1Binding;
93
101
  let d1Id;
102
+ let r2Binding;
94
103
  const flushD1 = () => {
95
104
  if (d1Binding === 'AUTH_DB') {
96
105
  facts.hasAuthDb = true;
@@ -100,10 +109,16 @@ function factsFromToml(text) {
100
109
  d1Binding = undefined;
101
110
  d1Id = undefined;
102
111
  };
112
+ const flushR2 = () => {
113
+ if (r2Binding !== undefined)
114
+ facts.r2Buckets.push(r2Binding);
115
+ r2Binding = undefined;
116
+ };
103
117
  for (const line of text.split('\n')) {
104
118
  const header = line.match(/^\s*(\[\[?[\w.]+\]?\])\s*(?:#.*)?$/);
105
119
  if (header) {
106
120
  flushD1();
121
+ flushR2();
107
122
  section = header[1];
108
123
  continue;
109
124
  }
@@ -121,6 +136,10 @@ function factsFromToml(text) {
121
136
  if (key === 'database_id')
122
137
  d1Id = str;
123
138
  }
139
+ else if (section === '[[r2_buckets]]') {
140
+ if (key === 'binding' && str !== undefined)
141
+ r2Binding = str;
142
+ }
124
143
  else if (section === '[observability]' && key === 'enabled' && value.startsWith('true')) {
125
144
  facts.observabilityEnabled = true;
126
145
  }
@@ -132,5 +151,6 @@ function factsFromToml(text) {
132
151
  }
133
152
  }
134
153
  flushD1();
154
+ flushR2();
135
155
  return facts;
136
156
  }
package/dist/env.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { D1Database } from '@cloudflare/workers-types';
2
+ import type { DeliveryBucket } from './media/delivery-bucket.js';
2
3
  /**
3
4
  * Returns the site's public origin from configuration.
4
5
  *
@@ -22,3 +23,21 @@ export declare function requireOrigin(env: {
22
23
  export declare function requireDb(env: {
23
24
  AUTH_DB?: D1Database;
24
25
  }): D1Database;
26
+ /**
27
+ * Returns the media R2 bucket named by `bindingName`, or throws a clear error when a site has not
28
+ * wired it. The binding name is config-derived (the adapter's `bucketBinding`), so it is read off
29
+ * `env` dynamically rather than as a fixed key.
30
+ *
31
+ * The return type is the narrow structural `DeliveryBucket` seam, never the `@cloudflare/workers-types`
32
+ * `R2Bucket`, so no workers-types name reaches the public `.d.ts` (the delivery route is a public
33
+ * export and that package is only a devDependency). The cast through `unknown` is sound because the
34
+ * seam models a subset of the real R2 bucket API.
35
+ *
36
+ * The guard rejects a value wired to the wrong kind of binding too (a KV namespace, a string var),
37
+ * which is truthy but carries no callable `get`. Without that check the cast would succeed and the
38
+ * first `bucket.get(...)` would throw an uncaught 500 rather than the drained 503 a missing binding
39
+ * earns.
40
+ *
41
+ * @throws CairnError (`config.bindings-missing`) when the named binding is absent or not an R2 bucket.
42
+ */
43
+ export declare function requireBucket(env: Record<string, unknown>, bindingName: string): DeliveryBucket;
package/dist/env.js CHANGED
@@ -47,3 +47,29 @@ export function requireDb(env) {
47
47
  }
48
48
  return env.AUTH_DB;
49
49
  }
50
+ /**
51
+ * Returns the media R2 bucket named by `bindingName`, or throws a clear error when a site has not
52
+ * wired it. The binding name is config-derived (the adapter's `bucketBinding`), so it is read off
53
+ * `env` dynamically rather than as a fixed key.
54
+ *
55
+ * The return type is the narrow structural `DeliveryBucket` seam, never the `@cloudflare/workers-types`
56
+ * `R2Bucket`, so no workers-types name reaches the public `.d.ts` (the delivery route is a public
57
+ * export and that package is only a devDependency). The cast through `unknown` is sound because the
58
+ * seam models a subset of the real R2 bucket API.
59
+ *
60
+ * The guard rejects a value wired to the wrong kind of binding too (a KV namespace, a string var),
61
+ * which is truthy but carries no callable `get`. Without that check the cast would succeed and the
62
+ * first `bucket.get(...)` would throw an uncaught 500 rather than the drained 503 a missing binding
63
+ * earns.
64
+ *
65
+ * @throws CairnError (`config.bindings-missing`) when the named binding is absent or not an R2 bucket.
66
+ */
67
+ export function requireBucket(env, bindingName) {
68
+ const bucket = env[bindingName];
69
+ if (!bucket || typeof bucket.get !== 'function') {
70
+ throw new CairnError('config.bindings-missing', {
71
+ message: `${bindingName} binding is not configured`,
72
+ });
73
+ }
74
+ return bucket;
75
+ }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { requireOrigin } from './env.js';
2
2
  export type { Role, Editor, AuthEnv } from './auth/types.js';
3
3
  export type { AuthBranding, MagicLinkMessage, SendMagicLink } from './email.js';
4
4
  export { buildMagicLinkMessage, cloudflareSend } from './email.js';
5
- export type { CairnAdapter, ConceptConfig, FrontmatterField, TextField, TextareaField, DateField, BooleanField, TagsField, FreeTagsField, ValidationResult, BackendConfig, SenderConfig, NavMenuConfig, PreviewConfig, ResolvedPreview, AssetConfig, RoutingRule, ConceptDescriptor, ConceptUrlPolicy, CairnExtension, CairnRuntime, AdminPanel, FieldTypeDef, } from './content/types.js';
5
+ export type { CairnAdapter, ConceptConfig, FrontmatterField, TextField, TextareaField, DateField, BooleanField, TagsField, FreeTagsField, ImageField, ImageValue, ValidationResult, BackendConfig, SenderConfig, NavMenuConfig, PreviewConfig, ResolvedPreview, AssetConfig, RoutingRule, ConceptDescriptor, ConceptUrlPolicy, CairnExtension, CairnRuntime, AdminPanel, FieldTypeDef, } from './content/types.js';
6
6
  export { CONCEPT_ROUTING, normalizeConcepts, findConcept } from './content/concepts.js';
7
7
  export { composeRuntime } from './content/compose.js';
8
8
  export type { ComposeInput } from './content/compose.js';
@@ -1 +1 @@
1
- export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'config.invalid' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected';
1
+ export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'config.invalid' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected' | 'media.uploaded' | 'media.upload_failed' | 'media.delivery_failed' | 'media.orphan_reconcile' | 'media.resolve_missing' | 'media.deleted' | 'media.delete_blocked';
@@ -0,0 +1,24 @@
1
+ import type { AssetConfig } from '../content/types.js';
2
+ import type { VariantSpec } from './transform-url.js';
3
+ /** The resolved media config the engine serves from. When a site declares no assets block, media is
4
+ * off and the value is `{ enabled: false }`; otherwise every field is filled from the AssetConfig
5
+ * or its default. */
6
+ export type ResolvedAssetConfig = {
7
+ enabled: false;
8
+ } | {
9
+ enabled: true;
10
+ bucketBinding: string;
11
+ publicBase: string;
12
+ urlForm: 'slug' | 'opaque';
13
+ maxUploadBytes: number;
14
+ allowedTypes: string[];
15
+ variants: Record<string, VariantSpec>;
16
+ /** Whether Cloudflare Image Transformations are enabled for the zone. With it false, the media
17
+ * resolver serves the bare full-size delivery path and ignores any preset. */
18
+ transformations: boolean;
19
+ };
20
+ /** Validate a site's AssetConfig and resolve it into a ResolvedAssetConfig. An undefined block leaves
21
+ * media off and returns `{ enabled: false }` rather than throwing. A declared block must name its R2
22
+ * bucket and carry a known urlForm and valid variant fit and gravity values; each failure throws a
23
+ * cairn:-prefixed error. The named variants merge over the built-in presets. */
24
+ export declare function normalizeAssets(assets: AssetConfig | undefined): ResolvedAssetConfig;