@sleekcms/sync 1.2.3 → 1.4.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/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  **Build complete websites with AI. Edit them locally. Deploy instantly.**
4
4
 
5
- The SleekCMS CLI brings your CMS to your code editor — with full AI-agent support baked in. Spin up an entirely new site by describing it in plain English, or drop into any existing site and edit templates, content, and styles the way you'd expect: locally, with real files, in your editor of choice.
5
+ SleekCMS Sync brings your CMS to your code editor — with full AI-agent support baked in. Spin up an entirely new site by describing it in plain English, or drop into any existing site and edit templates, content, and styles the way you'd expect: locally, with real files, in your editor of choice.
6
6
 
7
7
  Every save auto-syncs. Every change goes live. No build steps. No Git hooks. No infrastructure to manage.
8
8
 
9
9
  ---
10
10
 
11
- ## Why developers love it
11
+ ## How does it work
12
12
 
13
13
  - **AI generates your entire site from a description** — models, templates, layouts, styles, and content, all wired up and ready to go.
14
14
  - **AI-generated sites are plain files** — EJS templates, JSON content, CSS, JS. You own every line. Open in VS Code, Cursor, or any editor.
@@ -24,7 +24,7 @@ Every save auto-syncs. Every change goes live. No build steps. No Git hooks. No
24
24
  npx @sleekcms/sync --token <YOUR_AUTH_TOKEN>
25
25
  ```
26
26
 
27
- That's it. The CLI fetches your site, opens an editor prompt, and starts watching for changes. Grab a token from your [SleekCMS dashboard](https://app.sleekcms.com).
27
+ That's it. The CLI fetches your site, opens an editor prompt, and starts watching for changes. Grab a token from your [SleekCMS > Builder](https://app.sleekcms.com).
28
28
 
29
29
  ---
30
30
 
@@ -47,24 +47,10 @@ sleekcms --token <YOUR_AUTH_TOKEN>
47
47
 
48
48
  ## Usage
49
49
 
50
- ```bash
51
- npx @sleekcms/sync [OPTIONS]
52
- ```
53
-
54
- | Option | Alias | Description | Default |
55
- |--------------------|-------|--------------------------------------------------------|---------------|
56
- | `--token <token>` | `-t` | Your SleekCMS CLI auth token (required) | — |
57
- | `--path <path>` | `-p` | Parent directory for the local workspace | `~/.sleekcms` |
58
- | `--help` | `-h` | Show help | — |
59
-
60
- > The actual workspace folder is created at `<path>/<site-name>-<site-id>/`.
61
-
62
50
  ```bash
63
51
  # Basic
64
52
  npx @sleekcms/sync --token abc123-xxxx
65
53
 
66
- # Custom workspace folder
67
- npx @sleekcms/sync -t abc123-xxxx -p ~/Sites
68
54
  ```
69
55
 
70
56
  Once running, press `r` to re-fetch all files or `x` / `Ctrl+C` to exit.
@@ -272,12 +258,14 @@ Models describe the shape of your content. They're JSON-like files with unquoted
272
258
  | `markdown` | Markdown string |
273
259
  | `number` | Number |
274
260
  | `boolean` | `true` / `false` |
261
+ | `emoji` | Single Unicode emoji character, e.g. `"😀"` |
275
262
  | `date` | `YYYY-MM-DD` |
276
263
  | `image` | `{ url, alt }` |
277
264
  | `video` | `{ url, embed }` |
278
265
  | `link` | URL string |
279
266
  | `json` | Object or array |
280
267
  | `block(key)` | Embedded block object |
268
+ | `stack` | Array of heterogeneous block objects; each item carries `_block: "<block_key>"` to name its block |
281
269
  | `entry(key)` | Entry object or slug reference |
282
270
 
283
271
  ---
package/dist/AGENT.md CHANGED
@@ -52,6 +52,7 @@ Examples:
52
52
 
53
53
  /content/pages/<key>.json Content for a single (non-list) page
54
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)
55
56
  /content/entries/<key>.json Content for a single entry (object)
56
57
  /content/entries/<key+>.json Content for a collection entry (array of objects; <key>+ matches the model filename)
57
58
 
@@ -188,6 +189,62 @@ Block models cannot contain other blocks. Use groups (`{ ... }`) or collections
188
189
 
189
190
  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)]`.
190
191
 
