@o-a/cms-agent 0.4.0 → 0.5.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.
- package/dist/create-site/generate-site.js +11 -0
- package/dist/create-site/template/AGENTS.md +75 -11
- package/dist/create-site/template/theme/assets/style.css +70 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +10 -1
- package/dist/create-site/template/theme/sections/hero.liquid +13 -1
- package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
- package/dist/site-check/cli.js +1 -0
- package/dist/site-check/run-check.d.ts +1 -1
- package/dist/site-check/run-check.js +58 -9
- package/package.json +1 -1
|
@@ -124,6 +124,17 @@ export function scaffoldSite(targetDir) {
|
|
|
124
124
|
tunnel: 'node server.js --tunnel',
|
|
125
125
|
dev: 'node --watch-path=../theme server.js',
|
|
126
126
|
check: 'check-site',
|
|
127
|
+
// seed-media is the package's own installed CLI bin, exposed
|
|
128
|
+
// as a script for exactly the same reason check is: there is
|
|
129
|
+
// no package.json or node_modules at the SITE root, only here
|
|
130
|
+
// in vhost/, so the obvious-looking `npx seed-media` run from
|
|
131
|
+
// the site directory resolves nothing locally, goes to the
|
|
132
|
+
// public registry for a package by that name and dies with
|
|
133
|
+
// E404 - while still exiting 0. That is not hypothetical: it
|
|
134
|
+
// is what sent a generated site's images into theme/root/
|
|
135
|
+
// instead of media/. Run it from here as
|
|
136
|
+
// `npm run seed-media -- .. <file>`.
|
|
137
|
+
'seed-media': 'seed-media',
|
|
127
138
|
},
|
|
128
139
|
dependencies: {
|
|
129
140
|
// Pinned exact, never a ^range - at v0.x even a minor bump
|
|
@@ -38,7 +38,7 @@ 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. **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 `
|
|
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 `npm run seed-media -- .. <file>...` from `vhost/` (the same place `npm run check` runs from) 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
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".
|
|
43
43
|
|
|
44
44
|
### Worked example - a section
|
|
@@ -47,6 +47,14 @@ This scaffold already ships real, working examples worth reading before writing
|
|
|
47
47
|
<section class="hero" data-section-id="{{ section.id }}">
|
|
48
48
|
<h1>{{ section.settings.heading }}</h1>
|
|
49
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 %}
|
|
50
58
|
<div class="hero__blocks">{% for html in blocksHtml %}{{ html | raw }}{% endfor %}</div>
|
|
51
59
|
</section>
|
|
52
60
|
{% schema %}
|
|
@@ -56,7 +64,8 @@ This scaffold already ships real, working examples worth reading before writing
|
|
|
56
64
|
"required": ["heading"],
|
|
57
65
|
"properties": {
|
|
58
66
|
"heading": { "type": "string", "minLength": 1, "default": "New section" },
|
|
59
|
-
"subheading": { "type": "string" }
|
|
67
|
+
"subheading": { "type": "string" },
|
|
68
|
+
"image": { "type": "object", "format": "image" }
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
71
|
{% endschema %}
|
|
@@ -102,6 +111,8 @@ Write the description as one short sentence about what the section **is**, not w
|
|
|
102
111
|
|
|
103
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.
|
|
104
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
|
+
|
|
105
116
|
## Field format hints
|
|
106
117
|
|
|
107
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:
|
|
@@ -109,8 +120,8 @@ Every setting is plain JSON Schema (`string`, `integer`, `number`, `boolean`, `a
|
|
|
109
120
|
| `format` | On type | Effect |
|
|
110
121
|
|---|---|---|
|
|
111
122
|
| `richtext` | `string` | Rich-text editor; render with `{{ ... | raw }}`, not plain `{{ }}` |
|
|
112
|
-
| `image` | `object` | Image picker with focal point; object shape is exactly `{ "url": "...", "focalX": 0.5, "focalY": 0.5 }
|
|
113
|
-
| `video` | `object` | Video picker for a short, silent background loop; object shape is exactly `{ "url": "...", "poster": "..." }` -
|
|
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 |
|
|
114
125
|
| `textarea` | `string` | Multi-line `<textarea>` |
|
|
115
126
|
| `uri` | `string` | `<input type="url">` |
|
|
116
127
|
| `date` | `string` | `<input type="date">`, value as `YYYY-MM-DD` |
|
|
@@ -319,26 +330,77 @@ Each entry in `sections` requires `id` (any non-empty string, unique within the
|
|
|
319
330
|
|
|
320
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.
|
|
321
332
|
|
|
322
|
-
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
|
|
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:
|
|
323
334
|
|
|
324
335
|
```json
|
|
325
|
-
{ "type": "
|
|
336
|
+
"image": { "type": "object", "format": "image" }
|
|
337
|
+
```
|
|
338
|
+
|
|
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 %}
|
|
326
366
|
```
|
|
327
367
|
|
|
328
|
-
|
|
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.
|
|
329
384
|
|
|
330
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:
|
|
331
386
|
|
|
332
387
|
```
|
|
333
|
-
|
|
388
|
+
cd vhost
|
|
389
|
+
npm run seed-media -- .. photo.jpg another.png
|
|
334
390
|
# photo.jpg -> /media/photo-3f9a2b7c1e04.jpg
|
|
335
391
|
# another.png -> /media/another-91cd4a08f2b1.png
|
|
336
392
|
```
|
|
337
393
|
|
|
338
|
-
|
|
394
|
+
Run it from `vhost/`, not the site root. There is no `package.json` or `node_modules` at the site root - the CMS is installed under `vhost/` - so a bare `npx seed-media` there resolves nothing locally, goes to the public npm registry looking for a package called "seed-media", and fails with a 404 while still exiting 0.
|
|
395
|
+
|
|
396
|
+
Then use the printed URL as the `url` of a `format: "image"` setting, exactly like a real upload's - not as a plain string:
|
|
339
397
|
|
|
340
398
|
```json
|
|
341
|
-
|
|
399
|
+
"image": {
|
|
400
|
+
"type": "object",
|
|
401
|
+
"format": "image",
|
|
402
|
+
"default": { "url": "/media/photo-3f9a2b7c1e04.jpg", "focalX": 0.5, "focalY": 0.5 }
|
|
403
|
+
}
|
|
342
404
|
```
|
|
343
405
|
|
|
344
406
|
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.
|
|
@@ -381,6 +443,8 @@ From `vhost/`, with the site's dependencies already installed (no need for the s
|
|
|
381
443
|
npm run check
|
|
382
444
|
```
|
|
383
445
|
|
|
384
|
-
Renders every published page for real and reports, in one pass: any theme component excluded at boot (same warnings the server itself prints, see "Minimal form" above); any
|
|
446
|
+
Renders every published page for real and reports, in one pass: any theme component excluded at boot (same warnings the server itself prints, see "Minimal form" above); any `src`/`srcset`/`poster`/`href` in the rendered HTML pointing at a `/media/`, `/assets/`, or root-static file that doesn't actually exist on disk; any internal link that doesn't point at a real, published page; and **any content image or video being served from `theme/root/` or `theme/assets/` instead of `media/`**. Exits non-zero if it finds anything - safe to run after generating content, not just as a manual spot-check.
|
|
447
|
+
|
|
448
|
+
That last one has no other way of being noticed: the file exists, so the page renders perfectly. It is simply invisible to the media library and an editor can never replace it.
|
|
385
449
|
|
|
386
450
|
This catches the specific failure mode a snippet like `responsive-image` can introduce silently: a `widths` list that includes a size nothing was actually uploaded/generated for renders a perfectly normal-looking page with one broken image at that breakpoint - nothing about the page itself is wrong, so nothing else would ever flag it.
|
|
@@ -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.
|
|
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 -%}
|
package/dist/site-check/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type CheckFindingKind = 'schema' | 'render-error' | 'missing-asset' | 'broken-link';
|
|
1
|
+
export type CheckFindingKind = 'schema' | 'render-error' | 'missing-asset' | 'broken-link' | 'misplaced-media';
|
|
2
2
|
export interface CheckFinding {
|
|
3
3
|
kind: CheckFindingKind;
|
|
4
4
|
message: string;
|
|
@@ -4,6 +4,7 @@ import { renderPage } from "../renderer/render-page.js";
|
|
|
4
4
|
import { PathSafetyError, sanitisePath } from "../services/path-safety.js";
|
|
5
5
|
import { buildSitemapUrls } from "../routes/sitemap.js";
|
|
6
6
|
import { urlToPagePath } from "../services/urls.js";
|
|
7
|
+
import { ALLOWED_UPLOAD_EXTENSIONS } from "../media/filename.js";
|
|
7
8
|
// A reference this project's own theme conventions actually produce:
|
|
8
9
|
// src="...", srcset="w1 480w, w2 960w" (comma-separated, each entry a
|
|
9
10
|
// url then a space then a width descriptor - strip the descriptor),
|
|
@@ -11,25 +12,46 @@ import { urlToPagePath } from "../services/urls.js";
|
|
|
11
12
|
// "the schema surface here is narrow and flat... a library would be
|
|
12
13
|
// heavier than the problem warrants" precedent for the equivalent
|
|
13
14
|
// choice on the admin side.
|
|
14
|
-
|
|
15
|
+
// poster is included deliberately: a video's poster is a real content
|
|
16
|
+
// image and was not being checked at all before.
|
|
17
|
+
const ATTR_PATTERN = /\b(src|href|poster|srcset)="([^"]*)"/g;
|
|
15
18
|
function extractReferences(html) {
|
|
16
19
|
const refs = [];
|
|
17
20
|
for (const match of html.matchAll(ATTR_PATTERN)) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
const attribute = match[1] ?? '';
|
|
22
|
+
const value = match[2] ?? '';
|
|
23
|
+
if (attribute !== 'srcset') {
|
|
24
|
+
refs.push({ url: value, attribute });
|
|
21
25
|
}
|
|
22
|
-
else
|
|
23
|
-
for (const entry of
|
|
26
|
+
else {
|
|
27
|
+
for (const entry of value.split(',')) {
|
|
24
28
|
const url = entry.trim().split(/\s+/)[0];
|
|
25
29
|
if (url) {
|
|
26
|
-
refs.push(url);
|
|
30
|
+
refs.push({ url, attribute });
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
}
|
|
31
35
|
return refs;
|
|
32
36
|
}
|
|
37
|
+
// Any scheme at all that is not http(s). The previous version listed
|
|
38
|
+
// data: explicitly and nothing else, so mailto: (and tel:, and anything
|
|
39
|
+
// future) fell through to the static-file branch below and was reported
|
|
40
|
+
// as a missing file under theme/root/, purely because the text after
|
|
41
|
+
// the "@" contains a dot. One real site produced 39 such findings and
|
|
42
|
+
// not one true one, which is worse than no check: it teaches whoever
|
|
43
|
+
// reads the output to ignore it.
|
|
44
|
+
function hasExternalScheme(ref) {
|
|
45
|
+
return ref.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(ref);
|
|
46
|
+
}
|
|
47
|
+
// A content photograph or clip, as opposed to a design asset. Keyed off
|
|
48
|
+
// the same extension list the media upload route accepts, so the two
|
|
49
|
+
// cannot drift; .svg is absent from it, which is what keeps an inline
|
|
50
|
+
// logo or icon from being mistaken for misplaced content.
|
|
51
|
+
function isUploadableMedia(path) {
|
|
52
|
+
const lower = path.toLowerCase();
|
|
53
|
+
return [...ALLOWED_UPLOAD_EXTENSIONS].some((extension) => lower.endsWith(extension));
|
|
54
|
+
}
|
|
33
55
|
// True for a root-relative static path that looks like a real file
|
|
34
56
|
// (has a "." in its last path segment - /favicon.ico, /robots.txt),
|
|
35
57
|
// as opposed to a page URL like /about or /blog/hello-world, which
|
|
@@ -48,6 +70,17 @@ function looksLikeStaticFile(path) {
|
|
|
48
70
|
// escaping root) is exactly as real a finding as one that's simply
|
|
49
71
|
// missing - reported the same way, not silently skipped.
|
|
50
72
|
function checkStaticReference(findings, root, relativePath, originalPath, rootLabel, pageUrl) {
|
|
73
|
+
// sanitisePath realpaths its root unconditionally, so a site that has
|
|
74
|
+
// no media/, theme/assets/ or theme/root/ directory at all threw a
|
|
75
|
+
// raw ENOENT out of the whole check rather than reporting anything -
|
|
76
|
+
// the check crashing with a stack trace on exactly the sites most
|
|
77
|
+
// likely to hold a broken reference. A root that does not exist means
|
|
78
|
+
// the file underneath it does not exist either, which is an ordinary
|
|
79
|
+
// finding, not an error.
|
|
80
|
+
if (!existsSync(root)) {
|
|
81
|
+
findings.push({ kind: 'missing-asset', message: `${originalPath} does not exist under ${rootLabel}`, pageUrl });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
51
84
|
try {
|
|
52
85
|
const filePath = sanitisePath(root, relativePath);
|
|
53
86
|
if (!existsSync(filePath)) {
|
|
@@ -62,6 +95,13 @@ function checkStaticReference(findings, root, relativePath, originalPath, rootLa
|
|
|
62
95
|
throw error;
|
|
63
96
|
}
|
|
64
97
|
}
|
|
98
|
+
// The file exists, so nothing else would ever flag it - which is
|
|
99
|
+
// precisely why this needs saying. A content image outside media/ is
|
|
100
|
+
// invisible to the media library, cannot be replaced by an editor, and
|
|
101
|
+
// is not what the CMS manages.
|
|
102
|
+
function misplacedMessage(path, where) {
|
|
103
|
+
return `${path} is a content image served from ${where} - it belongs in media/ (run \`npm run seed-media -- .. <file>\` from vhost/ and use the /media/... URL it prints)`;
|
|
104
|
+
}
|
|
65
105
|
export async function runSiteCheck(siteRoot) {
|
|
66
106
|
const booted = bootSite(siteRoot);
|
|
67
107
|
const findings = [];
|
|
@@ -86,18 +126,27 @@ export async function runSiteCheck(siteRoot) {
|
|
|
86
126
|
findings.push({ kind: 'render-error', message: detail, pageUrl });
|
|
87
127
|
continue;
|
|
88
128
|
}
|
|
89
|
-
for (const ref of extractReferences(html)) {
|
|
90
|
-
if (
|
|
129
|
+
for (const { url: ref, attribute } of extractReferences(html)) {
|
|
130
|
+
if (hasExternalScheme(ref)) {
|
|
91
131
|
continue;
|
|
92
132
|
}
|
|
93
133
|
const path = ref.split(/[?#]/)[0] ?? ref;
|
|
134
|
+
// Only src/srcset/poster can carry a content image. An href is a
|
|
135
|
+
// link or a favicon, and flagging those would make this useless.
|
|
136
|
+
const rendersMedia = attribute !== 'href' && isUploadableMedia(path);
|
|
94
137
|
if (path.startsWith('/media/')) {
|
|
95
138
|
checkStaticReference(findings, booted.config.mediaRoot, path.slice('/media/'.length), path, 'media/', pageUrl);
|
|
96
139
|
}
|
|
97
140
|
else if (path.startsWith('/assets/')) {
|
|
141
|
+
if (rendersMedia) {
|
|
142
|
+
findings.push({ kind: 'misplaced-media', message: misplacedMessage(path, 'theme/assets/'), pageUrl });
|
|
143
|
+
}
|
|
98
144
|
checkStaticReference(findings, booted.config.assetsRoot, path.slice('/assets/'.length), path, 'theme/assets/', pageUrl);
|
|
99
145
|
}
|
|
100
146
|
else if (looksLikeStaticFile(path)) {
|
|
147
|
+
if (rendersMedia) {
|
|
148
|
+
findings.push({ kind: 'misplaced-media', message: misplacedMessage(path, 'theme/root/'), pageUrl });
|
|
149
|
+
}
|
|
101
150
|
checkStaticReference(findings, booted.config.rootMirrorRoot, path.slice(1), path, 'theme/root/', pageUrl);
|
|
102
151
|
}
|
|
103
152
|
else if (path !== '' && !publishedUrlSet.has(path)) {
|