@o-a/cms-agent 0.2.1 → 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 (53) 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 +189 -5
  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/renderer/render-page.d.ts +3 -0
  25. package/dist/renderer/render-page.js +13 -6
  26. package/dist/routes/media.js +5 -9
  27. package/dist/routes/publish.js +37 -3
  28. package/dist/routes/sitemap.d.ts +1 -0
  29. package/dist/routes/sitemap.js +7 -1
  30. package/dist/search/rebuild-index.d.ts +1 -0
  31. package/dist/search/rebuild-index.js +17 -1
  32. package/dist/server.js +17 -0
  33. package/dist/services/batch.js +10 -1
  34. package/dist/services/delete-content.js +4 -1
  35. package/dist/services/move.js +4 -1
  36. package/dist/services/publish.d.ts +1 -0
  37. package/dist/services/publish.js +67 -2
  38. package/dist/services/reindex-on-write.d.ts +3 -0
  39. package/dist/services/reindex-on-write.js +30 -0
  40. package/dist/services/theme-schemas.js +13 -5
  41. package/dist/services/validation.d.ts +1 -0
  42. package/dist/services/validation.js +33 -1
  43. package/dist/site-check/cli.d.ts +2 -0
  44. package/dist/site-check/cli.js +36 -0
  45. package/dist/site-check/run-check.d.ts +11 -0
  46. package/dist/site-check/run-check.js +109 -0
  47. package/package.json +5 -3
  48. package/dist/search/query-index.d.ts +0 -5
  49. package/dist/search/query-index.js +0 -21
  50. package/dist/services/post-urls.d.ts +0 -3
  51. package/dist/services/post-urls.js +0 -27
  52. package/dist/services/resolve-blog-url.d.ts +0 -11
  53. 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
 
@@ -58,19 +61,39 @@ This scaffold already ships real, working examples worth reading before writing
58
61
  {% endschema %}
59
62
  ```
60
63
 
61
- Available variables in a section: `section.id`, `section.settings.<key>`, and `blocksHtml` (an array of already-rendered child block HTML strings - a section never sees raw block data, only finished HTML, output with `{{ html | raw }}`). A block template gets the same shape: `block.id`, `block.settings.<key>`, and (rarely) its own `blocksHtml` if it nests further blocks.
64
+ Available variables in a section: `section.id`, `section.settings.<key>`, `blocksHtml` (an array of already-rendered child block HTML strings - a section never sees raw block data, only finished HTML, output with `{{ html | raw }}`), and `page` - the same built-in envelope a layout gets (`page.title`, `page.author`, `page.publishDate`, `page.tags`; see "Content JSON model" below). A block template gets the same shape: `block.id`, `block.settings.<key>`, `page`, and (rarely) its own `blocksHtml` if it nests further blocks. `page.author`/`publishDate`/`tags` render as empty/absent, not an error, on a page that doesn't set them - useful for printing a byline/date inside the page body without duplicating the value into a settings field.
62
65
 
63
- **Every property listed in a schema's `"required"` array must also declare a `"default"`** that itself satisfies the property's own constraints (e.g. not `"default": ""` against `"minLength": 1`). A schema that violates this is silently excluded from the theme entirely - it simply won't be selectable, with no error printed anywhere obvious. If a new section/block isn't showing up, check this first.
66
+ **Every property listed in a schema's `"required"` array must also declare a `"default"`** that itself satisfies the property's own constraints (e.g. not `"default": ""` against `"minLength": 1`). A schema that violates this - or one with no `{% schema %}` block at all, or invalid JSON inside it - is excluded from the theme entirely (it simply won't be selectable), but never silently: boot prints a warning naming the type and the specific reason. If a new section/block isn't showing up, check the server's console output first.
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:
70
90
 
71
91
  ```liquid
72
92
  {{ content_for_layout | raw }} the page's fully-rendered sections, concatenated
73
- {{ page.title }} the page's title (the ONLY page field exposed to layouts)
93
+ {{ page.title }} the page's title
94
+ {{ page.author }} the page's author, if set
95
+ {{ page.publishDate }} the page's publish date, if set
96
+ {% for tag in page.tags %}...{% endfor %} the page's tags, if any
74
97
  {{ menus.<name>.items }} every menu in content/menus/, keyed by filename
75
98
  ```
76
99
 
@@ -94,9 +117,136 @@ Every setting is plain JSON Schema (`string`, `integer`, `number`, `boolean`, `a
94
117
  | `toggle` | `boolean` | Switch instead of a checkbox (same underlying data) |
95
118
  | (none) | `boolean` | Plain checkbox |
96
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) |
97
122
 
98
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.
99
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
+
100
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.
101
251
 
102
252
  ## Content JSON model
