@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.
- package/dist/create-site/cli.js +0 -0
- package/dist/create-site/generate-site.js +21 -0
- package/dist/create-site/mint-token-cli.js +0 -0
- package/dist/create-site/template/AGENTS.md +189 -5
- package/dist/create-site/template/content/menus/footerCompany.json +1 -1
- package/dist/create-site/template/content/menus/footerProduct.json +1 -1
- package/dist/create-site/template/content/menus/footerResources.json +1 -1
- package/dist/create-site/template/content/menus/main.json +1 -1
- package/dist/create-site/template/content/pages/404.json +1 -1
- package/dist/create-site/template/content/pages/about/careers.json +1 -1
- package/dist/create-site/template/content/pages/about/team.json +1 -1
- package/dist/create-site/template/content/pages/about.json +1 -1
- package/dist/create-site/template/content/pages/docs/deployment.json +1 -1
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +1 -1
- package/dist/create-site/template/content/pages/docs/getting-started.json +1 -1
- package/dist/create-site/template/content/pages/docs.json +1 -1
- package/dist/create-site/template/content/pages/index.json +1 -1
- package/dist/media/filename.d.ts +1 -0
- package/dist/media/filename.js +13 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/renderer/render-page.d.ts +3 -0
- package/dist/renderer/render-page.js +13 -6
- package/dist/routes/media.js +5 -9
- package/dist/routes/publish.js +37 -3
- package/dist/routes/sitemap.d.ts +1 -0
- package/dist/routes/sitemap.js +7 -1
- package/dist/search/rebuild-index.d.ts +1 -0
- package/dist/search/rebuild-index.js +17 -1
- package/dist/server.js +17 -0
- package/dist/services/batch.js +10 -1
- package/dist/services/delete-content.js +4 -1
- package/dist/services/move.js +4 -1
- package/dist/services/publish.d.ts +1 -0
- package/dist/services/publish.js +67 -2
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/theme-schemas.js +13 -5
- package/dist/services/validation.d.ts +1 -0
- package/dist/services/validation.js +33 -1
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +36 -0
- package/dist/site-check/run-check.d.ts +11 -0
- package/dist/site-check/run-check.js +109 -0
- package/package.json +5 -3
- package/dist/search/query-index.d.ts +0 -5
- package/dist/search/query-index.js +0 -21
- package/dist/services/post-urls.d.ts +0 -3
- package/dist/services/post-urls.js +0 -27
- package/dist/services/resolve-blog-url.d.ts +0 -11
- package/dist/services/resolve-blog-url.js +0 -31
package/dist/create-site/cli.js
CHANGED
|
File without changes
|
|
@@ -86,6 +86,13 @@ export function scaffoldSite(targetDir) {
|
|
|
86
86
|
// could lose or that leaks into version control.
|
|
87
87
|
const token = generateToken();
|
|
88
88
|
writeFileSync(join(vhostDir, 'site.config.json'), JSON.stringify({
|
|
89
|
+
// Written explicitly, matching server-config.ts's own
|
|
90
|
+
// DEFAULT_PORT, rather than left absent - a real value here
|
|
91
|
+
// is a visible, editable config knob; an absent key is
|
|
92
|
+
// invisible until something else is already using the
|
|
93
|
+
// default port, at which point the failure is a raw
|
|
94
|
+
// EADDRINUSE crash instead of an obvious setting to change.
|
|
95
|
+
port: 3000,
|
|
89
96
|
tokens: [{ hash: token.hash, scopes: ['content', 'theme', 'media'] }],
|
|
90
97
|
}, null, 2));
|
|
91
98
|
const packageName = sanitisePackageName(basename(targetDir));
|
|
@@ -100,9 +107,23 @@ export function scaffoldSite(targetDir) {
|
|
|
100
107
|
// straight to one of those (no Dockerfile in the loop) simply
|
|
101
108
|
// wouldn't boot. "tunnel" mirrors the --tunnel flag create-site
|
|
102
109
|
// already tells the operator about in its own next-steps output.
|
|
110
|
+
// "dev" is Node's own --watch-path, not custom fs-watching code
|
|
111
|
+
// - confirmed empirically that it both picks up changes to a
|
|
112
|
+
// plain file under a watched directory that's never imported
|
|
113
|
+
// (theme/*.liquid is only ever read via readFileSync) and sends
|
|
114
|
+
// a real SIGTERM on restart, which server.js's own shutdown
|
|
115
|
+
// handling already listens for. "../theme" because npm run dev
|
|
116
|
+
// runs with cwd vhost/, a sibling of theme/. Deliberately not
|
|
117
|
+
// content/ - that's already read fresh on every request, so
|
|
118
|
+
// watching it would only cause pointless restarts on every
|
|
119
|
+
// content edit. "check" is check-site, the package's own
|
|
120
|
+
// installed CLI bin - runs from vhost/ (site-check/cli.ts's own
|
|
121
|
+
// assumed cwd relationship), same pattern as start/tunnel/dev.
|
|
103
122
|
scripts: {
|
|
104
123
|
start: 'node server.js',
|
|
105
124
|
tunnel: 'node server.js --tunnel',
|
|
125
|
+
dev: 'node --watch-path=../theme server.js',
|
|
126
|
+
check: 'check-site',
|
|
106
127
|
},
|
|
107
128
|
dependencies: {
|
|
108
129
|
// Pinned exact, never a ^range - at v0.x even a minor bump
|
|
File without changes
|
|
@@ -33,9 +33,12 @@ This scaffold already ships real, working examples worth reading before writing
|
|
|
33
33
|
## Turning a design into code - the actual workflow
|
|
34
34
|
|
|
35
35
|
1. Break the design into distinct repeating/reusable visual components. Each one becomes a `theme/sections/<name>.liquid` (a self-contained region of a page) or `theme/blocks/<name>.liquid` (a smaller item nested inside a section, e.g. one card in a grid, one FAQ row).
|
|
36
|
-
2. Each file has two parts: ordinary Liquid/HTML markup, and a `{% schema %} ... {% endschema %}` block containing a single JSON object - a
|
|
36
|
+
2. Each file has two parts: ordinary Liquid/HTML markup, and a `{% schema %} ... {% endschema %}` block containing a single JSON object - a settings description using *only* the JSON Schema draft-07 keywords listed below, never the full spec. The schema block is stripped out before rendering, so a real Liquid tag never sees it - place it anywhere in the file (convention: at the end).
|
|
37
|
+
|
|
38
|
+
**Only these keywords are supported**: `type`, `properties`, `required`, `additionalProperties`, `default`, `minLength`, `maxLength`, `minimum`, `maximum`, `pattern`, `enum`, `items`, `minItems`, `maxItems`, plus the custom `format`/`title`/`description`/`allowedBlocks`/`api`/`swatches`/`step`/`unit` keywords documented below. **Never `$ref`, `$defs`, `definitions`, `allOf`, `anyOf`, `oneOf`, `not`, or `if`/`then`/`else`** - every property's schema must be fully self-contained, written out in full where it's used. If the same shape (e.g. an image object) repeats across several properties or several component files, write it out each time rather than trying to share/reference a definition - there is no cross-referencing mechanism here, in a single schema block or across files, regardless of what standard JSON Schema itself supports elsewhere.
|
|
37
39
|
3. Once the theme components exist, compose an actual page by writing a file under `content/pages/` whose `sections` array references those types by filename, with a `settings` object matching each one's schema (see "Content JSON model" below).
|
|
38
|
-
4.
|
|
40
|
+
4. **For any page type the site will have more than one of** - a project, an article, a case study, a team member - also write a starting point for it under `theme/templates/<name>.json`. This is easy to skip and worth not skipping: without a template, every new page an editor creates starts completely blank, and they have to rebuild the same section stack by hand every time. A template is just a real page file kept in a different folder - the same shape as anything under `content/pages/` (`schemaVersion`, `name`, `title`, `type`, `layout`, `published`, `sections`), validated identically, using the section types this theme already defines. Its `"title"` is the label an editor picks from, so name it for the page type (`"Project"`, `"Article"`), never `"Untitled"`. Fill each section's settings with short placeholder copy rather than leaving them empty - a template is a starting point to edit, not a blank form. A template that fails validation is skipped silently at boot, so preview a page built from it.
|
|
41
|
+
5. Preview the result before considering the task done - see "Previewing your work".
|
|
39
42
|
|
|
40
43
|
### Worked example - a section
|
|
41
44
|
|
|
@@ -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>`,
|
|
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
|
|
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
|
|
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.
|
package/dist/media/filename.d.ts
CHANGED
package/dist/media/filename.js
CHANGED
|
@@ -21,6 +21,19 @@ import { basename, extname } from 'node:path';
|
|
|
21
21
|
// lowercase, non [a-z0-9-] runs collapsed to a single "-", trimmed -
|
|
22
22
|
// not a second, independently-invented convention.
|
|
23
23
|
const HASH_LENGTH = 12;
|
|
24
|
+
// Images only - confirmed with the user, not a general document
|
|
25
|
+
// library. .svg is rejected regardless of this list even though it's
|
|
26
|
+
// technically an image format - docs/cms-build-plan.md's own "SVG
|
|
27
|
+
// rejected outright, not sanitised" decision (a real stored-XSS path
|
|
28
|
+
// otherwise: mime-types.ts maps .svg to a real image/svg+xml content
|
|
29
|
+
// type, and an SVG loaded as a top-level navigation, not just <img>-
|
|
30
|
+
// embedded, can execute a script it carries).
|
|
31
|
+
//
|
|
32
|
+
// Exported from here, not left as routes/media.ts's own private
|
|
33
|
+
// constant, specifically so seed-media.ts (an offline tool with no
|
|
34
|
+
// HTTP request to validate) enforces the exact same rule rather than
|
|
35
|
+
// a second, independently-maintained copy of it.
|
|
36
|
+
export const ALLOWED_UPLOAD_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
24
37
|
export function buildMediaFilename(originalFilename, bytes) {
|
|
25
38
|
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, HASH_LENGTH);
|
|
26
39
|
const base = basename(originalFilename, extname(originalFilename));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { seedMedia } from "./seed-media.js";
|
|
4
|
+
const [targetArg, ...sourceArgs] = process.argv.slice(2);
|
|
5
|
+
if (!targetArg || sourceArgs.length === 0) {
|
|
6
|
+
console.error('Usage: seed-media <site-directory> <path> [<path> ...]');
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
const siteDir = resolve(process.cwd(), targetArg);
|
|
10
|
+
const sourcePaths = sourceArgs.map((sourceArg) => resolve(process.cwd(), sourceArg));
|
|
11
|
+
const result = seedMedia(siteDir, sourcePaths);
|
|
12
|
+
for (const entry of result.entries) {
|
|
13
|
+
if (entry.status === 'seeded') {
|
|
14
|
+
console.log(`${entry.sourcePath} -> ${entry.url}`);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
console.log(`${entry.sourcePath}: skipped (${entry.reason})`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SeedMediaEntry {
|
|
2
|
+
sourcePath: string;
|
|
3
|
+
status: 'seeded' | 'skipped-invalid-type';
|
|
4
|
+
url?: string;
|
|
5
|
+
reason?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SeedMediaResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
entries: SeedMediaEntry[];
|
|
10
|
+
}
|
|
11
|
+
export declare function seedMedia(siteRoot: string, sourcePaths: string[]): SeedMediaResult;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { ALLOWED_UPLOAD_EXTENSIONS, buildMediaFilename } from "./filename.js";
|
|
4
|
+
// A directory argument expands to its own direct child files only -
|
|
5
|
+
// one level, not recursive, matching theme-schemas.ts's own flat-
|
|
6
|
+
// directory convention for exactly the same reason (a predictable,
|
|
7
|
+
// easy-to-reason-about walk, not a surprise deep scan).
|
|
8
|
+
function expandSourcePaths(sourcePaths) {
|
|
9
|
+
const files = [];
|
|
10
|
+
for (const sourcePath of sourcePaths) {
|
|
11
|
+
if (statSync(sourcePath).isDirectory()) {
|
|
12
|
+
for (const entry of readdirSync(sourcePath, { withFileTypes: true })) {
|
|
13
|
+
if (entry.isFile()) {
|
|
14
|
+
files.push(join(sourcePath, entry.name));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
files.push(sourcePath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return files;
|
|
23
|
+
}
|
|
24
|
+
// Seeds one or more local image files directly into a site's media/,
|
|
25
|
+
// computing the exact same content-addressed filename a real
|
|
26
|
+
// POST /v1/media upload would - see this module's own filename.ts for
|
|
27
|
+
// why that's safe to do offline (a pure function of a filename and the
|
|
28
|
+
// file's own bytes, no server/database involved). For an AI agent (or
|
|
29
|
+
// any offline tool) generating a site's starter content before a
|
|
30
|
+
// server is even running - GET /media/* just serves whatever file
|
|
31
|
+
// exists at the requested path, so the result is indistinguishable
|
|
32
|
+
// from a real upload.
|
|
33
|
+
//
|
|
34
|
+
// siteRoot is an operator/agent-supplied directory, the same category
|
|
35
|
+
// as scaffoldSite/mintToken - not a web request's own :path, so
|
|
36
|
+
// sanitisePath's traversal concern doesn't apply here (see
|
|
37
|
+
// mint-token.ts's own comment on this exact point).
|
|
38
|
+
//
|
|
39
|
+
// Never a git commit - media is never git-tracked (constraint 2,
|
|
40
|
+
// matching manage-media.ts's own "no commit, no author... Media is
|
|
41
|
+
// never git-tracked" comment).
|
|
42
|
+
//
|
|
43
|
+
// A disallowed file type is skipped, not a reason to abort the whole
|
|
44
|
+
// batch - an agent seeding a dozen images should still get the eleven
|
|
45
|
+
// good ones, with the one bad one clearly reported. The allowlist
|
|
46
|
+
// itself is the one thing that must not be relaxed here: it's the
|
|
47
|
+
// same rule the real upload route enforces (ALLOWED_UPLOAD_EXTENSIONS,
|
|
48
|
+
// shared, not a second copy), because skipping it would reopen the
|
|
49
|
+
// real stored-XSS path SVG rejection exists to close, for whoever
|
|
50
|
+
// later visits the live site - not a concern about this tool's own
|
|
51
|
+
// caller, who already has full local trust.
|
|
52
|
+
export function seedMedia(siteRoot, sourcePaths) {
|
|
53
|
+
const mediaRoot = join(siteRoot, 'media');
|
|
54
|
+
mkdirSync(mediaRoot, { recursive: true });
|
|
55
|
+
const entries = [];
|
|
56
|
+
for (const sourcePath of expandSourcePaths(sourcePaths)) {
|
|
57
|
+
const extension = extname(sourcePath).toLowerCase();
|
|
58
|
+
if (!ALLOWED_UPLOAD_EXTENSIONS.has(extension)) {
|
|
59
|
+
entries.push({
|
|
60
|
+
sourcePath,
|
|
61
|
+
status: 'skipped-invalid-type',
|
|
62
|
+
reason: `"${extension}" is not an accepted image type`,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const bytes = readFileSync(sourcePath);
|
|
67
|
+
const name = buildMediaFilename(basename(sourcePath), bytes);
|
|
68
|
+
// No existence check first: content-addressed naming means
|
|
69
|
+
// re-seeding the same bytes writes identical bytes over identical
|
|
70
|
+
// bytes - a harmless idempotent overwrite, matching
|
|
71
|
+
// local-fs-driver.ts's own put() precedent exactly.
|
|
72
|
+
writeFileSync(join(mediaRoot, name), bytes);
|
|
73
|
+
entries.push({ sourcePath, status: 'seeded', url: `/media/${name}` });
|
|
74
|
+
}
|
|
75
|
+
return { ok: entries.every((entry) => entry.status === 'seeded'), entries };
|
|
76
|
+
}
|
|
@@ -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:
|
|
144
|
+
page: pageEnvelope(page),
|
|
138
145
|
menus,
|
|
139
146
|
}));
|
|
140
147
|
}
|