@o-a/cms-agent 0.3.1 → 0.5.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.
@@ -21,7 +21,7 @@ theme/
21
21
  sections/ *.liquid, flat, one per section type - markup + embedded settings schema
22
22
  blocks/ *.liquid, flat, one per block type - markup + embedded settings schema
23
23
  snippets/ *.liquid, flat, no schema - small reusable partials, invoked with {% render %}
24
- assets/ static files (CSS, JS, images) - served as-is at /assets/<path>
24
+ assets/ design assets only (CSS, JS, icons, sprites) - served as-is at /assets/<path>. NOT content images: photographs and any image an editor would ever replace belong in media/, see "Images" below
25
25
  root/ static files served at the bare site root (robots.txt, favicon.ico, etc.)
26
26
  templates/ *.json, flat, optional - prebuilt starting pages an editor can pick from
27
27
  ```
@@ -38,7 +38,8 @@ This scaffold already ships real, working examples worth reading before writing
38
38
  **Only these keywords are supported**: `type`, `properties`, `required`, `additionalProperties`, `default`, `minLength`, `maxLength`, `minimum`, `maximum`, `pattern`, `enum`, `items`, `minItems`, `maxItems`, plus the custom `format`/`title`/`description`/`allowedBlocks`/`api`/`swatches`/`step`/`unit` keywords documented below. **Never `$ref`, `$defs`, `definitions`, `allOf`, `anyOf`, `oneOf`, `not`, or `if`/`then`/`else`** - every property's schema must be fully self-contained, written out in full where it's used. If the same shape (e.g. an image object) repeats across several properties or several component files, write it out each time rather than trying to share/reference a definition - there is no cross-referencing mechanism here, in a single schema block or across files, regardless of what standard JSON Schema itself supports elsewhere.
39
39
  3. Once the theme components exist, compose an actual page by writing a file under `content/pages/` whose `sections` array references those types by filename, with a `settings` object matching each one's schema (see "Content JSON model" below).
40
40
  4. **For any page type the site will have more than one of** - a project, an article, a case study, a team member - also write a starting point for it under `theme/templates/<name>.json`. This is easy to skip and worth not skipping: without a template, every new page an editor creates starts completely blank, and they have to rebuild the same section stack by hand every time. A template is just a real page file kept in a different folder - the same shape as anything under `content/pages/` (`schemaVersion`, `name`, `title`, `type`, `layout`, `published`, `sections`), validated identically, using the section types this theme already defines. Its `"title"` is the label an editor picks from, so name it for the page type (`"Project"`, `"Article"`), never `"Untitled"`. **Set its `"type"` to that kind too** (`"project"`, `"article"`) - not `"page"`. Every page an editor creates from a template inherits the template's own `type`, and that value is what listings filter on, so an Article template left at `"type": "page"` silently produces articles no blog index can find. This is easy to get wrong because the template still validates and previews perfectly either way; nothing fails, the listing is simply always empty. Fill each section's settings with short placeholder copy rather than leaving them empty - a template is a starting point to edit, not a blank form. A template that fails validation is skipped silently at boot, so preview a page built from it.
41
- 5. Preview the result before considering the task done - see "Previewing your work".
41
+ 5. **Put every image through `seed-media` before referencing it - never copy image files into the repo by hand.** Photographs and other content images do not belong in the site root, in `theme/root/`, or in `theme/assets/`; they belong in `media/`, under a content-addressed filename the CMS generates. Run `npx seed-media . <file>...` from the site directory and use the `/media/...` URL it prints, as the `url` of a `format: "image"` setting rendered through the `responsive-media` snippet - never a hand-written `<img>`. See "Images" below for the detail and for how this changes once a server is running. Getting this wrong is quiet rather than loud: the page still renders, the image is simply missing.
42
+ 6. Preview the result, then run `npm run check` before considering the task done - it renders every page for real and fails on any image or link pointing at a file that does not exist, which is the fastest way to catch a misplaced image. See "Previewing your work".
42
43
 
43
44
  ### Worked example - a section
44
45
 
@@ -46,6 +47,14 @@ This scaffold already ships real, working examples worth reading before writing
46
47
  <section class="hero" data-section-id="{{ section.id }}">
47
48
  <h1>{{ section.settings.heading }}</h1>
48
49
  {% if section.settings.subheading %}<p>{{ section.settings.subheading }}</p>{% endif %}
50
+ {% if section.settings.image.url %}
51
+ {% render 'responsive-media',
52
+ media: section.settings.image,
53
+ kind: 'image',
54
+ field: 'image',
55
+ alt: section.settings.heading,
56
+ ratio: '16 / 9' %}
57
+ {% endif %}
49
58
  <div class="hero__blocks">{% for html in blocksHtml %}{{ html | raw }}{% endfor %}</div>
50
59
  </section>
51
60
  {% schema %}
@@ -55,7 +64,8 @@ This scaffold already ships real, working examples worth reading before writing
55
64
  "required": ["heading"],
56
65
  "properties": {
57
66
  "heading": { "type": "string", "minLength": 1, "default": "New section" },
58
- "subheading": { "type": "string" }
67
+ "subheading": { "type": "string" },
68
+ "image": { "type": "object", "format": "image" }
59
69
  }
60
70
  }
61
71
  {% endschema %}
@@ -101,6 +111,8 @@ Write the description as one short sentence about what the section **is**, not w
101
111
 
102
112
  Flat `.liquid` files in `snippets/`, invoked with `{% render 'name', param1: value %}` - never `{% include %}`. A snippet only sees parameters explicitly passed to it; the calling scope never leaks in.
103
113
 
114
+ This scaffold ships one you must use: **`snippets/responsive-media.liquid`, through which every content image and video is rendered.** It emits the attribute that lets an editor drag a file from the media library straight onto the picture in the live preview. See "Making an image or video droppable" below, and `theme/sections/hero.liquid` / `theme/sections/cta-banner.liquid` for real call sites.
115
+
104
116
  ## Field format hints
105
117
 
106
118
  Every setting is plain JSON Schema (`string`, `integer`, `number`, `boolean`, `array`, with `minLength`/`minimum`/`enum`/etc. for real validation). One extra keyword, `"format"`, is a UI hint only (never validated server-side) that the admin reads to choose a richer input widget:
@@ -108,7 +120,8 @@ Every setting is plain JSON Schema (`string`, `integer`, `number`, `boolean`, `a
108
120
  | `format` | On type | Effect |