@@ -166,10 +316,28 @@ Uploads go through `POST /v1/media` (multipart, requires a token with `media` sc
166
316
 
167
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.
168
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
+
169
335
  ## `GET /search.json`
170
336
 
171
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.
172
338
 
339
+ The index behind this endpoint keeps itself current automatically - it rebuilds in the background after every publish/unpublish/delete/move, and once at boot if no index exists yet (a fresh clone, since the index itself is never git-tracked). No manual step is needed for a blog listing built on this endpoint to work on a freshly deployed site.
340
+
173
341
  ## Hard constraints - do not deviate from these
174
342
 
175
343
  - **No dynamically registered Liquid tags or filters, ever.** Only standard LiquidJS built-ins (`if`, `for`, `assign`, `render`, filters like `upcase`, `times`) plus the CMS-provided context objects described above. Never invent a custom tag.
@@ -178,6 +346,7 @@ A public, unauthenticated, read-only endpoint - safe to call directly from a sec
178
346
  - **One file, one type, no subfolders** inside `layouts/`, `sections/`, `blocks/`, `snippets/` - and the filename must match `^[a-z0-9][a-z0-9-]*$` exactly.
179
347
  - **The `{% schema %}` block must be valid, parseable JSON.** A malformed or missing schema fails the whole component, not just the settings half.
180
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.
181
350
 
182
351
  ## Previewing your work
183
352
 
@@ -186,6 +355,21 @@ From `vhost/`:
186
355
  ```
187
356
  npm start # boots the site on the port set in vhost/site.config.json
188
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
189
359
  ```
190
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
+
191
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
+ }
@@ -20,6 +20,9 @@ export interface PageContent {
20
20
  published: boolean;
21
21
  layout: string;
22
22
  sections: SectionOrBlockInstance[];
23
+ author?: string;
24
+ publishDate?: string;
25
+ tags?: string[];
23
26
  }
24
27
  export declare function renderSections(page: PageContent, themeTemplates: ThemeTemplates, engine: Liquid): Promise<string>;
25
28
  export type RenderMode = 'public' | 'preview';
@@ -10,13 +10,16 @@ export class PageRenderError extends Error {
10
10
  this.reason = reason;
11
11
  }
12
12
  }
13
+ function pageEnvelope(page) {
14
+ return { title: page.title, author: page.author, publishDate: page.publishDate, tags: page.tags };
15
+ }
13
16
  // Renders one section or block, recursively rendering any nested blocks
14
17
  // first (instance.schema.json is self-referential, so a block can carry
15
18
  // its own nested blocks). Never uses {% include %}/{% render %}: block
16
19
  // HTML is pre-rendered here in JS and handed to the parent template as
17
20
  // a plain array, which sidesteps LiquidJS's own filesystem include
18
21
  // resolution entirely.
19
- async function renderInstance(instance, kind, themeTemplates, engine) {
22
+ async function renderInstance(instance, kind, themeTemplates, engine, page) {
20
23
  const templates = kind === 'section' ? themeTemplates.sections : themeTemplates.blocks;
21
24
  const template = templates[instance.type];
22
25
  if (!template) {
@@ -24,13 +27,16 @@ async function renderInstance(instance, kind, themeTemplates, engine) {
24
27
  }
25
28
  const blocksHtml = [];
26
29
  for (const block of instance.blocks ?? []) {
27
- blocksHtml.push(await renderInstance(block, 'block', themeTemplates, engine));
30
+ blocksHtml.push(await renderInstance(block, 'block', themeTemplates, engine, page));
28
31
  }
29
32
  // Shopify-style scope shape: settings nested under the instance, not
30
33
  // flattened, so templates read section.settings.x / block.settings.x.
34
+ // `page` is the same built-in envelope a layout gets (title plus the
35
+ // optional author/publishDate/tags fields) - lets a section print a
36
+ // byline/date without duplicating it into a settings field.
31
37
  const scope = kind === 'section'
32
- ? { section: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml }
33
- : { block: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml };
38
+ ? { section: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml, page }
39
+ : { block: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml, page };
34
40
  try {
35
41
  return (await engine.parseAndRender(template, scope));
36
42
  }
@@ -46,9 +52,10 @@ async function renderInstance(instance, kind, themeTemplates, engine) {
46
52
  // No page-level layout/wrapper concept in Phase 1 - this is the whole
47
53
  // output.
48
54
  export async function renderSections(page, themeTemplates, engine) {
55
+ const envelope = pageEnvelope(page);
49
56
  const html = [];
50
57
  for (const section of page.sections) {
51
- html.push(await renderInstance(section, 'section', themeTemplates, engine));
58
+ html.push(await renderInstance(section, 'section', themeTemplates, engine, envelope));
52
59
  }
53
60
  return html.join('');
54
61
  }
@@ -134,7 +141,7 @@ export async function renderLoadedPage(page, config, themeTemplates, layouts, en
134
141
  // outputEscape: 'escape' double-escapes it into literal text.
135
142
  return (await engine.parseAndRender(layoutTemplate, {
136
143
  content_for_layout: bodyHtml,
137
- page: { title: page.title },
144
+ page: pageEnvelope(page),
138
145
  menus,
139
146
  }));
140
147
  }