192
+ ### Stacks
193
+
194
+ 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."
195
+
196
+ **Model syntax** — bare `stack`, no parentheses and no `[]`. Stacks are always multiple by definition:
197
+
198
+ ```
199
+ {
200
+ title: text,
201
+ sections: stack
202
+ }
203
+ ```
204
+
205
+ 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.
206
+
207
+ **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):
208
+
209
+ **`content/pages/about.json`**
210
+ ```json
211
+ {
212
+ "title": "About us",
213
+ "sections": [
214
+ {
215
+ "_block": "hero",
216
+ "heading": "Care that feels personal",
217
+ "subheading": "A small team focused on long-term relationships.",
218
+ "background": "pexels:doctor"
219
+ },
220
+ {
221
+ "_block": "cta",
222
+ "label": "Book a visit",
223
+ "link": "/contact"
224
+ }
225
+ ]
226
+ }
227
+ ```
228
+
229
+ The `_block` value is the block's key (e.g., `"hero"`, `"cta"`) — never a numeric id. The CMS resolves it server-side on save.
230
+
231
+ **Rendering** — each item flows through its own `blocks/<key>.ejs` template. Pass the whole array to `render()` and it dispatches per `_block`:
232
+
233
+ **`pages/about.ejs`**
234
+ ```ejs
235
+ <h1><%= item.title %></h1>
236
+ <%- render(item.sections) %>
237
+ ```
238
+
239
+ **Stack vs block vs collection** — same decision tree as blocks, with one extra rung:
240
+
241
+ - One-off shape used on one page → group `{ ... }` or `[{ ... }]`.
242
+ - Reusable shape, always the same per item → `block(key)` or `[block(key)]`.
243
+ - Reusable shape, but items can be different blocks chosen by the editor → `stack`.
244
+ - Shared content referenced from many pages → `entry(key)` / `[entry(key)]`.
245
+
246
+ 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.
247
+
191
248
  **Entry reference** — Use `entry(key)` for one, `[entry(key)]` for many:
192
249
  ```
193
250
  {
@@ -206,16 +263,19 @@ Model fields declare both the editor type and the shape expected in content JSON
206
263
  | `text`, `paragraph`, `richtext`, `markdown`, `code`, `color`, `link` | String |
207
264
  | `number` | Number |
208
265
  | `boolean` | `true` / `false` |
266
+ | `emoji` | Single Unicode emoji character string, e.g. `"😀"` or `"👋🏽"` (one emoji per record; not free text) |
209
267
  | `date` | `"YYYY-MM-DD"` string |
210
268
  | `datetime` | ISO 8601 string |
211
269
  | `time` | `"HH:mm"` string |
212
270
  | `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. |
213
- | `video` | `{ "url": "...", "embed": "..." }` |
271
+ | `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. |
272
+ | `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>`. |
214
273
  | `json` | Object or array |
215
274
  | `sheet` | Array of arrays |
216
275
  | `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 |
217
276
  | `block(key)` | Object matching that block's model; stored inline in the parent page/entry content and rendered with `blocks/<key>.ejs` |
218
277
  | `[block(key)]` | Array of block objects; each item matches the block model and renders with `blocks/<key>.ejs` |
278
+ | `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. |
219
279
  | `entry(key)` / `[entry(key)]` | Slug string / array of slug strings in content JSON; entry object / array of entry objects in templates |
220
280
  | Group `{ ... }` | Nested object |
221
281
  | Collection `[{ ... }]` | Array of nested objects |
@@ -232,10 +292,11 @@ Content files are JSON records under `/content/` that hold the actual values for
232
292
 
233
293
  ### File layout
234
294
 
235
- | Model shape | File path | JSON top-level |
295
+ | Model shape | File path | Top-level shape |
236
296
  |---|---|---|
237
297
  | Single page (e.g., `about`) | `content/pages/about.json` | Object |
238
298
  | 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 |
299
+ | Collection page with one markdown body (see below) | `content/pages/blog+/<slug>.md` | YAML frontmatter + markdown body |
239
300
  | Single entry (e.g., `header`) | `content/entries/header.json` | Object |
240
301
  | Collection entry (e.g., `authors`) | `content/entries/authors+.json` (the `+` is part of the key — same as the model filename) | Array of objects |
241
302
 
@@ -266,6 +327,69 @@ The content file at `content/pages/about.json`:
266
327
 
267
328
  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.
268
329
 
330
+ ### Markdown content files
331
+
332
+ 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.
333
+
334
+ A model qualifies when **all** of the following hold:
335
+
336
+ 1. It is a **page** model (lives in `models/pages/`).
337
+ 2. It is a **collection** (key ends with `+`, so each record is its own file).
338
+ 3. It has **exactly one** top-level field of type `markdown`.
339
+ 4. It has **no** `richtext` or `stack` fields anywhere (top-level or nested).
340
+
341
+ 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.
342
+
343
+ Example — given the model `models/pages/blog+.model`:
344
+
345
+ ```
346
+ {
347
+ title: text,
348
+ date: date,
349
+ summary: paragraph,
350
+ body: markdown
351
+ }
352
+ ```
353
+
354
+ The record `content/pages/blog+/welcome-post.md` looks like:
355
+
356
+ ```markdown
357
+ ---
358
+ {
359
+ "title": "Welcome to the blog",
360
+ "date": "2026-05-19",
361
+ "summary": "A short post about how the new blog works."
362
+ }
363
+ ---
364
+ # Hello
365
+
366
+ This is the **first** post.
367
+
368
+ - one
369
+ - two
370
+ ```
371
+
372
+ Notes:
373
+ - 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.
374
+ - Image shortcut conventions still apply inside the markdown body (`![alt](pexels:doctor)`) and inside frontmatter image fields (`"pexels:doctor"`).
375
+ - 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.
376
+ - 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.
377
+
378
+ **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.
379
+
380
+ ```markdown
381
+ ---
382
+ title: Welcome to the blog
383
+ date: '2026-05-19'
384
+ summary: A short post.
385
+ ---
386
+ # Hello
387
+
388
+ This is the **first** post.
389
+ ```
390
+
391
+ 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.
392
+
269
393
  ### Reusable images (`/images.json`)
270
394
 
271
395
  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:
@@ -356,6 +480,25 @@ Page records include: `item._path`, `item._slug` (collections), `item._meta.upda
356
480
 
