@o-a/cms-agent 0.3.0 → 0.4.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.
- package/dist/create-site/template/AGENTS.md +16 -5
- package/dist/media/filename.js +10 -3
- package/dist/routes/media-public.d.ts +6 -0
- package/dist/routes/media-public.js +73 -2
- package/dist/routes/media.js +1 -1
- package/dist/services/mime-types.js +7 -0
- package/dist/services/validation.js +5 -5
- package/package.json +1 -1
|
@@ -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/
|
|
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
|
```
|
|
@@ -37,8 +37,9 @@ This scaffold already ships real, working examples worth reading before writing
|
|
|
37
37
|
|
|
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
|
-
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"`. 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.
|
|
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 `npx seed-media . <file>...` from the site directory and use the `/media/...` URL it prints. 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
|
|
|
@@ -109,6 +110,7 @@ Every setting is plain JSON Schema (`string`, `integer`, `number`, `boolean`, `a
|
|
|
109
110
|
|---|---|---|
|
|
110
111
|
| `richtext` | `string` | Rich-text editor; render with `{{ ... | raw }}`, not plain `{{ }}` |
|
|
111
112
|
| `image` | `object` | Image picker with focal point; object shape is exactly `{ "url": "...", "focalX": 0.5, "focalY": 0.5 }` - render `{{ section.settings.<field>.url }}` |
|
|
113
|
+
| `video` | `object` | Video picker for a short, silent background loop; object shape is exactly `{ "url": "...", "poster": "..." }` - render the `url` as the `<video>` source and always set `poster="{{ section.settings.<field>.poster }}"`, since 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
114
|
| `textarea` | `string` | Multi-line `<textarea>` |
|
|
113
115
|
| `uri` | `string` | `<input type="url">` |
|
|
114
116
|
| `date` | `string` | `<input type="date">`, value as `YYYY-MM-DD` |
|
|
@@ -186,6 +188,11 @@ Every type/format combination above needs nothing beyond what triggers it - no `
|
|
|
186
188
|
"format": "image"
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
"backgroundLoop": {
|
|
192
|
+
"type": "object",
|
|
193
|
+
"format": "video"
|
|
194
|
+
}
|
|
195
|
+
|
|
189
196
|
"tags": {
|
|
190
197
|
"type": "array",
|
|
191
198
|
"items": { "type": "string" }
|
|
@@ -260,7 +267,7 @@ Required fields, `additionalProperties: false`:
|
|
|
260
267
|
| `schemaVersion` | integer | Always `6` for new content |
|
|
261
268
|
| `name` | string | Internal label (shown in the admin's page tree) |
|
|
262
269
|
| `title` | string | Rendered as `{{ page.title }}` |
|
|
263
|
-
| `type` | string | Free-form (e.g. `"page"`, `"
|
|
270
|
+
| `type` | string | **The field that decides which listings a page appears in.** Free-form (e.g. `"page"`, `"project"`, `"article"`), lowercase by convention. It is indexed as `pageType` and is what `GET /search.json?pageType=...` filters on, so a project listing, a blog index and a team directory each depend on their pages carrying the right value here. Give every kind of page its own type; `"page"` is for ordinary one-off pages only |
|
|
264
271
|
| `layout` | string | A filename in `theme/layouts/` (no extension) - `"theme"` unless a different layout exists |
|
|
265
272
|
| `published` | boolean | `false` behaves as if the page doesn't exist on the live site at all |
|
|
266
273
|
| `sections` | array | Section instances - see below |
|
|
@@ -308,7 +315,11 @@ Each entry in `sections` requires `id` (any non-empty string, unique within the
|
|
|
308
315
|
|
|
309
316
|
## Images
|
|
310
317
|
|
|
311
|
-
|
|
318
|
+
**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).
|
|
319
|
+
|
|
320
|
+
**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
|
+
|
|
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 just a plain string setting holding that URL:
|
|
312
323
|
|
|
313
324
|
```json
|
|
314
325
|
{ "type": "string", "default": "" }
|
package/dist/media/filename.js
CHANGED
|
@@ -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
|
|
25
|
-
//
|
|
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
|
-
.
|
|
38
|
-
.
|
|
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
|
package/dist/routes/media.js
CHANGED
|
@@ -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
|
|
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
|
|
9
|
-
// document (richtext/image/textarea/uri/date/color/range/toggle -
|
|
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
|
-
//
|
|
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');
|