domma-cms 0.17.0 → 0.18.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.
Files changed (43) hide show
  1. package/CLAUDE.md +2 -0
  2. package/admin/css/dashboard.css +1 -1
  3. package/admin/index.html +2 -2
  4. package/admin/js/app.js +1 -1
  5. package/admin/js/lib/card-builder.js +3 -3
  6. package/admin/js/lib/effects-builder.js +1 -1
  7. package/admin/js/lib/markdown-toolbar.js +5 -5
  8. package/admin/js/templates/dashboard/cache.html +32 -0
  9. package/admin/js/templates/dashboard.html +4 -0
  10. package/admin/js/templates/settings.html +26 -0
  11. package/admin/js/views/block-editor-enhance.js +1 -1
  12. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
  13. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  14. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  15. package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
  16. package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
  17. package/admin/js/views/dashboard.js +1 -1
  18. package/admin/js/views/form-editor.js +6 -6
  19. package/admin/js/views/index.js +1 -1
  20. package/admin/js/views/page-editor.js +42 -37
  21. package/admin/js/views/settings.js +3 -3
  22. package/config/cache.json +4 -0
  23. package/config/cache.json.example +12 -0
  24. package/package.json +1 -1
  25. package/public/js/forms.js +1 -1
  26. package/public/js/site.js +1 -1
  27. package/server/config.js +12 -1
  28. package/server/routes/api/cache.js +57 -0
  29. package/server/routes/api/navigation.js +2 -0
  30. package/server/routes/api/settings.js +3 -0
  31. package/server/routes/public.js +11 -3
  32. package/server/server.js +16 -3
  33. package/server/services/blocks.js +3 -0
  34. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  35. package/server/services/cache/drivers/NoneDriver.js +12 -0
  36. package/server/services/cache/index.js +229 -0
  37. package/server/services/cache/lru.js +61 -0
  38. package/server/services/collections.js +17 -4
  39. package/server/services/content.js +7 -2
  40. package/server/services/forms.js +3 -0
  41. package/server/services/markdown.js +25 -15
  42. package/server/services/views.js +4 -0
  43. package/server/templates/page.html +130 -130