109
121
  |---|---|---|
110
122
  | `richtext` | `string` | Rich-text editor; render with `{{ ... | raw }}`, not plain `{{ }}` |
111
- | `image` | `object` | Image picker with focal point; object shape is exactly `{ "url": "...", "focalX": 0.5, "focalY": 0.5 }` - render `{{ section.settings.<field>.url }}` |
123
+ | `image` | `object` | Image picker with focal point; object shape is exactly `{ "url": "...", "focalX": 0.5, "focalY": 0.5 }`. **Render it with `{% render 'responsive-media' %}`, never a hand-written `<img>`** - see "Making an image or video droppable" below |
124
+ | `video` | `object` | Video picker for a short, silent background loop; object shape is exactly `{ "url": "...", "poster": "..." }`. **Render it with `{% render 'responsive-media' %}`, never a hand-written `<video>`** - see "Making an image or video droppable" below. The snippet sets the poster for you, which matters because the poster is what shows before the clip loads and whenever it cannot play. Only `.mp4`/`.webm` can be uploaded, under the same 10MB media cap - long-form video belongs on YouTube/Vimeo as an embed, not here. No focal point: a loop is played as a background rather than cropped around a subject |
112
125
  | `textarea` | `string` | Multi-line `<textarea>` |
113
126
  | `uri` | `string` | `<input type="url">` |
114
127
  | `date` | `string` | `<input type="date">`, value as `YYYY-MM-DD` |
