@o-a/cms-agent 0.5.0 → 0.5.2
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 +14 -8
- package/dist/create-site/template/content/menus/footerCompany.json +1 -1
- package/dist/create-site/template/content/menus/footerProduct.json +1 -1
- package/dist/create-site/template/content/menus/footerResources.json +1 -1
- package/dist/create-site/template/content/menus/main.json +1 -1
- package/dist/create-site/template/content/pages/404.json +1 -1
- package/dist/create-site/template/content/pages/about/careers.json +1 -1
- package/dist/create-site/template/content/pages/about/team.json +1 -1
- package/dist/create-site/template/content/pages/about.json +1 -1
- package/dist/create-site/template/content/pages/docs/deployment.json +1 -1
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +1 -1
- package/dist/create-site/template/content/pages/docs/getting-started.json +1 -1
- package/dist/create-site/template/content/pages/docs.json +1 -1
- package/dist/create-site/template/content/pages/index.json +1 -1
- package/dist/create-site/template/vhost/docker-entrypoint.sh +33 -7
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +12 -1
- package/dist/renderer/render-page.js +13 -3
- package/dist/routes/menus.js +72 -1
- package/dist/schemas/menu.schema.json +1 -0
- package/dist/services/manage-menus.d.ts +6 -1
- package/dist/services/manage-menus.js +55 -1
- package/dist/services/menu-references.d.ts +3 -0
- package/dist/services/menu-references.js +38 -0
- package/dist/services/menus.d.ts +1 -0
- package/dist/services/menus.js +2 -1
- 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
|
|
@@ -104,7 +104,8 @@ Write the description as one short sentence about what the section **is**, not w
|
|
|
104
104
|
{{ page.author }} the page's author, if set
|
|
105
105
|
{{ page.publishDate }} the page's publish date, if set
|
|
106
106
|
{% for tag in page.tags %}...{% endfor %} the page's tags, if any
|
|
107
|
-
{{ menus.<
|
|
107
|
+
{{ menus.<handle>.items }} every menu in content/menus/, keyed by handle (its filename)
|
|
108
|
+
{{ menus.<handle>.name }} that menu's optional display name (blank when unset)
|
|
108
109
|
```
|
|
109
110
|
|
|
110
111
|
### Snippets
|
|
@@ -275,7 +276,7 @@ Required fields, `additionalProperties: false`:
|
|
|
275
276
|
|
|
276
277
|
| Field | Type | Notes |
|
|
277
278
|
|---|---|---|
|
|
278
|
-
| `schemaVersion` | integer | Always `
|
|
279
|
+
| `schemaVersion` | integer | Always `7` for new content |
|
|
279
280
|
| `name` | string | Internal label (shown in the admin's page tree) |
|
|
280
281
|
| `title` | string | Rendered as `{{ page.title }}` |
|
|
281
282
|
| `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 |
|
|
@@ -289,7 +290,7 @@ Each entry in `sections` requires `id` (any non-empty string, unique within the
|
|
|
289
290
|
|
|
290
291
|
```json
|
|
291
292
|
{
|
|
292
|
-
"schemaVersion":
|
|
293
|
+
"schemaVersion": 7,
|
|
293
294
|
"name": "Home",
|
|
294
295
|
"title": "Welcome",
|
|
295
296
|
"type": "page",
|
|
@@ -308,10 +309,10 @@ Each entry in `sections` requires `id` (any non-empty string, unique within the
|
|
|
308
309
|
}
|
|
309
310
|
```
|
|
310
311
|
|
|
311
|
-
`content/menus/<
|
|
312
|
+
`content/menus/<handle>.json` - the filename is the menu's handle, referenced in layouts as `{{ menus.<handle>.items }}`. An optional `"name"` is its display name (editable in the admin, available as `{{ menus.<handle>.name }}`). Renaming the file changes the handle and empties every layout still using the old one, so change `"name"` to rename a menu:
|
|
312
313
|
|
|
313
314
|
```json
|
|
314
|
-
{ "schemaVersion":
|
|
315
|
+
{ "schemaVersion": 7, "items": [{ "label": "Home", "url": "/" }, { "label": "About", "url": "/about" }] }
|
|
315
316
|
```
|
|
316
317
|
|
|
317
318
|
`content/redirects.json` - a single file, not a folder:
|
|
@@ -385,11 +386,14 @@ Guard the call on the url, as above. An unset image renders nothing at all rathe
|
|
|
385
386
|
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
387
|
|
|
387
388
|
```
|
|
388
|
-
|
|
389
|
+
cd vhost
|
|
390
|
+
npm run seed-media -- .. photo.jpg another.png
|
|
389
391
|
# photo.jpg -> /media/photo-3f9a2b7c1e04.jpg
|
|
390
392
|
# another.png -> /media/another-91cd4a08f2b1.png
|
|
391
393
|
```
|
|
392
394
|
|
|
395
|
+
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.
|
|
396
|
+
|
|
393
397
|
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
398
|
|
|
395
399
|
```json
|
|
@@ -440,6 +444,8 @@ From `vhost/`, with the site's dependencies already installed (no need for the s
|
|
|
440
444
|
npm run check
|
|
441
445
|
```
|
|
442
446
|
|
|
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
|
|
447
|
+
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.
|
|
448
|
+
|
|
449
|
+
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
450
|
|
|
445
451
|
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.
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
2
|
set -e
|
|
3
3
|
|
|
4
|
+
# /seed is the image-baked copy of the site; /site is where the
|
|
5
|
+
# persistent volume is mounted. Overridable only so the test suite can
|
|
6
|
+
# run this exact script against temporary directories.
|
|
7
|
+
SEED="${CMS_SEED_DIR:-/seed}"
|
|
8
|
+
SITE="${CMS_SITE_DIR:-/site}"
|
|
9
|
+
|
|
4
10
|
# First boot against an empty mounted volume: seed it from the
|
|
5
11
|
# image-baked copy at /seed (content, theme, the already-`npm
|
|
6
12
|
# install`ed vhost/node_modules). A later boot finds /site/vhost
|
|
@@ -8,8 +14,28 @@ set -e
|
|
|
8
14
|
# identically with a real persistent volume mounted at /site
|
|
9
15
|
# (production) or with nothing mounted at all (a quick local trial,
|
|
10
16
|
# where content just lives in the ephemeral container layer).
|
|
11
|
-
if [ ! -d /
|
|
12
|
-
cp -r
|
|
17
|
+
if [ ! -d "$SITE/vhost" ]; then
|
|
18
|
+
cp -r "$SEED/." "$SITE/"
|
|
19
|
+
else
|
|
20
|
+
# Every later boot: refresh the agent itself from the image, and
|
|
21
|
+
# nothing else. Content, drafts, media and theme on the volume are
|
|
22
|
+
# the live site's own and are never touched - but the installed
|
|
23
|
+
# @o-a/cms-agent is code, not content, so without this a redeploy
|
|
24
|
+
# built from a newer agent would keep running the old one from the
|
|
25
|
+
# volume forever. Only the dependency manifest and its installed
|
|
26
|
+
# packages are copied; site.config.json (tokens) and server.js stay
|
|
27
|
+
# as they are on the volume.
|
|
28
|
+
#
|
|
29
|
+
# This leaves vhost/package.json (and package-lock.json) showing as
|
|
30
|
+
# modified in the volume's git working tree after an upgrade. That is
|
|
31
|
+
# deliberate: nothing here commits, since publishing is the only
|
|
32
|
+
# routine operation that creates a commit.
|
|
33
|
+
cp "$SEED/vhost/package.json" "$SITE/vhost/package.json"
|
|
34
|
+
if [ -f "$SEED/vhost/package-lock.json" ]; then
|
|
35
|
+
cp "$SEED/vhost/package-lock.json" "$SITE/vhost/package-lock.json"
|
|
36
|
+
fi
|
|
37
|
+
rm -rf "$SITE/vhost/node_modules"
|
|
38
|
+
cp -r "$SEED/vhost/node_modules" "$SITE/vhost/node_modules"
|
|
13
39
|
fi
|
|
14
40
|
|
|
15
41
|
# The site root must be a real git repository (services/startup-checks.ts) -
|
|
@@ -18,13 +44,13 @@ fi
|
|
|
18
44
|
# git-archive-style upload of tracked file contents only, which never
|
|
19
45
|
# includes .git at all. Recovered here rather than assumed away: if
|
|
20
46
|
# it's missing, start a fresh repo over the already-seeded content.
|
|
21
|
-
if [ ! -d
|
|
22
|
-
git -C
|
|
23
|
-
git -C
|
|
47
|
+
if [ ! -d "$SITE/.git" ]; then
|
|
48
|
+
git -C "$SITE" init --quiet
|
|
49
|
+
git -C "$SITE" add -A
|
|
24
50
|
GIT_AUTHOR_NAME="cms-agent" GIT_AUTHOR_EMAIL="cms-agent@localhost" \
|
|
25
51
|
GIT_COMMITTER_NAME="cms-agent" GIT_COMMITTER_EMAIL="cms-agent@localhost" \
|
|
26
|
-
git -C
|
|
52
|
+
git -C "$SITE" commit --quiet -m "chore: initial scaffold (recovered - .git was not part of the deploy upload)"
|
|
27
53
|
fi
|
|
28
54
|
|
|
29
|
-
cd /
|
|
55
|
+
cd "$SITE/vhost"
|
|
30
56
|
exec node server.js
|
package/dist/migrations/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The current content schema version. Bumping this and adding a new
|
|
2
2
|
// migrations[N] entry is the only way a content shape may change
|
|
3
3
|
// (constraint 4) - never a manual edit convention.
|
|
4
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
4
|
+
export const CURRENT_SCHEMA_VERSION = 7;
|
|
5
5
|
// A trivial identity migration, proving the mechanism (per the build
|
|
6
6
|
// plan's Phase 1 scope): no shape change, only the version bump. Safe
|
|
7
7
|
// against page.schema.json's schemaVersion: { minimum: 1 } (not an
|
|
@@ -63,10 +63,21 @@ function migrateV5ToV6(content) {
|
|
|
63
63
|
const title = typeof content.title === 'string' ? content.title : '';
|
|
64
64
|
return { ...content, schemaVersion: 6, name: title };
|
|
65
65
|
}
|
|
66
|
+
// menu.schema.json gains an optional "name": the admin's own display
|
|
67
|
+
// label for a menu, editable without touching its filename (which is
|
|
68
|
+
// what themes reference it by, as menus.<filename>, so renaming the
|
|
69
|
+
// file would silently empty every nav that uses it). Optional, so no
|
|
70
|
+
// existing menu needs a value invented for it - the admin falls back
|
|
71
|
+
// to deriving one from the filename, exactly as it always has. No
|
|
72
|
+
// shape change for pages at all; only the version bump.
|
|
73
|
+
function migrateV6ToV7(content) {
|
|
74
|
+
return { ...content, schemaVersion: 7 };
|
|
75
|
+
}
|
|
66
76
|
export const migrations = {
|
|
67
77
|
1: migrateV1ToV2,
|
|
68
78
|
2: migrateV2ToV3,
|
|
69
79
|
3: migrateV3ToV4,
|
|
70
80
|
4: migrateV4ToV5,
|
|
71
81
|
5: migrateV5ToV6,
|
|
82
|
+
6: migrateV6ToV7,
|
|
72
83
|
};
|
|
@@ -168,11 +168,21 @@ export function getPageMtimeMs(config, relativePath) {
|
|
|
168
168
|
// A menu edit affects every page's rendered nav, not just one page, so
|
|
169
169
|
// the cache's freshness check needs one value covering all menus
|
|
170
170
|
// together rather than per-page tracking. The max mtime across every
|
|
171
|
-
// menu file
|
|
172
|
-
//
|
|
173
|
-
//
|
|
171
|
+
// menu file covers an edit. It does not cover a menu being renamed (a
|
|
172
|
+
// handle change keeps the file's mtime) or deleted (removes one), so
|
|
173
|
+
// the menus directory's own mtime is included too: that changes
|
|
174
|
+
// whenever an entry is added, renamed or removed. Menus are flat, so
|
|
175
|
+
// the one directory is enough. 0 (never stale relative to anything)
|
|
176
|
+
// when there are no menus at all, matching listFilesRecursively's own
|
|
177
|
+
// "missing directory returns []" behaviour.
|
|
174
178
|
export function getMenusMtimeMs(config) {
|
|
175
179
|
let max = 0;
|
|
180
|
+
try {
|
|
181
|
+
max = statSync(config.menusRoot).mtimeMs;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// No content/menus/ at all - nothing to track.
|
|
185
|
+
}
|
|
176
186
|
for (const relativePath of listFilesRecursively(config.menusRoot, config.menusRoot, '.json')) {
|
|
177
187
|
try {
|
|
178
188
|
const { mtimeMs } = statSync(sanitisePath(config.menusRoot, relativePath));
|
package/dist/routes/menus.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
|
-
import { ManageMenuError, saveMenu } from "../services/manage-menus.js";
|
|
2
|
+
import { ManageMenuError, renameMenu, saveMenu } from "../services/manage-menus.js";
|
|
3
|
+
import { findMenuReferences, isValidMenuHandle } from "../services/menu-references.js";
|
|
3
4
|
import { PathSafetyError } from "../services/path-safety.js";
|
|
4
5
|
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
5
6
|
import { requireScope } from "../services/token-auth.js";
|
|
@@ -67,6 +68,72 @@ async function handleSaveMenu(request, reply, config) {
|
|
|
67
68
|
throw error;
|
|
68
69
|
}
|
|
69
70
|
}
|
|
71
|
+
function parseRenameMenuBody(body) {
|
|
72
|
+
if (typeof body !== 'object' || body === null) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const { from, to, message, author } = body;
|
|
76
|
+
if (!isNonEmptyString(from) || !isNonEmptyString(to) || !isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return { from, to, message, author };
|
|
80
|
+
}
|
|
81
|
+
// Takes handles (what a layout writes: menus.<handle>.items), not file
|
|
82
|
+
// paths - a rename can only ever move a menu within content/menus/.
|
|
83
|
+
// If-Match is the menu's current etag, as for a save.
|
|
84
|
+
async function handleRenameMenu(request, reply, config) {
|
|
85
|
+
const ifMatch = request.headers['if-match'];
|
|
86
|
+
if (typeof ifMatch !== 'string' || ifMatch.length === 0) {
|
|
87
|
+
reply.code(428).send({
|
|
88
|
+
statusCode: 428,
|
|
89
|
+
error: 'Precondition Required',
|
|
90
|
+
message: 'An If-Match header is required to rename a menu',
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const parsed = parseRenameMenuBody(request.body);
|
|
95
|
+
if (!parsed) {
|
|
96
|
+
reply.code(400).send({
|
|
97
|
+
statusCode: 400,
|
|
98
|
+
error: 'Bad Request',
|
|
99
|
+
message: 'Expected { from: string, to: string, message: string, author: { name, email } }',
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const result = await renameMenu(config, parsed.from, parsed.to, ifMatch, parsed.message, parsed.author);
|
|
105
|
+
reply.header('etag', result.etag).send({ ok: true, staleThemeReferences: result.staleThemeReferences });
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error instanceof PathSafetyError) {
|
|
109
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Not a valid menu handle' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (error instanceof ManageMenuError && error.reason === 'not-found') {
|
|
113
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (error instanceof ManageMenuError && error.reason === 'conflict') {
|
|
117
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (error instanceof ManageMenuError && error.reason === 'validation-failed') {
|
|
121
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Read-only and advisory: which theme files mention a handle, so the
|
|
128
|
+
// admin can warn before a rename or delete empties a nav.
|
|
129
|
+
function handleMenuReferences(request, reply, config) {
|
|
130
|
+
const handle = request.query.handle;
|
|
131
|
+
if (typeof handle !== 'string' || !isValidMenuHandle(handle)) {
|
|
132
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Expected ?handle=<menu handle>' });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
reply.send({ handle, themeFiles: findMenuReferences(config, handle) });
|
|
136
|
+
}
|
|
70
137
|
// GET/DELETE/move for menus/ paths deliberately stay on the existing
|
|
71
138
|
// generic /v1/content routes (Group N) - only this write path is new.
|
|
72
139
|
// Its own namespace (/v1/menus, not wedged into /v1/content), matching
|
|
@@ -74,4 +141,8 @@ async function handleSaveMenu(request, reply, config) {
|
|
|
74
141
|
// with type-conditional write behaviour.
|
|
75
142
|
export const menusRoutes = async (fastify, opts) => {
|
|
76
143
|
fastify.put('/menus/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleSaveMenu(request, reply, opts.config));
|
|
144
|
+
// Static paths, distinct from PUT /menus/* by method (POST/GET), the
|
|
145
|
+
// same way POST /content/move sits beside DELETE /content/*.
|
|
146
|
+
fastify.post('/menus/rename', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleRenameMenu(request, reply, opts.config));
|
|
147
|
+
fastify.get('/menus/references', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleMenuReferences(request, reply, opts.config));
|
|
77
148
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SiteConfig } from '../config.ts';
|
|
2
2
|
import type { CommitAuthor } from './git.ts';
|
|
3
|
-
export type ManageMenuReason = 'validation-failed' | 'conflict' | 'write-failed' | 'commit-failed' | 'rollback-failed';
|
|
3
|
+
export type ManageMenuReason = 'validation-failed' | 'not-found' | 'conflict' | 'write-failed' | 'commit-failed' | 'rollback-failed';
|
|
4
4
|
export declare class ManageMenuError extends Error {
|
|
5
5
|
readonly reason: ManageMenuReason;
|
|
6
6
|
constructor(reason: ManageMenuReason, message: string, options?: {
|
|
@@ -8,3 +8,8 @@ export declare class ManageMenuError extends Error {
|
|
|
8
8
|
});
|
|
9
9
|
}
|
|
10
10
|
export declare function saveMenu(config: SiteConfig, relativePath: string, content: unknown, expectedEtag: string, message: string, author: CommitAuthor): Promise<string>;
|
|
11
|
+
export interface RenameMenuResult {
|
|
12
|
+
etag: string;
|
|
13
|
+
staleThemeReferences: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare function renameMenu(config: SiteConfig, from: string, to: string, expectedEtag: string, message: string, author: CommitAuthor): Promise<RenameMenuResult>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
import { ContentReadError, readContentFile } from "./content-read.js";
|
|
4
4
|
import { computeEtag, etagsMatch } from "./etag.js";
|
|
5
5
|
import { commitPaths } from "./git.js";
|
|
6
|
+
import { findMenuReferences, isValidMenuHandle } from "./menu-references.js";
|
|
6
7
|
import { sanitisePath } from "./path-safety.js";
|
|
7
8
|
import { validateMenu } from "./validation.js";
|
|
8
9
|
import { enqueue } from "./write-queue.js";
|
|
@@ -82,3 +83,56 @@ async function saveMenuJob(config, relativePath, content, expectedEtag, message,
|
|
|
82
83
|
export function saveMenu(config, relativePath, content, expectedEtag, message, author) {
|
|
83
84
|
return enqueue(() => saveMenuJob(config, relativePath, content, expectedEtag, message, author));
|
|
84
85
|
}
|
|
86
|
+
// Changes a menu's handle: moves content/menus/<from>.json to
|
|
87
|
+
// <to>.json in one commit. Contents are untouched, so the returned
|
|
88
|
+
// etag is the same one the menu already had. Refuses rather than
|
|
89
|
+
// overwriting when <to> is taken, and checks If-Match against <from>
|
|
90
|
+
// like every other menu write, so a rename made from a stale view is
|
|
91
|
+
// refused rather than applied.
|
|
92
|
+
async function renameMenuJob(config, from, to, expectedEtag, message, author) {
|
|
93
|
+
if (!isValidMenuHandle(from) || !isValidMenuHandle(to)) {
|
|
94
|
+
throw new ManageMenuError('validation-failed', 'A menu handle may only contain letters, numbers, hyphens and underscores');
|
|
95
|
+
}
|
|
96
|
+
if (from === to) {
|
|
97
|
+
throw new ManageMenuError('validation-failed', `The menu is already called "${to}"`);
|
|
98
|
+
}
|
|
99
|
+
mkdirSync(config.menusRoot, { recursive: true });
|
|
100
|
+
const fromPath = sanitisePath(config.menusRoot, `${from}.json`);
|
|
101
|
+
const toPath = sanitisePath(config.menusRoot, `${to}.json`);
|
|
102
|
+
const currentEtag = readCurrentEtag(config, `${from}.json`);
|
|
103
|
+
if (currentEtag === null) {
|
|
104
|
+
throw new ManageMenuError('not-found', `No menu with the handle "${from}"`);
|
|
105
|
+
}
|
|
106
|
+
if (!etagsMatch(currentEtag, expectedEtag)) {
|
|
107
|
+
throw new ManageMenuError('conflict', `If-Match "${expectedEtag}" does not match the current ETag for "${from}.json"`);
|
|
108
|
+
}
|
|
109
|
+
// existsSync is case-insensitive on a case-insensitive filesystem
|
|
110
|
+
// (macOS by default), so "main" -> "Main" is refused there rather
|
|
111
|
+
// than risking a rename onto itself. Harmless: pick another handle.
|
|
112
|
+
if (existsSync(toPath)) {
|
|
113
|
+
throw new ManageMenuError('conflict', `A menu with the handle "${to}" already exists`);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
renameSync(fromPath, toPath);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw new ManageMenuError('write-failed', `Could not rename "${from}" to "${to}"`, { cause: error });
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
commitPaths(config.siteRoot, [fromPath, toPath], message, author);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
try {
|
|
126
|
+
renameSync(toPath, fromPath);
|
|
127
|
+
}
|
|
128
|
+
catch (rollbackError) {
|
|
129
|
+
throw new ManageMenuError('rollback-failed', 'Menu rename failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: rollbackError });
|
|
130
|
+
}
|
|
131
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
132
|
+
throw new ManageMenuError('commit-failed', `Menu rename failed: ${detail}`, { cause: error });
|
|
133
|
+
}
|
|
134
|
+
return { etag: currentEtag, staleThemeReferences: findMenuReferences(config, from) };
|
|
135
|
+
}
|
|
136
|
+
export function renameMenu(config, from, to, expectedEtag, message, author) {
|
|
137
|
+
return enqueue(() => renameMenuJob(config, from, to, expectedEtag, message, author));
|
|
138
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { listFilesRecursively } from "./fs-walk.js";
|
|
4
|
+
// A menu's handle is its filename without .json - what a layout writes
|
|
5
|
+
// as menus.<handle>.items. Menus are flat (no subfolders), and these
|
|
6
|
+
// are the only characters a Liquid dot-lookup reads as one name.
|
|
7
|
+
const MENU_HANDLE = /^[A-Za-z0-9_-]+$/;
|
|
8
|
+
export function isValidMenuHandle(handle) {
|
|
9
|
+
return MENU_HANDLE.test(handle);
|
|
10
|
+
}
|
|
11
|
+
function escapeRegExp(value) {
|
|
12
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
13
|
+
}
|
|
14
|
+
// Which theme files mention a menu by handle, as site-relative paths
|
|
15
|
+
// (e.g. "theme/layouts/theme.liquid"). Read-only and advisory: it lets
|
|
16
|
+
// the admin warn before a rename or delete leaves a nav empty. Matches
|
|
17
|
+
// both menus.<handle> and menus['<handle>'] / menus["<handle>"], and
|
|
18
|
+
// the dot form only as a whole name, so "main" never matches
|
|
19
|
+
// menus.mainFooter. A handle built at runtime ({% assign m = ... %})
|
|
20
|
+
// can't be seen here, which is why this only ever informs a warning,
|
|
21
|
+
// never blocks anything.
|
|
22
|
+
export function findMenuReferences(config, handle) {
|
|
23
|
+
if (!isValidMenuHandle(handle)) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const escaped = escapeRegExp(handle);
|
|
27
|
+
const pattern = new RegExp(`menus(?:\\.${escaped}(?![A-Za-z0-9_-])|\\[\\s*(['"])${escaped}\\1\\s*\\])`);
|
|
28
|
+
return listFilesRecursively(config.themeRoot, config.siteRoot, '.liquid')
|
|
29
|
+
.filter((relativePath) => {
|
|
30
|
+
try {
|
|
31
|
+
return pattern.test(readFileSync(join(config.siteRoot, relativePath), 'utf-8'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
.sort();
|
|
38
|
+
}
|
package/dist/services/menus.d.ts
CHANGED
package/dist/services/menus.js
CHANGED
|
@@ -7,7 +7,8 @@ function tryReadMenu(fullPath) {
|
|
|
7
7
|
if (!Array.isArray(parsed.items)) {
|
|
8
8
|
return null;
|
|
9
9
|
}
|
|
10
|
-
|
|
10
|
+
const items = parsed.items;
|
|
11
|
+
return typeof parsed.name === 'string' ? { name: parsed.name, items } : { items };
|
|
11
12
|
}
|
|
12
13
|
catch {
|
|
13
14
|
// One malformed menu file is skipped individually, not fatal to
|
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)) {
|