@o-a/cms-agent 0.5.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.
@@ -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 `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.
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
@@ -385,11 +385,14 @@ Guard the call on the url, as above. An unset image renders nothing at all rathe
385
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:
386
386
 
387
387
  ```
388
- npx seed-media <site-directory> photo.jpg another.png
388
+ cd vhost
389
+ npm run seed-media -- .. photo.jpg another.png
389
390
  # photo.jpg -> /media/photo-3f9a2b7c1e04.jpg
390
391
  # another.png -> /media/another-91cd4a08f2b1.png
391
392
  ```
392
393
 
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
+
393
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:
394
397
 
395
398
  ```json
@@ -440,6 +443,8 @@ From `vhost/`, with the site's dependencies already installed (no need for the s
440
443
  npm run check
441
444
  ```
442
445
 
443
- 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 `<img src>`/`srcset`/`<a href>` in the rendered HTML pointing at a `/media/`, `/assets/`, or root-static file that doesn't actually exist on disk; and any internal link that doesn't point at a real, published page. Exits non-zero if it finds anything - safe to run after generating content, not just as a manual spot-check.
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.
444
449
 
445
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.
@@ -10,6 +10,7 @@ const KIND_LABELS = {
10
10
  'render-error': 'Render error',
11
11
  'missing-asset': 'Missing asset',
12
12
  'broken-link': 'Broken link',
13
+ 'misplaced-media': 'Misplaced media',
13
14
  };
14
15
  function printGrouped(findings) {
15
16
  const byKind = new Map();
@@ -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
- const ATTR_PATTERN = /\b(?:src|href)="([^"]*)"|\bsrcset="([^"]*)"/g;
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 [, single, srcset] = match;
19
- if (single !== undefined) {
20
- refs.push(single);
21
+ const attribute = match[1] ?? '';
22
+ const value = match[2] ?? '';
23
+ if (attribute !== 'srcset') {
24
+ refs.push({ url: value, attribute });
21
25
  }
22
- else if (srcset !== undefined) {
23
- for (const entry of srcset.split(',')) {
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 (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('data:') || ref.startsWith('//')) {
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o-a/cms-agent",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"