@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.
Files changed (41) hide show
  1. package/dist/create-site/cli.js +0 -0
  2. package/dist/create-site/generate-site.js +21 -0
  3. package/dist/create-site/mint-token-cli.js +0 -0
  4. package/dist/create-site/template/AGENTS.md +181 -2
  5. package/dist/create-site/template/content/menus/footerCompany.json +1 -1
  6. package/dist/create-site/template/content/menus/footerProduct.json +1 -1
  7. package/dist/create-site/template/content/menus/footerResources.json +1 -1
  8. package/dist/create-site/template/content/menus/main.json +1 -1
  9. package/dist/create-site/template/content/pages/404.json +1 -1
  10. package/dist/create-site/template/content/pages/about/careers.json +1 -1
  11. package/dist/create-site/template/content/pages/about/team.json +1 -1
  12. package/dist/create-site/template/content/pages/about.json +1 -1
  13. package/dist/create-site/template/content/pages/docs/deployment.json +1 -1
  14. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +1 -1
  15. package/dist/create-site/template/content/pages/docs/getting-started.json +1 -1
  16. package/dist/create-site/template/content/pages/docs.json +1 -1
  17. package/dist/create-site/template/content/pages/index.json +1 -1
  18. package/dist/media/filename.d.ts +1 -0
  19. package/dist/media/filename.js +13 -0
  20. package/dist/media/seed-media-cli.d.ts +2 -0
  21. package/dist/media/seed-media-cli.js +22 -0
  22. package/dist/media/seed-media.d.ts +11 -0
  23. package/dist/media/seed-media.js +76 -0
  24. package/dist/routes/media.js +5 -9
  25. package/dist/routes/publish.js +37 -3
  26. package/dist/routes/sitemap.d.ts +1 -0
  27. package/dist/routes/sitemap.js +7 -1
  28. package/dist/services/publish.d.ts +1 -0
  29. package/dist/services/publish.js +54 -0
  30. package/dist/services/validation.js +33 -1
  31. package/dist/site-check/cli.d.ts +2 -0
  32. package/dist/site-check/cli.js +36 -0
  33. package/dist/site-check/run-check.d.ts +11 -0
  34. package/dist/site-check/run-check.js +109 -0
  35. package/package.json +5 -3
  36. package/dist/search/query-index.d.ts +0 -5
  37. package/dist/search/query-index.js +0 -21
  38. package/dist/services/post-urls.d.ts +0 -3
  39. package/dist/services/post-urls.js +0 -27
  40. package/dist/services/resolve-blog-url.d.ts +0 -11
  41. package/dist/services/resolve-blog-url.js +0 -31
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 plain [JSON Schema draft-07](https://json-schema.org/draft-07) description of that component's `settings`. 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).
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. Preview the result before considering the task done - see "Previewing your work".
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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 1,
2
+ "schemaVersion": 6,
3
3
  "items": [
4
4
  { "label": "About", "url": "/about" },
5
5
  { "label": "Careers", "url": "/careers" },
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 1,
2
+ "schemaVersion": 6,
3
3
  "items": [
4
4
  { "label": "Features", "url": "#features" },
5
5
  { "label": "Pricing", "url": "#pricing" },
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 1,
2
+ "schemaVersion": 6,
3
3
  "items": [
4
4
  { "label": "Documentation", "url": "/docs" },
5
5
  { "label": "Theme authoring guide", "url": "/docs/themes" },
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 1,
2
+ "schemaVersion": 6,
3
3
  "items": [
4
4
  { "label": "Features", "url": "#features" },
5
5
  { "label": "How it works", "url": "#how-it-works" },
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 4,
2
+ "schemaVersion": 6,
3
3
  "title": "Page not found — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 4,
2
+ "schemaVersion": 6,
3
3
  "title": "Careers — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 4,
2
+ "schemaVersion": 6,
3
3
  "title": "Team — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 4,
2
+ "schemaVersion": 6,
3
3
  "title": "About — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 4,
2
+ "schemaVersion": 6,
3
3
  "title": "Deployment — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 4,
2
+ "schemaVersion": 6,
3
3
  "title": "Quickstart — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 4,
2
+ "schemaVersion": 6,
3
3
  "title": "Getting Started — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 4,
2
+ "schemaVersion": 6,
3
3
  "title": "Docs — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 4,
2
+ "schemaVersion": 6,
3
3
  "title": "Granite CMS — Solid foundations for client sites",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1 +1,2 @@
1
+ export declare const ALLOWED_UPLOAD_EXTENSIONS: Set<string>;
1
2
  export declare function buildMediaFilename(originalFilename: string, bytes: Buffer): string;
@@ -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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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
+ }
@@ -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({
@@ -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 parseUnpublishBody(body) {
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 = parseUnpublishBody(request.body);
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
  };
@@ -3,4 +3,5 @@ import type { SiteConfig } from '../config.ts';
3
3
  export interface SitemapRouteOptions {
4
4
  config: SiteConfig;
5
5
  }
6
+ export declare function buildSitemapUrls(config: SiteConfig): string[];
6
7
  export declare const sitemapRoutes: FastifyPluginAsync<SitemapRouteOptions>;
@@ -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
- function buildSitemapUrls(config) {
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>;
@@ -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
- return ajv.validate(propertySchema, propertySchema.default) === true;
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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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.2.2",
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,5 +0,0 @@
1
- export interface SearchResult {
2
- url: string;
3
- title: string;
4
- }
5
- export declare function queryIndex(searchIndexPath: string, term: string): SearchResult[];
@@ -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,3 +0,0 @@
1
- export declare function isBlogUrl(url: string): boolean;
2
- export declare function urlToPostPath(url: string): string | null;
3
- export declare function postPathToUrl(relativePostPath: string): string;
@@ -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
- }