domma-cms 0.16.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.
- package/CLAUDE.md +2 -0
- package/admin/css/dashboard.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/index.html +2 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +1 -1
- package/admin/js/lib/card-builder.js +3 -3
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +5 -5
- package/admin/js/templates/dashboard/activity-feed.html +3 -0
- package/admin/js/templates/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard/health-detail.html +2 -0
- package/admin/js/templates/dashboard/journeys.html +17 -0
- package/admin/js/templates/dashboard/kpi-strip.html +34 -0
- package/admin/js/templates/dashboard/spike-feed.html +3 -0
- package/admin/js/templates/dashboard/top-pages.html +3 -0
- package/admin/js/templates/dashboard/traffic-chart.html +3 -0
- package/admin/js/templates/dashboard.html +26 -44
- package/admin/js/templates/settings.html +26 -0
- package/admin/js/views/block-editor-enhance.js +1 -1
- package/admin/js/views/dashboard/lib/escape.js +1 -0
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -0
- package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
- package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
- package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/page-editor.js +42 -37
- package/admin/js/views/settings.js +3 -3
- package/config/cache.json +4 -0
- package/config/cache.json.example +12 -0
- package/config/plugins.json +3 -0
- package/package.json +2 -2
- package/plugins/analytics/daily.json +5 -0
- package/plugins/analytics/journeys.json +10 -0
- package/plugins/analytics/lifetime.json +25 -0
- package/plugins/analytics/plugin.js +231 -16
- package/plugins/analytics/public/inject-body.html +26 -2
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/config.js +12 -1
- package/server/routes/api/cache.js +57 -0
- package/server/routes/api/dashboard.js +239 -0
- package/server/routes/api/navigation.js +2 -0
- package/server/routes/api/settings.js +3 -0
- package/server/routes/public.js +11 -3
- package/server/server.js +18 -3
- package/server/services/blocks.js +3 -0
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +17 -4
- package/server/services/content.js +7 -2
- package/server/services/email.js +60 -20
- package/server/services/forms.js +3 -0
- package/server/services/health.js +282 -0
- package/server/services/markdown.js +25 -15
- package/server/services/plugins.js +37 -5
- package/server/services/views.js +4 -0
- package/server/templates/page.html +130 -130
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {LRU} from '../lru.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory cache driver with TTL and tag invalidation.
|
|
5
|
+
*
|
|
6
|
+
* Each entry is stored as {value, expiresAt, tags}. TTL is lazy — entries
|
|
7
|
+
* past their expiresAt are returned as undefined on the next get and dropped.
|
|
8
|
+
*
|
|
9
|
+
* Tag index: a Map<tag, Set<key>>. set() with {tags} adds the key to each
|
|
10
|
+
* tag's set; invalidateTags() iterates the listed tags and deletes every
|
|
11
|
+
* key in their sets. The forward index (entry.tags) lets us clean up old
|
|
12
|
+
* links when a key is re-set with a different tag list.
|
|
13
|
+
*/
|
|
14
|
+
export class MemoryDriver {
|
|
15
|
+
/**
|
|
16
|
+
* @param {{maxItems?: number, defaultTtlSeconds?: number}} options
|
|
17
|
+
*/
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
const maxItems = options.maxItems ?? 1000;
|
|
20
|
+
this._defaultTtlSeconds = options.defaultTtlSeconds ?? null;
|
|
21
|
+
this._lru = new LRU(maxItems);
|
|
22
|
+
/** @type {Map<string, Set<string>>} */
|
|
23
|
+
this._tagIndex = new Map();
|
|
24
|
+
this._now = () => Date.now();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async get(key) {
|
|
28
|
+
const entry = this._lru.get(key);
|
|
29
|
+
if (!entry) return undefined;
|
|
30
|
+
if (entry.expiresAt !== null && entry.expiresAt <= this._now()) {
|
|
31
|
+
this._removeKey(key);
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return entry.value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async set(key, value, opts = {}) {
|
|
38
|
+
const existing = this._lru.get(key);
|
|
39
|
+
if (existing && existing.tags) {
|
|
40
|
+
for (const t of existing.tags) {
|
|
41
|
+
const set = this._tagIndex.get(t);
|
|
42
|
+
if (set) {
|
|
43
|
+
set.delete(key);
|
|
44
|
+
if (set.size === 0) this._tagIndex.delete(t);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ttl = opts.ttlSeconds ?? this._defaultTtlSeconds;
|
|
50
|
+
const expiresAt = ttl ? this._now() + ttl * 1000 : null;
|
|
51
|
+
const tags = opts.tags && opts.tags.length ? [...opts.tags] : null;
|
|
52
|
+
|
|
53
|
+
this._lru.set(key, {value, expiresAt, tags});
|
|
54
|
+
|
|
55
|
+
if (tags) {
|
|
56
|
+
for (const t of tags) {
|
|
57
|
+
let set = this._tagIndex.get(t);
|
|
58
|
+
if (!set) {
|
|
59
|
+
set = new Set();
|
|
60
|
+
this._tagIndex.set(t, set);
|
|
61
|
+
}
|
|
62
|
+
set.add(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async invalidateTags(tags) {
|
|
68
|
+
for (const t of tags) {
|
|
69
|
+
const set = this._tagIndex.get(t);
|
|
70
|
+
if (!set) continue;
|
|
71
|
+
for (const key of [...set]) {
|
|
72
|
+
this._removeKey(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async clear() {
|
|
78
|
+
this._lru.clear();
|
|
79
|
+
this._tagIndex.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Current entry count (sync — for the dashboard widget). */
|
|
83
|
+
size() {
|
|
84
|
+
return this._lru.size;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Snapshot the currently cached entries as `{key, tags, expiresAt}`.
|
|
89
|
+
* No values — they can be megabytes of HTML. Uses `entries()` (non-mutating)
|
|
90
|
+
* so the LRU order is preserved through the iteration.
|
|
91
|
+
*/
|
|
92
|
+
listEntries() {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const [key, entry] of this._lru.entries()) {
|
|
95
|
+
if (!entry) continue;
|
|
96
|
+
out.push({
|
|
97
|
+
key,
|
|
98
|
+
tags: entry.tags ? [...entry.tags] : [],
|
|
99
|
+
expiresAt: entry.expiresAt
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_removeKey(key) {
|
|
106
|
+
const entry = this._lru.get(key);
|
|
107
|
+
if (entry && entry.tags) {
|
|
108
|
+
for (const t of entry.tags) {
|
|
109
|
+
const set = this._tagIndex.get(t);
|
|
110
|
+
if (set) {
|
|
111
|
+
set.delete(key);
|
|
112
|
+
if (set.size === 0) this._tagIndex.delete(t);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
this._lru.delete(key);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Null driver — used when the cache is disabled.
|
|
3
|
+
* Every read misses; every write is dropped on the floor.
|
|
4
|
+
*/
|
|
5
|
+
export class NoneDriver {
|
|
6
|
+
async get() { return undefined; }
|
|
7
|
+
async set() { /* no-op */ }
|
|
8
|
+
async invalidateTags() { /* no-op */ }
|
|
9
|
+
async clear() { /* no-op */ }
|
|
10
|
+
size() { return 0; }
|
|
11
|
+
listEntries() { return []; }
|
|
12
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/server/services/email.js
CHANGED
|
@@ -4,6 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import nodemailer from 'nodemailer';
|
|
6
6
|
|
|
7
|
+
let lastSendResult = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Return the most recent email send result, or null if no send has occurred.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ ok: boolean, at: string, info: string|null } | null}
|
|
13
|
+
*/
|
|
14
|
+
export function getLastSendResult() {
|
|
15
|
+
return lastSendResult;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Record the outcome of an email send for health reporting.
|
|
20
|
+
*
|
|
21
|
+
* @param {boolean} ok
|
|
22
|
+
* @param {string|null} info
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
function recordSendResult(ok, info) {
|
|
26
|
+
lastSendResult = {
|
|
27
|
+
ok,
|
|
28
|
+
at: new Date().toISOString(),
|
|
29
|
+
info: info || null
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
7
33
|
/**
|
|
8
34
|
* Escape HTML special characters for safe use in email bodies.
|
|
9
35
|
*
|
|
@@ -58,17 +84,24 @@ export async function createTransport(smtp) {
|
|
|
58
84
|
* @throws {Error} If sending the email fails.
|
|
59
85
|
*/
|
|
60
86
|
export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
try {
|
|
88
|
+
const info = await transport.sendMail({
|
|
89
|
+
from: `"${fromName}" <${from}>`,
|
|
90
|
+
to,
|
|
91
|
+
subject,
|
|
92
|
+
text,
|
|
93
|
+
html
|
|
94
|
+
});
|
|
95
|
+
recordSendResult(true, info.messageId);
|
|
68
96
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
97
|
+
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
98
|
+
if (previewUrl) {
|
|
99
|
+
console.log('[email] Preview URL:', previewUrl);
|
|
100
|
+
}
|
|
101
|
+
return info;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
recordSendResult(false, err.message);
|
|
104
|
+
throw err;
|
|
72
105
|
}
|
|
73
106
|
}
|
|
74
107
|
|
|
@@ -112,16 +145,23 @@ export async function sendFormEmail(transport, { from, fromName, to, subject, fo
|
|
|
112
145
|
|
|
113
146
|
const text = `New form submission: ${formTitle}\n\n${plainRows}`;
|
|
114
147
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
148
|
+
try {
|
|
149
|
+
const info = await transport.sendMail({
|
|
150
|
+
from: `"${fromName}" <${from}>`,
|
|
151
|
+
to,
|
|
152
|
+
subject,
|
|
153
|
+
text,
|
|
154
|
+
html
|
|
155
|
+
});
|
|
156
|
+
recordSendResult(true, info.messageId);
|
|
122
157
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
158
|
+
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
159
|
+
if (previewUrl) {
|
|
160
|
+
console.log('[email] Preview URL:', previewUrl);
|
|
161
|
+
}
|
|
162
|
+
return info;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
recordSendResult(false, err.message);
|
|
165
|
+
throw err;
|
|
126
166
|
}
|
|
127
167
|
}
|
package/server/services/forms.js
CHANGED
|
@@ -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
|
/**
|