@o-a/cms-agent 0.2.2 → 0.3.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/cli.js +0 -0
- package/dist/create-site/generate-site.js +21 -0
- package/dist/create-site/mint-token-cli.js +0 -0
- package/dist/create-site/template/AGENTS.md +181 -2
- 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/media/filename.d.ts +1 -0
- package/dist/media/filename.js +13 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/routes/media.js +5 -9
- package/dist/routes/publish.js +37 -3
- package/dist/routes/sitemap.d.ts +1 -0
- package/dist/routes/sitemap.js +7 -1
- package/dist/services/publish.d.ts +1 -0
- package/dist/services/publish.js +54 -0
- package/dist/services/validation.js +33 -1
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +36 -0
- package/dist/site-check/run-check.d.ts +11 -0
- package/dist/site-check/run-check.js +109 -0
- package/package.json +5 -3
- package/dist/search/query-index.d.ts +0 -5
- package/dist/search/query-index.js +0 -21
- package/dist/services/post-urls.d.ts +0 -3
- package/dist/services/post-urls.js +0 -27
- package/dist/services/resolve-blog-url.d.ts +0 -11
- package/dist/services/resolve-blog-url.js +0 -31
package/dist/create-site/cli.js
CHANGED
|
File without changes
|
|
@@ -86,6 +86,13 @@ export function scaffoldSite(targetDir) {
|
|
|
86
86
|
// could lose or that leaks into version control.
|
|
87
87
|
const token = generateToken();
|
|
88
88
|
writeFileSync(join(vhostDir, 'site.config.json'), JSON.stringify({
|
|
89
|
+
// Written explicitly, matching server-config.ts's own
|
|
90
|
+
// DEFAULT_PORT, rather than left absent - a real value here
|
|
91
|
+
// is a visible, editable config knob; an absent key is
|
|
92
|
+
// invisible until something else is already using the
|
|
93
|
+
// default port, at which point the failure is a raw
|
|
94
|
+
// EADDRINUSE crash instead of an obvious setting to change.
|
|
95
|
+
port: 3000,
|
|
89
96
|
tokens: [{ hash: token.hash, scopes: ['content', 'theme', 'media'] }],
|
|
90
97
|
}, null, 2));
|
|
91
98
|
const packageName = sanitisePackageName(basename(targetDir));
|
|
@@ -100,9 +107,23 @@ export function scaffoldSite(targetDir) {
|
|
|
100
107
|
// straight to one of those (no Dockerfile in the loop) simply
|
|
101
108
|
// wouldn't boot. "tunnel" mirrors the --tunnel flag create-site
|
|
102
109
|
// already tells the operator about in its own next-steps output.
|
|
110
|
+
// "dev" is Node's own --watch-path, not custom fs-watching code
|
|
111
|
+
// - confirmed empirically that it both picks up changes to a
|
|
112
|
+
// plain file under a watched directory that's never imported
|
|
113
|
+
// (theme/*.liquid is only ever read via readFileSync) and sends
|
|
114
|
+
// a real SIGTERM on restart, which server.js's own shutdown
|
|
115
|
+
// handling already listens for. "../theme" because npm run dev
|
|
116
|
+
// runs with cwd vhost/, a sibling of theme/. Deliberately not
|
|
117
|
+
// content/ - that's already read fresh on every request, so
|
|
118
|
+
// watching it would only cause pointless restarts on every
|
|
119
|
+
// content edit. "check" is check-site, the package's own
|
|
120
|
+
// installed CLI bin - runs from vhost/ (site-check/cli.ts's own
|
|
121
|
+
// assumed cwd relationship), same pattern as start/tunnel/dev.
|
|
103
122
|
scripts: {
|
|
104
123
|
start: 'node server.js',
|
|
105
124
|
tunnel: 'node server.js --tunnel',
|
|
125
|
+
dev: 'node --watch-path=../theme server.js',
|
|
126
|
+
check: 'check-site',
|
|
106
127
|
},
|
|
107
128
|
dependencies: {
|
|
108
129
|
// Pinned exact, never a ^range - at v0.x even a minor bump
|
|
File without changes
|
|
@@ -33,9 +33,12 @@ This scaffold already ships real, working examples worth reading before writing
|
|
|
33
33
|
## Turning a design into code - the actual workflow
|
|
34
34
|
|
|
35
35
|
1. Break the design into distinct repeating/reusable visual components. Each one becomes a `theme/sections/<name>.liquid` (a self-contained region of a page) or `theme/blocks/<name>.liquid` (a smaller item nested inside a section, e.g. one card in a grid, one FAQ row).
|
|
36
|
-
2. Each file has two parts: ordinary Liquid/HTML markup, and a `{% schema %} ... {% endschema %}` block containing a single JSON object - a
|
|
36
|
+
2. Each file has two parts: ordinary Liquid/HTML markup, and a `{% schema %} ... {% endschema %}` block containing a single JSON object - a settings description using *only* the JSON Schema draft-07 keywords listed below, never the full spec. The schema block is stripped out before rendering, so a real Liquid tag never sees it - place it anywhere in the file (convention: at the end).
|
|
37
|
+
|
|
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.
|
|
37
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).
|
|
38
|
-
4.
|
|
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. Preview the result before considering the task done - see "Previewing your work".
|
|
39
42
|
|
|
40
43
|
### Worked example - a section
|
|
41
44
|
|
|
@@ -64,6 +67,23 @@ Available variables in a section: `section.id`, `section.settings.<key>`, `block
|
|
|
64
67
|
|
|
65
68
|
To restrict which block types are allowed under a given section/block, add `"allowedBlocks": ["button", "logo-mark"]` alongside `"properties"` in its schema - omit it entirely for no restriction (the default).
|
|
66
69
|
|
|
70
|
+
### Naming a section or block for the admin
|
|
71
|
+
|
|
72
|
+
Give every section and block a `"title"` and a `"description"`, as plain annotations on the schema object alongside `"properties"`:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"title": "Image band",
|
|
77
|
+
"description": "A full width photograph with an optional caption.",
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": { }
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Neither affects validation. The admin's "Add a Section" dialog lists one row per type showing the title with the description beside it, so a type with no description shows a bare name and an editor has to guess what it is for. Omit the `"title"` and the type's filename is shown instead (`image-band`), which reads as code, not as a choice.
|
|
84
|
+
|
|
85
|
+
Write the description as one short sentence about what the section **is**, not which fields it has - the fields are already visible the moment the section is added. "A full width photograph with an optional caption" is useful; "Heading, image and caption fields" is not.
|
|
86
|
+
|
|
67
87
|
### Layouts
|
|
68
88
|
|
|
69
89
|
`theme/layouts/theme.liquid` is required - every theme must define a layout named exactly `theme` as the default (a page can opt into a different one via its own `"layout"` field). A layout only ever sees:
|
|
@@ -97,9 +117,136 @@ Every setting is plain JSON Schema (`string`, `integer`, `number`, `boolean`, `a
|
|
|
97
117
|
| `toggle` | `boolean` | Switch instead of a checkbox (same underlying data) |
|
|
98
118
|
| (none) | `boolean` | Plain checkbox |
|
|
99
119
|
| (none) | `string` + `"enum"` | Segmented tabs (few short options) or a `<select>` (more/longer) - decided automatically, not choosable |
|
|
120
|
+
| (none) | `array` + `items.type: "string"` | Repeatable list of text lines, with add/remove/drag-to-reorder - `minItems`/`maxItems` bound how many lines the admin UI allows; `items.minLength`/`items.maxLength` apply per line |
|
|
121
|
+
| (none) | `array` + `items.type: "object", items.format: "image"` | A gallery: a grid of image thumbnails, add via the media picker, remove/drag-to-reorder - `minItems`/`maxItems` bound how many images the admin UI allows. Each item is exactly `{ "url": "...", "focalX": 0.5, "focalY": 0.5 }`, the same shape a lone `format: "image"` field stores - **no other properties are supported on a gallery item** (see "This is a closed set" below) |
|
|
100
122
|
|
|
101
123
|
A `format` on the wrong `type` (e.g. `image` on a `string`) is a mistake, not something the admin guesses around - it silently falls back to a plain widget for that type.
|
|
102
124
|
|
|
125
|
+
### Minimal form - prefer this
|
|
126
|
+
|
|
127
|
+
Every type/format combination above needs nothing beyond what triggers it - no `additionalProperties`, no `required`, no `default`, no nested `properties` describing an object's own shape. None of that is what makes the admin recognise a field; it only matters if you actually need the stricter validation or a guaranteed starting value (see below). Default to the minimal form:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
"heading": {
|
|
131
|
+
"type": "string"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
"columns": {
|
|
135
|
+
"type": "integer"
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
"enabled": {
|
|
139
|
+
"type": "boolean"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
"enabled": {
|
|
143
|
+
"type": "boolean",
|
|
144
|
+
"format": "toggle"
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
"bio": {
|
|
148
|
+
"type": "string",
|
|
149
|
+
"format": "textarea"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
"body": {
|
|
153
|
+
"type": "string",
|
|
154
|
+
"format": "richtext"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
"link": {
|
|
158
|
+
"type": "string",
|
|
159
|
+
"format": "uri"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
"publishDate": {
|
|
163
|
+
"type": "string",
|
|
164
|
+
"format": "date"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
"accent": {
|
|
168
|
+
"type": "string",
|
|
169
|
+
"format": "color"
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
"align": {
|
|
173
|
+
"type": "string",
|
|
174
|
+
"enum": ["left", "center", "right"]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
"fontSize": {
|
|
178
|
+
"type": "integer",
|
|
179
|
+
"format": "range",
|
|
180
|
+
"minimum": 12,
|
|
181
|
+
"maximum": 24
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
"poster": {
|
|
185
|
+
"type": "object",
|
|
186
|
+
"format": "image"
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
"tags": {
|
|
190
|
+
"type": "array",
|
|
191
|
+
"items": { "type": "string" }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
"gallery": {
|
|
195
|
+
"type": "array",
|
|
196
|
+
"items": { "type": "object", "format": "image" }
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`fontSize`'s `minimum`/`maximum` are the one exception - they're not optional boilerplate, the `range` widget genuinely doesn't trigger without both.
|
|
201
|
+
|
|
202
|
+
A custom display label uses the standard JSON Schema `"title"` keyword (`"title": "Section Heading"`) - skip it and the property key auto-humanizes instead (`backgroundImage` -> "Background Image"), which is why none of the examples above bother with one.
|
|
203
|
+
|
|
204
|
+
Only reach for `"required"` + `"default"` (and, for stricter content validation, `"additionalProperties": false` on an object/`minLength`/`pattern`/etc.) when a field genuinely must always have a value from the moment a component is added - skip both and it just starts empty/unset, which is a valid state for every type above. See "Worked example - a section" above for what that fuller form looks like once it's actually needed, and remember the L1 rule if you do use it: a required field's `default` is validated against that field's *own* full schema, including `minItems`/`minLength`/etc. - `"default": []` against `"minItems": 1` fails just as surely as an empty string against `"minLength": 1` does.
|
|
205
|
+
|
|
206
|
+
### This is a closed set - do not invent a new field shape
|
|
207
|
+
|
|
208
|
+
**The table above is exhaustive.** These are the only setting shapes the admin has a real editor for. A setting whose shape doesn't match one of these rows exactly still technically works - Ajv validates it, the content saves - but the admin can only offer a raw JSON textarea for it, which is a bad editing experience for a human, not a fallback to design around. Never invent a new combination of `type`/`format`/`items` hoping the admin will render something sensible for it; if a design need doesn't map onto one of these rows, use the pattern below instead of a wider array shape.
|
|
209
|
+
|
|
210
|
+
**A repeating item with more than one independent field is a block, never an array-shaped setting.** For example, a "before/after" or "lightbox" style section needing several frames, each with its own image *and* a caption *and* a timestamp, is not `"type": "array", "items": { "type": "object", "properties": { "image": ..., "caption": ..., "time": ... } } }` - that shape has no admin widget and never will (it's an open-ended amount of nested field types, not a closed set like the table above). Model it as a block type instead:
|
|
211
|
+
|
|
212
|
+
```liquid
|
|
213
|
+
{# theme/blocks/frame.liquid #}
|
|
214
|
+
<figure class="lightstudy__frame">
|
|
215
|
+
<img src="{{ block.settings.image.url }}" alt="{{ block.settings.caption }}">
|
|
216
|
+
<figcaption><span class="numeral">{{ block.settings.time }}</span> {{ block.settings.caption }}</figcaption>
|
|
217
|
+
</figure>
|
|
218
|
+
{% schema %}
|
|
219
|
+
{
|
|
220
|
+
"type": "object",
|
|
221
|
+
"additionalProperties": false,
|
|
222
|
+
"required": ["image", "caption", "time"],
|
|
223
|
+
"properties": {
|
|
224
|
+
"image": { "type": "object", "format": "image", "default": { "url": "", "focalX": 0.5, "focalY": 0.5 } },
|
|
225
|
+
"caption": { "type": "string", "minLength": 1, "default": "The room in a particular light" },
|
|
226
|
+
"time": { "type": "string", "minLength": 1, "default": "12:00" }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
{% endschema %}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Then the parent section just loops `blocksHtml`, exactly as it already does for any other block type - see "Section markup" above. This gets real add/remove/drag-to-reorder and a proper per-field settings form (image picker, text inputs) for every one of `image`/`caption`/`time` independently, for free - a single array setting never gets that, no matter how its `items` schema is shaped.
|
|
233
|
+
|
|
234
|
+
A multi-line field (e.g. an animated headline, one line per array entry) uses the array shape above rather than a single `format: "textarea"` string - each line is edited and reordered independently:
|
|
235
|
+
|
|
236
|
+
```json
|
|
237
|
+
{
|
|
238
|
+
"type": "array",
|
|
239
|
+
"minItems": 1,
|
|
240
|
+
"maxItems": 4,
|
|
241
|
+
"items": { "type": "string", "minLength": 1 },
|
|
242
|
+
"default": ["New section"]
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```liquid
|
|
247
|
+
{% for line in section.settings.heading %}<span>{{ line }}</span>{% endfor %}
|
|
248
|
+
```
|
|
249
|
+
|
|
103
250
|
A separate keyword, `"api": true`, can be added to any scalar property to expose its value through `GET /search.json` for structured filtering, independent of full-text search - see that section below.
|
|
104
251
|
|
|
105
252
|
## Content JSON model
|
|
@@ -169,6 +316,22 @@ Uploads go through `POST /v1/media` (multipart, requires a token with `media` sc
|
|
|
169
316
|
|
|
170
317
|
unless the design needs a focal point for a cropped image, in which case use `"format": "image"` (see the table above) instead of a plain string.
|
|
171
318
|
|
|
319
|
+
If you're writing starter content before a server is even running - so `POST /v1/media` isn't reachable yet - use the `seed-media` CLI instead of placing images under `theme/root/`. It computes the exact same content-addressed filename a real upload would, so the result is indistinguishable from one:
|
|
320
|
+
|
|
321
|
+
```
|
|
322
|
+
npx seed-media <site-directory> photo.jpg another.png
|
|
323
|
+
# photo.jpg -> /media/photo-3f9a2b7c1e04.jpg
|
|
324
|
+
# another.png -> /media/another-91cd4a08f2b1.png
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Then use the printed URL exactly like a real upload's:
|
|
328
|
+
|
|
329
|
+
```json
|
|
330
|
+
{ "type": "string", "default": "/media/photo-3f9a2b7c1e04.jpg" }
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
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.
|
|
334
|
+
|
|
172
335
|
## `GET /search.json`
|
|
173
336
|
|
|
174
337
|
A public, unauthenticated, read-only endpoint - safe to call directly from a section's own client-side JavaScript with a plain `fetch()`, no token needed. Only ever returns already-published content. Query params: `q` (full-text), `filter=field:op:value` (repeatable, ANDed; `op` is `eq`/`gt`/`gte`/`lt`/`lte`), `pageType`, `sort` (`-publishDate` for newest-first), `limit`, `offset`. Useful for a blog listing, a filterable directory, or a live search box.
|
|
@@ -183,6 +346,7 @@ The index behind this endpoint keeps itself current automatically - it rebuilds
|
|
|
183
346
|
- **One file, one type, no subfolders** inside `layouts/`, `sections/`, `blocks/`, `snippets/` - and the filename must match `^[a-z0-9][a-z0-9-]*$` exactly.
|
|
184
347
|
- **The `{% schema %}` block must be valid, parseable JSON.** A malformed or missing schema fails the whole component, not just the settings half.
|
|
185
348
|
- **`additionalProperties: false` applies everywhere in content JSON** - don't add a field "just in case"; anything not in the tables above fails validation.
|
|
349
|
+
- **Never invent a new setting field shape.** The "Field format hints" table is the complete, closed list of what the admin can actually edit - a plain type, a type plus one of the listed `format` values, `array` of plain strings, or `array` of plain images. A repeating item with more than one field of its own (an image plus a caption, a date, anything else) is a block type, not a wider array setting - see "This is a closed set" above.
|
|
186
350
|
|
|
187
351
|
## Previewing your work
|
|
188
352
|
|
|
@@ -191,6 +355,21 @@ From `vhost/`:
|
|
|
191
355
|
```
|
|
192
356
|
npm start # boots the site on the port set in vhost/site.config.json
|
|
193
357
|
npm run tunnel # same, plus a public tunnel URL for sharing a preview
|
|
358
|
+
npm run dev # same, plus auto-restart whenever a theme/ file changes - use this one while iterating
|
|
194
359
|
```
|
|
195
360
|
|
|
361
|
+
`npm run dev` only watches `theme/` - content changes (via the API) already show up on the next request with no restart needed, so there's nothing to gain watching `content/` too.
|
|
362
|
+
|
|
196
363
|
Then request the page you changed (`curl http://localhost:<port>/<path>`, or open it in a browser) and confirm it actually renders as expected before considering a change finished - a page that fails schema validation or references a non-existent section type won't crash the server, but the specific page/component involved will misbehave silently.
|
|
364
|
+
|
|
365
|
+
## Checking your work: `npm run check`
|
|
366
|
+
|
|
367
|
+
From `vhost/`, with the site's dependencies already installed (no need for the server to be running):
|
|
368
|
+
|
|
369
|
+
```
|
|
370
|
+
npm run check
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
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.
|
|
374
|
+
|
|
375
|
+
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.
|
package/dist/media/filename.d.ts
CHANGED
package/dist/media/filename.js
CHANGED
|
@@ -21,6 +21,19 @@ import { basename, extname } from 'node:path';
|
|
|
21
21
|
// lowercase, non [a-z0-9-] runs collapsed to a single "-", trimmed -
|
|
22
22
|
// not a second, independently-invented convention.
|
|
23
23
|
const HASH_LENGTH = 12;
|
|
24
|
+
// Images only - confirmed with the user, not a general document
|
|
25
|
+
// library. .svg is rejected regardless of this list even though it's
|
|
26
|
+
// technically an image format - docs/cms-build-plan.md's own "SVG
|
|
27
|
+
// rejected outright, not sanitised" decision (a real stored-XSS path
|
|
28
|
+
// otherwise: mime-types.ts maps .svg to a real image/svg+xml content
|
|
29
|
+
// type, and an SVG loaded as a top-level navigation, not just <img>-
|
|
30
|
+
// embedded, can execute a script it carries).
|
|
31
|
+
//
|
|
32
|
+
// Exported from here, not left as routes/media.ts's own private
|
|
33
|
+
// constant, specifically so seed-media.ts (an offline tool with no
|
|
34
|
+
// HTTP request to validate) enforces the exact same rule rather than
|
|
35
|
+
// a second, independently-maintained copy of it.
|
|
36
|
+
export const ALLOWED_UPLOAD_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
24
37
|
export function buildMediaFilename(originalFilename, bytes) {
|
|
25
38
|
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, HASH_LENGTH);
|
|
26
39
|
const base = basename(originalFilename, extname(originalFilename));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { seedMedia } from "./seed-media.js";
|
|
4
|
+
const [targetArg, ...sourceArgs] = process.argv.slice(2);
|
|
5
|
+
if (!targetArg || sourceArgs.length === 0) {
|
|
6
|
+
console.error('Usage: seed-media <site-directory> <path> [<path> ...]');
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
const siteDir = resolve(process.cwd(), targetArg);
|
|
10
|
+
const sourcePaths = sourceArgs.map((sourceArg) => resolve(process.cwd(), sourceArg));
|
|
11
|
+
const result = seedMedia(siteDir, sourcePaths);
|
|
12
|
+
for (const entry of result.entries) {
|
|
13
|
+
if (entry.status === 'seeded') {
|
|
14
|
+
console.log(`${entry.sourcePath} -> ${entry.url}`);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
console.log(`${entry.sourcePath}: skipped (${entry.reason})`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SeedMediaEntry {
|
|
2
|
+
sourcePath: string;
|
|
3
|
+
status: 'seeded' | 'skipped-invalid-type';
|
|
4
|
+
url?: string;
|
|
5
|
+
reason?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SeedMediaResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
entries: SeedMediaEntry[];
|
|
10
|
+
}
|
|
11
|
+
export declare function seedMedia(siteRoot: string, sourcePaths: string[]): SeedMediaResult;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { ALLOWED_UPLOAD_EXTENSIONS, buildMediaFilename } from "./filename.js";
|
|
4
|
+
// A directory argument expands to its own direct child files only -
|
|
5
|
+
// one level, not recursive, matching theme-schemas.ts's own flat-
|
|
6
|
+
// directory convention for exactly the same reason (a predictable,
|
|
7
|
+
// easy-to-reason-about walk, not a surprise deep scan).
|
|
8
|
+
function expandSourcePaths(sourcePaths) {
|
|
9
|
+
const files = [];
|
|
10
|
+
for (const sourcePath of sourcePaths) {
|
|
11
|
+
if (statSync(sourcePath).isDirectory()) {
|
|
12
|
+
for (const entry of readdirSync(sourcePath, { withFileTypes: true })) {
|
|
13
|
+
if (entry.isFile()) {
|
|
14
|
+
files.push(join(sourcePath, entry.name));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
files.push(sourcePath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return files;
|
|
23
|
+
}
|
|
24
|
+
// Seeds one or more local image files directly into a site's media/,
|
|
25
|
+
// computing the exact same content-addressed filename a real
|
|
26
|
+
// POST /v1/media upload would - see this module's own filename.ts for
|
|
27
|
+
// why that's safe to do offline (a pure function of a filename and the
|
|
28
|
+
// file's own bytes, no server/database involved). For an AI agent (or
|
|
29
|
+
// any offline tool) generating a site's starter content before a
|
|
30
|
+
// server is even running - GET /media/* just serves whatever file
|
|
31
|
+
// exists at the requested path, so the result is indistinguishable
|
|
32
|
+
// from a real upload.
|
|
33
|
+
//
|
|
34
|
+
// siteRoot is an operator/agent-supplied directory, the same category
|
|
35
|
+
// as scaffoldSite/mintToken - not a web request's own :path, so
|
|
36
|
+
// sanitisePath's traversal concern doesn't apply here (see
|
|
37
|
+
// mint-token.ts's own comment on this exact point).
|
|
38
|
+
//
|
|
39
|
+
// Never a git commit - media is never git-tracked (constraint 2,
|
|
40
|
+
// matching manage-media.ts's own "no commit, no author... Media is
|
|
41
|
+
// never git-tracked" comment).
|
|
42
|
+
//
|
|
43
|
+
// A disallowed file type is skipped, not a reason to abort the whole
|
|
44
|
+
// batch - an agent seeding a dozen images should still get the eleven
|
|
45
|
+
// good ones, with the one bad one clearly reported. The allowlist
|
|
46
|
+
// itself is the one thing that must not be relaxed here: it's the
|
|
47
|
+
// same rule the real upload route enforces (ALLOWED_UPLOAD_EXTENSIONS,
|
|
48
|
+
// shared, not a second copy), because skipping it would reopen the
|
|
49
|
+
// real stored-XSS path SVG rejection exists to close, for whoever
|
|
50
|
+
// later visits the live site - not a concern about this tool's own
|
|
51
|
+
// caller, who already has full local trust.
|
|
52
|
+
export function seedMedia(siteRoot, sourcePaths) {
|
|
53
|
+
const mediaRoot = join(siteRoot, 'media');
|
|
54
|
+
mkdirSync(mediaRoot, { recursive: true });
|
|
55
|
+
const entries = [];
|
|
56
|
+
for (const sourcePath of expandSourcePaths(sourcePaths)) {
|
|
57
|
+
const extension = extname(sourcePath).toLowerCase();
|
|
58
|
+
if (!ALLOWED_UPLOAD_EXTENSIONS.has(extension)) {
|
|
59
|
+
entries.push({
|
|
60
|
+
sourcePath,
|
|
61
|
+
status: 'skipped-invalid-type',
|
|
62
|
+
reason: `"${extension}" is not an accepted image type`,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const bytes = readFileSync(sourcePath);
|
|
67
|
+
const name = buildMediaFilename(basename(sourcePath), bytes);
|
|
68
|
+
// No existence check first: content-addressed naming means
|
|
69
|
+
// re-seeding the same bytes writes identical bytes over identical
|
|
70
|
+
// bytes - a harmless idempotent overwrite, matching
|
|
71
|
+
// local-fs-driver.ts's own put() precedent exactly.
|
|
72
|
+
writeFileSync(join(mediaRoot, name), bytes);
|
|
73
|
+
entries.push({ sourcePath, status: 'seeded', url: `/media/${name}` });
|
|
74
|
+
}
|
|
75
|
+
return { ok: entries.every((entry) => entry.status === 'seeded'), entries };
|
|
76
|
+
}
|
package/dist/routes/media.js
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
import { extname } from 'node:path';
|
|
2
2
|
import multipart from '@fastify/multipart';
|
|
3
|
+
import { ALLOWED_UPLOAD_EXTENSIONS } from "../media/filename.js";
|
|
3
4
|
import { ManageMediaError, deleteMedia, listMedia, putMedia } from "../media/manage-media.js";
|
|
4
5
|
import { PathSafetyError } from "../services/path-safety.js";
|
|
5
6
|
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
6
7
|
import { requireScope } from "../services/token-auth.js";
|
|
7
|
-
// Images only - confirmed with the user, not a general document
|
|
8
|
-
// library. Checked against the *original* uploaded filename, not the
|
|
9
|
-
// client-supplied mimetype header (trivially spoofable) and not the
|
|
10
|
-
// stored content-addressed filename (built only after this check
|
|
11
|
-
// passes, from the same already-validated extension). .svg is
|
|
12
|
-
// rejected regardless of this list even though it's technically an
|
|
13
|
-
// image format - docs/cms-build-plan.md's own "SVG rejected outright,
|
|
14
|
-
// not sanitised" decision.
|
|
15
|
-
const ALLOWED_UPLOAD_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
16
8
|
function sendManageMediaError(reply, error) {
|
|
17
9
|
if (error.reason === 'not-found') {
|
|
18
10
|
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
@@ -29,6 +21,10 @@ async function handleUploadMedia(request, reply, config) {
|
|
|
29
21
|
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Expected a multipart file upload' });
|
|
30
22
|
return;
|
|
31
23
|
}
|
|
24
|
+
// Checked against the *original* uploaded filename, not the
|
|
25
|
+
// client-supplied mimetype header (trivially spoofable) and not the
|
|
26
|
+
// stored content-addressed filename (built only after this check
|
|
27
|
+
// passes, from the same already-validated extension).
|
|
32
28
|
const extension = extname(data.filename).toLowerCase();
|
|
33
29
|
if (!ALLOWED_UPLOAD_EXTENSIONS.has(extension)) {
|
|
34
30
|
reply.code(415).send({
|
package/dist/routes/publish.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
2
|
import { PathSafetyError } from "../services/path-safety.js";
|
|
3
3
|
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
4
|
-
import { PublishError, publishDrafts, unpublishPage } from "../services/publish.js";
|
|
4
|
+
import { PublishError, publishDrafts, publishPage, unpublishPage } from "../services/publish.js";
|
|
5
5
|
import { requireScope } from "../services/token-auth.js";
|
|
6
6
|
function isNonEmptyString(value) {
|
|
7
7
|
return typeof value === 'string' && value.length > 0;
|
|
@@ -19,7 +19,7 @@ function parsePublishBody(body) {
|
|
|
19
19
|
}
|
|
20
20
|
return { paths, message, author };
|
|
21
21
|
}
|
|
22
|
-
function
|
|
22
|
+
function parsePublishedFlagBody(body) {
|
|
23
23
|
if (typeof body !== 'object' || body === null) {
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
@@ -73,7 +73,7 @@ export const publishRoutes = async (fastify, opts) => {
|
|
|
73
73
|
}
|
|
74
74
|
});
|
|
75
75
|
fastify.post('/unpublish/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
76
|
-
const parsed =
|
|
76
|
+
const parsed = parsePublishedFlagBody(request.body);
|
|
77
77
|
if (!parsed) {
|
|
78
78
|
reply.code(400).send({
|
|
79
79
|
statusCode: 400,
|
|
@@ -102,4 +102,38 @@ export const publishRoutes = async (fastify, opts) => {
|
|
|
102
102
|
throw error;
|
|
103
103
|
}
|
|
104
104
|
});
|
|
105
|
+
// The twin of /unpublish/* above: sets published:true on a live page
|
|
106
|
+
// in place and commits. Deliberately separate from /publish, which
|
|
107
|
+
// promotes drafts - a page that is live but unpublished has no draft
|
|
108
|
+
// to promote, so /publish cannot reach it at all (draft-not-found),
|
|
109
|
+
// and promoting a draft would publish every pending edit along with
|
|
110
|
+
// the flag. This only ever changes the one boolean.
|
|
111
|
+
fastify.post('/publish-page/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
112
|
+
const parsed = parsePublishedFlagBody(request.body);
|
|
113
|
+
if (!parsed) {
|
|
114
|
+
reply.code(400).send({
|
|
115
|
+
statusCode: 400,
|
|
116
|
+
error: 'Bad Request',
|
|
117
|
+
message: 'Expected { message: string, author: { name, email } }',
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const relativePath = request.params['*'];
|
|
122
|
+
try {
|
|
123
|
+
await publishPage(opts.config, relativePath, parsed.message, parsed.author);
|
|
124
|
+
reply.send({ ok: true });
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// Same PathSafetyError guard as every other :path route here.
|
|
128
|
+
if (error instanceof PathSafetyError) {
|
|
129
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (error instanceof PublishError) {
|
|
133
|
+
replyForPublishError(reply, error);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
105
139
|
};
|
package/dist/routes/sitemap.d.ts
CHANGED
package/dist/routes/sitemap.js
CHANGED
|
@@ -24,7 +24,13 @@ function isPublished(contentRoot, relativePath) {
|
|
|
24
24
|
// never authoritative", see cms-build-plan.md). A saved sitemap would
|
|
25
25
|
// go stale the moment anything is published or unpublished; this
|
|
26
26
|
// can't.
|
|
27
|
-
|
|
27
|
+
// Exported for site-check/run-check.ts's own reuse - it needs the
|
|
28
|
+
// identical "every published page's own URL" walk this route already
|
|
29
|
+
// does, and duplicating it would be the exact kind of drift this
|
|
30
|
+
// codebase avoids elsewhere (see slugify.ts's own "second use
|
|
31
|
+
// justifies the abstraction" precedent, cited directly in this
|
|
32
|
+
// project's own admin sibling repo).
|
|
33
|
+
export function buildSitemapUrls(config) {
|
|
28
34
|
const urls = [];
|
|
29
35
|
for (const relativePath of listFilesRecursively(config.pagesRoot, config.pagesRoot, '.json')) {
|
|
30
36
|
// The 404 page must never be listed as a real crawlable URL,
|
|
@@ -12,3 +12,4 @@ export declare class PublishError extends Error {
|
|
|
12
12
|
export declare function preparePublishDrafts(config: SiteConfig, themeSchemas: ThemeSchemas, relativePaths: string[]): PreparedOperation;
|
|
13
13
|
export declare function publishDrafts(config: SiteConfig, themeSchemas: ThemeSchemas, relativePaths: string[], message: string, author: CommitAuthor): Promise<void>;
|
|
14
14
|
export declare function unpublishPage(config: SiteConfig, relativePath: string, message: string, author: CommitAuthor): Promise<void>;
|
|
15
|
+
export declare function publishPage(config: SiteConfig, relativePath: string, message: string, author: CommitAuthor): Promise<void>;
|
package/dist/services/publish.js
CHANGED
|
@@ -241,6 +241,53 @@ async function unpublishPageJob(config, relativePath, message, author) {
|
|
|
241
241
|
throw new PublishError(reason, `Unpublish failed: ${errorMessage}`, { cause: error });
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
|
+
// The exact twin of unpublishPageJob above, flipping the same flag the
|
|
245
|
+
// other way: reads the live file, sets published true, commits. Kept as
|
|
246
|
+
// its own job rather than a parameterised shared one - the two read
|
|
247
|
+
// identically but say opposite things, and a boolean argument at every
|
|
248
|
+
// call site ("publishPage(config, path, true)") reads far worse than
|
|
249
|
+
// two named functions.
|
|
250
|
+
//
|
|
251
|
+
// Deliberately does NOT validate against the theme schemas, matching
|
|
252
|
+
// unpublish rather than publishDrafts: this only ever touches content
|
|
253
|
+
// that is already live, and was therefore already validated when it was
|
|
254
|
+
// published in the first place. publishDrafts validates because it
|
|
255
|
+
// promotes a draft, which may never have been checked before.
|
|
256
|
+
//
|
|
257
|
+
// Like unpublish, never touches a draft. A page with unpublished edits
|
|
258
|
+
// pending keeps them, and this only changes whether what is already
|
|
259
|
+
// live is publicly visible.
|
|
260
|
+
async function publishPageJob(config, relativePath, message, author) {
|
|
261
|
+
const livePath = sanitisePath(config.contentRoot, relativePath);
|
|
262
|
+
let original;
|
|
263
|
+
try {
|
|
264
|
+
original = readFileSync(livePath);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new PublishError('page-not-found', `No live page found at "${relativePath}"`);
|
|
268
|
+
}
|
|
269
|
+
const parsed = JSON.parse(original.toString('utf-8'));
|
|
270
|
+
parsed.published = true;
|
|
271
|
+
const updated = Buffer.from(JSON.stringify(parsed, null, 2));
|
|
272
|
+
// Same minimal inline restore unpublishPageJob uses, and for the same
|
|
273
|
+
// reason - one file, no draft, so the two-file publish rollback()
|
|
274
|
+
// above would need an unused draft slot for no benefit.
|
|
275
|
+
try {
|
|
276
|
+
writeFileSync(livePath, updated);
|
|
277
|
+
commitPaths(config.siteRoot, [livePath], message, author);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
try {
|
|
281
|
+
writeFileSync(livePath, original);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw new PublishError('rollback-failed', 'Publishing this page failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
285
|
+
}
|
|
286
|
+
const reason = error instanceof GitOperationError ? 'commit-failed' : 'write-failed';
|
|
287
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
288
|
+
throw new PublishError(reason, `Publishing this page failed: ${errorMessage}`, { cause: error });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
244
291
|
export function publishDrafts(config, themeSchemas, relativePaths, message, author) {
|
|
245
292
|
const result = enqueue(() => publishDraftsJob(config, themeSchemas, relativePaths, message, author));
|
|
246
293
|
// Only on success - a failed publish changed nothing, so there's
|
|
@@ -257,3 +304,10 @@ export function unpublishPage(config, relativePath, message, author) {
|
|
|
257
304
|
result.then(() => reindexInBackground(config), () => undefined);
|
|
258
305
|
return result;
|
|
259
306
|
}
|
|
307
|
+
// Twin of unpublishPage - same queue, same background reindex (a page
|
|
308
|
+
// becoming visible changes the index exactly as much as one leaving it).
|
|
309
|
+
export function publishPage(config, relativePath, message, author) {
|
|
310
|
+
const result = enqueue(() => publishPageJob(config, relativePath, message, author));
|
|
311
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
@@ -5,6 +5,20 @@ import { Ajv } from 'ajv';
|
|
|
5
5
|
// authors, not agent code, and are validated the same lenient way
|
|
6
6
|
// everywhere (see theme-schemas.ts).
|
|
7
7
|
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
8
|
+
// The six format values guide-theme-authoring.md/AGENTS.md actually
|
|
9
|
+
// document (richtext/image/textarea/uri/date/color/range/toggle - uri
|
|
10
|
+
// and date are real standard JSON Schema formats already understood
|
|
11
|
+
// without this) are still, correctly, UI hints only: registering them
|
|
12
|
+
// as a literal no-op format (the `true` here, not a real validator
|
|
13
|
+
// function) doesn't make Ajv enforce anything about them, it only
|
|
14
|
+
// stops it printing "unknown format \"x\" ignored" for values that are
|
|
15
|
+
// completely expected. A theme author's genuine typo (e.g. "iamge")
|
|
16
|
+
// still isn't in this list, so it still warns - this only silences the
|
|
17
|
+
// six we ourselves tell theme authors to use, not unknown-format
|
|
18
|
+
// warnings in general.
|
|
19
|
+
for (const format of ['richtext', 'image', 'textarea', 'color', 'range', 'toggle']) {
|
|
20
|
+
ajv.addFormat(format, true);
|
|
21
|
+
}
|
|
8
22
|
const schemasDir = join(import.meta.dirname, '..', 'schemas');
|
|
9
23
|
function readSchema(filename) {
|
|
10
24
|
return JSON.parse(readFileSync(join(schemasDir, filename), 'utf-8'));
|
|
@@ -70,7 +84,25 @@ export function requiredFieldsHaveValidDefaults(schema) {
|
|
|
70
84
|
if (typeof propertySchema !== 'object' || propertySchema === null || !('default' in propertySchema)) {
|
|
71
85
|
return false;
|
|
72
86
|
}
|
|
73
|
-
|
|
87
|
+
// ajv.validate compiles+runs propertySchema in isolation, detached
|
|
88
|
+
// from whatever schema it was pulled out of - a "$ref" pointing at
|
|
89
|
+
// a "$defs" entry declared on the parent (not inside this property
|
|
90
|
+
// sub-schema itself) can't resolve here, and Ajv throws
|
|
91
|
+
// (MissingRefError) rather than returning false. That's a real
|
|
92
|
+
// theme-authoring mistake (this function's whole contract assumes
|
|
93
|
+
// a schema self-contained enough to validate standalone), not a
|
|
94
|
+
// reason to crash the caller - loadTypeSchemas already treats a
|
|
95
|
+
// false return here as "type excluded, boot warning printed", so
|
|
96
|
+
// folding a thrown validation error into that exact same false
|
|
97
|
+
// keeps every possible way a required field's default can be
|
|
98
|
+
// unusable on the one graceful path, instead of one of them being
|
|
99
|
+
// the sole exception that takes the whole server down.
|
|
100
|
+
try {
|
|
101
|
+
return ajv.validate(propertySchema, propertySchema.default) === true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
74
106
|
});
|
|
75
107
|
}
|
|
76
108
|
export function validateInstance(instance, kind, themeSchemas) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { runSiteCheck } from "./run-check.js";
|
|
4
|
+
// Run from vhost/ (see the scaffold's own "check" script), so the
|
|
5
|
+
// site root is one level up - the exact same relative relationship
|
|
6
|
+
// SERVER_JS in create-site/generate-site.ts already relies on.
|
|
7
|
+
const siteRoot = resolve(process.cwd(), '..');
|
|
8
|
+
const KIND_LABELS = {
|
|
9
|
+
schema: 'Theme schema',
|
|
10
|
+
'render-error': 'Render error',
|
|
11
|
+
'missing-asset': 'Missing asset',
|
|
12
|
+
'broken-link': 'Broken link',
|
|
13
|
+
};
|
|
14
|
+
function printGrouped(findings) {
|
|
15
|
+
const byKind = new Map();
|
|
16
|
+
for (const finding of findings) {
|
|
17
|
+
const list = byKind.get(finding.kind) ?? [];
|
|
18
|
+
list.push(finding);
|
|
19
|
+
byKind.set(finding.kind, list);
|
|
20
|
+
}
|
|
21
|
+
for (const [kind, list] of byKind) {
|
|
22
|
+
console.log(`\n${KIND_LABELS[kind]} (${list.length}):`);
|
|
23
|
+
for (const finding of list) {
|
|
24
|
+
const location = finding.pageUrl ? ` ${finding.pageUrl}: ` : ' ';
|
|
25
|
+
console.log(`${location}${finding.message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const result = await runSiteCheck(siteRoot);
|
|
30
|
+
if (result.ok) {
|
|
31
|
+
console.log('No problems found.');
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
console.log(`${result.findings.length} problem${result.findings.length === 1 ? '' : 's'} found:`);
|
|
35
|
+
printGrouped(result.findings);
|
|
36
|
+
process.exit(1);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type CheckFindingKind = 'schema' | 'render-error' | 'missing-asset' | 'broken-link';
|
|
2
|
+
export interface CheckFinding {
|
|
3
|
+
kind: CheckFindingKind;
|
|
4
|
+
message: string;
|
|
5
|
+
pageUrl?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CheckResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
findings: CheckFinding[];
|
|
10
|
+
}
|
|
11
|
+
export declare function runSiteCheck(siteRoot: string): Promise<CheckResult>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { bootSite } from "../boot.js";
|
|
3
|
+
import { renderPage } from "../renderer/render-page.js";
|
|
4
|
+
import { PathSafetyError, sanitisePath } from "../services/path-safety.js";
|
|
5
|
+
import { buildSitemapUrls } from "../routes/sitemap.js";
|
|
6
|
+
import { urlToPagePath } from "../services/urls.js";
|
|
7
|
+
// A reference this project's own theme conventions actually produce:
|
|
8
|
+
// src="...", srcset="w1 480w, w2 960w" (comma-separated, each entry a
|
|
9
|
+
// url then a space then a width descriptor - strip the descriptor),
|
|
10
|
+
// href="...". Not a full HTML parser - matching SchemaField.tsx's own
|
|
11
|
+
// "the schema surface here is narrow and flat... a library would be
|
|
12
|
+
// heavier than the problem warrants" precedent for the equivalent
|
|
13
|
+
// choice on the admin side.
|
|
14
|
+
const ATTR_PATTERN = /\b(?:src|href)="([^"]*)"|\bsrcset="([^"]*)"/g;
|
|
15
|
+
function extractReferences(html) {
|
|
16
|
+
const refs = [];
|
|
17
|
+
for (const match of html.matchAll(ATTR_PATTERN)) {
|
|
18
|
+
const [, single, srcset] = match;
|
|
19
|
+
if (single !== undefined) {
|
|
20
|
+
refs.push(single);
|
|
21
|
+
}
|
|
22
|
+
else if (srcset !== undefined) {
|
|
23
|
+
for (const entry of srcset.split(',')) {
|
|
24
|
+
const url = entry.trim().split(/\s+/)[0];
|
|
25
|
+
if (url) {
|
|
26
|
+
refs.push(url);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return refs;
|
|
32
|
+
}
|
|
33
|
+
// True for a root-relative static path that looks like a real file
|
|
34
|
+
// (has a "." in its last path segment - /favicon.ico, /robots.txt),
|
|
35
|
+
// as opposed to a page URL like /about or /blog/hello-world, which
|
|
36
|
+
// never do. Doesn't need to be perfect - a page URL with a literal dot
|
|
37
|
+
// in its own slug is vanishingly unlikely and, worst case, just gets
|
|
38
|
+
// checked against the wrong bucket and reported as the wrong kind of
|
|
39
|
+
// finding, never silently skipped.
|
|
40
|
+
function looksLikeStaticFile(path) {
|
|
41
|
+
const lastSegment = path.split('/').pop() ?? '';
|
|
42
|
+
return lastSegment.includes('.');
|
|
43
|
+
}
|
|
44
|
+
// A rendered reference is theme/content-derived, not a live HTTP
|
|
45
|
+
// request's own :path - but the same traversal concern still applies
|
|
46
|
+
// (constraint 7), so it still goes through sanitisePath rather than a
|
|
47
|
+
// bare join+existsSync. A reference that fails to sanitise (a "../"
|
|
48
|
+
// escaping root) is exactly as real a finding as one that's simply
|
|
49
|
+
// missing - reported the same way, not silently skipped.
|
|
50
|
+
function checkStaticReference(findings, root, relativePath, originalPath, rootLabel, pageUrl) {
|
|
51
|
+
try {
|
|
52
|
+
const filePath = sanitisePath(root, relativePath);
|
|
53
|
+
if (!existsSync(filePath)) {
|
|
54
|
+
findings.push({ kind: 'missing-asset', message: `${originalPath} does not exist under ${rootLabel}`, pageUrl });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof PathSafetyError) {
|
|
59
|
+
findings.push({ kind: 'missing-asset', message: `${originalPath} is not a safe reference under ${rootLabel} (${error.message})`, pageUrl });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function runSiteCheck(siteRoot) {
|
|
66
|
+
const booted = bootSite(siteRoot);
|
|
67
|
+
const findings = [];
|
|
68
|
+
for (const warning of booted.themeSchemas.warnings ?? []) {
|
|
69
|
+
findings.push({ kind: 'schema', message: warning });
|
|
70
|
+
}
|
|
71
|
+
const publishedUrls = buildSitemapUrls(booted.config);
|
|
72
|
+
const publishedUrlSet = new Set(publishedUrls);
|
|
73
|
+
for (const pageUrl of publishedUrls) {
|
|
74
|
+
// urlToPagePath's own result is relative to pagesRoot (e.g.
|
|
75
|
+
// "about.json"), but renderPage's own relativePath is relative to
|
|
76
|
+
// contentRoot - the same "pages/" prefix routes/public.ts's own
|
|
77
|
+
// toRenderPath helper adds before every one of its renderPage
|
|
78
|
+
// calls.
|
|
79
|
+
const relativePath = `pages/${urlToPagePath(pageUrl)}`;
|
|
80
|
+
let html;
|
|
81
|
+
try {
|
|
82
|
+
html = await renderPage(booted.config, booted.themeTemplates, booted.layouts, booted.engine, relativePath, 'public');
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
86
|
+
findings.push({ kind: 'render-error', message: detail, pageUrl });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
for (const ref of extractReferences(html)) {
|
|
90
|
+
if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('data:') || ref.startsWith('//')) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const path = ref.split(/[?#]/)[0] ?? ref;
|
|
94
|
+
if (path.startsWith('/media/')) {
|
|
95
|
+
checkStaticReference(findings, booted.config.mediaRoot, path.slice('/media/'.length), path, 'media/', pageUrl);
|
|
96
|
+
}
|
|
97
|
+
else if (path.startsWith('/assets/')) {
|
|
98
|
+
checkStaticReference(findings, booted.config.assetsRoot, path.slice('/assets/'.length), path, 'theme/assets/', pageUrl);
|
|
99
|
+
}
|
|
100
|
+
else if (looksLikeStaticFile(path)) {
|
|
101
|
+
checkStaticReference(findings, booted.config.rootMirrorRoot, path.slice(1), path, 'theme/root/', pageUrl);
|
|
102
|
+
}
|
|
103
|
+
else if (path !== '' && !publishedUrlSet.has(path)) {
|
|
104
|
+
findings.push({ kind: 'broken-link', message: `${path} does not point at a published page`, pageUrl });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { ok: findings.length === 0, findings };
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o-a/cms-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
},
|
|
24
24
|
"bin": {
|
|
25
25
|
"create-site": "dist/create-site/cli.js",
|
|
26
|
-
"mint-token": "dist/create-site/mint-token-cli.js"
|
|
26
|
+
"mint-token": "dist/create-site/mint-token-cli.js",
|
|
27
|
+
"check-site": "dist/site-check/cli.js",
|
|
28
|
+
"seed-media": "dist/media/seed-media-cli.js"
|
|
27
29
|
},
|
|
28
30
|
"files": [
|
|
29
31
|
"dist"
|
|
@@ -35,7 +37,7 @@
|
|
|
35
37
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
36
38
|
"lint": "eslint .",
|
|
37
39
|
"test": "node --experimental-strip-types --test",
|
|
38
|
-
"test:packaging": "node --experimental-strip-types --test e2e/create-site-packaging.check.ts",
|
|
40
|
+
"test:packaging": "node --experimental-strip-types --test --test-concurrency=1 e2e/create-site-packaging.check.ts e2e/dev-watch.check.ts",
|
|
39
41
|
"build": "tsc -p tsconfig.build.json && rm -rf dist/schemas dist/create-site/template && cp -r src/schemas/. dist/schemas/ && cp -r src/create-site/template/. dist/create-site/template/",
|
|
40
42
|
"prepack": "npm run build"
|
|
41
43
|
},
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { openNodeSqliteDriver } from "./drivers/node-sqlite-driver.js";
|
|
2
|
-
// Never queued: an in-flight query holding an open handle during a
|
|
3
|
-
// concurrent rebuild's unlink just keeps reading the pre-rebuild inode
|
|
4
|
-
// (stale but consistent, never torn) - queuing a read against the same
|
|
5
|
-
// queue as writes would only add latency for no correctness benefit.
|
|
6
|
-
export function queryIndex(searchIndexPath, term) {
|
|
7
|
-
const driver = openNodeSqliteDriver(searchIndexPath);
|
|
8
|
-
try {
|
|
9
|
-
const rows = driver.prepare('SELECT url, title FROM pages_fts WHERE pages_fts MATCH ?').all(term);
|
|
10
|
-
// node:sqlite returns rows as [Object: null prototype] instances;
|
|
11
|
-
// rebuilt here as plain objects so callers (and assert.deepEqual)
|
|
12
|
-
// never have to know that's a driver implementation detail.
|
|
13
|
-
return rows.map((row) => {
|
|
14
|
-
const { url, title } = row;
|
|
15
|
-
return { url, title };
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
finally {
|
|
19
|
-
driver.close();
|
|
20
|
-
}
|
|
21
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// Pure, filesystem-free mapping between a /blog/<slug> URL and a
|
|
2
|
-
// post's path relative to postsRoot. Unlike pages' arbitrary nested
|
|
3
|
-
// paths (about.json beside a sibling about/ directory), posts are
|
|
4
|
-
// flat only - a URL with more than one segment after /blog/ is never
|
|
5
|
-
// a valid post URL, enforced here rather than left to sanitisePath.
|
|
6
|
-
const BLOG_PREFIX = '/blog/';
|
|
7
|
-
// /blog is a permanently reserved namespace: both "/blog" itself (no
|
|
8
|
-
// slug) and every "/blog/..." URL are recognised here, so a caller can
|
|
9
|
-
// route the whole namespace to post resolution before ever checking
|
|
10
|
-
// for a page, matching the confirmed reserved-namespace decision.
|
|
11
|
-
export function isBlogUrl(url) {
|
|
12
|
-
return url === '/blog' || url.startsWith(BLOG_PREFIX);
|
|
13
|
-
}
|
|
14
|
-
export function urlToPostPath(url) {
|
|
15
|
-
if (!url.startsWith(BLOG_PREFIX)) {
|
|
16
|
-
return null;
|
|
17
|
-
}
|
|
18
|
-
const slug = url.slice(BLOG_PREFIX.length);
|
|
19
|
-
if (slug === '' || slug.includes('/')) {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
return `${slug}.json`;
|
|
23
|
-
}
|
|
24
|
-
export function postPathToUrl(relativePostPath) {
|
|
25
|
-
const withoutExtension = relativePostPath.replace(/\.json$/, '');
|
|
26
|
-
return `${BLOG_PREFIX}${withoutExtension}`;
|
|
27
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { SiteConfig } from '../config.ts';
|
|
2
|
-
export type ResolvedBlogUrl = {
|
|
3
|
-
kind: 'post';
|
|
4
|
-
relativePath: string;
|
|
5
|
-
} | {
|
|
6
|
-
kind: 'redirect';
|
|
7
|
-
to: string;
|
|
8
|
-
} | {
|
|
9
|
-
kind: 'not-found';
|
|
10
|
-
};
|
|
11
|
-
export declare function resolveBlogUrl(config: SiteConfig, url: string): ResolvedBlogUrl;
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import { sanitisePath } from "./path-safety.js";
|
|
3
|
-
import { buildRedirectLookup, loadRedirects } from "./redirects.js";
|
|
4
|
-
import { urlToPostPath } from "./post-urls.js";
|
|
5
|
-
// Mirrors resolve-url.ts's shape exactly (a live post always wins over
|
|
6
|
-
// a redirect at the same URL), kept as its own distinct result type
|
|
7
|
-
// rather than reusing ResolvedUrl - clearer branching at the call site
|
|
8
|
-
// and avoids touching Group C's already-tested resolve-url.ts.
|
|
9
|
-
//
|
|
10
|
-
// Unlike pagesRoot (always expected to exist on a real site),
|
|
11
|
-
// postsRoot is optional - a site that has never used blog posts has
|
|
12
|
-
// no content/posts/ directory at all. sanitisePath calls realpathSync
|
|
13
|
-
// directly on its root argument, which throws a raw, uncaught ENOENT
|
|
14
|
-
// if that root itself is missing - so postsRoot's existence is checked
|
|
15
|
-
// first, before ever calling sanitisePath, rather than letting a
|
|
16
|
-
// perfectly ordinary "no posts yet" site crash on its first /blog/ hit.
|
|
17
|
-
export function resolveBlogUrl(config, url) {
|
|
18
|
-
const relativePath = urlToPostPath(url);
|
|
19
|
-
if (relativePath !== null && existsSync(config.postsRoot)) {
|
|
20
|
-
const postFile = sanitisePath(config.postsRoot, relativePath);
|
|
21
|
-
if (existsSync(postFile)) {
|
|
22
|
-
return { kind: 'post', relativePath };
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
const lookup = buildRedirectLookup(loadRedirects(config).entries);
|
|
26
|
-
const to = lookup.get(url);
|
|
27
|
-
if (to !== undefined) {
|
|
28
|
-
return { kind: 'redirect', to };
|
|
29
|
-
}
|
|
30
|
-
return { kind: 'not-found' };
|
|
31
|
-
}
|