@@ -186,6 +199,11 @@ Every type/format combination above needs nothing beyond what triggers it - no `
186
199
  "format": "image"
187
200
  }
188
201
 
202
+ "backgroundLoop": {
203
+ "type": "object",
204
+ "format": "video"
205
+ }
206
+
189
207
  "tags": {
190
208
  "type": "array",
191
209
  "items": { "type": "string" }
@@ -308,13 +326,61 @@ Each entry in `sections` requires `id` (any non-empty string, unique within the
308
326
 
309
327
  ## Images
310
328
 
311
- Uploads go through `POST /v1/media` (multipart, requires a token with `media` scope) or the admin's own media library UI - never write directly into `media/` from an agent, since the CMS names files by content hash. A successful upload returns `{ "url": "/media/<name>" }`. In theme content, an image is just a plain string setting holding that URL:
329
+ **Every content image lives in `media/`, named by the CMS itself - never placed there, or anywhere else in the repo, by hand.** If you are generating a site and have image files to use, `seed-media` (below) is the way to get them in; it is step 5 of the workflow above. Images do not go in the site root, in `theme/root/`, or in `theme/assets/` (that folder is for design assets - CSS, JS, icons).
330
+
331
+ **One image is one file and one URL. The CMS does not generate resized variants, and you must not hand-build them.** Do not produce fixed-width copies of a photo, do not write a `srcset` listing several generated files, and do not add a build step that emits them. Server-side resizing is a deliberately deferred feature - there is no image processing in the CMS at all - so hand-made variants are files nothing manages: the media library cannot show them as one image, and an editor replacing the picture gets the original swapped while every variant silently keeps the old photo. Reference the single `/media/` URL and let the browser scale it, with CSS (`max-width: 100%`, `object-fit`) doing the responsive work. `width`/`height` attributes and `loading="lazy"` are worth setting; multiple sources are not available.
332
+
333
+ Once a server is running, uploads go through `POST /v1/media` (multipart, requires a token with `media` scope) or the admin's own media library UI - never write directly into `media/` from an agent, since the CMS names files by content hash. A successful upload returns `{ "url": "/media/<name>" }`. In theme content, **an image is a `format: "image"` object setting, never a plain string** - and a short silent loop is a `format: "video"` one:
312
334
 
313
335
  ```json
314
- { "type": "string", "default": "" }
336
+ "image": { "type": "object", "format": "image" }
315
337
  ```
316
338
 
317
- unless the design needs a focal point for a cropped image, in which case use `"format": "image"` (see the table above) instead of a plain string.
339
+ This is not a stylistic preference. The admin's picker, the focal point, and drag-and-drop replacement all work on that object shape (`{ "url", "focalX", "focalY" }`; a video stores `{ "url", "poster" }`). A plain string setting gets a bare text box and nothing else - and since dropping an image writes the object shape, dragging onto a field declared as a string fails validation outright. Use a plain string only for a path that is never editable content, such as a theme asset bundled under `theme/assets/`.
340
+
341
+ ### Making an image or video droppable
342
+
343
+ Render every content image and video through the `responsive-media` snippet the scaffold ships in `theme/snippets/` - see `hero.liquid` and `cta-banner.liquid` for working call sites:
344
+
345
+ ```liquid
346
+ {% if section.settings.image.url %}
347
+ {% render 'responsive-media',
348
+ media: section.settings.image,
349
+ kind: 'image',
350
+ field: 'image',
351
+ alt: 'What is actually in the picture',
352
+ ratio: '16 / 9' %}
353
+ {% endif %}
354
+ ```
355
+
356
+ A video is the same call with `kind: 'video'` and the video setting - the snippet handles the `<video>`, the poster and the autoplay/muted/loop attributes itself:
357
+
358
+ ```liquid
359
+ {% if section.settings.backgroundLoop.url %}
360
+ {% render 'responsive-media',
361
+ media: section.settings.backgroundLoop,
362
+ kind: 'video',
363
+ field: 'backgroundLoop',
364
+ ratio: '21 / 9' %}
365
+ {% endif %}
366
+ ```
367
+
368
+ Parameters:
369
+
370
+ | Parameter | Effect |
371
+ |---|---|
372
+ | `media` | **Required.** The whole setting object, not its `.url` - the snippet reads `url`, and `focalX`/`focalY` or `poster` from it |
373
+ | `field` | **Required.** The schema property name this renders. A drop writes the new URL back to exactly this key, so a wrong value silently writes to the wrong setting |
374
+ | `kind` | `'image'` (the default) or `'video'` |
375
+ | `alt` | Real alt text describing what is in the picture. On a video it becomes an `aria-label`; omit it for a purely decorative loop and the clip is marked `aria-hidden` instead |
376
+ | `ratio` | Any CSS `aspect-ratio` value, e.g. `'16 / 9'`. Defaults to `'3 / 2'` |
377
+ | `loading` | `'eager'` for anything above the fold, otherwise omit - it defaults to `lazy`. Lazy-loading a hero image delays the largest thing on the page |
378
+ | `priority` | `true` adds `fetchpriority="high"`. The hero image only, never more than one per page |
379
+ | `class` | Extra classes on the wrapper element |
380
+
381
+ The snippet puts `data-cms-media="<field>"` on the element it renders (plus `data-cms-image` for images). **That attribute is the whole contract.** The admin finds a drop target by searching the previewed page for it, and uses its value to know which setting to write the new URL into, so `field` must match the schema property name exactly. A hand-written `<img>` without it renders perfectly and is simply never droppable - a silent gap, because nothing about the page looks wrong, which is exactly how a real generated site shipped with every image un-editable.
382
+
383
+ Guard the call on the url, as above. An unset image renders nothing at all rather than a placeholder: a theme cannot tell whether it is rendering for the admin preview or the public site, so an editor-only affordance would show to real visitors. The first image is set through the Fields panel's own picker; drag-and-drop replaces it from then on.
318
384
 
319
385
  If you're writing starter content before a server is even running - so `POST /v1/media` isn't reachable yet - use the `seed-media` CLI instead of placing images under `theme/root/`. It computes the exact same content-addressed filename a real upload would, so the result is indistinguishable from one:
320
386
 
@@ -324,10 +390,14 @@ npx seed-media <site-directory> photo.jpg another.png
324
390
  # another.png -> /media/another-91cd4a08f2b1.png
325
391
  ```