357
481
  `attr` can be `"WxH"` or `{ w, h, size, zoom, maptype, scale, class, style }`.
358
482
 
483
+ ### Video
484
+
485
+ `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.
486
+
487
+ | Function | Returns | Description |
488
+ |---|---|---|
489
+ | `embed(video, attr?)` | HTML string | Provider-aware `<iframe>` for the video |
490
+
491
+ `attr` is `"WxH"` or `{ w, h, size, autoplay, muted, loop, preload, t, class, style }`.
492
+
493
+ ```ejs
494
+ <%- embed(item.intro_video) %>
495
+ <%- embed(item.intro_video, '1280x720') %>
496
+ <%- embed(item.intro_video, { autoplay: true, muted: true, loop: true }) %>
497
+ <%- embed(item.intro_video, { size: '800x450', class: 'video', style: 'border-radius:8px' }) %>
498
+ ```
499
+
500
+ 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.
501
+
359
502
  ### Head Injection
360
503
 
361
504
  Call from **any template** (page, block, entry, or layout). Deduplicated automatically.
@@ -564,5 +707,6 @@ Template:
564
707
  13. Block models cannot contain `block(...)` fields. Use groups, collections, or `entry(key)` references instead.
565
708
  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>"`.
566
709
  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.
567
- 16. Always create RSS feed for blogs and link them in meta so it is discoverable. Use "rss.xml" as the key.
568
- 17. Make the sites extremely SEO friendly and sharing friendly.
710
+ 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.
711
+ 17. Always create RSS feed for blogs and link them in meta so it is discoverable. Use "rss.xml" as the key.
712
+ 18. Make the sites extremely SEO friendly and sharing friendly.
package/dist/watcher.js CHANGED
@@ -19,10 +19,15 @@ const path_1 = __importDefault(require("path"));
19
19
  const chokidar_1 = __importDefault(require("chokidar"));
20
20
  const DEBOUNCE_DELAY = 5000;
21
21
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
22
+ // Poll on a short interval and compare wall-clock time so the timeout still
23
+ // fires correctly after the system has been asleep (Node's setTimeout runs on
24
+ // a monotonic clock that pauses during sleep).
25
+ const IDLE_CHECK_INTERVAL_MS = 60 * 1000;
22
26
  let watcher = null;
23
27
  let isShuttingDown = false;
24
28
  let debounceTimer = null;
25
- let idleTimer = null;
29
+ let idleCheckTimer = null;
30
+ let lastActivityAt = 0;
26
31
  let dirty = false;
27
32
  let syncInFlight = false;
28
33
  let viewsDir = null;
@@ -34,17 +39,23 @@ function init(options) {
34
39
  onIdle = options.onIdle ?? null;
35
40
  }
36
41
  function resetIdleTimer() {
37
- if (idleTimer)
38
- clearTimeout(idleTimer);
42
+ lastActivityAt = Date.now();
39
43
  if (isShuttingDown || !onIdle)
40
44
  return;
41
- idleTimer = setTimeout(() => {
42
- idleTimer = null;
45
+ if (idleCheckTimer)
46
+ return;
47
+ idleCheckTimer = setInterval(() => {
43
48
  if (isShuttingDown)
44
49
  return;
50
+ if (Date.now() - lastActivityAt < IDLE_TIMEOUT_MS)
51
+ return;
52
+ if (idleCheckTimer) {
53
+ clearInterval(idleCheckTimer);
54
+ idleCheckTimer = null;
55
+ }
45
56
  console.log(`\n💤 No changes for ${IDLE_TIMEOUT_MS / 60000} minutes. Terminating.`);
46
57
  onIdle?.();
47
- }, IDLE_TIMEOUT_MS);
58
+ }, IDLE_CHECK_INTERVAL_MS);
48
59
  }
49
60
  function setShuttingDown(value) {
50
61
  isShuttingDown = value;
@@ -100,9 +111,9 @@ function monitorFiles() {
100
111
  resetIdleTimer();
101
112
  }
102
113
  async function stopWatching() {
103
- if (idleTimer) {
104
- clearTimeout(idleTimer);
105
- idleTimer = null;
114
+ if (idleCheckTimer) {
115
+ clearInterval(idleCheckTimer);
116
+ idleCheckTimer = null;
106
117
  }
107
118
  if (watcher) {
108
119
  await watcher.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleekcms/sync",
3
- "version": "1.2.3",
3
+ "version": "1.4.0",
4
4
  "description": "Edit SleekCMS sites locally — models, content, templates, images — with live two-way sync and AI agent support.",
5
5
  "keywords": [
6
6
  "sleekcms",