@@ -0,0 +1,229 @@
1
+ import {MemoryDriver} from './drivers/MemoryDriver.js';
2
+ import {NoneDriver} from './drivers/NoneDriver.js';
3
+
4
+ const KEY_VERSION = 'v1:';
5
+
6
+ let _driver = new NoneDriver();
7
+ let _enabled = false;
8
+ let _lastConfig = null;
9
+ /** @type {Map<string, Promise<*>>} */
10
+ const _inflight = new Map();
11
+
12
+ const _stats = {
13
+ hits: 0,
14
+ misses: 0,
15
+ writes: 0,
16
+ invalidations: 0,
17
+ lastClearedAt: null,
18
+ lastInvalidationAt: null,
19
+ initialisedAt: null
20
+ };
21
+
22
+ /**
23
+ * @param {{
24
+ * enabled?: boolean,
25
+ * driver?: 'memory' | 'none' | 'redis',
26
+ * memory?: {maxItems?: number, defaultTtlSeconds?: number},
27
+ * redis?: {url: string, keyPrefix?: string}
28
+ * }} config
29
+ */
30
+ export async function initCache(config = {}) {
31
+ _lastConfig = {...config};
32
+ _stats.initialisedAt = new Date().toISOString();
33
+ if (config.enabled === false) {
34
+ _driver = new NoneDriver();
35
+ _enabled = false;
36
+ return;
37
+ }
38
+ const name = config.driver ?? 'memory';
39
+ if (name === 'memory') {
40
+ _driver = new MemoryDriver(config.memory ?? {});
41
+ _enabled = true;
42
+ return;
43
+ }
44
+ if (name === 'none') {
45
+ _driver = new NoneDriver();
46
+ _enabled = false;
47
+ return;
48
+ }
49
+ if (name === 'redis') {
50
+ const {RedisDriver} = await import('./drivers/RedisDriver.js');
51
+ _driver = new RedisDriver(config.redis ?? {});
52
+ await _driver.connect();
53
+ _enabled = true;
54
+ return;
55
+ }
56
+ throw new Error(`Unknown cache driver: ${name}`);
57
+ }
58
+
59
+ /**
60
+ * Flip the cache on or off at runtime.
61
+ *
62
+ * When turning on, re-initialise with the last config (or sensible defaults)
63
+ * so a previously disabled cache gets a real driver. When turning off, keep
64
+ * the driver in place but short-circuit reads/writes — preserves cached data
65
+ * if the user toggles back on. The persistence of this choice across restarts
66
+ * is the caller's responsibility (the route handler writes to config/cache.json).
67
+ */
68
+ export async function setEnabled(enabled) {
69
+ if (Boolean(enabled) === _enabled) return;
70
+ if (enabled) {
71
+ if (_driver instanceof NoneDriver) {
72
+ // No usable driver yet — bootstrap one from last-known config.
73
+ const cfg = {...(_lastConfig || {}), enabled: true};
74
+ if (!cfg.driver || cfg.driver === 'none') cfg.driver = 'memory';
75
+ await initCache(cfg);
76
+ } else {
77
+ // Memory/Redis driver still live — flip the flag and preserve data.
78
+ _enabled = true;
79
+ }
80
+ } else {
81
+ _enabled = false;
82
+ _inflight.clear();
83
+ }
84
+ }
85
+
86
+ export function isEnabled() {
87
+ return _enabled;
88
+ }
89
+
90
+ export async function get(key) {
91
+ return _driver.get(_vk(key));
92
+ }
93
+
94
+ export async function set(key, value, opts = {}) {
95
+ return _driver.set(_vk(key), value, opts);
96
+ }
97
+
98
+ export async function invalidateTags(tags) {
99
+ if (!Array.isArray(tags) || tags.length === 0) return;
100
+ _stats.invalidations++;
101
+ _stats.lastInvalidationAt = new Date().toISOString();
102
+ return _driver.invalidateTags(tags);
103
+ }
104
+
105
+ export async function clear() {
106
+ _inflight.clear();
107
+ _stats.lastClearedAt = new Date().toISOString();
108
+ return _driver.clear();
109
+ }
110
+
111
+ /**
112
+ * List the cache's keys with their tags and expiry. Values are deliberately
113
+ * excluded — cached entries can be megabytes of HTML.
114
+ * @returns {{key: string, tags: string[], expiresAt: number|null}[]}
115
+ */
116
+ export function listEntries() {
117
+ if (typeof _driver.listEntries !== 'function') return [];
118
+ return _driver.listEntries().map(e => ({
119
+ ...e,
120
+ // Strip the internal v1: version prefix for display.
121
+ key: e.key.startsWith(KEY_VERSION) ? e.key.slice(KEY_VERSION.length) : e.key
122
+ }));
123
+ }
124
+
125
+ /**
126
+ * Snapshot of cache health for the dashboard widget.
127
+ * Counts are process-lifetime; restart resets them.
128
+ */
129
+ export function getStats() {
130
+ const total = _stats.hits + _stats.misses;
131
+ return {
132
+ enabled: _enabled,
133
+ driver: _enabled ? (_lastConfig?.driver || 'memory') : 'disabled',
134
+ size: typeof _driver.size === 'function' ? _driver.size() : null,
135
+ maxItems: _lastConfig?.memory?.maxItems ?? null,
136
+ hits: _stats.hits,
137
+ misses: _stats.misses,
138
+ writes: _stats.writes,
139
+ invalidations: _stats.invalidations,
140
+ hitRate: total > 0 ? _stats.hits / total : null,
141
+ lastClearedAt: _stats.lastClearedAt,
142
+ lastInvalidationAt: _stats.lastInvalidationAt,
143
+ initialisedAt: _stats.initialisedAt
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Get-or-set with in-flight coalescing.
149
+ *
150
+ * Producer may return either a raw value, or an object with a `_cacheTags`
151
+ * array — those tags are merged into opts.tags before storing, and the
152
+ * `_cacheTags` field is stripped from both the cached and returned value.
153
+ * This lets producers report tags discovered during rendering (e.g. the
154
+ * markdown pipeline noting embedded collection slugs) without the caller
155
+ * needing to know them up front.
156
+ *
157
+ * @param {string} key
158
+ * @param {() => Promise<*>} producer
159
+ * @param {{ttlSeconds?: number, tags?: string[]}} [opts]
160
+ */
161
+ export async function wrap(key, producer, opts = {}) {
162
+ if (!_enabled) {
163
+ const raw = await producer();
164
+ return _stripCacheTags(raw);
165
+ }
166
+
167
+ const vkey = _vk(key);
168
+ const hit = await _driver.get(vkey);
169
+ if (hit !== undefined) {
170
+ _stats.hits++;
171
+ return hit;
172
+ }
173
+
174
+ if (_inflight.has(vkey)) return _inflight.get(vkey);
175
+
176
+ _stats.misses++;
177
+ const promise = (async () => {
178
+ try {
179
+ const raw = await producer();
180
+ const {value, extraTags} = _extractCacheTags(raw);
181
+ const setOpts = {...opts};
182
+ if (extraTags) {
183
+ setOpts.tags = [...(opts.tags ?? []), ...extraTags];
184
+ }
185
+ await _driver.set(vkey, value, setOpts);
186
+ _stats.writes++;
187
+ return value;
188
+ } finally {
189
+ _inflight.delete(vkey);
190
+ }
191
+ })();
192
+
193
+ _inflight.set(vkey, promise);
194
+ return promise;
195
+ }
196
+
197
+ function _vk(key) {
198
+ return KEY_VERSION + key;
199
+ }
200
+
201
+ function _extractCacheTags(raw) {
202
+ if (raw && typeof raw === 'object' && Array.isArray(raw._cacheTags)) {
203
+ const {_cacheTags, ...value} = raw;
204
+ return {value, extraTags: _cacheTags};
205
+ }
206
+ return {value: raw, extraTags: null};
207
+ }
208
+
209
+ function _stripCacheTags(raw) {
210
+ if (raw && typeof raw === 'object' && Array.isArray(raw._cacheTags)) {
211
+ const {_cacheTags, ...value} = raw;
212
+ return value;
213
+ }
214
+ return raw;
215
+ }
216
+
217
+ export function _resetForTests() {
218
+ _driver = new NoneDriver();
219
+ _enabled = false;
220
+ _lastConfig = null;
221
+ _inflight.clear();
222
+ _stats.hits = 0;
223
+ _stats.misses = 0;
224
+ _stats.writes = 0;
225
+ _stats.invalidations = 0;
226
+ _stats.lastClearedAt = null;
227
+ _stats.lastInvalidationAt = null;
228
+ _stats.initialisedAt = null;
229
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Bounded LRU built on the insertion-order property of Map.
3
+ * On hit, delete + re-set the entry so it becomes the most-recent.
4
+ * On set when at capacity, delete the first (oldest) key.
5
+ */
6
+ export class LRU {
7
+ /**
8
+ * @param {number} maxItems
9
+ */
10
+ constructor(maxItems) {
11
+ if (!Number.isInteger(maxItems) || maxItems < 1) {
12
+ throw new TypeError('LRU: maxItems must be a positive integer');
13
+ }
14
+ this._max = maxItems;
15
+ this._map = new Map();
16
+ }
17
+
18
+ get size() {
19
+ return this._map.size;
20
+ }
21
+
22
+ get(key) {
23
+ if (!this._map.has(key)) return undefined;
24
+ const value = this._map.get(key);
25
+ this._map.delete(key);
26
+ this._map.set(key, value);
27
+ return value;
28
+ }
29
+
30
+ /** Read without promoting. Use for snapshots / iteration. */
31
+ peek(key) {
32
+ return this._map.get(key);
33
+ }
34
+
35
+ /** Iterate `[key, value]` pairs in oldest-to-newest order, no mutation. */
36
+ entries() {
37
+ return this._map.entries();
38
+ }
39
+
40
+ set(key, value) {
41
+ if (this._map.has(key)) {
42
+ this._map.delete(key);
43
+ } else if (this._map.size >= this._max) {
44
+ const oldest = this._map.keys().next().value;
45
+ this._map.delete(oldest);
46
+ }
47
+ this._map.set(key, value);
48
+ }
49
+
50
+ delete(key) {
51
+ return this._map.delete(key);
52
+ }
53
+
54
+ clear() {
55
+ this._map.clear();
56
+ }
57
+
58
+ keys() {
59
+ return this._map.keys();
60
+ }
61
+ }
@@ -11,6 +11,7 @@ import path from 'path';
11
11
  import {v4 as uuidv4} from 'uuid';
12
12
  import {config} from '../config.js';
13
13
  import {getAdapter, invalidate} from './adapterRegistry.js';
14
+ import * as cache from './cache/index.js';
14
15
 
15
16
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
16
17
 
@@ -149,6 +150,7 @@ export async function createCollection({title, slug, description = '', fields =
149
150
 
150
151
  await writeSchema(schema);
151
152
  await (await getAdapter(finalSlug)).insertMany(finalSlug, []);
153
+ await cache.invalidateTags([`collection:${finalSlug}`]);
152
154
  return schema;
153
155
  }
154
156
 
@@ -191,6 +193,7 @@ export async function updateCollection(slug, updates) {
191
193
 
192
194
  // Invalidate cached adapter in case storage config changed.
193
195
  invalidate(slug);
196
+ await cache.invalidateTags([`collection:${slug}`]);
194
197
 
195
198
  return updated;
196
199
  }
@@ -218,6 +221,7 @@ export async function deleteCollection(slug) {
218
221
 
219
222
  invalidate(slug);
220
223
  await fs.rm(collectionDir(slug), { recursive: true, force: true });
224
+ await cache.invalidateTags([`collection:${slug}`]);
221
225
  }
222
226
 
223
227
  // ---------------------------------------------------------------------------
@@ -304,7 +308,9 @@ export async function createEntry(slug, data, { createdBy = null, source = 'admi
304
308
  };
305
309
 
306
310
  const adapter = await getAdapter(slug);
307
- return adapter.insert(slug, entry);
311
+ const created = await adapter.insert(slug, entry);
312
+ await cache.invalidateTags([`collection:${slug}`]);
313
+ return created;
308
314
  }
309
315
 
310
316
  /**
@@ -333,7 +339,9 @@ export async function updateEntry(slug, entryId, data) {
333
339
  meta: { ...existing.meta, updatedAt: new Date().toISOString() }
334
340
  };
335
341
 
336
- return adapter.update(slug, entryId, updated);
342
+ const result = await adapter.update(slug, entryId, updated);
343
+ await cache.invalidateTags([`collection:${slug}`]);
344
+ return result;
337
345
  }
338
346
 
339
347
  /**
@@ -346,7 +354,9 @@ export async function updateEntry(slug, entryId, data) {
346
354
  */
347
355
  export async function deleteEntry(slug, entryId) {
348
356
  const adapter = await getAdapter(slug);
349
- return adapter.remove(slug, entryId);
357
+ const result = await adapter.remove(slug, entryId);
358
+ await cache.invalidateTags([`collection:${slug}`]);
359
+ return result;
350
360
  }
351
361
 
352
362
  /**
@@ -359,7 +369,9 @@ export async function clearEntries(slug) {
359
369
  const schema = await getCollection(slug);
360
370
  if (!schema) throw new Error(`Collection "${slug}" not found`);
361
371
  const adapter = await getAdapter(slug);
362
- return adapter.clear(slug);
372
+ const result = await adapter.clear(slug);
373
+ await cache.invalidateTags([`collection:${slug}`]);
374
+ return result;
363
375
  }
364
376
 
365
377
  // ---------------------------------------------------------------------------
@@ -433,6 +445,7 @@ export async function importEntries(slug, incoming, { createdBy = null } = {}) {
433
445
  const adapter = await getAdapter(slug);
434
446
  if (valid.length > 0) {
435
447
  await adapter.insertMany(slug, valid);
448
+ await cache.invalidateTags([`collection:${slug}`]);
436
449
  }
437
450
 
438
451
  return { imported: valid.length, skipped, errors };
@@ -9,6 +9,7 @@ import {parseMarkdown, serialiseMarkdown} from './markdown.js';
9
9
  import {config} from '../config.js';
10
10
  import {hooks} from './hooks.js';
11
11
  import {createVersion, deleteAllVersions, renameVersionDir} from './versions.js';
12
+ import * as cache from './cache/index.js';
12
13
 
13
14
  const CONTENT_DIR = config.content.contentDir;
14
15
  const PAGES_DIR = path.join(CONTENT_DIR, 'pages');
@@ -77,6 +78,7 @@ export async function createPage(urlPath, frontmatter, body) {
77
78
 
78
79
  await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
79
80
  const page = await readPageFile(filePath);
81
+ await cache.invalidateTags([`page:${urlPath}`]);
80
82
  hooks.emit('content:pageCreated', {page, urlPath});
81
83
  return page;
82
84
  }
@@ -111,6 +113,7 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
111
113
 
112
114
  await fs.writeFile(filePath, serialiseMarkdown(meta, body ?? existingContent), 'utf8');
113
115
  const page = await readPageFile(filePath);
116
+ await cache.invalidateTags([`page:${urlPath}`]);
114
117
  hooks.emit('content:pageUpdated', {page, urlPath});
115
118
  return page;
116
119
  }
@@ -128,6 +131,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
128
131
  const newFile = urlPathToFilePath(newUrlPath);
129
132
  await fs.mkdir(path.dirname(newFile), { recursive: true });
130
133
  await fs.rename(oldFile, newFile);
134
+ await cache.invalidateTags([`page:${oldUrlPath}`, `page:${newUrlPath}`]);
131
135
  try {
132
136
  await renameVersionDir(oldUrlPath, newUrlPath);
133
137
  } catch {
@@ -144,6 +148,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
144
148
  export async function deletePage(urlPath) {
145
149
  const filePath = await resolveExistingFilePath(urlPath);
146
150
  await fs.unlink(filePath);
151
+ await cache.invalidateTags([`page:${urlPath}`]);
147
152
  hooks.emit('content:pageDeleted', {urlPath});
148
153
  try {
149
154
  await deleteAllVersions(urlPath);
@@ -288,9 +293,9 @@ async function collectMdFiles(dir) {
288
293
  */
289
294
  async function readPageFile(filePath) {
290
295
  const raw = await fs.readFile(filePath, 'utf8');
291
- const { data, content, html, usedComponents } = await parseMarkdown(raw);
296
+ const { data, content, html, usedComponents, tags } = await parseMarkdown(raw);
292
297
  const urlPath = filePathToUrlPath(filePath);
293
- return { ...data, urlPath, content, html, usedComponents };
298
+ return { ...data, urlPath, content, html, usedComponents, tags };
294
299
  }
295
300
 
296
301
  /**
@@ -6,6 +6,7 @@
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
8
  import {fileURLToPath} from 'url';
9
+ import * as cache from './cache/index.js';
9
10
 
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -43,6 +44,7 @@ export async function writeForm(slug, data) {
43
44
  await ensureFormsDir();
44
45
  const file = path.join(FORMS_DIR, `${slug}.json`);
45
46
  await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
47
+ await cache.invalidateTags([`form:${slug}`]);
46
48
  }
47
49
 
48
50
  /**
@@ -78,6 +80,7 @@ export async function listForms() {
78
80
  */
79
81
  export async function deleteForm(slug) {
80
82
  await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
83
+ await cache.invalidateTags([`form:${slug}`]);
81
84
  }
82
85
 
83
86
  /**
@@ -247,7 +247,7 @@ async function getBlockShortTagNames() {
247
247
  * @param {string} content
248
248
  * @returns {Promise<string>}
249
249
  */
250
- async function processStaticBlocks(content) {
250
+ async function processStaticBlocks(content, tagSet) {
251
251
  // --- [block template="name" .../] ---
252
252
  const pattern = /\[block\s+([\s\S]+?)\/\]/g;
253
253
  const matches = [...content.matchAll(pattern)];
@@ -262,6 +262,7 @@ async function processStaticBlocks(content) {
262
262
  output = output.slice(0, match.index) + output.slice(match.index + match[0].length);
263
263
  continue;
264
264
  }
265
+ tagSet?.add(`block:${attrs.template}`);
265
266
  let replacement = '';
266
267
  try {
267
268
  const [tpl, css] = await Promise.all([
@@ -287,6 +288,7 @@ async function processStaticBlocks(content) {
287
288
  for (const [, key, dq, sq] of (match[1] ?? '').matchAll(/([\w-]+)=(?:"([^"]*)"|'([^']*)')/g)) {
288
289
  attrs[key] = dq ?? sq ?? '';
289
290
  }
291
+ tagSet?.add(`block:${tagName}`);
290
292
  let replacement = '';
291
293
  try {
292
294
  const [tpl, css] = await Promise.all([
@@ -458,7 +460,7 @@ function renderCollectionTimeline(entries, opts) {
458
460
  * @param {string} markdown
459
461
  * @returns {Promise<string>}
460
462
  */
461
- async function processViewBlocks(markdown) {
463
+ async function processViewBlocks(markdown, tagSet) {
462
464
  const {scrubbed, restore} = scrubCodeRegions(markdown);
463
465
  const pattern = /\[view([^\]]*?)\/\]/gi;
464
466
  const matches = [...scrubbed.matchAll(pattern)];
@@ -474,6 +476,7 @@ async function processViewBlocks(markdown) {
474
476
  result = result.replace(fullMatch, '');
475
477
  continue;
476
478
  }
479
+ tagSet?.add(`view:${slug}`);
477
480
 
478
481
  const displayAttr = attrs.display || '';
479
482
  const emptyMsg = attrs.empty || 'No results found';
@@ -579,7 +582,7 @@ async function processViewBlocks(markdown) {
579
582
  * @param {string} markdown
580
583
  * @returns {Promise<string>}
581
584
  */
582
- async function processCollectionBlocks(markdown) {
585
+ async function processCollectionBlocks(markdown, tagSet) {
583
586
  const {scrubbed, restore} = scrubCodeRegions(markdown);
584
587
  // Find all [collection ...] shortcodes
585
588
  const pattern = /\[collection([^\]]*?)\/\]/gi;
@@ -596,6 +599,7 @@ async function processCollectionBlocks(markdown) {
596
599
  result = result.replace(fullMatch, '');
597
600
  continue;
598
601
  }
602
+ tagSet?.add(`collection:${slug}`);
599
603
 
600
604
  const display = attrs.display || 'table';
601
605
  const limitAttr = parseInt(attrs.limit, 10) || 0;
@@ -1947,11 +1951,13 @@ function processAccordionBlocks(markdown) {
1947
1951
  * [/carousel]
1948
1952
  *
1949
1953
  * Supported attributes on [carousel]:
1950
- * autoplay - "true" to auto-advance slides
1951
- * interval - milliseconds between slides (default 5000)
1952
- * loop - "false" to disable loop (default true)
1953
- * animation - "fade" or "slide" (default slide)
1954
- * id - optional id on the wrapper
1954
+ * autoplay - "true" to auto-advance slides
1955
+ * interval - milliseconds between slides (default 5000)
1956
+ * loop - "false" to disable loop (default true)
1957
+ * animation - "slide" | "fade" | "crossfade" (default slide)
1958
+ * animation-duration - transition length in milliseconds (default 500)
1959
+ * animation-easing - CSS timing function (e.g. ease, linear, ease-in-out, cubic-bezier(...))
1960
+ * id - optional id on the wrapper
1955
1961
  *
1956
1962
  * Supported attributes on [slide]:
1957
1963
  * image - URL of a background/header image
@@ -1970,7 +1976,9 @@ function processCarouselBlocks(markdown) {
1970
1976
  attrs.autoplay ? ` data-autoplay="${escapeAttr(attrs.autoplay)}"` : '',
1971
1977
  attrs.interval ? ` data-interval="${escapeAttr(attrs.interval)}"` : '',
1972
1978
  attrs.loop ? ` data-loop="${escapeAttr(attrs.loop)}"` : '',
1973
- attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : ''
1979
+ attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : '',
1980
+ attrs['animation-duration'] ? ` data-animation-duration="${escapeAttr(attrs['animation-duration'])}"` : '',
1981
+ attrs['animation-easing'] ? ` data-animation-easing="${escapeAttr(attrs['animation-easing'])}"` : ''
1974
1982
  ].join('');
1975
1983
  const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
1976
1984
 
@@ -2617,7 +2625,7 @@ function processIconBlocks(markdown) {
2617
2625
  */
2618
2626
  const FORMS_DIR = path.resolve(__dirname_md, '../../content/forms');
2619
2627
 
2620
- async function processFormBlocks(markdown) {
2628
+ async function processFormBlocks(markdown, tagSet) {
2621
2629
  const {scrubbed, restore} = scrubCodeRegions(markdown);
2622
2630
  const regex = /\[form([^\]]*?)\/\]/gi;
2623
2631
  let result = scrubbed;
@@ -2638,6 +2646,7 @@ async function processFormBlocks(markdown) {
2638
2646
  result = result.slice(0, index) + `<div class="cms-form-error">Invalid form slug: ${escapeAttr(slug)}</div>` + result.slice(index + full.length);
2639
2647
  continue;
2640
2648
  }
2649
+ tagSet?.add(`form:${slug}`);
2641
2650
  let replacement;
2642
2651
  try {
2643
2652
  const filePath = path.resolve(FORMS_DIR, `${slug}.json`);
@@ -2916,9 +2925,10 @@ export async function parseMarkdown(raw) {
2916
2925
  // → grid → card → slideover → marked → sanitize → afterParse
2917
2926
  const preprocessed = applyTransforms('markdown:beforeParse', content);
2918
2927
  const {output: withComponents, used: usedComponents} = collectAndRewriteComponents(preprocessed);
2919
- const withCollection = await processCollectionBlocks(withComponents);
2920
- const withView = await processViewBlocks(withCollection);
2921
- const withStaticBlock = await processStaticBlocks(withView);
2928
+ const tagSet = new Set();
2929
+ const withCollection = await processCollectionBlocks(withComponents, tagSet);
2930
+ const withView = await processViewBlocks(withCollection, tagSet);
2931
+ const withStaticBlock = await processStaticBlocks(withView, tagSet);
2922
2932
  const withDconfig = processDConfigBlocks(withStaticBlock);
2923
2933
  const withEffects = processEffectsBlocks(withDconfig);
2924
2934
  const withPluginShortcodes = await processPluginShortcodes(withEffects);
@@ -2931,7 +2941,7 @@ export async function parseMarkdown(raw) {
2931
2941
  const withSpacer = processSpacerBlocks(withListGroup);
2932
2942
  const withCenter = processCenterBlocks(withSpacer);
2933
2943
  const withIcon = processIconBlocks(withCenter);
2934
- const withForm = await processFormBlocks(withIcon);
2944
+ const withForm = await processFormBlocks(withIcon, tagSet);
2935
2945
  const withHero = processHeroBlocks(withForm);
2936
2946
  const withTable = processTableBlocks(withHero);
2937
2947
  const withBadge = processBadgeBlocks(withTable);
@@ -2997,7 +3007,7 @@ export async function parseMarkdown(raw) {
2997
3007
  const allowed = new Set(_dmTagAllowlist.map(t => t.replace(/^dm-/, '')));
2998
3008
  const filteredUsed = [...usedComponents].filter(name => allowed.has(name));
2999
3009
 
3000
- return {data, content, html, usedComponents: filteredUsed};
3010
+ return {data, content, html, usedComponents: filteredUsed, tags: [...tagSet]};
3001
3011
  }
3002
3012
 
3003
3013
  /**
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import {v4 as uuidv4} from 'uuid';
16
16
  import {buildRowLevelMatch} from './rowAccess.js';
17
+ import * as cache from './cache/index.js';
17
18
 
18
19
  /** MongoDB collection where view configs are stored. */
19
20
  const VIEWS_COLLECTION = 'cms__views';
@@ -173,6 +174,7 @@ export async function createView(data, userId = null) {
173
174
  };
174
175
 
175
176
  await db.collection(VIEWS_COLLECTION).insertOne({ ...view });
177
+ await cache.invalidateTags([`view:${slug}`]);
176
178
  return view;
177
179
  }
178
180
 
@@ -206,6 +208,7 @@ export async function updateView(slug, data) {
206
208
  };
207
209
 
208
210
  await db.collection(VIEWS_COLLECTION).replaceOne({ slug }, { ...updated });
211
+ await cache.invalidateTags([`view:${slug}`]);
209
212
  const { _id: _stripped, ...result } = updated;
210
213
  return result;
211
214
  }
@@ -221,6 +224,7 @@ export async function deleteView(slug) {
221
224
  const db = await getMetaDb();
222
225
  const result = await db.collection(VIEWS_COLLECTION).deleteOne({ slug });
223
226
  if (result.deletedCount === 0) throw new Error(`View "${slug}" not found`);
227
+ await cache.invalidateTags([`view:${slug}`]);
224
228
  }
225
229
 
226
230
  // ---------------------------------------------------------------------------