@o-a/cms-agent 0.1.7 → 0.2.1
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/boot.d.ts +2 -0
- package/dist/boot.js +3 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -1
- package/dist/create-site/cli.js +0 -0
- package/dist/create-site/generate-site.js +1 -1
- package/dist/create-site/mint-token-cli.js +0 -0
- package/dist/create-site/template/AGENTS.md +191 -0
- package/dist/create-site/template/vhost/Dockerfile +1 -1
- package/dist/media/filename.js +4 -1
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +24 -1
- package/dist/renderer/render-cache.d.ts +10 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +2 -0
- package/dist/renderer/render-page.js +40 -1
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/capabilities.js +2 -2
- package/dist/routes/media-public.js +6 -0
- package/dist/routes/preview-revision.js +3 -19
- package/dist/routes/preview.js +0 -18
- package/dist/routes/public.d.ts +2 -0
- package/dist/routes/public.js +25 -30
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.js +4 -0
- package/dist/routes/sitemap.js +4 -10
- package/dist/schemas/page.schema.json +6 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +5 -1
- package/dist/search/drivers/node-sqlite-driver.js +2 -2
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/rebuild-index.js +248 -55
- package/dist/server-config.d.ts +1 -0
- package/dist/server-config.js +28 -1
- package/dist/server.js +22 -0
- package/dist/services/content-read.js +5 -10
- package/dist/services/delete-content.js +2 -13
- package/dist/services/manage-redirects.js +5 -15
- package/dist/services/migration-runner.js +55 -13
- package/dist/services/publish.js +8 -14
- package/dist/services/rate-limit-config.d.ts +1 -1
- package/dist/services/rate-limit-config.js +6 -4
- package/dist/services/theme-schemas.js +2 -2
- package/dist/services/validation.d.ts +0 -1
- package/dist/services/validation.js +0 -11
- package/package.json +2 -2
- package/dist/schemas/post.schema.json +0 -25
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
|
package/dist/boot.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Liquid } from 'liquidjs';
|
|
2
2
|
import type { SiteConfig } from './config.ts';
|
|
3
|
+
import type { RenderCache } from './renderer/render-cache.ts';
|
|
3
4
|
import type { ThemeTemplates } from './renderer/theme-templates.ts';
|
|
4
5
|
import type { StartupCheckOptions } from './services/startup-checks.ts';
|
|
5
6
|
import type { ThemeSchemas } from './services/validation.ts';
|
|
@@ -11,5 +12,6 @@ export interface BootedSite {
|
|
|
11
12
|
layouts: Record<string, string>;
|
|
12
13
|
pageTemplates: PageTemplate[];
|
|
13
14
|
engine: Liquid;
|
|
15
|
+
renderCache: RenderCache;
|
|
14
16
|
}
|
|
15
17
|
export declare function bootSite(siteRoot: string, options?: StartupCheckOptions): BootedSite;
|
package/dist/boot.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadSiteConfig } from "./config.js";
|
|
2
2
|
import { createEngine } from "./renderer/engine.js";
|
|
3
|
+
import { createRenderCache } from "./renderer/render-cache.js";
|
|
3
4
|
import { loadLayouts, loadSnippets, loadThemeTemplates } from "./renderer/theme-templates.js";
|
|
4
5
|
import { runStartupChecks } from "./services/startup-checks.js";
|
|
5
6
|
import { loadThemeSchemas } from "./services/theme-schemas.js";
|
|
@@ -29,5 +30,6 @@ export function bootSite(siteRoot, options) {
|
|
|
29
30
|
// page.
|
|
30
31
|
const pageTemplates = loadPageTemplates(config.templatesRoot, themeSchemas);
|
|
31
32
|
const engine = createEngine(snippets);
|
|
32
|
-
|
|
33
|
+
const renderCache = createRenderCache();
|
|
34
|
+
return { config, themeSchemas, themeTemplates, layouts, pageTemplates, engine, renderCache };
|
|
33
35
|
}
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -33,7 +33,6 @@ export function loadSiteConfig(siteRoot) {
|
|
|
33
33
|
rootMirrorRoot: join(themeRoot, 'root'),
|
|
34
34
|
templatesRoot: join(themeRoot, 'templates'),
|
|
35
35
|
pagesRoot: join(contentRoot, 'pages'),
|
|
36
|
-
postsRoot: join(contentRoot, 'posts'),
|
|
37
36
|
menusRoot: join(contentRoot, 'menus'),
|
|
38
37
|
// Also nested inside contentRoot, for the same reason.
|
|
39
38
|
redirectsPath: join(contentRoot, 'redirects.json'),
|
package/dist/create-site/cli.js
CHANGED
|
File without changes
|
|
@@ -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
|
}
|
|
File without changes
|
|
@@ -0,0 +1,191 @@
|
|
|
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>`, 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.
|
|
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 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.
|
|
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 (the ONLY page field exposed to layouts)
|
|
74
|
+
{{ menus.<name>.items }} every menu in content/menus/, keyed by filename
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Snippets
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
## Field format hints
|
|
82
|
+
|
|
83
|
+
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:
|
|
84
|
+
|
|
85
|
+
| `format` | On type | Effect |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `richtext` | `string` | Rich-text editor; render with `{{ ... | raw }}`, not plain `{{ }}` |
|
|
88
|
+
| `image` | `object` | Image picker with focal point; object shape is exactly `{ "url": "...", "focalX": 0.5, "focalY": 0.5 }` - render `{{ section.settings.<field>.url }}` |
|
|
89
|
+
| `textarea` | `string` | Multi-line `<textarea>` |
|
|
90
|
+
| `uri` | `string` | `<input type="url">` |
|
|
91
|
+
| `date` | `string` | `<input type="date">`, value as `YYYY-MM-DD` |
|
|
92
|
+
| `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) |
|
|
93
|
+
| `range` | `integer`/`number` | Slider + number box; requires `minimum`/`maximum`; optional `"step"` (default `1`) and `"unit"` (e.g. `"px"`) |
|
|
94
|
+
| `toggle` | `boolean` | Switch instead of a checkbox (same underlying data) |
|
|
95
|
+
| (none) | `boolean` | Plain checkbox |
|
|
96
|
+
| (none) | `string` + `"enum"` | Segmented tabs (few short options) or a `<select>` (more/longer) - decided automatically, not choosable |
|
|
97
|
+
|
|
98
|
+
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
|
+
|
|
100
|
+
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
|
+
|
|
102
|
+
## Content JSON model
|
|
103
|
+
|
|
104
|
+
`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`.
|
|
105
|
+
|
|
106
|
+
Required fields, `additionalProperties: false`:
|
|
107
|
+
|
|
108
|
+
| Field | Type | Notes |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `schemaVersion` | integer | Always `6` for new content |
|
|
111
|
+
| `name` | string | Internal label (shown in the admin's page tree) |
|
|
112
|
+
| `title` | string | Rendered as `{{ page.title }}` |
|
|
113
|
+
| `type` | string | Free-form (e.g. `"page"`, `"blog-article"`) - use `pageType` filtering below to distinguish kinds |
|
|
114
|
+
| `layout` | string | A filename in `theme/layouts/` (no extension) - `"theme"` unless a different layout exists |
|
|
115
|
+
| `published` | boolean | `false` behaves as if the page doesn't exist on the live site at all |
|
|
116
|
+
| `sections` | array | Section instances - see below |
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
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/`.
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"schemaVersion": 6,
|
|
125
|
+
"name": "Home",
|
|
126
|
+
"title": "Welcome",
|
|
127
|
+
"type": "page",
|
|
128
|
+
"layout": "theme",
|
|
129
|
+
"published": true,
|
|
130
|
+
"sections": [
|
|
131
|
+
{
|
|
132
|
+
"id": "sec-hero",
|
|
133
|
+
"type": "hero",
|
|
134
|
+
"settings": { "heading": "Welcome" },
|
|
135
|
+
"blocks": [
|
|
136
|
+
{ "id": "blk-cta", "type": "button", "settings": { "label": "Get started", "url": "/" } }
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`content/menus/<name>.json` - referenced in layouts as `{{ menus.<name>.items }}`:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{ "schemaVersion": 6, "items": [{ "label": "Home", "url": "/" }, { "label": "About", "url": "/about" }] }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`content/redirects.json` - a single file, not a folder:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{ "schemaVersion": 1, "entries": [{ "from": "/old-path", "to": "/new-path" }] }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`to` must be a bare internal path (no `https://`, no leading `//`). A redirect never overrides a real page at the same URL.
|
|
156
|
+
|
|
157
|
+
`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.
|
|
158
|
+
|
|
159
|
+
## Images
|
|
160
|
+
|
|
161
|
+
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:
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{ "type": "string", "default": "" }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
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
|
+
|
|
169
|
+
## `GET /search.json`
|
|
170
|
+
|
|
171
|
+
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
|
+
|
|
173
|
+
## Hard constraints - do not deviate from these
|
|
174
|
+
|
|
175
|
+
- **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.
|
|
176
|
+
- **`{{ }}` 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.
|
|
177
|
+
- **Every template render is bounded to roughly 50ms.** Keep Liquid logic simple - loops and conditionals, no heavy computation.
|
|
178
|
+
- **One file, one type, no subfolders** inside `layouts/`, `sections/`, `blocks/`, `snippets/` - and the filename must match `^[a-z0-9][a-z0-9-]*$` exactly.
|
|
179
|
+
- **The `{% schema %}` block must be valid, parseable JSON.** A malformed or missing schema fails the whole component, not just the settings half.
|
|
180
|
+
- **`additionalProperties: false` applies everywhere in content JSON** - don't add a field "just in case"; anything not in the tables above fails validation.
|
|
181
|
+
|
|
182
|
+
## Previewing your work
|
|
183
|
+
|
|
184
|
+
From `vhost/`:
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
npm start # boots the site on the port set in vhost/site.config.json
|
|
188
|
+
npm run tunnel # same, plus a public tunnel URL for sharing a preview
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
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
|
package/dist/media/filename.js
CHANGED
|
@@ -27,5 +27,8 @@ export function buildMediaFilename(originalFilename, bytes) {
|
|
|
27
27
|
const cleaned = base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
28
28
|
const slug = cleaned.length > 0 ? cleaned : 'file';
|
|
29
29
|
const extension = extname(originalFilename).toLowerCase();
|
|
30
|
-
|
|
30
|
+
// Slug first, hash last (requested directly) - the readable part of
|
|
31
|
+
// the filename sorts and scans first in a directory listing/URL,
|
|
32
|
+
// with the hash as a suffix rather than something to read past.
|
|
33
|
+
return `${slug}-${hash}${extension}`;
|
|
31
34
|
}
|
package/dist/migrations/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The current content schema version. Bumping this and adding a new
|
|
2
2
|
// migrations[N] entry is the only way a content shape may change
|
|
3
3
|
// (constraint 4) - never a manual edit convention.
|
|
4
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
4
|
+
export const CURRENT_SCHEMA_VERSION = 6;
|
|
5
5
|
// A trivial identity migration, proving the mechanism (per the build
|
|
6
6
|
// plan's Phase 1 scope): no shape change, only the version bump. Safe
|
|
7
7
|
// against page.schema.json's schemaVersion: { minimum: 1 } (not an
|
|
@@ -41,9 +41,32 @@ function migrateV4ToV5(content) {
|
|
|
41
41
|
const title = typeof content.title === 'string' ? content.title : '';
|
|
42
42
|
return { ...content, schemaVersion: 5, name: title };
|
|
43
43
|
}
|
|
44
|
+
// Folds the distinct "post" content type into "page" - author,
|
|
45
|
+
// publishDate, and tags become optional fields any page may carry
|
|
46
|
+
// (page.schema.json), and the post-only schema (which required them,
|
|
47
|
+
// but had no "name" field at all) is retired. Ordinary pages already
|
|
48
|
+
// at v5 already have "name"; this is only ever a genuine backfill for
|
|
49
|
+
// a legacy content/posts/*.json file, which never had one. Mirrors the
|
|
50
|
+
// admin's own backfillPageName (derivePageLabel.ts) precisely: fills
|
|
51
|
+
// name from title only when name is genuinely absent, never overwrites
|
|
52
|
+
// an existing one (a page's name may deliberately differ from its
|
|
53
|
+
// title - migrateV4ToV5's own unconditional set was safe only because
|
|
54
|
+
// no content reaching it could already have a name).
|
|
55
|
+
// migration-runner.ts's runMigrationsJob is what physically relocates
|
|
56
|
+
// a legacy content/posts/<slug>.json file to content/pages/blog/<slug>.json
|
|
57
|
+
// alongside this shape change - this function only ever transforms
|
|
58
|
+
// content, never a path.
|
|
59
|
+
function migrateV5ToV6(content) {
|
|
60
|
+
if ('name' in content) {
|
|
61
|
+
return { ...content, schemaVersion: 6 };
|
|
62
|
+
}
|
|
63
|
+
const title = typeof content.title === 'string' ? content.title : '';
|
|
64
|
+
return { ...content, schemaVersion: 6, name: title };
|
|
65
|
+
}
|
|
44
66
|
export const migrations = {
|
|
45
67
|
1: migrateV1ToV2,
|
|
46
68
|
2: migrateV2ToV3,
|
|
47
69
|
3: migrateV3ToV4,
|
|
48
70
|
4: migrateV4ToV5,
|
|
71
|
+
5: migrateV5ToV6,
|
|
49
72
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface RenderCacheEntry {
|
|
2
|
+
html: string;
|
|
3
|
+
pageMtimeMs: number;
|
|
4
|
+
menusMtimeMs: number;
|
|
5
|
+
}
|
|
6
|
+
export interface RenderCache {
|
|
7
|
+
get(renderPath: string): RenderCacheEntry | undefined;
|
|
8
|
+
set(renderPath: string, entry: RenderCacheEntry): void;
|
|
9
|
+
}
|
|
10
|
+
export declare function createRenderCache(): RenderCache;
|
|
@@ -26,3 +26,5 @@ export type RenderMode = 'public' | 'preview';
|
|
|
26
26
|
export declare function parsePageContent(raw: string): PageContent;
|
|
27
27
|
export declare function renderLoadedPage(page: PageContent, config: SiteConfig, themeTemplates: ThemeTemplates, layouts: Record<string, string>, engine: Liquid): Promise<string>;
|
|
28
28
|
export declare function renderPage(config: SiteConfig, themeTemplates: ThemeTemplates, layouts: Record<string, string>, engine: Liquid, relativePath: string, mode: RenderMode): Promise<string>;
|
|
29
|
+
export declare function getPageMtimeMs(config: SiteConfig, relativePath: string): number | null;
|
|
30
|
+
export declare function getMenusMtimeMs(config: SiteConfig): number;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { listFilesRecursively } from "../services/fs-walk.js";
|
|
2
3
|
import { loadMenus } from "../services/menus.js";
|
|
3
4
|
import { sanitisePath } from "../services/path-safety.js";
|
|
4
5
|
export class PageRenderError extends Error {
|
|
@@ -141,3 +142,41 @@ export async function renderPage(config, themeTemplates, layouts, engine, relati
|
|
|
141
142
|
const page = loadPageForRender(config, relativePath, mode);
|
|
142
143
|
return renderLoadedPage(page, config, themeTemplates, layouts, engine);
|
|
143
144
|
}
|
|
145
|
+
// For public.ts's render cache: the page's own current mtime, so a
|
|
146
|
+
// cached render can be validated against real filesystem state rather
|
|
147
|
+
// than needing every write path (publish/unpublish/delete/move/batch)
|
|
148
|
+
// to remember to invalidate something. null means "not cacheable this
|
|
149
|
+
// time" (the file vanished between resolveUrl confirming it exists and
|
|
150
|
+
// this call - vanishingly unlikely, not a hard failure) rather than
|
|
151
|
+
// throwing, since a cache-freshness check is never the place to
|
|
152
|
+
// surface a real error - the ordinary render path below still will.
|
|
153
|
+
export function getPageMtimeMs(config, relativePath) {
|
|
154
|
+
try {
|
|
155
|
+
return statSync(sanitisePath(config.contentRoot, relativePath)).mtimeMs;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// A menu edit affects every page's rendered nav, not just one page, so
|
|
162
|
+
// the cache's freshness check needs one value covering all menus
|
|
163
|
+
// together rather than per-page tracking. The max mtime across every
|
|
164
|
+
// menu file serves that - any single menu changing bumps it. 0 (never
|
|
165
|
+
// stale relative to anything) when there are no menus at all, matching
|
|
166
|
+
// listFilesRecursively's own "missing directory returns []" behaviour.
|
|
167
|
+
export function getMenusMtimeMs(config) {
|
|
168
|
+
let max = 0;
|
|
169
|
+
for (const relativePath of listFilesRecursively(config.menusRoot, config.menusRoot, '.json')) {
|
|
170
|
+
try {
|
|
171
|
+
const { mtimeMs } = statSync(sanitisePath(config.menusRoot, relativePath));
|
|
172
|
+
if (mtimeMs > max) {
|
|
173
|
+
max = mtimeMs;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Vanished between the directory walk and this stat (e.g. a
|
|
178
|
+
// concurrent delete) - doesn't contribute, not fatal.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return max;
|
|
182
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NO_AUTH_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
2
|
+
// Only ever registered by server.ts when serverConfig.adminBaseUrl is
|
|
3
|
+
// actually set - that's what makes this genuinely opt-in, not a
|
|
4
|
+
// reserved namespace like /media or /assets. A site that hasn't
|
|
5
|
+
// configured this can still use "admin" as an ordinary page path,
|
|
6
|
+
// same reasoning as GET /search.json over reserving all of /search.
|
|
7
|
+
//
|
|
8
|
+
// Redirects to adminBaseUrl with the requesting host appended as
|
|
9
|
+
// ?site=, never a deep link with a site ID baked in - keeps this
|
|
10
|
+
// agent completely decoupled from any particular admin app's own URL
|
|
11
|
+
// scheme, so a self-hoster running a different admin frontend just
|
|
12
|
+
// points adminBaseUrl at it. request.host (not request.hostname,
|
|
13
|
+
// which strips the port - same reasoning sitemap.ts already
|
|
14
|
+
// documents) so the admin side can match it directly against
|
|
15
|
+
// new URL(registeredSiteUrl).host.
|
|
16
|
+
//
|
|
17
|
+
// Deliberately unauthenticated: this is a discovery aid an operator
|
|
18
|
+
// (or anyone) can hit before they have a token, same class of
|
|
19
|
+
// zero-credential route as GET /v1/capabilities and GET /search.json.
|
|
20
|
+
export const adminRedirectRoutes = async (fastify, opts) => {
|
|
21
|
+
fastify.get('/admin', { config: NO_AUTH_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
22
|
+
const target = new URL(opts.adminBaseUrl);
|
|
23
|
+
target.searchParams.set('site', request.host);
|
|
24
|
+
reply.code(302).header('location', target.toString()).send();
|
|
25
|
+
});
|
|
26
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { CURRENT_SCHEMA_VERSION } from "../migrations/index.js";
|
|
4
|
-
import {
|
|
4
|
+
import { NO_AUTH_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
5
5
|
import { DRIVER_NAME } from "../search/drivers/node-sqlite-driver.js";
|
|
6
6
|
// The agent's own bundled package.json, not site data - the same
|
|
7
7
|
// import.meta.dirname-relative pattern already established in
|
|
@@ -12,7 +12,7 @@ function readAgentVersion() {
|
|
|
12
12
|
return packageJson.version ?? '0.0.0';
|
|
13
13
|
}
|
|
14
14
|
export const capabilitiesRoutes = async (fastify, opts) => {
|
|
15
|
-
fastify.get('/capabilities', { config:
|
|
15
|
+
fastify.get('/capabilities', { config: NO_AUTH_ROUTE_RATE_LIMIT }, async () => ({
|
|
16
16
|
agentVersion: readAgentVersion(),
|
|
17
17
|
contentSchemaVersion: CURRENT_SCHEMA_VERSION,
|
|
18
18
|
sqliteDriver: DRIVER_NAME,
|
|
@@ -25,9 +25,15 @@ async function handleMediaRequest(request, reply, config) {
|
|
|
25
25
|
// <base href> fix), making genuinely cross-origin browser requests
|
|
26
26
|
// for what looks like a same-origin path. Consistent with this
|
|
27
27
|
// route already being deliberately unauthenticated and public.
|
|
28
|
+
//
|
|
29
|
+
// Cache-Control: immutable, a full year - safe unconditionally
|
|
30
|
+
// because filenames are content-addressed (a hash of the file's own
|
|
31
|
+
// bytes, see media/filename.ts): a given URL's content can never
|
|
32
|
+
// change, so there is no invalidation case to ever design for here.
|
|
28
33
|
reply
|
|
29
34
|
.header('X-Content-Type-Options', 'nosniff')
|
|
30
35
|
.header('Access-Control-Allow-Origin', '*')
|
|
36
|
+
.header('Cache-Control', 'public, max-age=31536000, immutable')
|
|
31
37
|
.type(mimeTypeFor(relativePath))
|
|
32
38
|
.send(bytes);
|
|
33
39
|
}
|
|
@@ -3,7 +3,6 @@ import { PageRenderError, parsePageContent, renderLoadedPage } from "../renderer
|
|
|
3
3
|
import { GitShowError, readFileAtRevision } from "../services/git-history.js";
|
|
4
4
|
import { isValidGitRef } from "../services/git.js";
|
|
5
5
|
import { PathSafetyError } from "../services/path-safety.js";
|
|
6
|
-
import { isBlogUrl, urlToPostPath } from "../services/post-urls.js";
|
|
7
6
|
import { requireScope } from "../services/token-auth.js";
|
|
8
7
|
import { urlToPagePath } from "../services/urls.js";
|
|
9
8
|
// A dedicated top-level route rather than nesting under /preview/*
|
|
@@ -20,14 +19,11 @@ import { urlToPagePath } from "../services/urls.js";
|
|
|
20
19
|
// readFileAtRevision resolves paths relative to config.siteRoot, not
|
|
21
20
|
// config.contentRoot (confirmed by git-history.ts/git.test.ts's own
|
|
22
21
|
// "content/pages/about.json" usage) - so unlike preview.ts's
|
|
23
|
-
// toRenderPath
|
|
24
|
-
//
|
|
22
|
+
// toRenderPath (contentRoot-relative), the path built here is prefixed
|
|
23
|
+
// with "content/".
|
|
25
24
|
function toRevisionPath(pagesRelativePath) {
|
|
26
25
|
return join('content', 'pages', pagesRelativePath);
|
|
27
26
|
}
|
|
28
|
-
function toPostsRevisionPath(postsRelativePath) {
|
|
29
|
-
return join('content', 'posts', postsRelativePath);
|
|
30
|
-
}
|
|
31
27
|
async function handlePreviewRevisionRequest(request, reply, config, themeTemplates, layouts, engine) {
|
|
32
28
|
const { ref } = request.params;
|
|
33
29
|
const url = `/${request.params['*']}`;
|
|
@@ -36,19 +32,7 @@ async function handlePreviewRevisionRequest(request, reply, config, themeTemplat
|
|
|
36
32
|
return;
|
|
37
33
|
}
|
|
38
34
|
try {
|
|
39
|
-
const
|
|
40
|
-
let repoRelativePath;
|
|
41
|
-
if (isPost) {
|
|
42
|
-
const relativePath = urlToPostPath(url);
|
|
43
|
-
if (relativePath === null) {
|
|
44
|
-
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
repoRelativePath = toPostsRevisionPath(relativePath);
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
repoRelativePath = toRevisionPath(urlToPagePath(url));
|
|
51
|
-
}
|
|
35
|
+
const repoRelativePath = toRevisionPath(urlToPagePath(url));
|
|
52
36
|
const raw = readFileAtRevision(config, ref, repoRelativePath);
|
|
53
37
|
const page = parsePageContent(raw.toString('utf-8'));
|
|
54
38
|
const html = await renderLoadedPage(page, config, themeTemplates, layouts, engine);
|
package/dist/routes/preview.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { PageRenderError, renderPage } from "../renderer/render-page.js";
|
|
3
3
|
import { PathSafetyError } from "../services/path-safety.js";
|
|
4
|
-
import { isBlogUrl, urlToPostPath } from "../services/post-urls.js";
|
|
5
4
|
import { requireScope } from "../services/token-auth.js";
|
|
6
5
|
import { urlToPagePath } from "../services/urls.js";
|
|
7
6
|
// Same pagesRoot/contentRoot seam as public.ts - see that file's
|
|
@@ -11,26 +10,9 @@ import { urlToPagePath } from "../services/urls.js";
|
|
|
11
10
|
function toRenderPath(pagesRelativePath) {
|
|
12
11
|
return join('pages', pagesRelativePath);
|
|
13
12
|
}
|
|
14
|
-
// Same seam as toRenderPath above, for posts.
|
|
15
|
-
function toPostsRenderPath(postsRelativePath) {
|
|
16
|
-
return join('posts', postsRelativePath);
|
|
17
|
-
}
|
|
18
13
|
async function handlePreviewRequest(request, reply, config, themeTemplates, layouts, engine) {
|
|
19
14
|
const url = `/${request.params['*']}`;
|
|
20
15
|
try {
|
|
21
|
-
// /blog is a permanently reserved namespace, same as public.ts -
|
|
22
|
-
// no redirects.json fallback here either way (preview never
|
|
23
|
-
// consults it, per this file's own established comment above).
|
|
24
|
-
if (isBlogUrl(url)) {
|
|
25
|
-
const relativePath = urlToPostPath(url);
|
|
26
|
-
if (relativePath === null) {
|
|
27
|
-
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
const html = await renderPage(config, themeTemplates, layouts, engine, toPostsRenderPath(relativePath), 'preview');
|
|
31
|
-
reply.type('text/html; charset=utf-8').send(html);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
16
|
const relativePath = urlToPagePath(url);
|
|
35
17
|
const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath(relativePath), 'preview');
|
|
36
18
|
reply.type('text/html; charset=utf-8').send(html);
|
package/dist/routes/public.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { FastifyPluginAsync } from 'fastify';
|
|
2
2
|
import type { Liquid } from 'liquidjs';
|
|
3
3
|
import type { SiteConfig } from '../config.ts';
|
|
4
|
+
import type { RenderCache } from '../renderer/render-cache.ts';
|
|
4
5
|
import type { ThemeTemplates } from '../renderer/theme-templates.ts';
|
|
5
6
|
export interface PublicRouteOptions {
|
|
6
7
|
config: SiteConfig;
|
|
7
8
|
themeTemplates: ThemeTemplates;
|
|
8
9
|
layouts: Record<string, string>;
|
|
9
10
|
engine: Liquid;
|
|
11
|
+
renderCache: RenderCache;
|
|
10
12
|
}
|
|
11
13
|
export declare const publicRoutes: FastifyPluginAsync<PublicRouteOptions>;
|