@o-a/cms-agent 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -16
- package/dist/create-site/generate-site.js +1 -1
- package/dist/create-site/template/AGENTS.md +196 -0
- package/dist/create-site/template/vhost/Dockerfile +1 -1
- package/dist/renderer/render-page.d.ts +3 -0
- package/dist/renderer/render-page.js +13 -6
- package/dist/routes/sitemap.js +1 -1
- package/dist/search/rebuild-index.d.ts +1 -0
- package/dist/search/rebuild-index.js +18 -2
- 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.js +13 -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 +15 -7
- package/dist/services/validation.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,27 +35,18 @@ Granite CMS is the combination neither of those is:
|
|
|
35
35
|
|
|
36
36
|
- **Developers** build the theme: Liquid layouts, sections, and blocks,
|
|
37
37
|
each with an embedded JSON Schema for its settings. Start with
|
|
38
|
-
[`docs/theme-authoring
|
|
38
|
+
[`docs/guide-theme-authoring.md`](docs/guide-theme-authoring.md).
|
|
39
39
|
- **Content editors / marketing managers** never touch this repository at
|
|
40
40
|
all - they work entirely through a separate admin application (see
|
|
41
41
|
[Companion projects](#companion-projects) below), browsing pages,
|
|
42
42
|
editing sections, and publishing through the API this package exposes.
|
|
43
43
|
The content model itself is documented in
|
|
44
|
-
[`docs/content-authoring
|
|
44
|
+
[`docs/guide-content-authoring.md`](docs/guide-content-authoring.md).
|
|
45
45
|
|
|
46
46
|
## Quick start
|
|
47
47
|
|
|
48
|
-
> **Not yet published to npm.** Until it is, install from a local build -
|
|
49
|
-
> see [`docs/hosting.md`](docs/hosting.md#installing-o-acms-agent-today) for
|
|
50
|
-
> the exact steps. Once published, this becomes:
|
|
51
|
-
>
|
|
52
|
-
> ```
|
|
53
|
-
> npx -p @o-a/cms-agent create-site my-site
|
|
54
|
-
> ```
|
|
55
|
-
|
|
56
|
-
Either way, the result is the same:
|
|
57
|
-
|
|
58
48
|
```
|
|
49
|
+
npx -p @o-a/cms-agent create-site my-site
|
|
59
50
|
cd my-site/vhost
|
|
60
51
|
npm install
|
|
61
52
|
npm start
|
|
@@ -76,7 +67,7 @@ and the site's own serving configuration lives under `vhost/`:
|
|
|
76
67
|
|
|
77
68
|
```
|
|
78
69
|
my-site/
|
|
79
|
-
content/ pages,
|
|
70
|
+
content/ pages, menus, redirects, drafts
|
|
80
71
|
theme/ layouts, sections, blocks, snippets, assets, root, templates
|
|
81
72
|
media/ uploaded files - gitignored, backed up separately
|
|
82
73
|
vhost/ site.config.json, package.json, server.js
|
|
@@ -97,9 +88,9 @@ install.
|
|
|
97
88
|
|
|
98
89
|
## Documentation
|
|
99
90
|
|
|
100
|
-
- [`docs/theme-authoring
|
|
101
|
-
- [`docs/content-authoring
|
|
102
|
-
- [`docs/hosting.md`](docs/hosting.md) - running a site somewhere real
|
|
91
|
+
- [`docs/guide-theme-authoring.md`](docs/guide-theme-authoring.md) - building a theme
|
|
92
|
+
- [`docs/guide-content-authoring.md`](docs/guide-content-authoring.md) - the content model
|
|
93
|
+
- [`docs/guide-hosting.md`](docs/guide-hosting.md) - running a site somewhere real
|
|
103
94
|
- [`docs/cms-build-plan.md`](docs/cms-build-plan.md) - full architecture and design rationale
|
|
104
95
|
|
|
105
96
|
A friendlier, browsable documentation site (covering both this engine and
|
|
@@ -114,6 +114,6 @@ export function scaffoldSite(targetDir) {
|
|
|
114
114
|
// runStartupChecks hard-fails not-a-git-repo otherwise - a scaffold
|
|
115
115
|
// without a real git repo cannot boot at all.
|
|
116
116
|
execFileSync('git', ['init', '--quiet'], { cwd: targetDir });
|
|
117
|
-
commitPaths(targetDir, ['theme', 'content', 'vhost', '.gitignore', '.dockerignore'], 'chore: initial scaffold', CHECKPOINT_AUTHOR);
|
|
117
|
+
commitPaths(targetDir, ['theme', 'content', 'vhost', 'AGENTS.md', '.gitignore', '.dockerignore'], 'chore: initial scaffold', CHECKPOINT_AUTHOR);
|
|
118
118
|
return { raw: token.raw };
|
|
119
119
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
This file orients an AI coding agent working in this repository. It is written for the task of turning a visual design (a screenshot, a Figma export, a written brief) into working site code - not for general software engineering advice.
|
|
4
|
+
|
|
5
|
+
This repo is a single site built on Granite CMS: a self-hosted, git-backed CMS. There is no framework source code here to read - the CMS itself is an installed dependency (`vhost/node_modules/@o-a/cms-agent`). Everything that makes this site what it is lives in two folders:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
theme/ Liquid templates - the design/markup layer
|
|
9
|
+
content/ JSON files - the actual page content, git-tracked, the source of truth
|
|
10
|
+
media/ uploaded images - not git-tracked, see "Images" below
|
|
11
|
+
vhost/ deploy config (package.json, server.js, site.config.json) - rarely needs editing
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Every rule below is exact, not a rough guide - the CMS validates content against real JSON Schemas and will reject anything that deviates. Where this file gives a worked example, prefer copying its shape over improvising a new one.
|
|
15
|
+
|
|
16
|
+
## Folder structure inside `theme/`
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
theme/
|
|
20
|
+
layouts/ *.liquid, flat, no schema - page wrappers (<html>, <head>, nav, footer)
|
|
21
|
+
sections/ *.liquid, flat, one per section type - markup + embedded settings schema
|
|
22
|
+
blocks/ *.liquid, flat, one per block type - markup + embedded settings schema
|
|
23
|
+
snippets/ *.liquid, flat, no schema - small reusable partials, invoked with {% render %}
|
|
24
|
+
assets/ static files (CSS, JS, images) - served as-is at /assets/<path>
|
|
25
|
+
root/ static files served at the bare site root (robots.txt, favicon.ico, etc.)
|
|
26
|
+
templates/ *.json, flat, optional - prebuilt starting pages an editor can pick from
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
No subfolders inside `layouts/`, `sections/`, `blocks/`, or `snippets/` - one file, one component, named directly. The filename (without `.liquid`) is that component's type identifier and must match `^[a-z0-9][a-z0-9-]*$` (lowercase, digits, hyphens only). This exact string is what page content JSON uses in its own `"type"` field - they must match exactly.
|
|
30
|
+
|
|
31
|
+
This scaffold already ships real, working examples worth reading before writing anything new: `theme/sections/hero.liquid`, `theme/blocks/button.liquid`, and `theme/layouts/theme.liquid`. Match their conventions rather than inventing a different style.
|
|
32
|
+
|
|
33
|
+
## Turning a design into code - the actual workflow
|
|
34
|
+
|
|
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).
|
|
37
|
+
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".
|
|
39
|
+
|
|
40
|
+
### Worked example - a section
|
|
41
|
+
|
|
42
|
+
```liquid
|
|
43
|
+
<section class="hero" data-section-id="{{ section.id }}">
|
|
44
|
+
<h1>{{ section.settings.heading }}</h1>
|
|
45
|
+
{% if section.settings.subheading %}<p>{{ section.settings.subheading }}</p>{% endif %}
|
|
46
|
+
<div class="hero__blocks">{% for html in blocksHtml %}{{ html | raw }}{% endfor %}</div>
|
|
47
|
+
</section>
|
|
48
|
+
{% schema %}
|
|
49
|
+
{
|
|
50
|
+
"type": "object",
|
|
51
|
+
"additionalProperties": false,
|
|
52
|
+
"required": ["heading"],
|
|
53
|
+
"properties": {
|
|
54
|
+
"heading": { "type": "string", "minLength": 1, "default": "New section" },
|
|
55
|
+
"subheading": { "type": "string" }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
{% endschema %}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
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
|
+
|
|
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 - 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
|
+
|
|
65
|
+
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
|
+
|
|
67
|
+
### Layouts
|
|
68
|
+
|
|
69
|
+
`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
|
+
|
|
71
|
+
```liquid
|
|
72
|
+
{{ content_for_layout | raw }} the page's fully-rendered sections, concatenated
|
|
73
|
+
{{ page.title }} the page's title
|
|
74
|
+
{{ page.author }} the page's author, if set
|
|
75
|
+
{{ page.publishDate }} the page's publish date, if set
|
|
76
|
+
{% for tag in page.tags %}...{% endfor %} the page's tags, if any
|
|
77
|
+
{{ menus.<name>.items }} every menu in content/menus/, keyed by filename
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Snippets
|
|
81
|
+
|
|
82
|
+
Flat `.liquid` files in `snippets/`, invoked with `{% render 'name', param1: value %}` - never `{% include %}`. A snippet only sees parameters explicitly passed to it; the calling scope never leaks in.
|
|
83
|
+
|
|
84
|
+
## Field format hints
|
|
85
|
+
|
|
86
|
+
Every setting is plain JSON Schema (`string`, `integer`, `number`, `boolean`, `array`, with `minLength`/`minimum`/`enum`/etc. for real validation). One extra keyword, `"format"`, is a UI hint only (never validated server-side) that the admin reads to choose a richer input widget:
|
|
87
|
+
|
|
88
|
+
| `format` | On type | Effect |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `richtext` | `string` | Rich-text editor; render with `{{ ... | raw }}`, not plain `{{ }}` |
|
|
91
|
+
| `image` | `object` | Image picker with focal point; object shape is exactly `{ "url": "...", "focalX": 0.5, "focalY": 0.5 }` - render `{{ section.settings.<field>.url }}` |
|
|
92
|
+
| `textarea` | `string` | Multi-line `<textarea>` |
|
|
93
|
+
| `uri` | `string` | `<input type="url">` |
|
|
94
|
+
| `date` | `string` | `<input type="date">`, value as `YYYY-MM-DD` |
|
|
95
|
+
| `color` | `string` | Hex value (e.g. `"#ff6600"`); optional sibling `"swatches": ["#c2410c", ...]` for a preset palette (not an `enum` - a custom colour is still always allowed) |
|
|
96
|
+
| `range` | `integer`/`number` | Slider + number box; requires `minimum`/`maximum`; optional `"step"` (default `1`) and `"unit"` (e.g. `"px"`) |
|
|
97
|
+
| `toggle` | `boolean` | Switch instead of a checkbox (same underlying data) |
|
|
98
|
+
| (none) | `boolean` | Plain checkbox |
|
|
99
|
+
| (none) | `string` + `"enum"` | Segmented tabs (few short options) or a `<select>` (more/longer) - decided automatically, not choosable |
|
|
100
|
+
|
|
101
|
+
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
|
+
|
|
103
|
+
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
|
+
|
|
105
|
+
## Content JSON model
|
|
106
|
+
|
|
107
|
+
`content/pages/*.json` - nested folders allowed via a **sibling** pattern: a page with children is a `.json` file sitting beside a same-named folder (`about.json` next to `about/team.json`, never `about/about.json`). A URL maps directly to this path; `/` maps to `index.json`.
|
|
108
|
+
|
|
109
|
+
Required fields, `additionalProperties: false`:
|
|
110
|
+
|
|
111
|
+
| Field | Type | Notes |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `schemaVersion` | integer | Always `6` for new content |
|
|
114
|
+
| `name` | string | Internal label (shown in the admin's page tree) |
|
|
115
|
+
| `title` | string | Rendered as `{{ page.title }}` |
|
|
116
|
+
| `type` | string | Free-form (e.g. `"page"`, `"blog-article"`) - use `pageType` filtering below to distinguish kinds |
|
|
117
|
+
| `layout` | string | A filename in `theme/layouts/` (no extension) - `"theme"` unless a different layout exists |
|
|
118
|
+
| `published` | boolean | `false` behaves as if the page doesn't exist on the live site at all |
|
|
119
|
+
| `sections` | array | Section instances - see below |
|
|
120
|
+
|
|
121
|
+
Optional fields, any page may carry them: `author` (string), `publishDate` (string, `YYYY-MM-DD` recommended - it's indexed numerically for sorting/range filters), `tags` (array of non-empty strings). There is no separate "post" content type - a blog article is just a page, conventionally nested under a `content/pages/blog/` folder.
|
|
122
|
+
|
|
123
|
+
Each entry in `sections` requires `id` (any non-empty string, unique within the page), `type` (must exactly match a filename in `theme/sections/`), and `settings` (matching that type's schema). Optional `blocks` array, same shape, referencing `theme/blocks/`.
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"schemaVersion": 6,
|
|
128
|
+
"name": "Home",
|
|
129
|
+
"title": "Welcome",
|
|
130
|
+
"type": "page",
|
|
131
|
+
"layout": "theme",
|
|
132
|
+
"published": true,
|
|
133
|
+
"sections": [
|
|
134
|
+
{
|
|
135
|
+
"id": "sec-hero",
|
|
136
|
+
"type": "hero",
|
|
137
|
+
"settings": { "heading": "Welcome" },
|
|
138
|
+
"blocks": [
|
|
139
|
+
{ "id": "blk-cta", "type": "button", "settings": { "label": "Get started", "url": "/" } }
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`content/menus/<name>.json` - referenced in layouts as `{{ menus.<name>.items }}`:
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{ "schemaVersion": 6, "items": [{ "label": "Home", "url": "/" }, { "label": "About", "url": "/about" }] }
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`content/redirects.json` - a single file, not a folder:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{ "schemaVersion": 1, "entries": [{ "from": "/old-path", "to": "/new-path" }] }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`to` must be a bare internal path (no `https://`, no leading `//`). A redirect never overrides a real page at the same URL.
|
|
159
|
+
|
|
160
|
+
`content/pages/404.json`, if present and `published`, renders through the normal page pipeline with the HTTP status forced to 404 - the standard way to give a broken URL a real branded page instead of a bare JSON error.
|
|
161
|
+
|
|
162
|
+
## Images
|
|
163
|
+
|
|
164
|
+
Uploads go through `POST /v1/media` (multipart, requires a token with `media` scope) or the admin's own media library UI - never write directly into `media/` from an agent, since the CMS names files by content hash. A successful upload returns `{ "url": "/media/<name>" }`. In theme content, an image is just a plain string setting holding that URL:
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{ "type": "string", "default": "" }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
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
|
+
|
|
172
|
+
## `GET /search.json`
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
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.
|
|
177
|
+
|
|
178
|
+
## Hard constraints - do not deviate from these
|
|
179
|
+
|
|
180
|
+
- **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.
|
|
181
|
+
- **`{{ }}` auto-escapes HTML by default.** Only use `| raw` for values the CMS itself already produced as safe HTML (`blocksHtml` entries, `content_for_layout`, a `format: "richtext"` field). Never apply `| raw` to an ordinary setting value.
|
|
182
|
+
- **Every template render is bounded to roughly 50ms.** Keep Liquid logic simple - loops and conditionals, no heavy computation.
|
|
183
|
+
- **One file, one type, no subfolders** inside `layouts/`, `sections/`, `blocks/`, `snippets/` - and the filename must match `^[a-z0-9][a-z0-9-]*$` exactly.
|
|
184
|
+
- **The `{% schema %}` block must be valid, parseable JSON.** A malformed or missing schema fails the whole component, not just the settings half.
|
|
185
|
+
- **`additionalProperties: false` applies everywhere in content JSON** - don't add a field "just in case"; anything not in the tables above fails validation.
|
|
186
|
+
|
|
187
|
+
## Previewing your work
|
|
188
|
+
|
|
189
|
+
From `vhost/`:
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
npm start # boots the site on the port set in vhost/site.config.json
|
|
193
|
+
npm run tunnel # same, plus a public tunnel URL for sharing a preview
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
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.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Generic and site-content-agnostic - this file never needs to change
|
|
2
2
|
# per site. Runs identically via plain `docker build`/`docker run` on a
|
|
3
3
|
# bare VPS, Fly, ECS, or any PaaS - nothing platform-specific is baked
|
|
4
|
-
# in here (see docs/hosting.md for platform-specific deploy config,
|
|
4
|
+
# in here (see docs/guide-hosting.md for platform-specific deploy config,
|
|
5
5
|
# which always layers on top of this, never inside it).
|
|
6
6
|
#
|
|
7
7
|
# Lives in vhost/, not the site root, alongside the rest of the site's
|
|
@@ -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
|
}
|
package/dist/routes/sitemap.js
CHANGED
|
@@ -29,7 +29,7 @@ function buildSitemapUrls(config) {
|
|
|
29
29
|
for (const relativePath of listFilesRecursively(config.pagesRoot, config.pagesRoot, '.json')) {
|
|
30
30
|
// The 404 page must never be listed as a real crawlable URL,
|
|
31
31
|
// regardless of its own published flag - it's a fallback
|
|
32
|
-
// convention (docs/content-authoring
|
|
32
|
+
// convention (docs/guide-content-authoring.md), not real content.
|
|
33
33
|
if (relativePath === '404.json') {
|
|
34
34
|
continue;
|
|
35
35
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
5
5
|
import { listFilesRecursively } from "../services/fs-walk.js";
|
|
@@ -106,7 +106,7 @@ function pushFieldValue(blockType, instanceId, fieldKey, value, isDateField, out
|
|
|
106
106
|
// Reads schema.properties for the given instance's own type, keeping
|
|
107
107
|
// only properties explicitly flagged "api": true (an unvalidated,
|
|
108
108
|
// theme-authored JSON Schema keyword - same status as "format"/
|
|
109
|
-
// "allowedBlocks", see docs/theme-authoring
|
|
109
|
+
// "allowedBlocks", see docs/guide-theme-authoring.md and
|
|
110
110
|
// services/validation.ts's own allowedBlockTypesOf) - and pairs each
|
|
111
111
|
// with its actual value out of instance.settings via pushFieldValue.
|
|
112
112
|
function extractInstanceApiFields(instance, schemaMap, out) {
|
|
@@ -302,3 +302,19 @@ async function rebuildIndexJob(config) {
|
|
|
302
302
|
export function rebuildIndex(config) {
|
|
303
303
|
return enqueue(() => rebuildIndexJob(config));
|
|
304
304
|
}
|
|
305
|
+
// For startServer's own boot-time call: a search index is never
|
|
306
|
+
// git-tracked (constraint 3 - a derived, disposable index), so a fresh
|
|
307
|
+
// clone or a first-ever boot has no index file at all and GET
|
|
308
|
+
// /search.json would stay permanently empty until some content write
|
|
309
|
+
// happened to trigger reindex-on-write.ts, or a caller manually hit
|
|
310
|
+
// POST /v1/search/rebuild. Guarded on existsSync rather than
|
|
311
|
+
// unconditionally rebuilding on every restart - a currently-running
|
|
312
|
+
// site's index is already kept fresh by every publish/unpublish/
|
|
313
|
+
// delete/move/batch (reindex-on-write.ts), so an unconditional rebuild
|
|
314
|
+
// here would just be redundant work on every ordinary restart.
|
|
315
|
+
export function rebuildIndexIfMissing(config) {
|
|
316
|
+
if (existsSync(config.searchIndexPath)) {
|
|
317
|
+
return Promise.resolve();
|
|
318
|
+
}
|
|
319
|
+
return rebuildIndex(config);
|
|
320
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { sitemapRoutes } from "./routes/sitemap.js";
|
|
|
12
12
|
import { CHECKPOINT_AUTHOR, runCheckpoint } from "./services/checkpoint.js";
|
|
13
13
|
import { startDevTunnel } from "./services/dev-tunnel.js";
|
|
14
14
|
import { startIntervalJob } from "./services/interval-job.js";
|
|
15
|
+
import { reindexOnBootIfMissing } from "./services/reindex-on-write.js";
|
|
15
16
|
// Never wrap v1Routes (or any route-group plugin it registers) with
|
|
16
17
|
// fastify-plugin (fp()): plain app.register() gives each file its own
|
|
17
18
|
// encapsulation scope by default, which Group B's auth preHandler
|
|
@@ -132,6 +133,15 @@ export async function startServer(siteRoot, options = {}) {
|
|
|
132
133
|
const booted = bootSite(siteRoot);
|
|
133
134
|
const serverConfig = loadServerConfig(siteRoot);
|
|
134
135
|
const app = buildServer(booted, serverConfig, options);
|
|
136
|
+
// Previously these three failure cases (missing/invalid {% schema %}
|
|
137
|
+
// block, or a required property with no valid default) were
|
|
138
|
+
// completely silent - a broken component just stopped being
|
|
139
|
+
// selectable, with nothing distinguishing "deliberately excluded"
|
|
140
|
+
// from "you have a typo". Never a boot failure (loadThemeSchemas's
|
|
141
|
+
// own contract), just no longer silent about it either.
|
|
142
|
+
for (const warning of booted.themeSchemas.warnings ?? []) {
|
|
143
|
+
console.warn(warning);
|
|
144
|
+
}
|
|
135
145
|
const doCheckpoint = () => runCheckpoint(booted.config, CHECKPOINT_AUTHOR);
|
|
136
146
|
const scheduler = startIntervalJob(doCheckpoint, serverConfig.checkpointIntervalMs, (error) => {
|
|
137
147
|
app.log.error(error, 'background draft checkpoint failed');
|
|
@@ -191,6 +201,13 @@ export async function startServer(siteRoot, options = {}) {
|
|
|
191
201
|
if (address !== null && typeof address !== 'string') {
|
|
192
202
|
console.log(`Site running at http://127.0.0.1:${address.port}`);
|
|
193
203
|
}
|
|
204
|
+
// A search index is never git-tracked (constraint 3), so a fresh
|
|
205
|
+
// clone or first-ever boot has no index file - without this,
|
|
206
|
+
// GET /search.json would stay empty until the first content write.
|
|
207
|
+
// Fire-and-forget, after the port is already bound: never blocks
|
|
208
|
+
// startup, and a slow/failed rebuild is never a reason the server
|
|
209
|
+
// itself fails to come up.
|
|
210
|
+
reindexOnBootIfMissing(booted.config);
|
|
194
211
|
if (options.tunnel) {
|
|
195
212
|
try {
|
|
196
213
|
tunnel = await (options.startTunnel ?? startDevTunnel)(serverConfig.port);
|
package/dist/services/batch.js
CHANGED
|
@@ -4,6 +4,7 @@ import { commitPaths } from "./git.js";
|
|
|
4
4
|
import { MoveError, prepareMovePage } from "./move.js";
|
|
5
5
|
import { PublishError, preparePublishDrafts } from "./publish.js";
|
|
6
6
|
import { enqueue } from "./write-queue.js";
|
|
7
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
7
8
|
export class BatchError extends Error {
|
|
8
9
|
reason;
|
|
9
10
|
// Which part of the batch failed. operationIndex indexes into the
|
|
@@ -132,5 +133,13 @@ async function rollbackAndThrow(undoStack, cause, context) {
|
|
|
132
133
|
throw new BatchError('commit-failed', `Batch failed: ${detail}`, { cause, ...context });
|
|
133
134
|
}
|
|
134
135
|
export function runBatch(config, themeSchemas, operations, publish, message, author) {
|
|
135
|
-
|
|
136
|
+
const result = enqueue(() => batchJob(config, themeSchemas, operations, publish, message, author));
|
|
137
|
+
// One trigger for the whole batch, regardless of which operation
|
|
138
|
+
// types it contained (content-delete/move/publish affect the index,
|
|
139
|
+
// draft-write/draft-discard don't) - simpler and no real cost than
|
|
140
|
+
// inspecting `operations` to decide, since this never blocks the
|
|
141
|
+
// caller either way. See reindex-on-write.ts for why this is
|
|
142
|
+
// fire-and-forget, chained onto `result` rather than awaited here.
|
|
143
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
144
|
+
return result;
|
|
136
145
|
}
|
|
@@ -5,6 +5,7 @@ import { sanitisePath } from "./path-safety.js";
|
|
|
5
5
|
import { RedirectError, addRedirect, isValidRedirectTarget, loadRedirects, serialiseRedirects, } from "./redirects.js";
|
|
6
6
|
import { pagePathToUrl } from "./urls.js";
|
|
7
7
|
import { enqueue } from "./write-queue.js";
|
|
8
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
8
9
|
const PAGES_PREFIX = 'pages/';
|
|
9
10
|
// Returns null for anything else (menus have no public URL at all, so
|
|
10
11
|
// redirects are meaningless there).
|
|
@@ -159,5 +160,7 @@ async function deleteContentJob(config, relativePath, redirectTo, message, autho
|
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
export function deleteContent(config, relativePath, redirectTo, message, author) {
|
|
162
|
-
|
|
163
|
+
const result = enqueue(() => deleteContentJob(config, relativePath, redirectTo, message, author));
|
|
164
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
165
|
+
return result;
|
|
163
166
|
}
|
package/dist/services/move.js
CHANGED
|
@@ -6,6 +6,7 @@ import { sanitisePath } from "./path-safety.js";
|
|
|
6
6
|
import { addRedirect, loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redirects.js";
|
|
7
7
|
import { pagePathToUrl, urlToPagePath } from "./urls.js";
|
|
8
8
|
import { enqueue } from "./write-queue.js";
|
|
9
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
9
10
|
export class MoveError extends Error {
|
|
10
11
|
reason;
|
|
11
12
|
constructor(reason, message, options) {
|
|
@@ -171,5 +172,7 @@ async function movePageJob(config, fromUrl, toUrl, message, author, options = {}
|
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
export function movePage(config, fromUrl, toUrl, message, author, options = {}) {
|
|
174
|
-
|
|
175
|
+
const result = enqueue(() => movePageJob(config, fromUrl, toUrl, message, author, options));
|
|
176
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
177
|
+
return result;
|
|
175
178
|
}
|
package/dist/services/publish.js
CHANGED
|
@@ -6,6 +6,7 @@ import { loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redi
|
|
|
6
6
|
import { pagePathToUrl } from "./urls.js";
|
|
7
7
|
import { validateContent } from "./validation.js";
|
|
8
8
|
import { enqueue } from "./write-queue.js";
|
|
9
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
9
10
|
const PAGES_PREFIX = 'pages/';
|
|
10
11
|
// Mirrors delete-content.ts's urlForDeletedEntry - a tiny duplicated
|
|
11
12
|
// helper, not a shared abstraction. Returns null for anything else
|
|
@@ -241,8 +242,18 @@ async function unpublishPageJob(config, relativePath, message, author) {
|
|
|
241
242
|
}
|
|
242
243
|
}
|
|
243
244
|
export function publishDrafts(config, themeSchemas, relativePaths, message, author) {
|
|
244
|
-
|
|
245
|
+
const result = enqueue(() => publishDraftsJob(config, themeSchemas, relativePaths, message, author));
|
|
246
|
+
// Only on success - a failed publish changed nothing, so there's
|
|
247
|
+
// nothing to reindex. Chained onto `result` rather than awaited here:
|
|
248
|
+
// by the time this callback runs, the write-queue's own tail has
|
|
249
|
+
// already advanced past this job, so reindexInBackground's own
|
|
250
|
+
// enqueue()d rebuild queues cleanly behind it (see that function's
|
|
251
|
+
// own comment on why calling it any earlier would deadlock).
|
|
252
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
253
|
+
return result;
|
|
245
254
|
}
|
|
246
255
|
export function unpublishPage(config, relativePath, message, author) {
|
|
247
|
-
|
|
256
|
+
const result = enqueue(() => unpublishPageJob(config, relativePath, message, author));
|
|
257
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
258
|
+
return result;
|
|
248
259
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { rebuildIndex, rebuildIndexIfMissing } from "../search/rebuild-index.js";
|
|
2
|
+
// Search is a derived, disposable index (constraint 3) - previously
|
|
3
|
+
// nothing ever rebuilt it except a caller manually hitting
|
|
4
|
+
// POST /v1/search/rebuild, so GET /search.json stayed empty forever on
|
|
5
|
+
// a fresh site and stale forever after a real edit. This is the one
|
|
6
|
+
// place that closes that gap: called after a content-affecting write's
|
|
7
|
+
// own enqueue()d promise has already resolved.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately fire-and-forget, never awaited by the caller: rebuildIndex
|
|
10
|
+
// is itself enqueue()d internally (search/rebuild-index.ts) - awaiting it
|
|
11
|
+
// from inside the very job whose own completion is what let this run
|
|
12
|
+
// would deadlock (routes/search.ts's own comment describes the same
|
|
13
|
+
// hazard for a second, nested enqueue() call). A failed reindex must
|
|
14
|
+
// also never surface as a failure of the write that triggered it - the
|
|
15
|
+
// write already succeeded, and the index is rebuildable from content at
|
|
16
|
+
// any time, so a logged-and-swallowed failure here is the correct
|
|
17
|
+
// severity, not a thrown error.
|
|
18
|
+
export function reindexInBackground(config) {
|
|
19
|
+
void rebuildIndex(config).catch((error) => {
|
|
20
|
+
console.error('Background search reindex failed:', error);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// startServer's own boot-time call - see rebuildIndexIfMissing's own
|
|
24
|
+
// comment for why this is conditional (existsSync), unlike the
|
|
25
|
+
// unconditional reindexInBackground above.
|
|
26
|
+
export function reindexOnBootIfMissing(config) {
|
|
27
|
+
void rebuildIndexIfMissing(config).catch((error) => {
|
|
28
|
+
console.error('Background search reindex failed:', error);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -12,9 +12,13 @@ import { requiredFieldsHaveValidDefaults } from "./validation.js";
|
|
|
12
12
|
// loadFlatTemplates walk exactly (already established for snippets/
|
|
13
13
|
// layouts), extended to extract the embedded {% schema %} block instead
|
|
14
14
|
// of returning the raw file contents.
|
|
15
|
-
|
|
15
|
+
//
|
|
16
|
+
// kind is only used to word each warning ("Section" vs "Block") -
|
|
17
|
+
// callers already know which directory they asked for.
|
|
18
|
+
function loadTypeSchemas(typesDir, kind) {
|
|
16
19
|
const schemas = {};
|
|
17
20
|
const acceptsBlocks = {};
|
|
21
|
+
const warnings = [];
|
|
18
22
|
let entries;
|
|
19
23
|
try {
|
|
20
24
|
entries = readdirSync(typesDir, { withFileTypes: true })
|
|
@@ -22,10 +26,11 @@ function loadTypeSchemas(typesDir) {
|
|
|
22
26
|
.map((entry) => entry.name);
|
|
23
27
|
}
|
|
24
28
|
catch {
|
|
25
|
-
return { schemas, acceptsBlocks };
|
|
29
|
+
return { schemas, acceptsBlocks, warnings };
|
|
26
30
|
}
|
|
27
31
|
for (const fileName of entries) {
|
|
28
32
|
const type = fileName.slice(0, -'.liquid'.length);
|
|
33
|
+
const label = `${kind} type "${type}" (${fileName}) was excluded from the theme`;
|
|
29
34
|
let source;
|
|
30
35
|
try {
|
|
31
36
|
source = readFileSync(join(typesDir, fileName), 'utf-8');
|
|
@@ -35,29 +40,32 @@ function loadTypeSchemas(typesDir) {
|
|
|
35
40
|
}
|
|
36
41
|
const parsed = parseThemeComponentFile(source);
|
|
37
42
|
if (!parsed) {
|
|
43
|
+
warnings.push(`${label}: no valid {% schema %} block found (missing, or not parseable JSON).`);
|
|
38
44
|
continue;
|
|
39
45
|
}
|
|
40
46
|
// A type whose required settings fields lack usable defaults is
|
|
41
47
|
// skipped the same way a malformed schema block already is -
|
|
42
48
|
// never a boot failure, just excluded from what gets registered
|
|
43
|
-
// (theme-authoring
|
|
49
|
+
// (guide-theme-authoring.md, Group L).
|
|
44
50
|
if (!requiredFieldsHaveValidDefaults(parsed.schema)) {
|
|
51
|
+
warnings.push(`${label}: a property listed in "required" has no valid "default" (see guide-theme-authoring.md).`);
|
|
45
52
|
continue;
|
|
46
53
|
}
|
|
47
54
|
schemas[type] = parsed.schema;
|
|
48
55
|
// The only place "does this type support nested blocks" is ever
|
|
49
56
|
// expressed - a markup convention (does the template loop
|
|
50
|
-
// blocksHtml), not a schema field (theme-authoring
|
|
57
|
+
// blocksHtml), not a schema field (guide-theme-authoring.md).
|
|
51
58
|
acceptsBlocks[type] = parsed.markup.includes('blocksHtml');
|
|
52
59
|
}
|
|
53
|
-
return { schemas, acceptsBlocks };
|
|
60
|
+
return { schemas, acceptsBlocks, warnings };
|
|
54
61
|
}
|
|
55
62
|
export function loadThemeSchemas(themeRoot) {
|
|
56
|
-
const sections = loadTypeSchemas(join(themeRoot, 'sections'));
|
|
57
|
-
const blocks = loadTypeSchemas(join(themeRoot, 'blocks'));
|
|
63
|
+
const sections = loadTypeSchemas(join(themeRoot, 'sections'), 'Section');
|
|
64
|
+
const blocks = loadTypeSchemas(join(themeRoot, 'blocks'), 'Block');
|
|
58
65
|
return {
|
|
59
66
|
sections: sections.schemas,
|
|
60
67
|
blocks: blocks.schemas,
|
|
61
68
|
acceptsBlocks: { sections: sections.acceptsBlocks, blocks: blocks.acceptsBlocks },
|
|
69
|
+
warnings: [...sections.warnings, ...blocks.warnings],
|
|
62
70
|
};
|
|
63
71
|
}
|
|
@@ -14,6 +14,7 @@ export interface ThemeSchemas {
|
|
|
14
14
|
sections: Record<string, boolean>;
|
|
15
15
|
blocks: Record<string, boolean>;
|
|
16
16
|
};
|
|
17
|
+
warnings?: string[];
|
|
17
18
|
}
|
|
18
19
|
export declare function requiredFieldsHaveValidDefaults(schema: object): boolean;
|
|
19
20
|
export declare function validateInstance(instance: unknown, kind: 'section' | 'block', themeSchemas: ThemeSchemas): ValidationResult;
|