@sleekcms/sync 1.6.0 → 2.0.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/AGENT.md CHANGED
@@ -1,107 +1,123 @@
1
- # SleekCMS Site Builder Reference
1
+ # SleekCMS Site Builder — Agent Guide
2
2
 
3
- Cloud-based headless CMS with an integrated static site builder. Files sync automatically — every save triggers a rebuild and deploy. No Git, no servers, no manual builds.
3
+ ## 1. Mental Model
4
4
 
5
- ---
5
+ SleekCMS is a headless CMS plus static site generator. There are no servers, no git repos, nothing to compile locally. You build a site by editing three kinds of files, which sync automatically to the platform:
6
+
7
+ | You edit | What it is | Where |
8
+ |---|---|---|
9
+ | **Model** (`.model`) | The shape of the content (field names + types) | `src/models/...` |
10
+ | **Content** (`.json` / `.md`) | The actual values for those fields | `src/content/...` |
11
+ | **View** (`.ejs`) | The HTML template that renders the content | `src/pages/`, `src/entries/`, `src/blocks/`, `src/layouts/` |
6
12
 
7
- ## How It Works
13
+ A page's HTML = **layout** wrapping ( **page view** rendering **page content** validated by **page model** ).
8
14
 
9
- Each page = **model** (schema) + **template** (EJS) + optional **layout** (EJS wrapper).
15
+ Three model kinds:
10
16
 
11
- The **file name** (key) links a model to its template and determines the URL path.
17
+ - **Page** routable; has a URL. Lives in `models/pages/`.
18
+ - **Entry** — shared data with no URL (nav, footer, authors, testimonials). Lives in `models/entries/`.
19
+ - **Block** — a reusable field *schema + view*, embedded inside pages/entries. Lives in `models/blocks/`. Blocks never have their own content files.
12
20
 
13
- Sync/build errors are written to `sync-errors.log` in the workspace root. If a save does not render or deploy as expected, check that file first.
21
+ Pages and entries are either **single** (one record) or a **collection** (many records). A collection is marked by a `+` suffix on the key, e.g. `blog+`.
14
22
 
15
- ---
23
+ ## 2. The Edit → Sync → Verify Loop
16
24
 
17
- ## File Naming Convention
25
+ 1. **Edit/create files** in the workspace under `src/`.
26
+ 2. The `@sleekcms/sync` utility runs in the background and pushes every save automatically. There is no build or deploy command to run.
27
+ 3. **Verify by reading `sync-errors.log`** in the workspace root after a batch of edits:
28
+ - A failed sync **adds a line** describing the error for that file.
29
+ - When the file is fixed and re-synced, **its line is removed**.
30
+ - Empty file (or no new lines) = everything synced successfully.
31
+ 4. Previews are online only; the user views the result in the SleekCMS preview, not locally.
18
32
 
19
- Keys are lowercase, dash-separated. For pages, `_` in the key maps to `/` in the URL. A `+` suffix marks a collection.
33
+ Never guess at why something isn't rendering read `sync-errors.log` first.
20
34
 
21
- | File key | URL |
35
+ ## 3. File Naming Rules (applies to models, views, and content)
36
+
37
+ 1. Keys are **lowercase**, words separated by `-` (dash). Example: `getting-started`.
38
+ 2. In **page** keys, `_` (underscore) maps to `/` in the URL. Example: key `docs_getting-started` → URL `/docs/getting-started`. Underscore is **only** for path nesting — never use it as a word separator.
39
+ 3. `_index` is the key for the **home page (`/`) only**. An index page for a section uses the plain section key: the page at `/docs` has key `docs`.
40
+ 4. A `+` suffix marks a **collection**. The `+` is part of the key and appears on the model, the view, and the content file/folder names. Example: `blog+`.
41
+ 5. A key may contain a file extension, e.g. `rss.xml` → URL `/rss.xml`. The dot is kept as-is, the file is served with the matching content type, and **the layout is skipped automatically** for such pages.
42
+ 6. `blog` and `blog+` are two different keys and may coexist: `blog` is a single page at `/blog` (e.g. the post listing), while `blog+` is the collection that generates `/blog/<slug>` (one page per post).
43
+
44
+ | Page key | URL |
22
45
  |---|---|
23
- | `_index` | `/` (home) |
46
+ | `_index` | `/` |
24
47
  | `about` | `/about` |
25
- | `blog+` | `/blog/<slug>` (one page per entry) |
48
+ | `blog` | `/blog` (single page) |
49
+ | `blog+` | `/blog/<slug>` (one page per content file) |
26
50
  | `docs_getting-started` | `/docs/getting-started` |
51
+ | `docs_guides+` | `/docs/guides/<slug>` |
52
+ | `rss.xml` | `/rss.xml` (no layout) |
27
53
 
28
- The keys for model, template, and content file are the same if the model is a **collection**, the `+` suffix is part of the key and must appear on **every** related file.
54
+ For collection pages, the `<slug>` is the content file's name: `content/pages/blog+/my-post.json` `/blog/my-post`. Renaming the file renames the URL.
29
55
 
30
- Examples:
31
- - Collection page `blog`: `models/pages/blog+.model`, `pages/blog+.ejs`, `content/pages/blog+/<slug>.json`
32
- - Collection entry `testimonials`: `models/entries/testimonials+.model`, `entries/testimonials+.ejs`, `content/entries/testimonials+.json`
33
- - Single page `about`: `models/pages/about.model`, `pages/about.ejs`, `content/pages/about.json` (no `+`)
56
+ ## 4. Folder Structure (under `src/`)
34
57
 