326
392
 
327
- Then use the printed URL exactly like a real upload's:
393
+ Then use the printed URL as the `url` of a `format: "image"` setting, exactly like a real upload's - not as a plain string:
328
394
 
329
395
  ```json
330
- { "type": "string", "default": "/media/photo-3f9a2b7c1e04.jpg" }
396
+ "image": {
397
+ "type": "object",
398
+ "format": "image",
399
+ "default": { "url": "/media/photo-3f9a2b7c1e04.jpg", "focalX": 0.5, "focalY": 0.5 }
400
+ }
331
401
  ```
332
402
 
333
403
  This is only for seeding starter content offline - once a server is running, a later image change from an editor still goes through `POST /v1/media` or the admin's media library as normal.
@@ -1000,6 +1000,74 @@ section {
1000
1000
  }
1001
1001
 
1002
1002
  /* ----- CTA banner ----- */
1003
+ /* responsive-media snippet -------------------------------------------
1004
+ One wrapper for both an image and a video, so a section does not care
1005
+ which it was given. The aspect ratio comes in as a custom property
1006
+ from the snippet's own `ratio` parameter. */
1007
+ .media {
1008
+ position: relative;
1009
+ overflow: hidden;
1010
+ aspect-ratio: var(--media-ratio, 3 / 2);
1011
+ border-radius: 12px;
1012
+ background: rgba(255, 255, 255, 0.04);
1013
+ }
1014
+
1015
+ .media img,
1016
+ .media video {
1017
+ display: block;
1018
+ width: 100%;
1019
+ height: 100%;
1020
+ object-fit: cover;
1021
+ }
1022
+
1023
+ /* The empty slot. Visibly a placeholder rather than a broken image, and
1024
+ still a drop target - dragging a file onto it is how it gets filled. */
1025
+ .media--empty {
1026
+ display: flex;
1027
+ align-items: center;
1028
+ justify-content: center;
1029
+ padding: 1rem;
1030
+ border: 1px dashed rgba(255, 255, 255, 0.25);
1031
+ background: rgba(255, 255, 255, 0.02);
1032
+ }
1033
+
1034
+ .media__hint {
1035
+ max-width: 22ch;
1036
+ text-align: center;
1037
+ font-size: 0.8rem;
1038
+ line-height: 1.4;
1039
+ opacity: 0.6;
1040
+ }
1041
+
1042
+ /* An autoplaying loop is motion the viewer did not ask for, and no
1043
+ amount of CSS can stop a video element autoplaying - but hiding it
1044
+ and painting its own poster in its place gets the same result, so a
1045
+ reduced-motion viewer sees the still rather than the movement. */
1046
+ @media (prefers-reduced-motion: reduce) {
1047
+ .media--has-poster {
1048
+ background-image: var(--media-poster);
1049
+ background-size: cover;
1050
+ background-position: center;
1051
+ }
1052
+
1053
+ .media--has-poster video {
1054
+ display: none;
1055
+ }
1056
+ }
1057
+
1058
+ .hero__media {
1059
+ width: 100%;
1060
+ }
1061
+
1062
+ /* Sits behind the banner's own copy rather than beside it. */
1063
+ .cta-banner__media {
1064
+ position: absolute;
1065
+ inset: 0;
1066
+ z-index: 0;
1067
+ border-radius: inherit;
1068
+ opacity: 0.35;
1069
+ }
1070
+
1003
1071
  .cta-banner {
1004
1072
  background: var(--color-dark);
1005
1073
  color: var(--color-dark-ink);
@@ -1019,6 +1087,8 @@ section {
1019
1087
  }
1020
1088
 
1021
1089
  .cta-banner__inner {
1090
+ position: relative;
1091
+ z-index: 1;
1022
1092
  position: relative;
1023
1093
  z-index: 1;
1024
1094
  max-width: 560px;
@@ -1,6 +1,14 @@
1
1
  <section data-section-id="{{ section.id }}">
2
2
  <div class="container">
3
3
  <div class="cta-banner reveal">
4
+ {% if section.settings.backgroundLoop.url %}
5
+ {% render 'responsive-media',
6
+ media: section.settings.backgroundLoop,
7
+ kind: 'video',
8
+ field: 'backgroundLoop',
9
+ ratio: '21 / 9',
10
+ class: 'cta-banner__media' %}
11
+ {% endif %}
4
12
  <div class="cta-banner__inner">
5
13
  <h2>{{ section.settings.heading }}</h2>
6
14
  {% if section.settings.subheading %}<p>{{ section.settings.subheading }}</p>{% endif %}
@@ -20,7 +28,8 @@
20
28
  "required": ["heading"],
21
29
  "properties": {
22
30
  "heading": { "type": "string", "minLength": 1, "default": "Ready to get started?" },
23
- "subheading": { "type": "string" }
31
+ "subheading": { "type": "string" },
32
+ "backgroundLoop": { "type": "object", "format": "video" }
24
33
  },
25
34
  "allowedBlocks": ["button"]
26
35
  }
@@ -6,7 +6,18 @@
6
6
  {% if section.settings.subheading %}<p>{{ section.settings.subheading }}</p>{% endif %}
7
7
  <div class="hero__actions">{% for html in blocksHtml %}{{ html | raw }}{% endfor %}</div>
8
8
  </div>
9
- {% if section.settings.codeSnippet %}
9
+ {% if section.settings.image.url %}
10
+ {% render 'responsive-media',
11
+ media: section.settings.image,
12
+ kind: 'image',
13
+ field: 'image',
14
+ alt: section.settings.heading,
15
+ ratio: '4 / 3',
16
+ loading: 'eager',
17
+ priority: true,
18
+ need: 'The main hero image',
19
+ class: 'hero__media reveal' %}
20
+ {% elsif section.settings.codeSnippet %}
10
21
  <div class="code-panel reveal">
11
22
  <div class="code-panel__bar">
12
23
  <span></span><span></span><span></span>
@@ -28,6 +39,7 @@
28
39
  "eyebrow": { "type": "string" },
29
40
  "heading": { "type": "string", "minLength": 1, "default": "New Section" },
30
41
  "subheading": { "type": "string" },
42
+ "image": { "type": "object", "format": "image" },
31
43
  "codeTitle": { "type": "string" },
32
44
  "codeSnippet": { "type": "string" }
33
45
  },
