@sleekcms/sync 1.7.0 → 2.1.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 +262 -535
- package/dist/index.js +9 -181
- package/package.json +11 -6
- package/dist/cli.d.ts +0 -21
- package/dist/cli.js +0 -173
- package/dist/index.d.ts +0 -9
- package/dist/setup-site.d.ts +0 -30
- package/dist/setup-site.js +0 -299
- package/dist/sync-site.d.ts +0 -14
- package/dist/sync-site.js +0 -49
- package/dist/watcher.d.ts +0 -18
- package/dist/watcher.js +0 -122
package/dist/AGENT.md
CHANGED
|
@@ -1,107 +1,123 @@
|
|
|
1
|
-
# SleekCMS
|
|
1
|
+
# SleekCMS Site Builder — Agent Guide
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
13
|
+
A page's HTML = **layout** wrapping ( **page view** rendering **page content** validated by **page model** ).
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
Three model kinds:
|
|
10
16
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
Never guess at why something isn't rendering — read `sync-errors.log` first.
|
|
20
34
|
|
|
21
|
-
|
|
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` | `/`
|
|
46
|
+
| `_index` | `/` |
|
|
24
47
|
| `about` | `/about` |
|
|
25
|
-
| `blog
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
/pages/<key>.
|
|
45
|
-
/
|
|
46
|
-
/
|
|
47
|
-
/
|
|
48
|
-
|
|
49
|
-
/
|
|
50
|
-
/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
91
|
+
### Create a collection (e.g. a blog at `/blog/<slug>`)
|
|
71
92
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
99
|
+
### Change what a page says
|
|
79
100
|
|
|
80
|
-
|
|
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
|
-
|
|
103
|
+
### Add a field to a page
|
|
83
104
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
```
|
|
150
|
-
{
|
|
151
|
-
title: text,
|
|
152
|
-
hero: block(hero)
|
|
153
|
-
}
|
|
154
|
-
```
|
|
162
|
+
### Entry references in content
|
|
155
163
|
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
### Choosing the right structure
|
|
190
173
|
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
### Blocks in detail
|
|
196
182
|
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
+
### Stacks in detail
|
|
207
191
|
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
+
## 7. Content Files
|
|
241
206
|
|
|
242
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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: ``. Append `\|<alt>` to set alt text, and include a `<width>x<height>` token to set URL `w`/`h` params (defaults to `600x400`), e.g. ``.
|
|
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
|
-
|
|
217
|
+
### Images — shortcut strings
|
|
295
218
|
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
226
|
+
On save, the sync engine resolves each shortcut to a real `{ "url", "alt" }` object.
|
|
307
227
|
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
+
then reference with `"cms:logo"` in any image field, or fetch in templates via `getImage('logo')`.
|
|
332
237
|
|
|
333
|
-
|
|
238
|
+
**Images inside markdown:** use the same shortcuts in standard syntax: ``. Append `|<alt>` for alt text and an optional `<W>x<H>` token to set rendered size (default `600x400`): ``. Refs are rewritten to CDN URLs on save.
|
|
334
239
|
|
|
335
|
-
|
|
240
|
+
### Videos — read-only
|
|
336
241
|
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
+
A collection-page record is stored as Markdown with frontmatter instead of JSON when its model meets **all** of:
|
|
345
247
|
|
|
346
|
-
|
|
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
|
-
|
|
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 (``) 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
|
-
|
|
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
|
-
|
|
267
|
+
## 8. Views (EJS)
|
|
397
268
|
|
|
398
|
-
|
|
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
|
|
436
|
-
| `<%- expr %>` | Output raw HTML (
|
|
437
|
-
| `<% code %>` |
|
|
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
|
-
###
|
|
277
|
+
### Context variables (every view)
|
|
440
278
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
|
444
|
-
|
|
445
|
-
| `
|
|
446
|
-
| `
|
|
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
|
-
|
|
286
|
+
Page records also expose `item._path`, `item._slug` (collections), and `item._meta.updated_at` (ISO timestamp).
|
|
451
287
|
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
307
|
+
### Helpers — content access
|
|
459
308
|
|
|
460
|
-
| Function | Returns |
|
|
309
|
+
| Function | Returns | Notes |
|
|
461
310
|
|---|---|---|
|
|
462
|
-
| `getPage(path)` | Object \| undefined | Page by exact path |
|
|
463
|
-
| `getPages(path, opts?)` | Array | Pages
|
|
464
|
-
| `getEntry(handle)` | Object \| Array | Entry by
|
|
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(
|
|
467
|
-
| `getVideo(
|
|
468
|
-
| `
|
|
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
|
|
319
|
+
| `url(pathOrPage?)` | String | Site origin; pass a path or page object for a full absolute URL |
|
|
472
320
|
|
|
473
|
-
###
|
|
321
|
+
### Helpers — rendering
|
|
474
322
|
|
|
475
|
-
| Function |
|
|
476
|
-
|
|
477
|
-
| `render(val, separator?)` |
|
|
478
|
-
| `marked(md)` |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
+
### Helpers — video
|
|
507
340
|
|
|
508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
+
`bookings(name, options?)` renders the appointment-booking widget mount div (see §11). `options` is `{ accent, class, style }`.
|
|
522
346
|
|
|
523
|
-
###
|
|
347
|
+
### Helpers — head injection
|
|
524
348
|
|
|
525
|
-
|
|
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
|
|
530
|
-
| `meta(attrs)` | Add `<meta>`
|
|
531
|
-
| `link(value, order?)` | Add `<link>`
|
|
532
|
-
| `style(css, order?)` | Add `<style>`
|
|
533
|
-
| `script(value, order?)` | Add `<script>`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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 `` — same sources as image fields. Append `|<alt>` to store alt on the image record (e.g. ``). 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`.
|