35
- ---
58
+ ```
59
+ models/pages/<key>.model Page models
60
+ models/entries/<key>.model Entry models
61
+ models/blocks/<key>.model Block models
36
62
 
37
- ## Folder Structure within src/
63
+ pages/<key>.ejs Page views
64
+ entries/<key>.ejs Entry views (only needed if you render() the entry)
65
+ blocks/<key>.ejs Block views (required for every block)
66
+ layouts/common.ejs The single site layout (see §8)
38
67
 
39
- ```
40
- /models/pages/<key>.model Page content models
41
- /models/entries/<key>.model Entry content models
42
- /models/blocks/<key>.model Block component models
43
-
44
- /pages/<key>.ejs Page templates
45
- /entries/<key>.ejs Entry templates
46
- /blocks/<key>.ejs Block templates
47
- /layouts/<name>.ejs Layout wrappers (default is common.ejs)
48
-
49
- /css/<name>.css Stylesheets (require head injection)
50
- /css/tailwind.css Tailwind CSS (auto-compiled, auto-injected)
51
- /js/<name>.js Scripts (require head injection)
52
-
53
- /content/pages/<key>.json Content for a single (non-list) page
54
- /content/pages/<key>/<slug>.json Content for one item of a collection page (<key> ends with +)
55
- /content/pages/<key>/<slug>.md Same as above, but as Markdown with YAML frontmatter — used when the model qualifies (see "Markdown content files" below)
56
- /content/entries/<key>.json Content for a single entry (object)
57
- /content/entries/<key+>.json Content for a collection entry (array of objects; <key>+ matches the model filename)
58
-
59
- /content/images.json Site-level reusable images (handle → shortcut)
60
- /content/videos.json Site-level videos (handle → embed_url) — READ-ONLY, populated by UI uploads
68
+ css/<name>.css Stylesheets — must be included via link()
69
+ css/tailwind.css Tailwind (auto-compiled, auto-injected — never link() it)
70
+ js/<name>.js Scripts must be included via script()
71
+
72
+ content/pages/<key>.json Content for a single page (object)
73
+ content/pages/<key>/<slug>.json Content for one record of a collection page (<key> ends with +)
74
+ content/pages/<key>/<slug>.md Same record as Markdown + frontmatter, when the model qualifies (§7)
75
+ content/entries/<key>.json Content for a single entry (object)
76
+ content/entries/<key>.json Collection entry: <key> ends with +, file holds an array of objects
77
+
78
+ content/images.json Site-level reusable images: handle → shortcut (§7)
79
+ content/videos.json Site-level videos: handle → embed_url. READ-ONLY (§7)
61
80
  ```
62
81
 
63
- > **Tailwind**: Creating `/css/tailwind.css` enables Tailwind. It is compiled and injected automatically — do NOT add it via `link()`.
64
- > All other CSS/JS files must be included via `link()` or `script()`.
82
+ ## 5. Common Tasks
65
83
 
66
- ---
84
+ ### Create a single page (e.g. `/about`)
67
85
 
68
- ## Content Models
86
+ 1. `models/pages/about.model` — define the fields.
87
+ 2. `content/pages/about.json` — fill in values (keys must match the model).
88
+ 3. `pages/about.ejs` — render `item.<field>`.
89
+ 4. Check `sync-errors.log`.
69
90
 
70
- ### Model Types
91
+ ### Create a collection (e.g. a blog at `/blog/<slug>`)
71
92
 
72
- | Type | Purpose | Has URL | File path |
73
- |---|---|---|---|
74
- | **Page** | Routable content | Yes | `models/pages/<key>.model` |
75
- | **Entry** | Shared/reusable data (nav, footer, authors) | No | `models/entries/<key>.model` |
76
- | **Block** | Reusable component schema embedded in pages/entries | No | `models/blocks/<key>.model` |
93
+ 1. `models/pages/blog+.model`
94
+ 2. `pages/blog+.ejs` — renders **one** post; `item` is that post's content.
95
+ 3. One content file per post: `content/pages/blog+/<slug>.json` (or `.md` when the model qualifies, §7).
96
+ 4. Optionally create a listing page with key `blog` (single page) that loops with `getPages('/blog', { collection: true })`.
97
+ 5. For blogs, also create an RSS feed (§11) and link it for autodiscovery.
77
98
 
78
- Pages and entries can be **single** (one record) or **collection** (many records, key ends with `+`). Blocks are embedded component definitions; they do not have their own top-level content records.
99
+ ### Change what a page says
79
100
 
80
- ### .model File Format
101
+ Edit the matching file under `content/` — **do not** hard-code editable copy into `.ejs` views. Views define structure; content files hold values. Exception: trivial fixed strings the user will never edit (e.g. a "Read more" label) may live in the view. Decide what is genuinely content.
81
102
 
82
- JSON structure without quotes on keys or string values. Scalar values are the field type name.
103
+ ### Add a field to a page
83
104
 
84
- ```
85
- {
86
- title: text,
87
- image: image,
88
- content: markdown
89
- }
90
- ```
105
+ 1. Add the field to the `.model` **first**.
106
+ 2. Then add the value to the content JSON. Content keys that don't exist in the model will not sync.
107
+ 3. Then use it in the view.
91
108
 
92
- **Groups**Nest fields in an object:
93
- ```
94
- {
95
- hero: {
96
- heading: text,
97
- background: image
98
- }
99
- }
100
- ```
109
+ All fields are optional a missing value simply renders as `undefined`, so guard with `<% if (item.field) { %>` where it matters.
110
+
111
+ ## 6. Models
112
+
113
+ ### `.model` file format
114
+
115
+ Like JSON but with **no quotes**; each value is a field type name:
101
116
 
102
- **Collections** (repeatable lists) — Wrap a group in `[]`:
103
117
  ```
104
118
  {
119
+ title: text,
120
+ hero: block(hero),
105
121
  features: [
106
122
  {
107
123
  title: text,
@@ -111,250 +127,127 @@ JSON structure without quotes on keys or string values. Scalar values are the fi
111
127
  }
112
128
  ```
113
129
 
114
- **Block field** — Use `block(key)` to embed one reusable block schema, or `[block(key)]` for a repeatable list of the same block:
115
- ```
116
- {
117
- cta: block(cta),
118
- ctas: [block(cta)]
119
- }
120
- ```
121
-
122
- ### Blocks
123
-
124
- Blocks are reusable components made from two files:
125
-
126
- - `models/blocks/<key>.model` defines the fields editors can fill in.
127
- - `blocks/<key>.ejs` defines how that block renders.
128
-
129
- A block does **not** own content. There is never a `content/blocks/...` file. Instead, each page or entry that declares `block(key)` or `[block(key)]` stores its own copy of that block's field values inside its own content JSON. The reusable part is the schema and template; the content is local to the page or entry using it.
130
-
131
- Use blocks when a group of fields also needs a reusable view, such as SEO metadata, cards, or calls to action that should render the same way across multiple pages. If the structure is only used on one page, a group (`{ ... }`) or repeating group (`[{ ... }]`) is usually simpler. Shared content like testimonials, authors, team members, categories, or reusable navigation data should generally be entries referenced with `entry(key)` or `[entry(key)]`, not blocks.
132
-
133
- Example block model:
130
+ - **Group**nest an object `{ ... }` for one-off structure.
131
+ - **Collection** — wrap a group in `[ ... ]` for a repeatable list.
132
+ - **Block field** — `block(key)` for one block, `[block(key)]` for a repeatable list of the same block.
133
+ - **Stack** — bare `stack` (no parentheses, no `[]`) for a list where each item can be a *different* block.
134
+ - **Entry reference** — `entry(key+)` for one, `[entry(key+)]` for many. The referenced entry must be a collection, so the key inside the parentheses **always includes the `+`**: `entry(authors+)`.
134
135
 
135
- **`models/blocks/hero.model`**
136
- ```
137
- {
138
- heading: text,
139
- subheading: paragraph,
140
- background: image,
141
- cta_label: text,
142
- cta_link: link
143
- }
144
- ```
136
+ ### Field types
145
137
 
146
- Use that block in a page model:
138
+ | Model type | Content JSON value (what templates receive) |
139
+ |---|---|
140
+ | `text`, `paragraph`, `code`, `color`, `link` | String |
141
+ | `richtext` | String of **HTML** — output with `<%- %>` |
142
+ | `markdown` | String of **raw markdown** — convert with `<%- marked(item.x) %>` |
143
+ | `number` | Number |
144
+ | `boolean` | `true` / `false` |
145
+ | `emoji` | One emoji character string, e.g. `"😀"` (not free text) |
146
+ | `date` | `"YYYY-MM-DD"` string |
147
+ | `datetime` | ISO 8601 string |
148
+ | `time` | `"HH:mm"` string |
149
+ | `image` | Shortcut string (preferred when writing) or resolved `{ "url", "alt" }` object — see §7. Use `image` for **anything visual, including icons** (`"iconify:..."`). Shortcuts only resolve in `image` fields — in a `text` field the string stays literal text |
150
+ | `video` | Object. **Never write or invent this value** — uploads are user-driven in the CMS UI. Render with `embed()` only |
151
+ | `file` | Object `{ name, url, size, type, ext }`. **Never write or invent this value** — uploads are user-driven. Render a link with `item.x.url` / `item.x.name` |
152
+ | `json` | Any JSON |
153
+ | `sheet` | Array of arrays |
154
+ | `location` | `{ "markers": [{ "lat", "lng" }], "img": "<google static map url>" }` — `img` supports Google Static Maps params; markers are already part of the URL |
155
+ | `block(key)` | Object matching that block's model, stored inline in the parent's content |
156
+ | `[block(key)]` | Array of such objects |
157
+ | `stack` | Array of mixed block objects, each `{ "_block": "<block_key>", ...fields }` — see below |
158
+ | `entry(key+)` / `[entry(key+)]` | Reference string (or array of them) — see below |
159
+ | Group `{ ... }` | Nested object |
160
+ | Collection `[{ ... }]` | Array of nested objects |
147
161
 
148
- **`models/pages/about.model`**
149
- ```
150
- {
151
- title: text,
152
- hero: block(hero)
153
- }
154
- ```
162
+ ### Entry references in content
155
163
 
156
- Store that page's block data inline:
164
+ The value is the **0-based index** of the target record in `content/entries/<key>+.json`, written as a string:
157
165
 
158
- **`content/pages/about.json`**
159
166
  ```json
160
- {
161
- "title": "About us",
162
- "hero": {
163
- "heading": "Care that feels personal",
164
- "subheading": "A small team focused on long-term relationships.",
165
- "background": "pexels:doctor",
166
- "cta_label": "Book a visit",
167
- "cta_link": "/contact"
168
- }
169
- }
170
- ```
171
-
172
- Render it from the page template with `render()`. The `block(hero)` declaration tells the renderer to use `blocks/hero.ejs`:
173
-
174
- **`pages/about.ejs`**
175
- ```ejs
176
- <h1><%= item.title %></h1>
177
- <%- render(item.hero) %>
167
+ { "author": "0", "tags": ["0", "2"] }
178
168
  ```
179
169
 
180
- **`blocks/hero.ejs`**
181
- ```ejs
182
- <section class="hero" style="background-image: url('<%- src(item.background, '1600x700') %>')">
183
- <h2><%= item.heading %></h2>
184
- <p><%= item.subheading %></p>
185
- <a href="<%= item.cta_link %>"><%= item.cta_label %></a>
186
- </section>
187
- ```
170
+ You may optionally append `|` plus the verbatim opening characters of that entry's content as a human-readable hint: `"0|Engineering"`. The index is what binds; an exact index is not critical for building — the user can re-point references in the CMS UI. Just make sure the field exists.
188
171
 
189
- Block models cannot contain other blocks. Use groups (`{ ... }`) or collections (`[{ ... }]`) inside a block when you need nested structure, and use `entry(key)` when a block needs shared reusable data.
172
+ ### Choosing the right structure
190
173
 
191
- Repeatable blocks use the same collection syntax as groups and entries: `[block(cta)]`. Use this sparingly, only when each repeated item should use the same reusable block template. For simple one-page repeated content, prefer a repeating group (`[{ ... }]`). For reusable content selected across pages, prefer a collection entry plus `[entry(key)]`.
174
+ - One-off shape used on a single page **group** `{ ... }` or repeating group `[{ ... }]`.
175
+ - Reusable shape **and** reusable view (SEO, cards, CTAs rendered identically on many pages) → **block**.
176
+ - A section where the editor mixes different blocks in any order → **stack** (any block in the library may appear; there is no per-field allow-list).
177
+ - Shared *content* referenced from many pages (testimonials, authors, people, tags, nav, footer) → **entry**, referenced with `entry(key+)` / `[entry(key+)]`. Use entries, not blocks, for this.
192
178
 
193
- ### Stacks
179
+ Nesting limits: block models cannot contain `block(...)` or `stack` fields. Stack items may contain groups, collections, and entry references — but not other stacks or blocks. Inside a block, use groups/collections for structure and `entry(key+)` for shared data.
194
180
 
195
- A **stack** is a heterogeneous list of blocks: each item picks its own block schema. Use a stack when a page section can contain a mix of different reusable components in an editor-chosen order. If every item would use the same block, prefer `[block(key)]` instead — stacks are for "section A could be a hero, a CTA, a quote, or a gallery."
181
+ ### Blocks in detail
196
182
 
197
- **Model syntax** — bare `stack`, no parentheses and no `[]`. Stacks are always multiple by definition:
183
+ A block = two files: `models/blocks/<key>.model` (fields) + `blocks/<key>.ejs` (view). A block **never** owns content there is no `content/blocks/...`. Each page/entry that declares `block(key)` stores its own copy of the block's values inline in its own content file.
198
184
 
199
185
  ```
200
- {
201
- title: text,
202
- sections: stack
203
- }
186
+ models/blocks/hero.model pages model: { ..., hero: block(hero) }
187
+ blocks/hero.ejs page view: <%- render(item.hero) %>
204
188
  ```
205
189
 
206
- Any block defined in `models/blocks/` may appear in any stack — the allowed set is the full block library, not a per-field allow-list.
190
+ ### Stacks in detail
207
191
 
208
- **Content shape** an array of objects. Each item is shaped like the block model it uses, plus a `_block` marker naming that block by key (as a plain string):
192
+ Model: `sections: stack`. Content is an array where each item names its block via `_block` (the block **key** as a plain string never a numeric id; the CMS resolves keys server-side):
209
193
 
210
- **`content/pages/about.json`**
211
194
  ```json
212
195
  {
213
- "title": "About us",
214
196
  "sections": [
215
- {
216
- "_block": "hero",
217
- "heading": "Care that feels personal",
218
- "subheading": "A small team focused on long-term relationships.",
219
- "background": "pexels:doctor"
220
- },
221
- {
222
- "_block": "cta",
223
- "label": "Book a visit",
224
- "link": "/contact"
225
- }
197
+ { "_block": "hero", "heading": "Care that feels personal", "background": "pexels:doctor" },
198
+ { "_block": "cta", "label": "Book a visit", "link": "/contact" }
226
199
  ]
227
200
  }
228
201
  ```
229
202
 
230
- The `_block` value is the block's key (e.g., `"hero"`, `"cta"`) never a numeric id. The CMS resolves it server-side on save.
231
-
232
- **Rendering** — each item flows through its own `blocks/<key>.ejs` template. Pass the whole array to `render()` and it dispatches per `_block`:
233
-
234
- **`pages/about.ejs`**
235
- ```ejs
236
- <h1><%= item.title %></h1>
237
- <%- render(item.sections) %>
238
- ```
203
+ Render the whole array: `<%- render(item.sections) %>`each item dispatches to its own `blocks/<key>.ejs`.
239
204
 
240
- **Stack vs block vs collection** — same decision tree as blocks, with one extra rung:
205
+ ## 7. Content Files
241
206
 
242
- - One-off shape used on one page group `{ ... }` or `[{ ... }]`.
243
- - Reusable shape, always the same per item → `block(key)` or `[block(key)]`.
244
- - Reusable shape, but items can be different blocks chosen by the editor → `stack`.
245
- - Shared content referenced from many pages → `entry(key)` / `[entry(key)]`.
207
+ Content files hold the values for model fields. Keys must match the model. Saving a content file triggers the same sync loop as any other file.
246
208
 
247
- Like blocks, stacks cannot be nested inside block models. Stack items themselves may contain groups, collections, and entry references, but not other stacks or blocks.
248
-
249
- If the Blocks are only used in one page, simply structure the model as nested fields. Do not use Block or Stack fields in those cases.
250
-
251
- **Entry reference** Use `entry(key)` for one, `[entry(key)]` for many:
252
- ```
253
- {
254
- author: entry(authors+),
255
- testimonials: [entry(testimonials+)],
256
- tags: [entry(tags+)]
257
- }
258
- ```
259
-
260
- ### Field Types and Serialization
261
-
262
- Model fields declare both the editor type and the shape expected in content JSON. Templates receive those same values, except image shortcuts are resolved to full image objects and entry references are resolved to entry object(s).
263
-
264
- | Model type | Content JSON value / template value |
265
- |---|---|
266
- | `text`, `paragraph`, `richtext`, `markdown`, `code`, `color`, `link` | String |
267
- | `number` | Number |
268
- | `boolean` | `true` / `false` |
269
- | `emoji` | Single Unicode emoji character string, e.g. `"😀"` or `"👋🏽"` (one emoji per record; not free text) |
270
- | `date` | `"YYYY-MM-DD"` string |
271
- | `datetime` | ISO 8601 string |
272
- | `time` | `"HH:mm"` string |
273
- | `image` | Either a resolved `{ "url": "...", "alt": "..." }` object, **or** a shortcut string `"<source>:<search>"` (e.g., `"pexels:doctor"`, `"url:https://picsum.photos/200.jpg"`, `"cms:logo"`). Supported sources: `unsplash`, `pexels`, `pixabay`, `iconify`, `url`, `cms` (reuses an image declared in `/images.json` by handle). Append `\|<alt text>` to set the image's alt, e.g. `"pexels:doctor\|Smiling doctor with stethoscope"`. On save, the sync engine resolves the shortcut/link to a full image object automatically. |
274
- | `video` | Object of shape `{ "source": "BUNNY", "video_id": "...", "embed_url": "...", "title": "..." }`. **Do not write or invent this value.** Video uploads are user-driven in the CMS UI — the user uploads to Bunny.net and the sync engine stores the resolved object. In templates, always render videos with the `embed()` helper (e.g. `<%- embed(item.intro_video, { size: '1280x720' }) %>`), never by hand-rolling an iframe. The site's available video handles are exposed read-only at `/content/videos.json` (`handle → embed_url`) — look them up in templates with `getVideo('<handle>')`. |
275
- | `file` | Object of shape `{ "name": "...", "url": "...", "size": 0, "type": "...", "ext": "..." }` describing a downloadable document (PDF, CSV, DOC/DOCX, XLS/XLSX, PPT/PPTX, TSV, TXT — max 10 MB). **Do not write or invent this value.** File uploads are user-driven in the CMS UI — the user attaches the file via the editor and the sync engine stores the resolved object. In templates, render a link with `item.file.url` (download URL on `files.sleekcms.com`) and `item.file.name` (display name), e.g. `<a href="<%= item.brochure.url %>" download><%= item.brochure.name %></a>`. |
276
- | `json` | Object or array |
277
- | `sheet` | Array of arrays |
278
- | `location` | `{ "markers": [{ "lat": n, "lng": n }], "img": "..." }`; `img` is a Google Maps Static Image URL that already includes the markers and can include formatting parameters such as `size`, `zoom`, `scale`, `maptype`, and styling |
279
- | `block(key)` | Object matching that block's model; stored inline in the parent page/entry content and rendered with `blocks/<key>.ejs` |
280
- | `[block(key)]` | Array of block objects; each item matches the block model and renders with `blocks/<key>.ejs` |
281
- | `stack` | Array of heterogeneous block-shaped objects. Each item is `{ "_block": "<block_key>", ...fields_of_that_block }`. `_block` is the target block's key as a plain string; the remaining keys must match that block's model. Different items in the same stack may use different blocks. Stored inline; each item renders through its own `blocks/<key>.ejs`. The CMS resolves block keys to ids server-side — never write numeric ids. |
282
- | `entry(key)` / `[entry(key)]` | Reference token `"<index>\|<text>"` (or an array of them) in content JSON; entry object / array of entry objects in templates. `index` is the 0-based position of the target entry in its `content/entries/<key>+.json` list — that is what binds. `\|<text>` is optional: the **opening characters of that entry's content, copied verbatim** (matched as a substring — not a description you compose), used only to relocate the entry if the list shifted. You may write just the index (`"2"` or `2`). |
283
- | Group `{ ... }` | Nested object |
284
- | Collection `[{ ... }]` | Array of nested objects |
285
-
286
- `richtext` returns HTML; output it with `<%- %>`. `markdown` returns raw markdown; convert it with `marked()` before output. Inside markdown, use the same image shortcut convention in standard image syntax: `![alt](pexels:doctor)`. Append `\|<alt>` to set alt text, and include a `<width>x<height>` token to set URL `w`/`h` params (defaults to `600x400`), e.g. `![doctor](pexels:doctor\|Smiling doctor 800x600)`.
287
-
288
- ---
289
-
290
- ## Content Records
291
-
292
- Content files are JSON records under `/content/` that hold the actual values for the fields declared in each `.model`. Editing a content file and saving it triggers the same sync-build-deploy loop as editing a template — you can view and edit content directly from this workspace.
209
+ | Model | File | Top-level shape |
210
+ |---|---|---|
211
+ | Single page `about` | `content/pages/about.json` | Object |
212
+ | Collection page `blog+` | `content/pages/blog+/<slug>.json` — one file per record | Object |
213
+ | Qualifying collection page (below) | `content/pages/blog+/<slug>.md` | Frontmatter + markdown body |
214
+ | Single entry `header` | `content/entries/header.json` | Object |
215
+ | Collection entry `authors+` | `content/entries/authors+.json` | **Array** of objects |
293
216
 
294
- **Blocks never have top-level content files.** Block data is always embedded inside the page or entry that declares the block field.
217
+ ### Images shortcut strings
295
218
 
296
- ### File layout
219
+ When writing an `image` value, prefer the shortcut form `"<source>:<search>"`. Sources: `unsplash`, `pexels`, `pixabay`, `iconify`, `url` (direct URL), `cms` (reusable handle, below).
297
220
 
298
- | Model shape | File path | Top-level shape |
299
- |---|---|---|
300
- | Single page (e.g., `about`) | `content/pages/about.json` | Object |
301
- | Collection page (e.g., `blog+`) | `content/pages/blog+/<slug>.json` (the `+` is part of the key, not an extra suffix) | Object; one file per slug |
302
- | Collection page with one markdown body (see below) | `content/pages/blog+/<slug>.md` | YAML frontmatter + markdown body |
303
- | Single entry (e.g., `header`) | `content/entries/header.json` | Object |
304
- | Collection entry (e.g., `authors`) | `content/entries/authors+.json` (the `+` is part of the key — same as the model filename) | Array of objects |
221
+ - `"pexels:doctor"` stock search
222
+ - `"url:https://example.com/photo.jpg"` — specific asset
223
+ - `"iconify:mdi:apple"` icon
224
+ - Append `|<alt>` to set alt text: `"pexels:doctor|Smiling doctor with stethoscope"`
305
225
 
306
- ### Example
226
+ On save, the sync engine resolves each shortcut to a real `{ "url", "alt" }` object.
307
227
 
308
- Given a model:
309
-
310
- ```
311
- { title: text, image: image, hero: block(hero), tags: [entry(tags+)] }
312
- ```
228
+ **Shortcuts only work in `image` fields.** A common mistake: declaring a visual field as `text` (e.g. `icon: text`) and then writing `"iconify:lucide:clock"` as its value. Nothing errors — the string simply stays literal text and never resolves to an image. Any field that will hold an icon, logo, photo, or illustration must be declared `icon: image` in the model. If a shortcut isn't resolving, check the field's type in the `.model` first.
313
229
 
314
- The content file at `content/pages/about.json`:
230
+ **Reusable images:** for images used in multiple places (logo, recurring icons), declare them once in `content/images.json` as `handle → shortcut`:
315
231
 
316
232
  ```json
317
- {
318
- "title": "About us",
319
- "image": "pexels:team meeting",
320
- "hero": {
321
- "heading": "Hello",
322
- "subheading": "Welcome",
323
- "background": "pexels:doctor",
324
- "cta_label": "Contact us",
325
- "cta_link": "/contact"
326
- },
327
- "tags": ["0|Engineering", "2|Design"]
328
- }
233
+ { "logo": "url:https://cdn.example.com/logo.svg", "hero": "pexels:mountain sunrise" }
329
234
  ```
330
235
 
331
- Here `hero` is embedded block data stored directly in the page content file. `image` and `hero.background` use the shortcut form — on save, the sync engine replaces each shortcut with a real image object (`{ "url": "...", "alt": "..." }`). Write the object form directly when you have a specific asset URL. `tags` references entries by position — `"0|Engineering"` points at the first entry in `content/entries/tags+.json`, where `Engineering` is just the start of that entry's content copied verbatim (a substring check, not a description you write). The index is what binds on save; the text only helps relocate the entry if the list shifted, and a bare index (`"0"`) is also accepted.
236
+ then reference with `"cms:logo"` in any image field, or fetch in templates via `getImage('logo')`.
332
237
 
333
- ### Markdown content files
238
+ **Images inside markdown:** use the same shortcuts in standard syntax: `![alt](pexels:doctor)`. Append `|<alt>` for alt text and an optional `<W>x<H>` token to set rendered size (default `600x400`): `![doctor](pexels:doctor|Friendly doctor 800x600)`. Refs are rewritten to CDN URLs on save.
334
239
 
335
- For a **collection page** whose model is shaped like "metadata + one long-form body", the sync engine stores each record as a **Markdown file with JSON frontmatter** (`.md`) instead of `.json`. The body is plain Markdown; the metadata is a JSON object between `---` markers.
240
+ ### Videos read-only
336
241
 
337
- A model qualifies when **all** of the following hold:
242
+ `content/videos.json` maps `handle embed_url` for every video the user uploaded via the CMS UI. **Never edit this file** it is regenerated and local edits are overwritten. Use it only to discover handles, then in templates: `<%- embed(getVideo('intro'), { size: '1280x720' }) %>`. Never write `video` field values or hand-roll an `<iframe>`.
338
243
 
339
- 1. It is a **page** model (lives in `models/pages/`).
340
- 2. It is a **collection** (key ends with `+`, so each record is its own file).
341
- 3. It has **exactly one** top-level field of type `markdown`.
342
- 4. It has **no** `richtext` or `stack` fields anywhere (top-level or nested).
244
+ ### Markdown content files (`.md`)
343
245
 
344
- When the model qualifies, content lives at `content/pages/<key+>/<slug>.md`. All non-markdown fields go into the JSON frontmatter; the single `markdown` field's value becomes the body.
246
+ A collection-page record is stored as Markdown with frontmatter instead of JSON when its model meets **all** of:
345
247
 
346
- Example given the model `models/pages/blog+.model`:
347
-
348
- ```
349
- {
350
- title: text,
351
- date: date,
352
- summary: paragraph,
353
- body: markdown
354
- }
355
- ```
248
+ 1. Page model, 2. collection (`+` key), 3. **exactly one** top-level `markdown` field, 4. **no** `richtext` or `stack` anywhere.
356
249
 
357
- The record `content/pages/blog+/welcome-post.md` looks like:
250
+ Then write `content/pages/<key+>/<slug>.md`: all non-markdown fields go in the frontmatter between `---` markers; the single markdown field's value is the body (its key never appears in frontmatter).
358
251
 
359
252
  ```markdown
360
253
  ---
@@ -366,177 +259,108 @@ The record `content/pages/blog+/welcome-post.md` looks like:
366
259
  ---
367
260
  # Hello
368
261
 
369
- This is the **first** post.
370
-
371
- - one
372
- - two
373
- ```
374
-
375
- Notes:
376
- - The body field's handle (`body` above) is whatever the single `markdown` field is called in the model — its key never appears in the frontmatter; its value is the markdown content.
377
- - Image shortcut conventions still apply inside the markdown body (`![alt](pexels:doctor)`) and inside frontmatter image fields (`"pexels:doctor"`).
378
- - If the model doesn't qualify (e.g., it has two markdown fields, or a `stack`), the content stays JSON at `content/pages/<key+>/<slug>.json` — no change.
379
- - The server still accepts `.json` for qualifying models — sending `.md` is preferred but not required. The next pull will re-emit the canonical `.md` form.
380
-
381
- **YAML frontmatter is also accepted as input.** The server detects whichever shape you write between the `---` markers and parses it correctly. The canonical form emitted by the server is JSON — on the next pull, YAML frontmatter is rewritten to JSON.
382
-
383
- ```markdown
384
- ---
385
- title: Welcome to the blog
386
- date: '2026-05-19'
387
- summary: A short post.
388
- ---
389
- # Hello
390
-
391
262
  This is the **first** post.
392
263
  ```
393
264
 
394
- JSON5 features (trailing commas, `//` or `/* */` comments, unquoted keys) are accepted in JSON frontmatter. Choose JSON for programmatic generation (and to match canonical output); YAML is fine for hand-edits.
265
+ Frontmatter may be JSON (canonical prefer it when generating), JSON5, or YAML; all parse correctly. The server also still accepts plain `.json` for qualifying models. Image shortcuts work in both frontmatter and body.
395
266
 
396
- ### Reusable images (`/images.json`)
267
+ ## 8. Views (EJS)
397
268
 
398
- For images used in more than one place (logos, recurring icons, hero art), declare them once in `/images.json` as a flat map of `handle → shortcut` using the same shortcut convention as `image` fields:
399
-
400
- ```json
401
- {
402
- "logo": "url:https://cdn.example.com/logo.svg",
403
- "hero": "pexels:mountain sunrise",
404
- "apple-icon": "iconify:mdi:apple"
405
- }
406
- ```
407
-
408
- Then reference any of them from any content `image` field with `"cms:<handle>"` (e.g., `"cms:logo"`). The sync engine resolves it to the full image object on save. Templates can also fetch the resolved object directly via `getImage('<handle>')`.
409
-
410
- ### Site videos (`/videos.json`) — READ-ONLY
411
-
412
- `/content/videos.json` is a flat map of `handle → embed_url` describing every video the user has uploaded to this site via the CMS UI (Bunny.net):
413
-
414
- ```json
415
- {
416
- "intro": "https://iframe.mediadelivery.net/embed/<lib>/<guid>",
417
- "testimonial-jane": "https://iframe.mediadelivery.net/embed/<lib>/<guid>"
418
- }
419
- ```
420
-
421
- **Do not edit `/content/videos.json`.** It is regenerated from the user's uploads — local edits will not sync and will be overwritten on the next pull. Use it only to discover which video handles are available, then fetch the resolved video object inside templates with `getVideo('<handle>')` and render it via `embed()`:
422
-
423
- ```ejs
424
- <%- embed(getVideo('intro'), { size: '1280x720' }) %>
425
- ```
426
-
427
- ---
428
-
429
- ## EJS Templates
430
-
431
- ### Syntax
269
+ ### Tags
432
270
 
433
271
  | Tag | Purpose |
434
272
  |---|---|
435
- | `<%= expr %>` | Output with HTML escaping (text content) |
436
- | `<%- expr %>` | Output raw HTML (blocks, images, rich text, helpers) |
437
- | `<% code %>` | Execute JS (loops, conditionals, variables) |
273
+ | `<%= expr %>` | Output, HTML-escaped (plain text) |
274
+ | `<%- expr %>` | Output raw HTML (`render()`, `img()`, `marked()`, richtext) |
275
+ | `<% code %>` | Run JS (loops, conditionals) |
438
276
 
439
- ### Template Context
277
+ ### Context variables (every view)
440
278
 
441
- Every template receives these variables:
442
-
443
- | Variable | Type | Description |
444
- |---|---|---|
445
- | `item` | Object | Current page, block, or entry being rendered |
446
- | `pages` | Array | All page records (each has `_path`, `_slug`, fields) |
447
- | `entries` | Object | All entries keyed by model handle |
448
- | `main` | String | Rendered page template output (**layout only**) |
279
+ | Variable | Description |
280
+ |---|---|
281
+ | `item` | The current record: the page in a page view, the block instance in a block view, the entry in an entry view |
282
+ | `pages` | All page records (each has `_path`, `_slug`, fields) |
283
+ | `entries` | All entries keyed by handle |
284
+ | `main` | Rendered page HTML **layout only** |
449
285
 
450
- `item` always refers to the current record. In a page template, `item` is the page. In a block template, `item` is the block instance. In an entry template, `item` is the entry.
286
+ Page records also expose `item._path`, `item._slug` (collections), and `item._meta.updated_at` (ISO timestamp).
451
287
 
452
- Page records include: `item._path`, `item._slug` (collections), `item._meta.updated_at`.
288
+ ### Layout
453
289
 
454
- ---
290
+ There is exactly **one** layout: `layouts/common.ejs`. It wraps every page automatically — pages cannot select a different layout or opt out. It contains `<html>`, `<head>`, `<body>`, and injects the page output via `<%- main %>`. The only exception: page keys with a file extension (e.g. `rss.xml`) are emitted raw, with no layout.
455
291
 
456
- ## Helper Functions
292
+ ```ejs
293
+ <!DOCTYPE html>
294
+ <html lang="en">
295
+ <head>
296
+ <meta charset="UTF-8">
297
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
298
+ </head>
299
+ <body>
300
+ <header><%- render(getEntry('header')) %></header>
301
+ <main><%- main %></main>
302
+ <footer><%- render(getEntry('footer')) %></footer>
303
+ </body>
304
+ </html>
305
+ ```
457
306
 
458
- ### Content Access
307
+ ### Helpers — content access
459
308
 
460
- | Function | Returns | Description |
309
+ | Function | Returns | Notes |
461
310
  |---|---|---|
462
- | `getPage(path)` | Object \| undefined | Page by exact path |
463
- | `getPages(path, opts?)` | Array | Pages where path starts with prefix. `{ collection: true }` for collection pages only |
464
- | `getEntry(handle)` | Object \| Array | Entry by handle. Single → object, collection → array |
311
+ | `getPage(path)` | Object \| undefined | Page by exact URL path |
312
+ | `getPages(path, opts?)` | Array | Pages whose path starts with the prefix. **Always a URL path, never a key.** `{ collection: true }` collection pages only |
313
+ | `getEntry(handle)` | Object \| Array | Entry by key. The `+` is optional — `getEntry('authors')` and `getEntry('authors+')` are equivalent. Single → object, collection → array |
465
314
  | `getSlugs(path)` | string[] | Slugs under a collection path |
466
- | `getImage(name)` | Object \| undefined | Site-level image by handle |
467
- | `getVideo(name)` | Object \| undefined | Site-level video by handle (handles listed in `/content/videos.json`, read-only) |
468
- | `getOptions(name)` | Array \| undefined | Option set as `[{ label, value }]` |
469
- | `getContent(query?)` | Any | Full content payload, or filter with JMESPath |
315
+ | `getImage(handle)` | Object \| undefined | Resolved image from `images.json` |
316
+ | `getVideo(handle)` | Object \| undefined | Video from `videos.json` |
317
+ | `getContent(query?)` | Any | Full content payload, optionally filtered with JMESPath |
470
318
  | `path(page)` | String | URL path of a page object |
471
- | `url(pathOrPage?)` | String | Site origin (e.g. `https://example.com`). Pass a path string to get a full URL (`url('/blog')` → `https://example.com/blog`), or pass a page object to resolve its path into a full URL. |
319
+ | `url(pathOrPage?)` | String | Site origin; pass a path or page object for a full absolute URL |
472
320
 
473
- ### Rendering
321
+ ### Helpers — rendering
474
322
 
475
- | Function | Returns | Description |
476
- |---|---|---|
477
- | `render(val, separator?)` | HTML string | Render a block/entry (or array of them) through its template |
478
- | `marked(md)` | HTML string | Convert a markdown string to HTML |
479
-
480
- ### Images
481
-
482
- | Function | Returns | Description |
483
- |---|---|---|
484
- | `src(image, attr)` | URL string | Optimized image URL |
485
- | `img(image, attr)` | HTML string | `<img>` element |
486
- | `picture(image, attr)` | HTML string | `<picture>` with dark/light variants |
487
- | `svg(image, attr?)` | HTML string | Inline SVG with optional attributes |
488
-
489
- `attr` can be `"WxH"` string or `{ w, h, size, fit, type, class, style }` object.
490
-
491
- ### Location
492
-
493
- `location` fields can be passed to image helpers for static maps, embedded maps, or marker data.
323
+ | Function | Notes |
324
+ |---|---|
325
+ | `render(val, separator?)` | Render a block, entry, or array of them through its `blocks/<key>.ejs` / `entries/<key>.ejs` view. An `entries/<key>.ejs` view is only needed if you call `render()` on that entry — otherwise just use its data directly |
326
+ | `marked(md)` | Markdown string HTML string |
494
327
 
495
- | Function | Returns | Description |
496
- |---|---|---|
497
- | `src(loc, attr)` | URL string | Static map URL |
498
- | `img(loc, attr)` | HTML string | Static map `<img>` |
499
- | `embed(loc, attr?)` | HTML string | Google Maps `<iframe>` |
500
- | `markers(loc)` | `{ lat, lng }[]` | Marker coordinates |
328
+ ### Helpers images & maps
501
329
 
502
- `attr` can be `"WxH"` or `{ w, h, size, zoom, maptype, scale, class, style }`.
330
+ | Function | Returns |
331
+ |---|---|
332
+ | `src(image, attr)` | Optimized image URL |
333
+ | `img(image, attr)` | `<img>` element |
334
+ | `picture(image, attr)` | `<picture>` with dark/light variants |
335
+ | `svg(image, attr?)` | Inline SVG |
503
336
 
504
- ### Video
337
+ `attr` is `"WxH"` or `{ w, h, size, fit, type, class, style }`. `location` values can be passed to `src()`/`img()` for static maps, to `embed(loc, attr?)` for a Google Maps iframe, or to `markers(loc)` for raw coordinates (`attr` adds `zoom`, `maptype`, `scale`).
505
338
 
506
- `video` field content is created by the user via the CMS UI (uploading to Bunny.net). **The agent must never write a value into a `video` field, build an `<iframe>` tag by hand, or guess a `video_id` / `embed_url`.** Always render a video by passing the field object to the `embed()` helper.
339
+ ### Helpers video
507
340
 
508
- | Function | Returns | Description |
509
- |---|---|---|
510
- | `embed(video, attr?)` | HTML string | Provider-aware `<iframe>` for the video |
341
+ `embed(video, attr?)` returns a provider-aware `<iframe>`. `attr` is `"WxH"` or `{ w, h, size, autoplay, muted, loop, preload, t, class, style }`. Browsers block autoplay unless `muted: true` is also set.
511
342
 
512
- `attr` is `"WxH"` or `{ w, h, size, autoplay, muted, loop, preload, t, class, style }`.
513
-
514
- ```ejs
515
- <%- embed(item.intro_video) %>
516
- <%- embed(item.intro_video, '1280x720') %>
517
- <%- embed(item.intro_video, { autoplay: true, muted: true, loop: true }) %>
518
- <%- embed(item.intro_video, { size: '800x450', class: 'video', style: 'border-radius:8px' }) %>
519
- ```
343
+ ### Helpers bookings
520
344
 
521
- Most browsers block autoplay unless `muted: true` is also set. `autoplay` is added to the iframe's `allow` attribute only when the caller opts in.
345
+ `bookings(name, options?)` renders the appointment-booking widget mount div (see §11). `options` is `{ accent, class, style }`.
522
346
 
523
- ### Head Injection
347
+ ### Helpers — head injection
524
348
 
525
- Call from **any template** (page, block, entry, or layout). Deduplicated automatically.
349
+ Callable from **any** view (page, block, entry, layout); duplicates are removed automatically.
526
350
 
527
351
  | Function | Description |
528
352
  |---|---|
529
- | `title(text)` | Set page `<title>` |
530
- | `meta(attrs)` | Add `<meta>` tag |
531
- | `link(value, order?)` | Add `<link>` tag (string URL auto-detects type, or pass object) |
532
- | `style(css, order?)` | Add `<style>` block |
533
- | `script(value, order?)` | Add `<script>` (`.js` URL → external, otherwise inline) |
353
+ | `title(text)` | Set `<title>` |
354
+ | `meta(attrs)` | Add `<meta>` |
355
+ | `link(value, order?)` | Add `<link>` a string URL auto-detects type, or pass an object |
356
+ | `style(css, order?)` | Add `<style>` |
357
+ | `script(value, order?)` | Add `<script>` `.js` URL → external, otherwise inline |
534
358
 
535
- ---
359
+ **CSS/JS rules:** include assets only via `link()` / `script()` — never raw `<link>`/`<script>` tags in views. Exception: creating `css/tailwind.css` enables Tailwind, which is compiled and injected automatically — never `link()` it. Default to a modern Tailwind design unless the user specifies otherwise.
536
360
 
537
- ## SEO
361
+ ## 9. SEO
538
362
 
539
- Create a **block model** (e.g., `seo.model`) and add SEO tags manually in its template:
363
+ Make every site strongly SEO- and sharing-friendly. The standard pattern is a reusable SEO block:
540
364
 
541
365
  **`models/blocks/seo.model`**
542
366
  ```
@@ -551,18 +375,14 @@ Create a **block model** (e.g., `seo.model`) and add SEO tags manually in its te
551
375
  ```ejs
552
376
  <% if (item.title) title(item.title) %>
553
377
  <% if (item.description) meta({ name: 'description', content: item.description }) %>
554
- <% if (item.image) { %>
555
- <% meta({ property: 'og:image', content: src(item.image, '1200x630') }) %>
556
- <% } %>
378
+ <% if (item.image) meta({ property: 'og:image', content: src(item.image, '1200x630') }) %>
557
379
  ```
558
380
 
559
- Then include `seo: block(seo)` in any page model and render it: `<%- render(item.seo) %>`
560
-
561
- ---
381
+ Add `seo: block(seo)` to page models and `<%- render(item.seo) %>` in page views.
562
382
 
563
- ## Forms
383
+ ## 10. Forms
564
384
 
565
- Any `<form>` with a `data-sleekcms="<name>"` attribute works automatically — submissions are captured, stored, and viewable in the CMS dashboard. No backend setup, no action URL, no JS required.
385
+ Any `<form>` with `data-sleekcms="<name>"` works automatically — submissions are captured and shown in the CMS dashboard. No backend, no action URL, no JS.
566
386
 
567
387
  ```html
568
388
  <form data-sleekcms="contact">
@@ -573,13 +393,23 @@ Any `<form>` with a `data-sleekcms="<name>"` attribute works automatically — s
573
393
  </form>
574
394
  ```
575
395
 
576
- The `<name>` value (e.g., `contact`, `newsletter`, `quote-request`) groups submissions by form. Use standard `name` attributes on inputs — each field is stored as-is.
396
+ The `data-sleekcms` value groups submissions by form; each input's `name` is stored as-is.
577
397
 
578
- ---
398
+ ## 11. Bookings
579
399
 
580
- ## RSS Feeds
400
+ Drop an appointment-booking widget on any page with the `bookings()` helper. Visitors pick a time slot and book right on the page; email confirmation, calendar invites, and cancel/reschedule are handled by the CMS — no backend, no JS.
401
+
402
+ ```ejs
403
+ <%- bookings('consultation') %>
404
+
405
+ <%- bookings('consultation', { accent: '#8c672a', class: 'mx-auto', style: 'max-width: 420px' }) %>
406
+ ```
581
407
 
582
- Create an RSS feed by adding a page with the key `rss.xml` this maps to the URL `/rss.xml`. Because the extension is `.xml`, the static server serves it with the correct content type automatically. The template outputs raw XML and must **not** use a layout.
408
+ Renders `<div data-sleekcms-booking="consultation"></div>` (a literal div with that attribute works too); the widget script is injected automatically at publish. A calendar named `consultation` is **auto-created on sync** with default settings: 30-minute slots, one booking at a time, Mon–Fri 9 AM–5 PM in America/New_York, email confirmation on. The defaults are guesses tell the user to review the timezone and open hours under **Customers Bookings › Calendars**. Reusing the same name across pages shares one calendar.
409
+
410
+ ## 12. RSS Feeds
411
+
412
+ Always create an RSS feed for blogs. Use the page key `rss.xml` — because the key has an extension, it is served as XML with **no layout**, automatically.
583
413
 
584
414
  **`models/pages/rss.xml.model`**
585
415
  ```
@@ -591,10 +421,7 @@ Create an RSS feed by adding a page with the key `rss.xml` — this maps to the
591
421
 
592
422
  **`content/pages/rss.xml.json`**
593
423
  ```json
594
- {
595
- "title": "My Blog",
596
- "description": "Latest posts from My Blog"
597
- }
424
+ { "title": "My Blog", "description": "Latest posts from My Blog" }
598
425
  ```
599
426
 
600
427
  **`pages/rss.xml.ejs`**
@@ -609,7 +436,7 @@ Create an RSS feed by adding a page with the key `rss.xml` — this maps to the
609
436
  <item>
610
437
  <title><%= post.title %></title>
611
438
  <link><%= url(post) %></link>
612
- <description><%= post.description %></description>
439
+ <description><%= post.summary %></description>
613
440
  <pubDate><%= new Date(post._meta.updated_at).toUTCString() %></pubDate>
614
441
  <guid><%= url(post) %></guid>
615
442
  </item>
@@ -618,118 +445,18 @@ Create an RSS feed by adding a page with the key `rss.xml` — this maps to the
618
445
  </rss>
619
446
  ```
620
447
 
621
- Notes:
622
- - The key `rss.xml` follows the standard naming convention — the dot is part of the key as-is.
623
- - `getPages('/blog', { collection: true })` fetches all blog collection pages; adjust the path to match your collection key.
624
- - `url(post)` resolves the page object to a full absolute URL (e.g. `https://example.com/blog/my-post`) — no need to store the site URL in content.
625
- - `post._meta.updated_at` is an ISO 8601 timestamp; `.toUTCString()` converts it to RFC 822 format required by RSS.
626
- - Use a dedicated `description` or `summary` field in your blog model for feed excerpts; fall back to any short-text field if one doesn't exist.
627
- - To autodiscover the feed, add `<% link({ rel: 'alternate', type: 'application/rss+xml', title: 'RSS', href: '/rss.xml' }) %>` in your layout or page templates.
628
-
629
- ---
630
-
631
- ## Examples
448
+ Make it discoverable from the layout: `<% link({ rel: 'alternate', type: 'application/rss+xml', title: 'RSS', href: '/rss.xml' }) %>`
632
449
 
633
- ### Layout
634
-
635
- ```ejs
636
- <!DOCTYPE html>
637
- <html lang="en">
638
- <head>
639
- <meta charset="UTF-8">
640
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
641
- </head>
642
- <body>
643
- <% const header = getEntry('header'); %>
644
- <header>
645
- <%- render(header) %>
646
- </header>
647
-
648
- <main><%- main %></main>
649
-
650
- <% const footer = getEntry('footer'); %>
651
- <footer>
652
- <%- render(footer) %>
653
- </footer>
654
- </body>
655
- </html>
656
- ```
657
-
658
- ### Page template
659
-
660
- ```ejs
661
- <% title(item.title + ' | My Site') %>
662
- <% link('/css/styles.css') %>
663
- <% script('/js/app.js') %>
664
-
665
- <h1><%= item.title %></h1>
666
- <%- img(item.image, '1200x600') %>
667
- <div><%- item.content %></div>
668
- ```
669
-
670
- ### Block template
671
-
672
- ```ejs
673
- <section class="hero" style="background-image: url('<%- src(item.background, '1920x800') %>')">
674
- <h2><%= item.heading %></h2>
675
- <p><%= item.subheading %></p>
676
- <a href="<%= item.cta_link %>" class="btn"><%= item.cta_label %></a>
677
- </section>
678
- ```
679
-
680
- ### List collection pages
681
-
682
- ```ejs
683
- <% for (const post of getPages('/blog', { collection: true })) { %>
684
- <a href="<%- path(post) %>">
685
- <%- img(post.image, '400x250') %>
686
- <h3><%= post.title %></h3>
687
- </a>
688
- <% } %>
689
- ```
690
-
691
- ### Render blocks and entry references
692
-
693
- Given a model:
694
- ```
695
- {
696
- hero: block(hero),
697
- ctas: [block(cta)],
698
- team: [entry(people+)]
699
- }
700
- ```
701
-
702
- Template:
703
- ```ejs
704
- <%- render(item.hero) %>
705
- <%- render(item.ctas) %>
706
-
707
- <% for (const person of item.team) { %>
708
- <%- render(person) %>
709
- <% } %>
710
- ```
711
-
712
- ---
450
+ ## 13. Critical Rules Checklist
713
451
 
714
- ## Rules for AI
715
-
716
- 1. Include CSS/JS files via **`link()`** and **`script()`** never raw `<link>` or `<script>` tags in templates.
717
- 2. Exception: `/css/tailwind.css` is auto-injected do **not** add it via `link()`.
718
- 3. `richtext` returns **HTML** — use `<%- %>` (unescaped) to output it. `markdown` returns **raw markdown** — convert with `marked()` first: `<%- marked(item.content) %>`.
719
- 4. Use modern design with tailwind unless design details are specified.
720
- 5. If sync, build, render, image resolution, or deploy behavior fails, check `sync-errors.log` in the workspace root before guessing.
721
- 6. To change what appears on a page or in shared data, edit the matching JSON under `/content/` do **not** hard-code content into `.ejs` templates. Templates define structure; content files hold the values.
722
- 7. Fields in a content JSON file must match the keys defined in the corresponding `.model`. Adding a new field requires updating the `.model` first.
723
- 8. Collection page items each live in their own file under `content/pages/<key>/<slug>.json` the collection key already includes `+` (e.g., `content/pages/blog+/my-post.json`). The `<slug>` filename is the URL segment; renaming the file renames the URL.
724
- 9. **Collection key suffix `+` is mandatory and must appear on every related file.** For a collection model (pages or entries — e.g., `blog`, `testimonials`, `authors`), the key `<name>+` is part of the filename on the model, template, **and** content JSON: `models/entries/testimonials+.model`, `entries/testimonials+.ejs`, `content/entries/testimonials+.json` (array). Same rule for collection pages: `models/pages/blog+.model`, `pages/blog+.ejs`, and one file per slug under `content/pages/blog+/<slug>.json`. Never drop the `+` — files without it are treated as singles and will not resolve.
725
- 10. Blocks are reusable component schemas/templates only. Never create `content/blocks/...`; store block values inline in the page or entry JSON that declares `block(key)` or `[block(key)]`.
726
- 11. Use blocks only when a group of fields also benefits from a reusable template. For one-off page-only structure, prefer groups (`{ ... }`) or repeating groups (`[{ ... }]`).
727
- 12. Use entries, not blocks, for shared reusable content such as testimonials, authors, people, categories, tags, navigation, or footer data.
728
- 13. Block models cannot contain `block(...)` fields. Use groups, collections, or `entry(key)` references instead.
729
- 14. For `image` fields in content JSON, prefer the shortcut form `"<source>:<search>"` (sources: `unsplash`, `pexels`, `pixabay`, `iconify`) — e.g., `"pexels:doctor"`. Add alt text by appending `|<alt>` to the shortcut: `"pexels:doctor|Smiling doctor with stethoscope"`. The sync engine resolves it to a full `{ url, alt }` object on save. When the same image is reused across pages (logos, shared icons, recurring art), declare it once in `/images.json` and reference it via `"cms:<handle>"`.
730
- 15. Inside `markdown` fields, embed images with `![alt](<source>:<search>)` — same sources as image fields. Append `|<alt>` to store alt on the image record (e.g. `![doctor](pexels:doctor|Friendly family doctor)`). Including a `<W>x<H>` token in the description (e.g. `|hero shot 1200x600`) sets the rendered URL's `w` and `h` query params; otherwise the default is `600x400`. On save, refs are rewritten to actual CDN URLs and the underlying image record is created automatically.
731
- 16. **Collection pages whose model qualifies as "markdown content" — page model, collection (key ends with `+`), exactly one top-level `markdown` field, no `richtext` or `stack` anywhere — are stored as `.md` files with JSON frontmatter at `content/pages/<key+>/<slug>.md` (not `.json`).** All non-markdown fields go into the JSON frontmatter object between the `---` markers; the single `markdown` field's value is the body. When creating or editing such a record, prefer `.md`; the server also accepts `.json` for the same model. Inside the `.md` file, frontmatter may be written as JSON (canonical), JSON5 (trailing commas, `//` or `/* */` comments, unquoted keys), or YAML — all three are parsed correctly on save. Pulls always re-emit JSON as the canonical form.
732
- 17. Always create RSS feed for blogs and link them in meta so it is discoverable. Use "rss.xml" as the key.
733
- 18. Make the sites extremely SEO friendly and sharing friendly.
734
- 19. When naming files for models, use - (dash) as word separator. Don't use _ (underscore) as it is mapped to / (slash) in path
735
- 20. Not all content details need to be modeled. If there is content which is not meant to be updated, such as button labels, links etc. you can inline that in the EJS view instead of adding fields to model and using from there. Decide what is relevant.
452
+ 1. **`+` is part of the key.** A collection's `+` appears on the model, the view, and the content file/folder: `models/pages/blog+.model`, `pages/blog+.ejs`, `content/pages/blog+/<slug>.json`. A file without the `+` is a *different*, single-type key.
453
+ 2. Models first: a content field that isn't in the `.model` won't sync. Update the model, then the content.
454
+ 3. Blocks never have content files. Block values live inline in the page/entry JSON that declares them.
455
+ 4. Never write `video` or `file` values, and never edit `content/videos.json` these are user-driven uploads.
456
+ 5. `richtext` `<%- item.x %>`. `markdown` `<%- marked(item.x) %>`.
457
+ 6. CSS/JS via `link()`/`script()` only; `css/tailwind.css` is auto-injected never `link()` it.
458
+ 7. Editable copy belongs in `content/`, not hard-coded in `.ejs`. Fixed micro-labels may stay in views.
459
+ 8. In page keys, `-` separates words and `_` means `/`. `_index` is the home page only.
460
+ 9. After edits, check `sync-errors.log` in the workspace root. New line = failure for that file; line removed = fixed.
461
+ 10. Image values: prefer shortcuts (`"pexels:doctor|alt text"`); use `"cms:<handle>"` + `images.json` for images reused across pages.
462
+ 11. Image shortcuts (`"iconify:..."`, `"pexels:..."`, etc.) resolve **only** in `image`-typed fields. Icons are images: declare `icon: image`, never `icon: text`.