@@ -0,0 +1,107 @@
1
+ {%- comment -%}
2
+ responsive-media - the only way a content image or video should reach a page.
3
+
4
+ Renders a `format: "image"` or `format: "video"` setting, and marks the
5
+ result as a drag-and-drop target so an editor can drag a file from the
6
+ admin's media library straight onto it in the live preview.
7
+
8
+ {% render 'responsive-media',
9
+ media: section.settings.image, the whole setting object, not .url
10
+ kind: 'image', 'image' or 'video'
11
+ field: 'image', REQUIRED - the settings key this
12
+ renders, which is what a drop writes
13
+ back to
14
+ alt: 'What is actually in the picture',
15
+ ratio: '16 / 9', any CSS aspect-ratio value
16
+ loading: 'eager', 'eager' above the fold, else lazy
17
+ priority: true, adds fetchpriority=high
18
+ need: 'Wide shot of the still house', shown in the empty slot
19
+ class: 'extra-class' %}
20
+
21
+ Why `field` matters
22
+ -------------------
23
+ The admin finds a drop target by looking for data-cms-media, and uses its
24
+ value to know which setting to write the new URL into. Get it wrong and the
25
+ drop silently writes to the wrong field; omit it and the image simply is not
26
+ droppable. It must match the schema property name exactly.
27
+
28
+ An empty slot is NOT a drop target (decided directly). A theme has no way to
29
+ tell whether it is rendering for the admin preview or for the public site -
30
+ the renderer does not expose that - so an editor-only affordance would leak
31
+ onto the live site as a dashed box in front of real visitors. Guard the call
32
+ instead, exactly as hero.liquid and cta-banner.liquid do:
33
+
34
+ {% if section.settings.image.url %}{% render 'responsive-media', ... %}{% endif %}
35
+
36
+ so a section with nothing set renders nothing at all. Set the first image
37
+ through the Fields panel's own picker; drag-and-drop then replaces it.
38
+
39
+ No srcset, on purpose
40
+ ---------------------
41
+ The CMS names uploaded files by a hash of their own bytes, so a resized
42
+ variant gets a completely different name - "photo-480w.webp" is not
43
+ derivable from "photo-3f9a2b7c1e04.jpg". Hand-built variant URLs therefore
44
+ point at files that do not exist, which renders a page that looks perfectly
45
+ fine except at one breakpoint. Do not add a srcset here. One image is one
46
+ file and one URL; let CSS do the responsive work.
47
+ {%- endcomment -%}
48
+ {%- assign media_kind = kind | default: 'image' -%}
49
+ {%- assign media_field = field | default: 'image' -%}
50
+ {%- assign media_url = media.url -%}
51
+ {%- capture ratio_style -%}--media-ratio: {{ ratio | default: '3 / 2' }};{%- endcapture -%}
52
+
53
+ {%- if media_url == nil or media_url == '' -%}
54
+ {%- comment -%}
55
+ Reached only if a caller renders this without guarding on a url - the
56
+ scaffold's own call sites guard, so this never appears on a real page.
57
+ Kept as a graceful fallback for a theme that genuinely wants a visible
58
+ placeholder, and deliberately carrying NO drag attributes: see above.
59
+ {%- endcomment -%}
60
+ <div class="media media--empty {{ class }}"
61
+ style="{{ ratio_style }}"
62
+ role="img"
63
+ aria-label="Placeholder. {{ need | default: alt }}">
64
+ <span class="media__hint">{{ need | default: alt | default: 'Drag a file here' }}</span>
65
+ </div>
66
+ {%- elsif media_kind == 'video' -%}
67
+ {%- comment -%}
68
+ No data-cms-image on a video: an older admin understands only that
69
+ attribute and would write an image-shaped value ({url, focalX, focalY})
70
+ over this field's own {url, poster}. Better it ignores the slot entirely
71
+ than corrupts it.
72
+
73
+ A background loop is muted and looping by definition, so it may autoplay.
74
+ The poster is what shows before the clip loads and wherever it will not
75
+ play at all, which is why it is worth setting rather than optional.
76
+ {%- endcomment -%}
77
+ <div class="media {{ class }}{% if media.poster and media.poster != '' %} media--has-poster{% endif %}"
78
+ style="{{ ratio_style }}{% if media.poster and media.poster != '' %} --media-poster: url('{{ media.poster }}');{% endif %}"
79
+ data-cms-media="{{ media_field }}"
80
+ data-cms-media-kind="video">
81
+ <video
82
+ src="{{ media_url }}"
83
+ {% if media.poster and media.poster != '' %}poster="{{ media.poster }}"{% endif %}
84
+ autoplay
85
+ muted
86
+ loop
87
+ playsinline
88
+ preload="metadata"
89
+ {% if alt and alt != '' %}aria-label="{{ alt }}"{% else %}aria-hidden="true"{% endif %}></video>
90
+ </div>
91
+ {%- else -%}
92
+ {%- assign fx = media.focalX | default: 0.5 | times: 100 | round: 1 -%}
93
+ {%- assign fy = media.focalY | default: 0.5 | times: 100 | round: 1 -%}
94
+ <div class="media {{ class }}"
95
+ style="{{ ratio_style }}"
96
+ data-cms-media="{{ media_field }}"
97
+ data-cms-media-kind="image"
98
+ data-cms-image="{{ media_field }}">
99
+ <img
100
+ src="{{ media_url }}"
101
+ alt="{{ alt }}"
102
+ loading="{{ loading | default: 'lazy' }}"
103
+ decoding="async"
104
+ {% if priority %}fetchpriority="high"{% endif %}
105
+ style="object-position: {{ fx }}% {{ fy }}%;">
106
+ </div>
107
+ {%- endif -%}
@@ -21,8 +21,15 @@ import { basename, extname } from 'node:path';
21
21
  // lowercase, non [a-z0-9-] runs collapsed to a single "-", trimmed -
22
22
  // not a second, independently-invented convention.
23
23
  const HASH_LENGTH = 12;
24
- // Images only - confirmed with the user, not a general document
25
- // library. .svg is rejected regardless of this list even though it's
24
+ // Images and short videos - not a general document library. Video was
25
+ // added deliberately (confirmed with the user) for silent looping
26
+ // clips used where a hero image would otherwise go; long-form video
27
+ // belongs on YouTube/Vimeo and needs nothing from here. The upload cap
28
+ // (site.config.json's media.maxUploadBytes, 10MB by default) is what
29
+ // keeps that distinction honest - it comfortably fits a well-compressed
30
+ // 10-15s loop and comfortably rejects a real film.
31
+ //
32
+ // .svg is rejected regardless of this list even though it's
26
33
  // technically an image format - docs/cms-build-plan.md's own "SVG
27
34
  // rejected outright, not sanitised" decision (a real stored-XSS path
28
35
  // otherwise: mime-types.ts maps .svg to a real image/svg+xml content
@@ -33,7 +40,7 @@ const HASH_LENGTH = 12;
33
40
  // constant, specifically so seed-media.ts (an offline tool with no
34
41
  // HTTP request to validate) enforces the exact same rule rather than
35
42
  // a second, independently-maintained copy of it.
36
- export const ALLOWED_UPLOAD_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
43
+ export const ALLOWED_UPLOAD_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.webm']);
37
44
  export function buildMediaFilename(originalFilename, bytes) {
38
45
  const hash = createHash('sha256').update(bytes).digest('hex').slice(0, HASH_LENGTH);
39
46
  const base = basename(originalFilename, extname(originalFilename));
@@ -1,5 +1,11 @@
1
1
  import type { FastifyPluginAsync } from 'fastify';
2
2
  import type { SiteConfig } from '../config.ts';
3
+ type ResolvedRange = {
4
+ start: number;
5
+ end: number;
6
+ } | null | 'unsatisfiable';
7
+ export declare function resolveRange(header: string | undefined, size: number): ResolvedRange;
3
8
  export declare const mediaPublicRoutes: FastifyPluginAsync<{
4
9
  config: SiteConfig;
5
10
  }>;
11
+ export {};
@@ -1,5 +1,40 @@
1
1
  import { openLocalFsMediaDriver } from "../media/drivers/local-fs-driver.js";
2
2
  import { mimeTypeFor } from "../services/mime-types.js";
3
+ export function resolveRange(header, size) {
4
+ if (header === undefined) {
5
+ return null;
6
+ }
7
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
8
+ if (!match) {
9
+ return null;
10
+ }
11
+ const [, rawStart, rawEnd] = match;
12
+ if (rawStart === '' && rawEnd === '') {
13
+ return null;
14
+ }
15
+ // "bytes=-500" means the LAST 500 bytes, not "from 0 to 500" - a
16
+ // classic misreading, and one that silently serves the wrong part of
17
+ // a file rather than failing.
18
+ if (rawStart === '') {
19
+ const suffixLength = Number(rawEnd);
20
+ if (suffixLength === 0) {
21
+ return 'unsatisfiable';
22
+ }
23
+ return { start: Math.max(0, size - suffixLength), end: size - 1 };
24
+ }
25
+ const start = Number(rawStart);
26
+ if (start >= size) {
27
+ return 'unsatisfiable';
28
+ }
29
+ // An end past the last byte is clamped, not rejected: browsers
30
+ // routinely ask for more than exists (e.g. bytes=0-999999 on a small
31
+ // file) and expect the server to simply return what it has.
32
+ const end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1);
33
+ if (end < start) {
34
+ return 'unsatisfiable';
35
+ }
36
+ return { start, end };
37
+ }
3
38
  async function handleMediaRequest(request, reply, config) {
4
39
  const relativePath = request.params['*'];
5
40
  // Never read: Fastify doesn't require query params to be declared or
@@ -30,12 +65,48 @@ async function handleMediaRequest(request, reply, config) {
30
65
  // because filenames are content-addressed (a hash of the file's own
31
66
  // bytes, see media/filename.ts): a given URL's content can never
32
67
  // change, so there is no invalidation case to ever design for here.
68
+ // Accept-Ranges on every media response, not just ranged ones - it is
69
+ // how a client learns ranges are available at all before asking.
70
+ //
71
+ // This matters far beyond scrubbing: Safari and iOS open a <video>
72
+ // with "Range: bytes=0-1" and will refuse to play at all if answered
73
+ // with a plain 200, so range support is what makes video work in
74
+ // those browsers rather than an optimisation. It also lets
75
+ // preload="metadata" fetch just the header instead of pulling the
76
+ // whole file down to read a duration.
77
+ //
78
+ // Sliced from the already-buffered read rather than streamed: the
79
+ // driver's own get() contract returns a whole Buffer (drivers/
80
+ // driver.ts) and assets.ts documents reply.send(stream) returning an
81
+ // empty body in this Fastify version, verified there with a minimal
82
+ // repro. With uploads capped (10MB by default) the memory held per
83
+ // request is bounded and no worse than this route's existing
84
+ // behaviour, so slicing buys correct semantics without taking on
85
+ // that risk or widening the driver interface a future object-storage
86
+ // driver would have to satisfy.
33
87
  reply
34
88
  .header('X-Content-Type-Options', 'nosniff')
35
89
  .header('Access-Control-Allow-Origin', '*')
36
90
  .header('Cache-Control', 'public, max-age=31536000, immutable')
37
- .type(mimeTypeFor(relativePath))
38
- .send(bytes);
91
+ .header('Accept-Ranges', 'bytes')
92
+ .type(mimeTypeFor(relativePath));
93
+ const range = resolveRange(request.headers.range, bytes.length);
94
+ if (range === 'unsatisfiable') {
95
+ // 416 carries its own required Content-Range naming the real size,
96
+ // which is how a client corrects itself rather than retrying blind.
97
+ reply.code(416).header('Content-Range', `bytes */${bytes.length}`).send();
98
+ return;
99
+ }
100
+ if (range !== null) {
101
+ const slice = bytes.subarray(range.start, range.end + 1);
102
+ reply
103
+ .code(206)
104
+ .header('Content-Range', `bytes ${range.start}-${range.end}/${bytes.length}`)
105
+ .header('Content-Length', slice.length)
106
+ .send(slice);
107
+ return;
108
+ }
109
+ reply.send(bytes);
39
110
  }
40
111
  // Deliberately and permanently unauthenticated, same reasoning as
41
112
  // assets.ts: uploaded media must be fetchable by any visitor's browser
@@ -30,7 +30,7 @@ async function handleUploadMedia(request, reply, config) {
30
30
  reply.code(415).send({
31
31
  statusCode: 415,
32
32
  error: 'Unsupported Media Type',
33
- message: `"${extension}" is not an accepted image type`,
33
+ message: `"${extension}" is not an accepted media type`,
34
34
  });
35
35
  return;
36
36
  }
@@ -22,6 +22,13 @@ export const MIME_TYPES = {
22
22
  '.gif': 'image/gif',
23
23
  '.svg': 'image/svg+xml',
24
24
  '.webp': 'image/webp',
25
+ // Video: without these an uploaded .mp4 serves as the
26
+ // DEFAULT_MIME_TYPE below (application/octet-stream), which every
27
+ // browser treats as "download this" rather than playing it - and
28
+ // media-public.ts sets X-Content-Type-Options: nosniff, so nothing
29
+ // rescues it by sniffing. Same failure robots.txt already hit.
30
+ '.mp4': 'video/mp4',
31
+ '.webm': 'video/webm',
25
32
  '.ico': 'image/x-icon',
26
33
  '.woff': 'font/woff',
27
34
  '.woff2': 'font/woff2',
@@ -5,18 +5,18 @@ import { Ajv } from 'ajv';
5
5
  // authors, not agent code, and are validated the same lenient way
6
6
  // everywhere (see theme-schemas.ts).
7
7
  const ajv = new Ajv({ allErrors: true, strict: false });
8
- // The six format values guide-theme-authoring.md/AGENTS.md actually
9
- // document (richtext/image/textarea/uri/date/color/range/toggle - uri
10
- // and date are real standard JSON Schema formats already understood
8
+ // The nine format values guide-theme-authoring.md/AGENTS.md actually
9
+ // document (richtext/image/video/textarea/uri/date/color/range/toggle -
10
+ // uri and date are real standard JSON Schema formats already understood
11
11
  // without this) are still, correctly, UI hints only: registering them
12
12
  // as a literal no-op format (the `true` here, not a real validator
13
13
  // function) doesn't make Ajv enforce anything about them, it only
14
14
  // stops it printing "unknown format \"x\" ignored" for values that are
15
15
  // completely expected. A theme author's genuine typo (e.g. "iamge")
16
16
  // still isn't in this list, so it still warns - this only silences the
17
- // six we ourselves tell theme authors to use, not unknown-format
17
+ // ones we ourselves tell theme authors to use, not unknown-format
18
18
  // warnings in general.
19
- for (const format of ['richtext', 'image', 'textarea', 'color', 'range', 'toggle']) {
19
+ for (const format of ['richtext', 'image', 'textarea', 'color', 'range', 'toggle', 'video']) {
20
20
  ajv.addFormat(format, true);
21
21
  }
22
22
  const schemasDir = join(import.meta.dirname, '..', 'schemas');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o-a/cms-agent",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"