domma-cms 0.113.0 → 0.114.1
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/package.json +1 -1
- package/plugins/free-tier.lock.json +23 -1
- package/plugins/support/CLAUDE.md +59 -0
- package/plugins/support/admin/css/index.css +95 -0
- package/plugins/support/admin/lib/kit.js +119 -0
- package/plugins/support/admin/templates/support.html +1 -0
- package/plugins/support/admin/views/desk.js +289 -0
- package/plugins/support/admin/views/inbox.js +211 -0
- package/plugins/support/admin/views/support.js +63 -0
- package/plugins/support/collections/support-tickets.json +30 -0
- package/plugins/support/config.js +28 -0
- package/plugins/support/lib/mail.js +58 -0
- package/plugins/support/lib/receiver.js +182 -0
- package/plugins/support/lib/sender.js +213 -0
- package/plugins/support/lib/shape.js +302 -0
- package/plugins/support/plugin.js +350 -0
- package/plugins/support/plugin.json +48 -0
- package/plugins/support/tests/api.test.js +265 -0
- package/plugins/support/tests/shape.test.js +122 -0
- package/public/js/site.js +1 -1
- package/server/server.js +3 -0
- package/server/services/embedCsp.js +33 -0
- package/server/services/markdown.js +6 -3
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Both sides at once: SITE apps whose fetch is wired straight into a MANAGER app
|
|
3
|
+
* via inject, against the real collection service in the domma-cms checkout
|
|
4
|
+
* `make test` runs in. The collection is made for the test and removed after.
|
|
5
|
+
* Mail goes to an array, never out.
|
|
6
|
+
*/
|
|
7
|
+
import {test, before, after} from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import Fastify from 'fastify';
|
|
13
|
+
import support from '../plugin.js';
|
|
14
|
+
import {getEntry, updateEntry} from '../../../server/services/collections.js';
|
|
15
|
+
|
|
16
|
+
const COLL = path.resolve('content/collections/support-tickets');
|
|
17
|
+
const TOKENS = {'tok-acme': 'acme', 'tok-beta': 'beta', 'tok-flood': 'flood'};
|
|
18
|
+
const SITES = {acme: {name: 'Acme Ltd', host: 'acme.example.test', cmsVersion: '0.113.0'}, beta: {name: 'Beta Co', host: 'beta.example.test'}};
|
|
19
|
+
let manager;
|
|
20
|
+
let dataDir;
|
|
21
|
+
let managerUp = true;
|
|
22
|
+
const mails = [];
|
|
23
|
+
const notices = [];
|
|
24
|
+
const siteNotices = [];
|
|
25
|
+
const mail = {send: async (m) => { mails.push(m); return true; }, siteUrl: () => 'https://dcm.example.test'};
|
|
26
|
+
|
|
27
|
+
let hang = false;
|
|
28
|
+
const fetchInto = (app) => async (url, init = {}) => {
|
|
29
|
+
if (hang) return new Promise(() => {}); // a manager that never answers
|
|
30
|
+
if (!managerUp) throw new Error('connect ECONNREFUSED 127.0.0.1:8192');
|
|
31
|
+
const res = await app.inject({method: init.method || 'GET', url: new URL(url).pathname, headers: init.headers, payload: init.body});
|
|
32
|
+
return {status: res.statusCode, json: async () => res.json()};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const ANN = {id: 'u1', name: 'Ann', email: 'ann@acme.test'};
|
|
36
|
+
|
|
37
|
+
async function site({token, user = ANN, settings = {}, env, screenWaitMs} = {}) {
|
|
38
|
+
const app = Fastify();
|
|
39
|
+
await app.register(support, {
|
|
40
|
+
...(screenWaitMs && {screenWaitMs}),
|
|
41
|
+
auth: {authenticate: async (req) => { req.user = user; }, requirePermission: () => async () => {}},
|
|
42
|
+
settings, mode: 'sender', timer: false, dataDir: await fs.mkdtemp(path.join(dataDir, 'site-')),
|
|
43
|
+
env: env ?? {MANAGER_URL: 'http://127.0.0.1:8192/', FLEET_TOKEN: token, SITE_SLUG: TOKENS[token] || 'x'},
|
|
44
|
+
fetch: fetchInto(manager),
|
|
45
|
+
hooks: {notify: (n) => siteNotices.push(n), registerNotificationSource() {}}
|
|
46
|
+
});
|
|
47
|
+
return app;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const call = async (app, method, url, body, headers) => {
|
|
51
|
+
const res = await app.inject({method, url, payload: body, headers});
|
|
52
|
+
let json = null;
|
|
53
|
+
try { json = res.json(); } catch { /* not JSON */ }
|
|
54
|
+
return {status: res.statusCode, body: json};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
before(async () => {
|
|
58
|
+
await fs.rm(COLL, {recursive: true, force: true});
|
|
59
|
+
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'support-test-'));
|
|
60
|
+
manager = Fastify();
|
|
61
|
+
await manager.register(support, {
|
|
62
|
+
auth: {authenticate: async (req) => { req.user = {id: 'v1', name: 'Darryl'}; }, requirePermission: () => async () => {}},
|
|
63
|
+
prefix: '/api/plugins/support', mode: 'receiver', mail, dataDir: await fs.mkdtemp(path.join(dataDir, 'manager-')),
|
|
64
|
+
settings: {perSiteMax: 30, perSiteMinutes: 10, phone: '+44 1234 567890', hours: 'Always', notifyEmail: 'support@dommajs.test'},
|
|
65
|
+
slugFromToken: async (h) => TOKENS[String(h || '').replace(/^Bearer\s+/i, '')] || null,
|
|
66
|
+
siteDirectory: () => SITES,
|
|
67
|
+
hooks: {notify: (n) => notices.push(n), registerNotificationSource() {}}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
after(async () => {
|
|
72
|
+
await manager?.close();
|
|
73
|
+
await fs.rm(COLL, {recursive: true, force: true});
|
|
74
|
+
await fs.rm(dataDir, {recursive: true, force: true});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a request travels: site -> manager inbox, Domma emailed, urgent shows red', async () => {
|
|
78
|
+
const acme = await site({token: 'tok-acme'});
|
|
79
|
+
const sent = await call(acme, 'POST', '/tickets', {subject: 'Checkout broken', body: 'Customers cannot pay.', priority: 'urgent', category: 'problem'});
|
|
80
|
+
assert.equal(sent.status, 201);
|
|
81
|
+
assert.equal(sent.body.state, 'sent');
|
|
82
|
+
|
|
83
|
+
const inbox = await call(manager, 'GET', '/api/plugins/support/tickets');
|
|
84
|
+
const t = inbox.body.tickets.find(x => x.ref === sent.body.ref);
|
|
85
|
+
assert.equal(t.site, 'acme');
|
|
86
|
+
assert.equal(t.siteName, 'Acme Ltd');
|
|
87
|
+
assert.equal(t.userEmail, 'ann@acme.test');
|
|
88
|
+
assert.equal(t.status, 'open');
|
|
89
|
+
assert.equal(t.cmsVersion, '0.113.0', 'the registry says which engine the site runs');
|
|
90
|
+
|
|
91
|
+
const m = mails.find(x => x.to === 'support@dommajs.test' && x.subject.includes('Checkout broken'));
|
|
92
|
+
assert.match(m.subject, /^\[URGENT\] Support request from Acme Ltd/);
|
|
93
|
+
assert.equal(m.replyTo, 'ann@acme.test');
|
|
94
|
+
assert.ok(m.text.includes(`https://dcm.example.test/admin/#/plugins/support/${t.id}`));
|
|
95
|
+
assert.ok(notices.some(n => n.title.startsWith('URGENT')));
|
|
96
|
+
|
|
97
|
+
const count = await call(manager, 'GET', '/api/plugins/support/count');
|
|
98
|
+
assert.equal(count.body.tone, 'danger');
|
|
99
|
+
assert.equal(count.body.count, 1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('Domma replies: the customer is emailed a link to their own admin, and the site sees it unread', async () => {
|
|
103
|
+
const acme = await site({token: 'tok-acme'});
|
|
104
|
+
const {body: s} = await call(acme, 'POST', '/tickets', {subject: 'How do I add a page?', body: 'New here.'});
|
|
105
|
+
const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
106
|
+
mails.length = 0;
|
|
107
|
+
const r = await call(manager, 'POST', `/api/plugins/support/tickets/${t.id}/reply`, {body: 'Pages > New page.'});
|
|
108
|
+
assert.equal(r.body.status, 'answered');
|
|
109
|
+
const toAnn = mails.find(x => x.to === 'ann@acme.test');
|
|
110
|
+
assert.equal(toAnn.subject, 'Re: How do I add a page?');
|
|
111
|
+
assert.ok(toAnn.text.includes(`https://acme.example.test/admin/#/plugins/support/${s.ref}`));
|
|
112
|
+
|
|
113
|
+
const list = await call(acme, 'GET', '/tickets');
|
|
114
|
+
const mine = list.body.tickets.find(x => x.ref === s.ref);
|
|
115
|
+
assert.equal(mine.unread, true);
|
|
116
|
+
assert.equal(mine.messages.at(-1).body, 'Pages > New page.');
|
|
117
|
+
assert.ok(siteNotices.some(n => n.link === `#/plugins/support/${s.ref}`));
|
|
118
|
+
assert.equal((await call(acme, 'GET', '/count')).body.count, 1);
|
|
119
|
+
// Read it: the badge goes calm.
|
|
120
|
+
await call(acme, 'PUT', `/tickets/${s.ref}/seen`, {at: mine.messages.at(-1).at});
|
|
121
|
+
assert.equal((await call(acme, 'GET', '/count')).body.inventory, true);
|
|
122
|
+
|
|
123
|
+
// The customer writes again: open, and Domma hears.
|
|
124
|
+
mails.length = 0;
|
|
125
|
+
const again = await call(acme, 'POST', `/tickets/${s.ref}/messages`, {body: 'Thanks! And menus?'});
|
|
126
|
+
assert.equal(again.body.state, 'sent');
|
|
127
|
+
assert.equal((await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body.status, 'open');
|
|
128
|
+
assert.ok(mails.some(x => x.to === 'support@dommajs.test' && x.subject.startsWith('Reply from Acme Ltd')));
|
|
129
|
+
|
|
130
|
+
// They resolve it themselves.
|
|
131
|
+
await call(acme, 'POST', `/tickets/${s.ref}/close`);
|
|
132
|
+
assert.equal((await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body.status, 'closed');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a site never sees another site\'s requests, nor internal notes', async () => {
|
|
136
|
+
const acme = await site({token: 'tok-acme'});
|
|
137
|
+
const beta = await site({token: 'tok-beta', user: {id: 'b1', name: 'Bob', email: 'bob@beta.test'}});
|
|
138
|
+
const {body: s} = await call(beta, 'POST', '/tickets', {subject: 'Beta only', body: 'private'});
|
|
139
|
+
const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
140
|
+
await call(manager, 'PUT', `/api/plugins/support/tickets/${t.id}`, {notes: 'secret note'});
|
|
141
|
+
assert.ok(!(await call(acme, 'GET', '/tickets')).body.tickets.some(x => x.ref === s.ref));
|
|
142
|
+
const mine = (await call(beta, 'GET', '/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
143
|
+
assert.equal(mine.notes, undefined);
|
|
144
|
+
// Acme cannot write into Beta's request by guessing its ref.
|
|
145
|
+
const bad = await call(manager, 'POST', '/api/plugins/support/fleet/messages', {ticketRef: s.ref, ref: 'x', body: 'hijack'},
|
|
146
|
+
{authorization: 'Bearer tok-acme'});
|
|
147
|
+
assert.equal(bad.status, 404);
|
|
148
|
+
assert.equal((await call(manager, 'POST', '/api/plugins/support/fleet/tickets', {}, {authorization: 'Bearer nope'})).status, 401);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('while the manager restarts, a request waits on the site and goes later - once', async () => {
|
|
152
|
+
const acme = await site({token: 'tok-acme'});
|
|
153
|
+
managerUp = false;
|
|
154
|
+
const s = await call(acme, 'POST', '/tickets', {subject: 'Sent while down', body: 'hello'});
|
|
155
|
+
assert.equal(s.body.state, 'waiting');
|
|
156
|
+
const follow = await call(acme, 'POST', `/tickets/${s.body.ref}/messages`, {body: 'and more'});
|
|
157
|
+
assert.equal(follow.body.state, 'waiting');
|
|
158
|
+
const offline = await call(acme, 'GET', '/tickets');
|
|
159
|
+
assert.equal(offline.body.tickets.find(x => x.ref === s.body.ref).statusLabel, 'Waiting to send');
|
|
160
|
+
managerUp = true;
|
|
161
|
+
const list = await call(acme, 'GET', '/tickets');
|
|
162
|
+
const t = list.body.tickets.find(x => x.ref === s.body.ref);
|
|
163
|
+
assert.equal(t.status, 'open');
|
|
164
|
+
assert.deepEqual(t.messages.map(m => m.body), ['hello', 'and more'], 'the follow-up went after its request');
|
|
165
|
+
await call(acme, 'GET', '/tickets');
|
|
166
|
+
const all = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.filter(x => x.ref === s.body.ref);
|
|
167
|
+
assert.equal(all.length, 1);
|
|
168
|
+
assert.equal(all[0].messages.length, 2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('the contact card: the manager\'s reaches every site; a standalone site still has one', async () => {
|
|
172
|
+
const acme = await site({token: 'tok-acme', settings: {hours: 'site default'}});
|
|
173
|
+
assert.equal((await call(acme, 'GET', '/mode')).body.contact.hours, 'site default', 'before the first sync');
|
|
174
|
+
await call(acme, 'GET', '/tickets');
|
|
175
|
+
const card = (await call(acme, 'GET', '/mode')).body.contact;
|
|
176
|
+
assert.equal(card.hours, 'Always');
|
|
177
|
+
assert.equal(card.phone, '+44 1234 567890');
|
|
178
|
+
|
|
179
|
+
const alone = await site({env: {}});
|
|
180
|
+
const mode = (await call(alone, 'GET', '/mode')).body;
|
|
181
|
+
assert.equal(mode.connected, false);
|
|
182
|
+
assert.equal(mode.contact.email, 'support@dommajs.org');
|
|
183
|
+
assert.equal((await call(alone, 'POST', '/tickets', {subject: 's', body: 'b'})).status, 409);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('one site cannot flood the inbox', async () => {
|
|
187
|
+
const flood = await site({token: 'tok-flood'});
|
|
188
|
+
const codes = [];
|
|
189
|
+
for (let i = 0; i < 32; i++) {
|
|
190
|
+
const r = await call(manager, 'POST', '/api/plugins/support/fleet/tickets', {ref: `f${i}`, subject: 's', body: 'b'},
|
|
191
|
+
{authorization: 'Bearer tok-flood'});
|
|
192
|
+
codes.push(r.status);
|
|
193
|
+
}
|
|
194
|
+
assert.deepEqual(codes.filter(c => c === 429).length, 2);
|
|
195
|
+
void flood;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
// ---- Review findings -------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
test('review: two syncs at once send a waiting request once', async () => {
|
|
202
|
+
const acme = await site({token: 'tok-acme'});
|
|
203
|
+
managerUp = false;
|
|
204
|
+
const s = (await call(acme, 'POST', '/tickets', {subject: 'Race me', body: 'once'})).body;
|
|
205
|
+
managerUp = true;
|
|
206
|
+
await Promise.all([call(acme, 'GET', '/tickets'), call(acme, 'GET', '/tickets'), call(acme, 'GET', '/count')]);
|
|
207
|
+
const all = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.filter(x => x.ref === s.ref);
|
|
208
|
+
assert.equal(all.length, 1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('review: a customer message lost on the manager is sent again', async () => {
|
|
212
|
+
const acme = await site({token: 'tok-acme'});
|
|
213
|
+
const s = (await call(acme, 'POST', '/tickets', {subject: 'Lost one', body: 'first'})).body;
|
|
214
|
+
await call(acme, 'POST', `/tickets/${s.ref}/messages`, {body: 'second'});
|
|
215
|
+
const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
216
|
+
// Simulate a concurrent write that dropped it.
|
|
217
|
+
const e = await getEntry('support-tickets', t.id);
|
|
218
|
+
await updateEntry('support-tickets', t.id, {...e.data, messages: e.data.messages.slice(0, 1)});
|
|
219
|
+
await call(acme, 'GET', '/tickets'); // notices it is missing, marks it waiting
|
|
220
|
+
await call(acme, 'GET', '/tickets'); // sends it again
|
|
221
|
+
const after = (await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body;
|
|
222
|
+
assert.deepEqual(after.messages.map(m => m.body), ['first', 'second']);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('review: concurrent turns on one request are all kept', async () => {
|
|
226
|
+
const acme = await site({token: 'tok-acme'});
|
|
227
|
+
const s = (await call(acme, 'POST', '/tickets', {subject: 'Busy', body: 'open'})).body;
|
|
228
|
+
const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
229
|
+
await Promise.all([
|
|
230
|
+
call(manager, 'POST', `/api/plugins/support/tickets/${t.id}/reply`, {body: 'from Domma'}),
|
|
231
|
+
call(manager, 'POST', '/api/plugins/support/fleet/messages', {ticketRef: s.ref, ref: 'c-x', body: 'from Ann'}, {authorization: 'Bearer tok-acme'}),
|
|
232
|
+
call(manager, 'PUT', `/api/plugins/support/tickets/${t.id}`, {notes: 'n'})
|
|
233
|
+
]);
|
|
234
|
+
const after = (await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body;
|
|
235
|
+
assert.deepEqual(after.messages.map(m => m.body).sort(), ['from Ann', 'from Domma', 'open']);
|
|
236
|
+
assert.equal(after.notes, 'n');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('review: control characters are stripped, so nothing is too big to ever send', async () => {
|
|
240
|
+
const acme = await site({token: 'tok-acme'});
|
|
241
|
+
const r = await call(acme, 'POST', '/tickets', {subject: 'ctl', body: 'ok' + '\u0001'.repeat(8000)});
|
|
242
|
+
assert.equal(r.body.state, 'sent');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('review: a deleted request does not come back on a retry', async () => {
|
|
246
|
+
const payload = {ref: 'zombie-1', subject: 'Zombie', body: 'b'};
|
|
247
|
+
const first = await call(manager, 'POST', '/api/plugins/support/fleet/tickets', payload, {authorization: 'Bearer tok-beta'});
|
|
248
|
+
await call(manager, 'DELETE', `/api/plugins/support/tickets/${first.body.id}`);
|
|
249
|
+
const retry = await call(manager, 'POST', '/api/plugins/support/fleet/tickets', payload, {authorization: 'Bearer tok-beta'});
|
|
250
|
+
assert.equal(retry.status, 404);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('review: a manager that never answers does not hang the screen', async () => {
|
|
254
|
+
const acme = await site({token: 'tok-acme', screenWaitMs: 300});
|
|
255
|
+
hang = true;
|
|
256
|
+
try {
|
|
257
|
+
const t0 = Date.now();
|
|
258
|
+
const r = await call(acme, 'GET', '/tickets');
|
|
259
|
+
assert.ok(Date.now() - t0 < 2000);
|
|
260
|
+
assert.equal(r.body.reachable, false);
|
|
261
|
+
const s = await call(acme, 'POST', '/tickets', {subject: 'slow', body: 'b'});
|
|
262
|
+
assert.equal(s.status, 201);
|
|
263
|
+
assert.equal(s.body.state, 'waiting');
|
|
264
|
+
} finally { hang = false; }
|
|
265
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Support - the pure half (lib/shape.js) and the site's merge (lib/sender.js mergeTickets). */
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {
|
|
5
|
+
addTurn, buildMessagePayload, buildTicketPayload, cleanRequest, cleanUpdate, contactCard, createThrottle, lastDommaAt,
|
|
6
|
+
receivedTicket, senderView, telHref, toCustomerEmail, toDommaEmail
|
|
7
|
+
} from '../lib/shape.js';
|
|
8
|
+
import {mergeTickets} from '../lib/sender.js';
|
|
9
|
+
|
|
10
|
+
const T0 = '2026-10-04T10:00:00.000Z';
|
|
11
|
+
const T1 = '2026-10-04T11:00:00.000Z';
|
|
12
|
+
const T2 = '2026-10-04T12:00:00.000Z';
|
|
13
|
+
|
|
14
|
+
test('a request needs a subject and a message; unknown choices fall back', () => {
|
|
15
|
+
assert.equal(cleanRequest({body: 'x'}).error, 'Give it a short subject.');
|
|
16
|
+
assert.equal(cleanRequest({subject: 'x'}).error, 'Tell us what you need.');
|
|
17
|
+
const {request} = cleanRequest({subject: ' Site\n down ', body: ' help\r\nplease ', category: 'nope', priority: 'panic', where: '/shop'});
|
|
18
|
+
assert.deepEqual(request, {category: 'help', priority: 'normal', subject: 'Site down', body: 'help\nplease', where: '/shop'});
|
|
19
|
+
assert.equal(cleanRequest({subject: 's', body: 'b', priority: 'urgent'}).request.priority, 'urgent');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('who sent it comes from the session user, never from the form', () => {
|
|
23
|
+
const {request} = cleanRequest({subject: 's', body: 'b', user: {name: 'Mallory'}});
|
|
24
|
+
const p = buildTicketPayload({request, user: {id: 'u1', name: 'Ann', email: 'ann@x.test', password: 'no'}, ref: 'r1', site: 'acme'});
|
|
25
|
+
assert.deepEqual(p.user, {id: 'u1', name: 'Ann', email: 'ann@x.test'});
|
|
26
|
+
assert.equal(buildMessagePayload({ticketRef: 'r1', ref: 'm1', body: 'hi', user: {name: 'Ann'}}).user.name, 'Ann');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('the manager files a request under the token\'s site, with the opening message as the first turn', () => {
|
|
30
|
+
const {data} = receivedTicket({ref: 'r1', subject: 's', body: 'b', site: 'evil', user: {name: 'Ann', email: 'not-an-email'}}, 'acme', T0);
|
|
31
|
+
assert.equal(data.site, 'acme');
|
|
32
|
+
assert.equal(data.status, 'open');
|
|
33
|
+
assert.equal(data.userEmail, '', 'a malformed email is never mailed');
|
|
34
|
+
assert.deepEqual(data.messages, [{id: 'r1', from: 'customer', name: 'Ann', body: 'b', at: T0}]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('turns move the status to whoever has to act next', () => {
|
|
38
|
+
let {data} = receivedTicket({ref: 'r1', subject: 's', body: 'b', user: {name: 'Ann'}}, 'acme', T0);
|
|
39
|
+
({data} = addTurn(data, {from: 'domma', name: 'Darryl', body: 'Try this', id: 'd1', at: T1}));
|
|
40
|
+
assert.equal(data.status, 'answered');
|
|
41
|
+
assert.equal(lastDommaAt(data), T1);
|
|
42
|
+
({data} = addTurn(data, {from: 'customer', name: 'Ann', body: 'Still broken', id: 'c2', at: T2}));
|
|
43
|
+
assert.equal(data.status, 'open');
|
|
44
|
+
({data} = addTurn(data, {from: 'domma', name: 'Darryl', body: 'Fixed', id: 'd2', at: T2, status: 'closed'}));
|
|
45
|
+
assert.equal(data.status, 'closed');
|
|
46
|
+
({data} = addTurn(data, {from: 'customer', name: 'Ann', body: 'Back again', id: 'c3', at: T2}));
|
|
47
|
+
assert.equal(data.status, 'open', 'writing reopens');
|
|
48
|
+
assert.equal(data.history.at(-1).what, 'Reopened');
|
|
49
|
+
({data} = addTurn(data, {from: 'customer', name: 'Ann', id: 'c4', at: T2, close: true}));
|
|
50
|
+
assert.equal(data.status, 'closed');
|
|
51
|
+
assert.equal(data.messages.length, 5, 'closing adds no message');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a turn delivered twice is added once', () => {
|
|
55
|
+
let {data} = receivedTicket({ref: 'r1', subject: 's', body: 'b'}, 'acme', T0);
|
|
56
|
+
({data} = addTurn(data, {from: 'customer', name: 'Ann', body: 'again', id: 'c2', at: T1}));
|
|
57
|
+
const again = addTurn(data, {from: 'customer', name: 'Ann', body: 'again', id: 'c2', at: T1});
|
|
58
|
+
assert.equal(again.added, false);
|
|
59
|
+
assert.equal(again.data.messages.length, 2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a site sees its conversation, never notes or history', () => {
|
|
63
|
+
const {data} = receivedTicket({ref: 'r1', subject: 's', body: 'b'}, 'acme', T0);
|
|
64
|
+
const v = senderView({id: 'e1', data: {...data, notes: 'awkward customer'}, meta: {createdAt: T0}});
|
|
65
|
+
assert.equal(v.notes, undefined);
|
|
66
|
+
assert.equal(v.history, undefined);
|
|
67
|
+
assert.equal(v.statusLabel, 'Waiting on Domma');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('inbox updates are checked', () => {
|
|
71
|
+
assert.equal(cleanUpdate({}).error, 'Nothing to change.');
|
|
72
|
+
assert.equal(cleanUpdate({status: 'gone'}).error, 'That is not a status.');
|
|
73
|
+
assert.deepEqual(cleanUpdate({status: 'closed', notes: ' n ', priority: 'urgent'}).patch, {status: 'closed', notes: 'n', priority: 'urgent'});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('the contact card lets through only what is well-formed', () => {
|
|
77
|
+
assert.deepEqual(contactCard({email: 'support@dommajs.org', phone: '+44 (0)1234 567890', hours: 'Mon\nFri', responseTime: 'Soon'}),
|
|
78
|
+
{email: 'support@dommajs.org', phone: '+44 (0)1234 567890', hours: 'Mon Fri', responseTime: 'Soon'});
|
|
79
|
+
assert.deepEqual(contactCard({email: 'javascript:alert(1)', phone: 'call me'}), {email: '', phone: '', hours: '', responseTime: ''});
|
|
80
|
+
assert.equal(telHref('+44 (0)1234 567890'), 'tel:+4401234567890');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('emails are escaped, flag urgency, and reply to the customer', () => {
|
|
84
|
+
const {data} = receivedTicket({ref: 'r1', subject: '<b>Down</b>', body: '<script>x</script>', priority: 'urgent',
|
|
85
|
+
user: {name: 'Ann', email: 'ann@x.test'}}, 'acme', T0);
|
|
86
|
+
const e = toDommaEmail({ticket: data, siteName: 'Acme', link: 'https://dcm/admin/#/plugins/support/e1', body: data.messages[0].body, isNew: true});
|
|
87
|
+
assert.match(e.subject, /^\[URGENT\] Support request from Acme: <b>Down<\/b>$/);
|
|
88
|
+
assert.equal(e.replyTo, 'ann@x.test');
|
|
89
|
+
assert.ok(!e.html.includes('<script>'));
|
|
90
|
+
assert.ok(e.html.includes('<script>'));
|
|
91
|
+
const c = toCustomerEmail({ticket: data, siteName: 'Acme', link: 'https://acme/admin/#/plugins/support/r1', body: 'Fixed', resolved: true});
|
|
92
|
+
assert.equal(c.subject, 'Re: <b>Down</b>');
|
|
93
|
+
assert.match(c.text, /marked it as resolved/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('the throttle allows max per window, per key', () => {
|
|
97
|
+
const ok = createThrottle({max: 2, windowMs: 1000});
|
|
98
|
+
assert.deepEqual([ok('a', 0), ok('a', 1), ok('a', 2), ok('b', 2), ok('a', 1001)], [true, true, false, true, true]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('the site\'s list: the manager\'s view, plus what is still waiting here', () => {
|
|
102
|
+
const remote = [{ref: 'r1', subject: 'One', status: 'answered', lastAt: T1,
|
|
103
|
+
messages: [{id: 'r1', from: 'customer', body: 'b', at: T0}, {id: 'd1', from: 'domma', body: 'Try', at: T1}]}];
|
|
104
|
+
const items = [
|
|
105
|
+
{kind: 'ticket', ref: 'r2', state: 'waiting', createdAt: T2, payload: {subject: 'Two', body: 'b2', user: {name: 'Ann'}}},
|
|
106
|
+
{kind: 'message', ref: 'm1', ticketRef: 'r1', state: 'waiting', createdAt: T2, payload: {body: 'thanks', user: {name: 'Ann'}}},
|
|
107
|
+
{kind: 'message', ref: 'm0', ticketRef: 'r1', state: 'waiting', createdAt: T2, payload: {body: '', close: true}}
|
|
108
|
+
];
|
|
109
|
+
const list = mergeTickets(items, remote, {});
|
|
110
|
+
assert.deepEqual(list.map(t => t.ref), ['r2', 'r1']);
|
|
111
|
+
assert.equal(list[0].statusLabel, 'Waiting to send');
|
|
112
|
+
const r1 = list[1];
|
|
113
|
+
assert.equal(r1.unread, true);
|
|
114
|
+
assert.equal(r1.messages.at(-1).body, 'thanks');
|
|
115
|
+
assert.equal(r1.messages.at(-1).pending, true);
|
|
116
|
+
assert.equal(r1.messages.length, 3, 'a waiting close adds no message');
|
|
117
|
+
assert.equal(mergeTickets(items, remote, {r1: T1})[1].unread, false);
|
|
118
|
+
// Offline: the cached list stands in.
|
|
119
|
+
const offline = mergeTickets([], null, {}, [{ref: 'r1', subject: 'One', status: 'answered', dommaAt: T1}]);
|
|
120
|
+
assert.equal(offline[0].stale, true);
|
|
121
|
+
assert.equal(offline[0].unread, true);
|
|
122
|
+
});
|
package/public/js/site.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{makeBadgeEl as M,applyColour as O,applyPill as V,applyFloat as J,applyHighlight as N,applyItemHighlight as W,itemHighlightAttrs as X,isCurrentItem as B,menuAlign as G}from"/public/js/menu-decor.mjs?v=20260918-item-highlight";import{initFloatingMenus as Y}from"/public/js/menu-float.js?v=20260829-float-panel";import{enhancePickerFields as j,markInvalidFields as R,showValidationPopup as P}from"/public/js/forms.js?v=20260927-submit-btn";import{buildWizardSteps as U}from"/public/js/form-steps.mjs";import{applySubmitButtons as K,applyWizardButtons as Q}from"/public/js/form-submit.mjs";$(()=>{const w=window.__CMS_NAV__||{},g=window.__CMS_SITE__||{};if(g.autoTheme?.enabled){let t=function(e){const s=(e||"07:00").split(":");return+s[0]*60+(+s[1]||0)},n=function(){const e=new Date,s=e.getHours()*60+e.getMinutes();return s>=t(o.dayStart)&&s<t(o.nightStart)?o.dayTheme:o.nightTheme},i=function(e){const s=o.apply&&o.apply[e]||{theme:e,custom:""};Domma.theme.set(s.theme);for(const p of[...document.body.classList])p.startsWith("dm-custom-")&&document.body.classList.remove(p);s.custom&&document.body.classList.add(s.custom)};const o=g.autoTheme;i(n()),setInterval(()=>i(n()),6e4)}const _=$("#site-navbar");if(_.length&&w.brand){let n=function(c,h=0){return Array.isArray(c)?c.filter(r=>!(r&&r.type==="spacer")).filter(r=>!(r&&r.type==="separator"&&h===0)).map(r=>r&&r.type==="separator"?{divider:!0}:Array.isArray(r?.items)?{...r,items:n(r.items,h+1)}:r):c},i=function(c){return Array.isArray(c)?c.filter(h=>!(h&&(h.type==="separator"||h.type==="spacer"))):[]};const o={...w.brand},t=o.size&&o.size!=="md"?` navbar-brand-${o.size}`:"";if(o.logo||o.icon||o.tagline){let c="";o.logo?c+=`<img src="${o.logo}" class="navbar-brand-logo" alt="${o.text||""}">`:o.icon&&(c+=`<span data-icon="${o.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),o.text&&(c+=`<span class="navbar-brand-text${t}">${o.text}</span>`),o.tagline&&(c+=`<small class="navbar-brand-tagline">${o.tagline}</small>`),o.html=c}else t&&o.text&&(o.html=`<span class="navbar-brand-text${t}">${o.text}</span>`);const e=w.position==="floating",s=w.orientation==="vertical";Domma.elements.navbar("#site-navbar",{brand:o,items:n(w.items||[]),variant:w.variant||"dark",position:e?"static":w.position||"sticky",collapsible:!0,appearOnHover:!!w.appearOnHover}),Domma.icons.scan("#site-navbar");const p=_.get(0);p.classList.add(`dm-nav-align-${G(w)||"centre"}`),s&&_.get(0).classList.add("dm-nav-vertical",w.side==="right"?"dm-nav-vertical-right":"dm-nav-vertical-left"),N(p,w),e&&(J(_.get(0),w),Y(_.get(0).parentNode||document));const y=p.querySelector(".navbar-toggle"),u=p.querySelector(".navbar-collapse");if(y&&u){let c=null;const h=()=>{u.classList.contains("show")&&y.click()},r=()=>{c&&(c.remove(),c=null),document.body.classList.remove("dm-drawer-open")},a=()=>{c||(c=document.createElement("div"),c.className="dm-navbar-backdrop",c.addEventListener("click",h),document.body.appendChild(c),document.body.classList.add("dm-drawer-open"))};y.addEventListener("click",()=>{requestAnimationFrame(()=>{u.classList.contains("show")?a():r()})}),u.addEventListener("click",l=>{l.target.closest("a")&&requestAnimationFrame(r)}),document.addEventListener("keydown",l=>{l.key==="Escape"&&u.classList.contains("show")&&h()})}const m=p.querySelector(".navbar-nav");if(m){const c=Array.from(m.children);let h=0;for(const r of w.items||[])if(!(r&&r.type==="separator")){if(r&&r.type==="spacer"){const a=document.createElement("li"),l=["xs","sm","md","lg","xl","flex"].includes(r.size)?r.size:"md";a.className=`dm-menu-spacer dm-menu-spacer--${l}`,a.setAttribute("aria-hidden","true"),m.insertBefore(a,c[h]||null);continue}h++}}const x=i(w.items||[]);p.querySelectorAll(".navbar-link[data-index], .navbar-dropdown-item[data-index]").forEach(c=>{const h=x[parseInt(c.dataset.index,10)];if(!h)return;const r=c.dataset.subindex!=null,a=r&&Array.isArray(h.items)?h.items[parseInt(c.dataset.subindex,10)]:h;if(!a)return;a.colour&&O(c,a.colour),a.pill&&!r&&V(c,a.pill),W(c,a);const l=M(a.badge);l&&c.appendChild(l),B(a.url,location.pathname)&&(c.classList.add("active"),c.setAttribute("aria-current","page"))})}const C=$("#site-footer");if(C.length){let e=function(a){return String(a??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},s=function(a){return e(a).replace(/'/g,"'")},p=function(a){if(a&&a.type==="separator")return'<li class="footer-sep"><hr></li>';if(a&&a.type==="spacer")return`<li class="dm-menu-spacer dm-menu-spacer--${["xs","sm","md","lg","xl","flex"].includes(a.size)?a.size:"md"}" aria-hidden="true"></li>`;const l=s(a.url||"#"),d=e(a.text||""),b=Array.isArray(a.items)&&a.items.length?`<ul>${a.items.map(p).join("")}</ul>`:"",v=X(a),I=[v.hover&&` data-menu-item-hover="${s(v.hover)}"`,v.active&&` data-menu-item-active="${s(v.active)}"`].filter(Boolean).join(""),q=v.accent?` style="--dm-menu-highlight:${s(v.accent)}"`:"";return`<li><a href="${l}"${I}${q}>${d}</a>${b}</li>`},y=function(a,l){return(a||[]).forEach(d=>{d&&(d.type==="separator"||d.type==="spacer")||(l.push(d),Array.isArray(d.items)&&d.items.length&&y(d.items,l))}),l};const o=g.social||{},t={twitter:{label:"X / Twitter",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.742l7.73-8.835L1.254 2.25H8.08l4.259 5.629L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>'},facebook:{label:"Facebook",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'},instagram:{label:"Instagram",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>'},linkedin:{label:"LinkedIn",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'},github:{label:"GitHub",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>'},youtube:{label:"YouTube",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.495 6.205a3.007 3.007 0 00-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 00.527 6.205a31.247 31.247 0 00-.522 5.805 31.247 31.247 0 00.522 5.783 3.007 3.007 0 002.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 002.088-2.088 31.247 31.247 0 00.5-5.783 31.247 31.247 0 00-.5-5.805zM9.609 15.601V8.408l6.264 3.602z"/></svg>'}};let n='<div class="footer-inner container">';const i=window.__CMS_FOOTER__||{primary:[],legal:[],copyright:""};if(i.copyright&&(n+=`<p>${e(i.copyright)}</p>`),i.primary.length&&(n+=`<nav class="footer-nav footer-nav--primary"><ul>${i.primary.map(p).join("")}</ul></nav>`),i.legal.length&&(n+=`<nav class="footer-nav footer-nav--legal"><ul>${i.legal.map(p).join("")}</ul></nav>`),g.footer){const a=Object.keys(t).filter(l=>o[l]);a.length&&(n+='<div class="footer-social">',a.forEach(l=>{const{label:d,svg:b}=t[l];n+=`<a href="${o[l]}" target="_blank" rel="noopener noreferrer" aria-label="${d}" class="footer-social-link">${b}</a>`}),n+="</div>")}n+="</div>",C.html(n);const u=[...y(i.primary,[]),...y(i.legal,[])];C.get(0).querySelectorAll(".footer-nav a").forEach((a,l)=>{const d=u[l];if(!d)return;d.colour&&O(a,d.colour);const b=M(d.badge);b&&a.appendChild(b),B(d.url,location.pathname)&&(a.classList.add("is-active"),a.setAttribute("aria-current","page"))}),N(C.get(0).querySelector(".footer-nav--primary"),{highlight:i.primaryHighlight}),N(C.get(0).querySelector(".footer-nav--legal"),{highlight:i.legalHighlight});const x=g.footer?.showMotionToggle!==!1,c=S.get("reduced_motion"),h=c!==null?!!c:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),r=C.get(0).querySelector(".footer-inner");if(x&&r){const a=document.createElement("input");a.type="checkbox",a.className="form-switch-input",a.id="dm-motion-switch",a.checked=h,a.addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()});const l=document.createElement("span");l.className="form-switch-label",l.textContent="Reduce motion";const d=document.createElement("label");d.className="form-switch footer-motion-switch",d.title="Reduce motion",d.appendChild(a),d.appendChild(l),r.appendChild(d)}}$("#site-sidebar").length&&Domma.elements.sidebar("#site-sidebar",{autoGenerate:!0,selector:"h2, h3",collapsible:!1,push:!0,contentSelector:".site-content"}),Domma.icons.scan(),Domma.syntax?.scan&&Domma.syntax.scan();const f=$(".page-body");if(f.length){f.find(".accordion").each(function(){Domma.elements.accordion(this,{allowMultiple:this.dataset.multi==="true"})}),f.find(".tabs").each(function(){Domma.elements.tabs(this)}),f.find(".carousel").each(function(){const o={autoplay:this.dataset.autoplay==="true",interval:parseInt(this.dataset.interval,10)||5e3,loop:this.dataset.loop!=="false",animation:this.dataset.animation||"slide"};if(this.dataset.animationDuration){const t=parseInt(this.dataset.animationDuration,10);Number.isFinite(t)&&(o.animationDuration=t)}this.dataset.animationEasing&&(o.animationEasing=this.dataset.animationEasing),this.dataset.ctx&&(this._dmCtxSlides=Array.from(this.querySelectorAll(".carousel-slide")).map(t=>t.cloneNode(!0)),this._dmCarouselOpts=o),this._dmCarousel=Domma.elements.carousel(this,o)}),f.find(".dm-countdown").each(function(){const o={autoStart:!0};this.dataset.to&&(o.targetDate=new Date(this.dataset.to)),this.dataset.duration&&(o.duration=parseInt(this.dataset.duration,10)),this.dataset.format&&(o.format=this.dataset.format),Domma.elements.timer(this,o)}),f.find("[data-tooltip]").each(function(){Domma.elements.tooltip(this,{content:$(this).data("tooltip"),position:$(this).data("tooltip-position")||"top"})}),f.find(".dm-progression").each(function(){const o=Array.from(this.querySelectorAll(".dm-progression-item")).map(i=>{const e={title:i.querySelector(".dm-progression-item-title")?.textContent?.trim()||"",description:i.querySelector(".dm-progression-item-body")?.innerHTML?.trim()||""};return i.dataset.date&&(e.date=i.dataset.date),i.dataset.status&&(e.status=i.dataset.status),i.dataset.entryId&&(e._entryId=i.dataset.entryId),e}),t=this.dataset.layout==="centred"?"centered":this.dataset.layout||"vertical",n=Domma.elements.progression(this,{layout:t,theme:this.dataset.theme||"minimal",mode:this.dataset.mode||"timeline",statusIcons:!0,allowHtml:!0,...o.length?{items:o}:{}});o.length&&(this._dmCtx={instance:n,items:o})});try{Domma.effects.reveal(".page-body .hero",{animation:"slide-up",duration:480,threshold:.06,stagger:60,once:!1})}catch{}document.querySelectorAll(".page-body .row[data-reveal]").forEach(o=>{const t=o.dataset.revealMode||"stagger",n=o.dataset.revealAnimation||"slide-up",i=parseInt(o.dataset.revealDuration,10)||400,e=parseInt(o.dataset.revealStagger,10)||60,s=parseInt(o.dataset.revealDelay,10)||0,p=o.dataset.revealDirection==="rtl",y=Array.from(o.children),u={"slide-up":"translateY(30px)","slide-down":"translateY(-30px)","slide-left":"translateX(30px)","slide-right":"translateX(-30px)",zoom:"scale(0.85)",flip:"perspective(600px) rotateX(15deg)"};y.forEach((m,x)=>{m.style.opacity="0",m.style.transform=u[n]||"",m.style.transition=`opacity ${i}ms ease, transform ${i}ms ease`;const c=p?y.length-1-x:x;m.style.transitionDelay=t==="stagger"?`${s+c*e}ms`:`${s}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const m=new IntersectionObserver(x=>{x.forEach(c=>{c.isIntersecting&&(c.target.offsetWidth,c.target.style.opacity="1",c.target.style.transform="none",m.unobserve(c.target))})},{threshold:.1});y.forEach(x=>m.observe(x))}))}),f.find(".card[data-collapsible]").each(function(){const o=this.querySelector(".card-header");o&&o.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),f.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const o=this.dataset.soTarget,t=document.getElementById(o);if(!t)return;const n=E.slideover({title:t.dataset.soTitle||"",size:t.dataset.soSize||"md",position:t.dataset.soPosition||"right"});t.style.display="",n.setContent(t),n.open()})})}if(typeof $.setup=="function"){const o=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(t=>{try{const n=atob(t.dataset.config),i=JSON.parse(n);Object.assign(o,i)}catch{}}),Object.keys(o).length>0){const t={};for(const[n,i]of Object.entries(o)){const e=i?.events?.click,{confirm:s,toast:p,alert:y,prompt:u,...m}=e||{};s||p||y||u?($(n).on("click",async function(c){if(c.preventDefault(),s&&!await E.confirm(s))return;let h=null;if(!(u&&(h=await E.prompt(u,{inputPlaceholder:m.promptPlaceholder||"",inputValue:m.promptDefault||""}),h===null))){if(m.target){const r=$(m.target);m.toggleClass&&r.toggleClass(m.toggleClass),m.addClass&&r.addClass(m.addClass),m.removeClass&&r.removeClass(m.removeClass),h!==null&&(m.setText&&r.text(h),m.setVal&&r.val(h),m.setAttr&&r.attr(m.setAttr,h))}m.href&&(window.location.href=m.href),p&&E.toast(p,{type:m.toastType||"success"}),y&&E.alert(y)}}),Object.keys(m).length&&(t[n]={...i,events:{...i.events,click:m}})):t[n]=i}$.setup(t)}}f.length&&D(f.get(0))}),window.wireCTAButtons=D;function D(w){w.querySelectorAll(".dm-cta-trigger").forEach(g=>{g.__ctaWired||(g.__ctaWired=!0,g.addEventListener("click",async()=>{const _=g.dataset.action,C=g.dataset.entry,A=g.dataset.confirm;let f=S.get("auth_token");if(!f){E.toast("Please log in to perform this action.",{type:"warning"});return}if(A&&!await E.confirm(A))return;const o=Array.from(g.childNodes).map(n=>n.cloneNode(!0));g.disabled=!0,g.textContent="Running\u2026";const t=n=>fetch(`/api/actions/${_}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${n}`},body:JSON.stringify(g.dataset.scope?{entryId:C,scope:g.dataset.scope}:{entryId:C})});try{let n=await t(f);if(n.status===401){const e=S.get("auth_refresh_token");if(e){const s=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(s.ok){const{token:p}=await s.json();S.set("auth_token",p),f=p,n=await t(f)}}}const i=await n.json().catch(()=>({}));if(!n.ok)throw new Error(i.error||i.message||`Error ${n.status}`);E.toast(i.message||"Action completed.",{type:"success"})}catch(n){E.toast(n.message||"Action failed.",{type:"error"})}finally{g.disabled=!1,g.textContent="",o.forEach(n=>g.appendChild(n)),Domma.icons.scan(g)}}))})}(function(){const g=document.querySelectorAll('.dm-collection-hydrate[data-collection-scope="mine"]');if(!g.length)return;const _=S.get("auth_token");function C(f,o){const t=document.createElement("div");t.className="dm-collection-display dm-collection-empty";const n=document.createElement("p");if(o){n.appendChild(document.createTextNode("Please "));const i=document.createElement("a");i.href="/login",i.textContent="sign in",n.appendChild(i),n.appendChild(document.createTextNode(" to view your entries."))}else n.textContent=f;return t.appendChild(n),t}function A(f,o){const t=document.createElement("template");t.innerHTML=o||"",f.replaceWith(t.content)}g.forEach(async f=>{const o=f.dataset.collectionAttrs;if(!o)return;if(!_){f.replaceChildren(C("",!0));return}const t=n=>fetch("/api/collections/render-scope",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${n}`},body:JSON.stringify({attrs:o})});try{let n=_,i=await t(n);if(i.status===401){const s=S.get("auth_refresh_token");if(s){const p=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:s})});if(p.ok){const{token:y}=await p.json();S.set("auth_token",y),n=y,i=await t(n)}}}if(!i.ok){const{error:s}=await i.json().catch(()=>({}));f.replaceChildren(C(s||"Unable to load entries.",!1));return}const{html:e}=await i.json();A(f,e),window.DommaCollectionBrowser?.hydrate&&window.DommaCollectionBrowser.hydrate(document)}catch{f.replaceChildren(C("Unable to load entries.",!1))}}),setTimeout(()=>{typeof Domma<"u"&&Domma.icons?.scan&&Domma.icons.scan(),typeof Domma<"u"&&Domma.syntax?.scan&&Domma.syntax.scan(),D(document.body)},100)})(),(function(){const g=document.querySelectorAll("[data-collection-table]");!g.length||typeof T>"u"||!T.create||g.forEach(_=>{let C;try{C=JSON.parse(atob(_.dataset.payload))}catch{return}const{columns:A,rows:f,search:o,sortable:t,exportable:n,pageSize:i,empty:e,ctaConfig:s,slug:p,exportCfg:y}=C;if(!A?.length)return;if(s){const x=A.findIndex(c=>c.key==="_cta");x!==-1&&(A[x]={key:"_cta",title:"",render:(c,h)=>{const r=document.createElement("button");if(r.className=`btn btn-${s.style||"primary"} dm-cta-trigger`,r.dataset.action=s.action||"",s.scope&&(r.dataset.scope=s.scope),r.dataset.entry=h._entryId||"",s.confirm&&(r.dataset.confirm=s.confirm),s.icon){const a=document.createElement("span");a.dataset.icon=s.icon,r.appendChild(a),r.appendChild(document.createTextNode(" "))}return r.appendChild(document.createTextNode(s.label||"Run")),r}})}const u="col-table-"+Math.random().toString(36).slice(2,7),m=document.createElement("div");m.id=u,_.replaceChildren(m),T.create("#"+u,{data:f,columns:A,search:o,sortable:t,exportable:n,pageSize:i,emptyMessage:e}),s&&D(m),_._dmTable={columns:A,rows:f,slug:p,exportCfg:y,render(x){T.create("#"+u,{data:x,columns:A,search:o,sortable:t,exportable:n,pageSize:i,emptyMessage:e}),s&&D(m)}}})})(),(function(){const g=document.querySelectorAll("[data-form-inline]");if(!g.length||typeof F>"u")return;function _(t){const n=t&&t.logic;return!!(n&&(n.visibility||n.requirement))}function C(t,n){const i={};return(t||[]).forEach(e=>{if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const s=e.type==="chooser"||!!(e.variant&&["select","radio","checkbox-group"].includes(e.type)),p=e.type==="checkbox"?"boolean":e.type==="date"||e.type==="icon"||e.type==="image"?"string":s?"chooser":e.type,y={...e.formConfig||{}};y.span==="full"&&n&&(y.span=n);const u={type:p,required:_(e)?!1:e.required,label:e.label,options:e.options,formConfig:{...e.placeholder&&{placeholder:e.placeholder},...e.helper&&{hint:e.helper},...e.tooltip&&{tooltip:e.tooltip},...y}};e.minLength!==void 0&&(u.minLength=e.minLength),e.maxLength!==void 0&&(u.maxLength=e.maxLength),e.min!==void 0&&(u.min=e.min),e.max!==void 0&&(u.max=e.max),s&&(e.variant&&(u.variant=e.variant),(e.multiple||e.type==="checkbox-group")&&(u.multiple=!0),e.density&&(u.density=e.density),e.columns&&(u.columns=e.columns),e.accent&&(u.accent=e.accent),e.accentStyle&&(u.accentStyle=e.accentStyle),e.glow&&(u.glow=!0),e.glowColour&&(u.glowColour=e.glowColour),e.shadow&&(u.shadow=e.shadow),e.shadowColour&&(u.shadowColour=e.shadowColour)),i[e.name]=u}),i}function A(t){const n={};return(t||[]).forEach(i=>{if(!(!i.name||i.type==="page-break"||i.type==="spacer")&&(i.type==="select"||i.type==="multiselect")&&i.required){const e=(i.options||[])[0];e&&(n[i.name]=typeof e=="object"?e.value:e)}}),n}function f(t,n){(n||[]).forEach(i=>{if(i.type!=="date"||!i.name)return;const e=t.querySelector(`[name="${i.name}"]`);e&&e.type!=="date"&&(e.type="date")})}function o(t,n,i){let e=t.querySelector(".cms-form-message");e||(e=document.createElement("p"),e.className="cms-form-message",t.appendChild(e)),e.textContent=n,e.style.cssText=i?"color:var(--dm-danger);margin-top:.75rem;":"color:var(--dm-success);margin-top:.75rem;"}g.forEach(t=>{let n;try{n=JSON.parse(atob(t.dataset.formInline))}catch{return}const i=n.fields||[],e=n.settings||{},s=Number(e.columns)===3?3:2,p=e.layout||"grid",y=i.some(r=>r.type==="page-break"),u=async r=>{if(t.dataset.triggerBlocked==="true"){const a=t._formLogicRuntime,l=a&&a.submitBlock?a.submitBlock().reasons:[];o(t,l.join(" ")||"This form cannot be submitted yet.",!0);const d=new Error("Blocked by a form trigger");throw d.formSubmitFailed=!0,d}try{const a=t.querySelector(".fb-form-honeypot"),l=a?.querySelector('input[type="text"]')||null,d=a?.querySelector('[name="_t"]')||null,b=Object.assign({},r);delete b._t,a?.dataset.hpName&&delete b[a.dataset.hpName],l!==null&&(b._hp=l.value),d!==null&&(b._t=d.value);const v=await H.post(`/api/forms/submit/${n.slug}`,b);if(v&&v.error){o(t,v.error,!0);const I=new Error(v.error);throw I.formSubmitFailed=!0,I}if(v?.redirect){window.location.href=v.redirect;return}for(;t.firstChild;)t.removeChild(t.firstChild);o(t,v?.message||e.successMessage||"Thank you for your submission.",!1)}catch(a){throw a.formSubmitFailed||o(t,a.message||"Submission failed. Please try again.",!0),a}},m=Date.now();function x(r){const a=r.querySelector("form");if(!a||a.querySelector(".fb-form-honeypot"))return;const l=document.createElement("div");l.className="fb-form-honeypot",l.setAttribute("aria-hidden","true");const d=document.createElement("input");d.name=a.querySelector('[name="website"]')?"_website":"website",l.dataset.hpName=d.name,d.type="text",d.tabIndex=-1,d.autocomplete="url",d.placeholder="https://",l.appendChild(d);const b=document.createElement("input");b.name="_t",b.type="hidden",b.value=m,l.appendChild(b),a.appendChild(l)}function c(r){if(!window.FormLogicEngine||!i.some(l=>l.logic||Array.isArray(l.triggers)&&l.triggers.length))return null;const a=new window.FormLogicEngine.FormLogicRuntime(n,t,r?{baseValues:r}:void 0);if(a.init(),t._formLogicRuntime=a,t._formLogicObserved)return a;if(t._formLogicObserved=!0,t.parentNode&&typeof MutationObserver<"u"){const l=new MutationObserver(function(d){for(const b of d)for(const v of b.removedNodes)if(v===t||v.nodeType===1&&v.contains&&v.contains(t)){t._formLogicRuntime&&t._formLogicRuntime.destroy(),l.disconnect();return}});l.observe(t.parentNode,{childList:!0,subtree:!1}),t._formLogicObserver=l}return a}let h=!1;if(t.addEventListener("dm-form-end",r=>{const a=r.detail?.validation;if(a&&!a.valid){R(t,a.missing),P(a);return}h||(h=!0,u(r.detail?.values||{}).catch(()=>{}).finally(()=>{h=!1}))}),y&&F.wizard){const r=U(i,n.title),a=r.map(k=>({title:k.title,description:k.description,fields:C(k.fields,s)})),l=r.map(k=>k.fields);let d=0,b=null,v=null;const I=k=>{const L=window.FormLogicEngine;if(!L||!L.validateFields)return;const z=L.validateFields(t,l[d]||[]);z.valid||(k.preventDefault(),k.stopImmediatePropagation(),R(t,z.missing),P(z))},q=()=>{const k=l[d]||i;Q(t,e),f(t,k),j(t,k),e.honeypot!==!1&&x(t),b&&b.destroy(),b=c(v?.getStepData?v.getStepData():void 0),t.querySelectorAll(".wizard-next, .wizard-submit").forEach(L=>L.addEventListener("click",I,!0))};v=F.wizard(t,{schema:{steps:a},layout:p,columns:s,finishText:e.submitText||"Finish",onSubmit:u,onStepChange:k=>{d=k,setTimeout(q,0)}}),t.addEventListener("dm-form-trigger",k=>{if(k.detail?.type!=="goto-step"||!v?.goToStep)return;const L=Number(k.detail.action.step);!Number.isFinite(L)||L<0||L>=a.length||L!==d&&v.goToStep(L)}),Promise.resolve(v).then(q)}else if(F.render){const r=F.render(t,C(i,s),A(i),{submitText:e.submitText||"Submit",layout:p,columns:s,onSubmit:u});Promise.resolve(r).then(function(){K(t,e,p),f(t,i),j(t,i),e.honeypot!==!1&&x(t),c()})}}),$(document).on("click",".dm-banner__dismiss",function(){$(this).closest(".dm-banner").remove()})})();
|
|
1
|
+
import{makeBadgeEl as z,applyColour as O,applyPill as V,applyFloat as J,applyHighlight as N,applyItemHighlight as W,itemHighlightAttrs as X,isCurrentItem as B,menuAlign as G}from"/public/js/menu-decor.mjs?v=20260918-item-highlight";import{initFloatingMenus as Y}from"/public/js/menu-float.js?v=20260829-float-panel";import{enhancePickerFields as j,markInvalidFields as R,showValidationPopup as P}from"/public/js/forms.js?v=20260927-submit-btn";import{buildWizardSteps as U}from"/public/js/form-steps.mjs";import{applySubmitButtons as K,applyWizardButtons as Q}from"/public/js/form-submit.mjs";$(()=>{const C=window.__CMS_NAV__||{},y=window.__CMS_SITE__||{};if(y.autoTheme?.enabled){let t=function(e){const s=(e||"07:00").split(":");return+s[0]*60+(+s[1]||0)},i=function(){const e=new Date,s=e.getHours()*60+e.getMinutes();return s>=t(a.dayStart)&&s<t(a.nightStart)?a.dayTheme:a.nightTheme},o=function(e){const s=a.apply&&a.apply[e]||{theme:e,custom:""};Domma.theme.set(s.theme);for(const p of[...document.body.classList])p.startsWith("dm-custom-")&&document.body.classList.remove(p);s.custom&&document.body.classList.add(s.custom)};const a=y.autoTheme;o(i()),setInterval(()=>o(i()),6e4)}const _=$("#site-navbar");if(_.length&&C.brand){let i=function(c,h=0){return Array.isArray(c)?c.filter(r=>!(r&&r.type==="spacer")).filter(r=>!(r&&r.type==="separator"&&h===0)).map(r=>r&&r.type==="separator"?{divider:!0}:Array.isArray(r?.items)?{...r,items:i(r.items,h+1)}:r):c},o=function(c){return Array.isArray(c)?c.filter(h=>!(h&&(h.type==="separator"||h.type==="spacer"))):[]};const a={...C.brand},t=a.size&&a.size!=="md"?` navbar-brand-${a.size}`:"";if(a.logo||a.icon||a.tagline){let c="";a.logo?c+=`<img src="${a.logo}" class="navbar-brand-logo" alt="${a.text||""}">`:a.icon&&(c+=`<span data-icon="${a.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),a.text&&(c+=`<span class="navbar-brand-text${t}">${a.text}</span>`),a.tagline&&(c+=`<small class="navbar-brand-tagline">${a.tagline}</small>`),a.html=c}else t&&a.text&&(a.html=`<span class="navbar-brand-text${t}">${a.text}</span>`);const e=C.position==="floating",s=C.orientation==="vertical";Domma.elements.navbar("#site-navbar",{brand:a,items:i(C.items||[]),variant:C.variant||"dark",position:e?"static":C.position||"sticky",collapsible:!0,appearOnHover:!!C.appearOnHover}),Domma.icons.scan("#site-navbar");const p=_.get(0);p.classList.add(`dm-nav-align-${G(C)||"centre"}`),s&&_.get(0).classList.add("dm-nav-vertical",C.side==="right"?"dm-nav-vertical-right":"dm-nav-vertical-left"),N(p,C),e&&(J(_.get(0),C),Y(_.get(0).parentNode||document));const g=p.querySelector(".navbar-toggle"),u=p.querySelector(".navbar-collapse");if(g&&u){let c=null;const h=()=>{u.classList.contains("show")&&g.click()},r=()=>{c&&(c.remove(),c=null),document.body.classList.remove("dm-drawer-open")},n=()=>{c||(c=document.createElement("div"),c.className="dm-navbar-backdrop",c.addEventListener("click",h),document.body.appendChild(c),document.body.classList.add("dm-drawer-open"))};g.addEventListener("click",()=>{requestAnimationFrame(()=>{u.classList.contains("show")?n():r()})}),u.addEventListener("click",l=>{l.target.closest("a")&&requestAnimationFrame(r)}),document.addEventListener("keydown",l=>{l.key==="Escape"&&u.classList.contains("show")&&h()})}const m=p.querySelector(".navbar-nav");if(m){const c=Array.from(m.children);let h=0;for(const r of C.items||[])if(!(r&&r.type==="separator")){if(r&&r.type==="spacer"){const n=document.createElement("li"),l=["xs","sm","md","lg","xl","flex"].includes(r.size)?r.size:"md";n.className=`dm-menu-spacer dm-menu-spacer--${l}`,n.setAttribute("aria-hidden","true"),m.insertBefore(n,c[h]||null);continue}h++}}const b=o(C.items||[]);p.querySelectorAll(".navbar-link[data-index], .navbar-dropdown-item[data-index]").forEach(c=>{const h=b[parseInt(c.dataset.index,10)];if(!h)return;const r=c.dataset.subindex!=null,n=r&&Array.isArray(h.items)?h.items[parseInt(c.dataset.subindex,10)]:h;if(!n)return;n.colour&&O(c,n.colour),n.pill&&!r&&V(c,n.pill),W(c,n);const l=z(n.badge);l&&c.appendChild(l),B(n.url,location.pathname)&&(c.classList.add("active"),c.setAttribute("aria-current","page"))})}const x=$("#site-footer");if(x.length){let e=function(n){return String(n??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},s=function(n){return e(n).replace(/'/g,"'")},p=function(n){if(n&&n.type==="separator")return'<li class="footer-sep"><hr></li>';if(n&&n.type==="spacer")return`<li class="dm-menu-spacer dm-menu-spacer--${["xs","sm","md","lg","xl","flex"].includes(n.size)?n.size:"md"}" aria-hidden="true"></li>`;const l=s(n.url||"#"),d=e(n.text||""),w=Array.isArray(n.items)&&n.items.length?`<ul>${n.items.map(p).join("")}</ul>`:"",v=X(n),D=[v.hover&&` data-menu-item-hover="${s(v.hover)}"`,v.active&&` data-menu-item-active="${s(v.active)}"`].filter(Boolean).join(""),M=v.accent?` style="--dm-menu-highlight:${s(v.accent)}"`:"";return`<li><a href="${l}"${D}${M}>${d}</a>${w}</li>`},g=function(n,l){return(n||[]).forEach(d=>{d&&(d.type==="separator"||d.type==="spacer")||(l.push(d),Array.isArray(d.items)&&d.items.length&&g(d.items,l))}),l};const a=y.social||{},t={twitter:{label:"X / Twitter",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.742l7.73-8.835L1.254 2.25H8.08l4.259 5.629L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>'},facebook:{label:"Facebook",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'},instagram:{label:"Instagram",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>'},linkedin:{label:"LinkedIn",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'},github:{label:"GitHub",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>'},youtube:{label:"YouTube",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.495 6.205a3.007 3.007 0 00-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 00.527 6.205a31.247 31.247 0 00-.522 5.805 31.247 31.247 0 00.522 5.783 3.007 3.007 0 002.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 002.088-2.088 31.247 31.247 0 00.5-5.783 31.247 31.247 0 00-.5-5.805zM9.609 15.601V8.408l6.264 3.602z"/></svg>'}};let i='<div class="footer-inner container">';const o=window.__CMS_FOOTER__||{primary:[],legal:[],copyright:""};if(o.copyright&&(i+=`<p>${e(o.copyright)}</p>`),o.primary.length&&(i+=`<nav class="footer-nav footer-nav--primary"><ul>${o.primary.map(p).join("")}</ul></nav>`),o.legal.length&&(i+=`<nav class="footer-nav footer-nav--legal"><ul>${o.legal.map(p).join("")}</ul></nav>`),y.footer){const n=Object.keys(t).filter(l=>a[l]);n.length&&(i+='<div class="footer-social">',n.forEach(l=>{const{label:d,svg:w}=t[l];i+=`<a href="${a[l]}" target="_blank" rel="noopener noreferrer" aria-label="${d}" class="footer-social-link">${w}</a>`}),i+="</div>")}i+="</div>",x.html(i);const u=[...g(o.primary,[]),...g(o.legal,[])];x.get(0).querySelectorAll(".footer-nav a").forEach((n,l)=>{const d=u[l];if(!d)return;d.colour&&O(n,d.colour);const w=z(d.badge);w&&n.appendChild(w),B(d.url,location.pathname)&&(n.classList.add("is-active"),n.setAttribute("aria-current","page"))}),N(x.get(0).querySelector(".footer-nav--primary"),{highlight:o.primaryHighlight}),N(x.get(0).querySelector(".footer-nav--legal"),{highlight:o.legalHighlight});const b=y.footer?.showMotionToggle!==!1,c=S.get("reduced_motion"),h=c!==null?!!c:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),r=x.get(0).querySelector(".footer-inner");if(b&&r){const n=document.createElement("input");n.type="checkbox",n.className="form-switch-input",n.id="dm-motion-switch",n.checked=h,n.addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()});const l=document.createElement("span");l.className="form-switch-label",l.textContent="Reduce motion";const d=document.createElement("label");d.className="form-switch footer-motion-switch",d.title="Reduce motion",d.appendChild(n),d.appendChild(l),r.appendChild(d)}}$("#site-sidebar").length&&Domma.elements.sidebar("#site-sidebar",{autoGenerate:!0,selector:"h2, h3",collapsible:!1,push:!0,contentSelector:".site-content"}),Domma.icons.scan(),Domma.syntax?.scan&&Domma.syntax.scan();const f=$(".page-body");if(f.length){f.find(".accordion").each(function(){Domma.elements.accordion(this,{allowMultiple:this.dataset.multi==="true"})}),f.find(".tabs").each(function(){Domma.elements.tabs(this)}),f.find(".carousel").each(function(){const a={autoplay:this.dataset.autoplay==="true",interval:parseInt(this.dataset.interval,10)||5e3,loop:this.dataset.loop!=="false",animation:this.dataset.animation||"slide"};if(this.dataset.animationDuration){const t=parseInt(this.dataset.animationDuration,10);Number.isFinite(t)&&(a.animationDuration=t)}this.dataset.animationEasing&&(a.animationEasing=this.dataset.animationEasing),this.dataset.ctx&&(this._dmCtxSlides=Array.from(this.querySelectorAll(".carousel-slide")).map(t=>t.cloneNode(!0)),this._dmCarouselOpts=a),this._dmCarousel=Domma.elements.carousel(this,a)}),f.find(".dm-countdown").each(function(){const a={autoStart:!0},t=String(this.dataset.format||"").trim(),i=String(this.dataset.to||"").trim(),o=/^(\d{4})-(\d{2})-(\d{2})$/.exec(i),e=o?new Date(+o[1],+o[2]-1,+o[3]).getTime():i?new Date(i).getTime():NaN;Number.isFinite(e)?a.duration=Math.max(0,e-Date.now()):this.dataset.duration&&(a.duration=(parseInt(this.dataset.duration,10)||0)*1e3);const s=/^dd:/i.test(t);a.format=s?"hh:mm:ss":t||(a.duration>=36e5?"hh:mm:ss":"mm:ss");const p=this,g=m=>String(m).padStart(2,"0"),u=m=>{const b=Math.max(0,Math.floor(m/1e3)),c=p.querySelector(".dm-timer-time");c&&(c.textContent=`${g(Math.floor(b/86400))}:${g(Math.floor(b%86400/3600))}:${g(Math.floor(b%3600/60))}:${g(b%60)}`)};s&&(a.onTick=u),Domma.elements.timer(this,a),s&&u(a.duration||0)}),f.find("[data-tooltip]").each(function(){Domma.elements.tooltip(this,{content:$(this).data("tooltip"),position:$(this).data("tooltip-position")||"top"})}),f.find(".dm-progression").each(function(){const a=Array.from(this.querySelectorAll(".dm-progression-item")).map(o=>{const e={title:o.querySelector(".dm-progression-item-title")?.textContent?.trim()||"",description:o.querySelector(".dm-progression-item-body")?.innerHTML?.trim()||""};return o.dataset.date&&(e.date=o.dataset.date),o.dataset.status&&(e.status=o.dataset.status),o.dataset.entryId&&(e._entryId=o.dataset.entryId),e}),t=this.dataset.layout==="centred"?"centered":this.dataset.layout||"vertical",i=Domma.elements.progression(this,{layout:t,theme:this.dataset.theme||"minimal",mode:this.dataset.mode||"timeline",statusIcons:!0,allowHtml:!0,...a.length?{items:a}:{}});a.length&&(this._dmCtx={instance:i,items:a})});try{Domma.effects.reveal(".page-body .hero",{animation:"slide-up",duration:480,threshold:.06,stagger:60,once:!1})}catch{}document.querySelectorAll(".page-body .row[data-reveal]").forEach(a=>{const t=a.dataset.revealMode||"stagger",i=a.dataset.revealAnimation||"slide-up",o=parseInt(a.dataset.revealDuration,10)||400,e=parseInt(a.dataset.revealStagger,10)||60,s=parseInt(a.dataset.revealDelay,10)||0,p=a.dataset.revealDirection==="rtl",g=Array.from(a.children),u={"slide-up":"translateY(30px)","slide-down":"translateY(-30px)","slide-left":"translateX(30px)","slide-right":"translateX(-30px)",zoom:"scale(0.85)",flip:"perspective(600px) rotateX(15deg)"};g.forEach((m,b)=>{m.style.opacity="0",m.style.transform=u[i]||"",m.style.transition=`opacity ${o}ms ease, transform ${o}ms ease`;const c=p?g.length-1-b:b;m.style.transitionDelay=t==="stagger"?`${s+c*e}ms`:`${s}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const m=new IntersectionObserver(b=>{b.forEach(c=>{c.isIntersecting&&(c.target.offsetWidth,c.target.style.opacity="1",c.target.style.transform="none",m.unobserve(c.target))})},{threshold:.1});g.forEach(b=>m.observe(b))}))}),f.find(".card[data-collapsible]").each(function(){const a=this.querySelector(".card-header");a&&a.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),f.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const a=this.dataset.soTarget,t=document.getElementById(a);if(!t)return;const i=E.slideover({title:t.dataset.soTitle||"",size:t.dataset.soSize||"md",position:t.dataset.soPosition||"right"});t.style.display="",i.setContent(t),i.open()})})}if(typeof $.setup=="function"){const a=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(t=>{try{const i=atob(t.dataset.config),o=JSON.parse(i);Object.assign(a,o)}catch{}}),Object.keys(a).length>0){const t={};for(const[i,o]of Object.entries(a)){const e=o?.events?.click,{confirm:s,toast:p,alert:g,prompt:u,...m}=e||{};s||p||g||u?($(i).on("click",async function(c){if(c.preventDefault(),s&&!await E.confirm(s))return;let h=null;if(!(u&&(h=await E.prompt(u,{inputPlaceholder:m.promptPlaceholder||"",inputValue:m.promptDefault||""}),h===null))){if(m.target){const r=$(m.target);m.toggleClass&&r.toggleClass(m.toggleClass),m.addClass&&r.addClass(m.addClass),m.removeClass&&r.removeClass(m.removeClass),h!==null&&(m.setText&&r.text(h),m.setVal&&r.val(h),m.setAttr&&r.attr(m.setAttr,h))}m.href&&(window.location.href=m.href),p&&E.toast(p,{type:m.toastType||"success"}),g&&E.alert(g)}}),Object.keys(m).length&&(t[i]={...o,events:{...o.events,click:m}})):t[i]=o}$.setup(t)}}f.length&&I(f.get(0))}),window.wireCTAButtons=I;function I(C){C.querySelectorAll(".dm-cta-trigger").forEach(y=>{y.__ctaWired||(y.__ctaWired=!0,y.addEventListener("click",async()=>{const _=y.dataset.action,x=y.dataset.entry,A=y.dataset.confirm;let f=S.get("auth_token");if(!f){E.toast("Please log in to perform this action.",{type:"warning"});return}if(A&&!await E.confirm(A))return;const a=Array.from(y.childNodes).map(i=>i.cloneNode(!0));y.disabled=!0,y.textContent="Running\u2026";const t=i=>fetch(`/api/actions/${_}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${i}`},body:JSON.stringify(y.dataset.scope?{entryId:x,scope:y.dataset.scope}:{entryId:x})});try{let i=await t(f);if(i.status===401){const e=S.get("auth_refresh_token");if(e){const s=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(s.ok){const{token:p}=await s.json();S.set("auth_token",p),f=p,i=await t(f)}}}const o=await i.json().catch(()=>({}));if(!i.ok)throw new Error(o.error||o.message||`Error ${i.status}`);E.toast(o.message||"Action completed.",{type:"success"})}catch(i){E.toast(i.message||"Action failed.",{type:"error"})}finally{y.disabled=!1,y.textContent="",a.forEach(i=>y.appendChild(i)),Domma.icons.scan(y)}}))})}(function(){const y=document.querySelectorAll('.dm-collection-hydrate[data-collection-scope="mine"]');if(!y.length)return;const _=S.get("auth_token");function x(f,a){const t=document.createElement("div");t.className="dm-collection-display dm-collection-empty";const i=document.createElement("p");if(a){i.appendChild(document.createTextNode("Please "));const o=document.createElement("a");o.href="/login",o.textContent="sign in",i.appendChild(o),i.appendChild(document.createTextNode(" to view your entries."))}else i.textContent=f;return t.appendChild(i),t}function A(f,a){const t=document.createElement("template");t.innerHTML=a||"",f.replaceWith(t.content)}y.forEach(async f=>{const a=f.dataset.collectionAttrs;if(!a)return;if(!_){f.replaceChildren(x("",!0));return}const t=i=>fetch("/api/collections/render-scope",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${i}`},body:JSON.stringify({attrs:a})});try{let i=_,o=await t(i);if(o.status===401){const s=S.get("auth_refresh_token");if(s){const p=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:s})});if(p.ok){const{token:g}=await p.json();S.set("auth_token",g),i=g,o=await t(i)}}}if(!o.ok){const{error:s}=await o.json().catch(()=>({}));f.replaceChildren(x(s||"Unable to load entries.",!1));return}const{html:e}=await o.json();A(f,e),window.DommaCollectionBrowser?.hydrate&&window.DommaCollectionBrowser.hydrate(document)}catch{f.replaceChildren(x("Unable to load entries.",!1))}}),setTimeout(()=>{typeof Domma<"u"&&Domma.icons?.scan&&Domma.icons.scan(),typeof Domma<"u"&&Domma.syntax?.scan&&Domma.syntax.scan(),I(document.body)},100)})(),(function(){const y=document.querySelectorAll("[data-collection-table]");!y.length||typeof T>"u"||!T.create||y.forEach(_=>{let x;try{x=JSON.parse(atob(_.dataset.payload))}catch{return}const{columns:A,rows:f,search:a,sortable:t,exportable:i,pageSize:o,empty:e,ctaConfig:s,slug:p,exportCfg:g}=x;if(!A?.length)return;if(s){const b=A.findIndex(c=>c.key==="_cta");b!==-1&&(A[b]={key:"_cta",title:"",render:(c,h)=>{const r=document.createElement("button");if(r.className=`btn btn-${s.style||"primary"} dm-cta-trigger`,r.dataset.action=s.action||"",s.scope&&(r.dataset.scope=s.scope),r.dataset.entry=h._entryId||"",s.confirm&&(r.dataset.confirm=s.confirm),s.icon){const n=document.createElement("span");n.dataset.icon=s.icon,r.appendChild(n),r.appendChild(document.createTextNode(" "))}return r.appendChild(document.createTextNode(s.label||"Run")),r}})}const u="col-table-"+Math.random().toString(36).slice(2,7),m=document.createElement("div");m.id=u,_.replaceChildren(m),T.create("#"+u,{data:f,columns:A,search:a,sortable:t,exportable:i,pageSize:o,emptyMessage:e}),s&&I(m),_._dmTable={columns:A,rows:f,slug:p,exportCfg:g,render(b){T.create("#"+u,{data:b,columns:A,search:a,sortable:t,exportable:i,pageSize:o,emptyMessage:e}),s&&I(m)}}})})(),(function(){const y=document.querySelectorAll("[data-form-inline]");if(!y.length||typeof F>"u")return;function _(t){const i=t&&t.logic;return!!(i&&(i.visibility||i.requirement))}function x(t,i){const o={};return(t||[]).forEach(e=>{if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const s=e.type==="chooser"||!!(e.variant&&["select","radio","checkbox-group"].includes(e.type)),p=e.type==="checkbox"?"boolean":e.type==="date"||e.type==="icon"||e.type==="image"?"string":s?"chooser":e.type,g={...e.formConfig||{}};g.span==="full"&&i&&(g.span=i);const u={type:p,required:_(e)?!1:e.required,label:e.label,options:e.options,formConfig:{...e.placeholder&&{placeholder:e.placeholder},...e.helper&&{hint:e.helper},...e.tooltip&&{tooltip:e.tooltip},...g}};e.minLength!==void 0&&(u.minLength=e.minLength),e.maxLength!==void 0&&(u.maxLength=e.maxLength),e.min!==void 0&&(u.min=e.min),e.max!==void 0&&(u.max=e.max),s&&(e.variant&&(u.variant=e.variant),(e.multiple||e.type==="checkbox-group")&&(u.multiple=!0),e.density&&(u.density=e.density),e.columns&&(u.columns=e.columns),e.accent&&(u.accent=e.accent),e.accentStyle&&(u.accentStyle=e.accentStyle),e.glow&&(u.glow=!0),e.glowColour&&(u.glowColour=e.glowColour),e.shadow&&(u.shadow=e.shadow),e.shadowColour&&(u.shadowColour=e.shadowColour)),o[e.name]=u}),o}function A(t){const i={};return(t||[]).forEach(o=>{if(!(!o.name||o.type==="page-break"||o.type==="spacer")&&(o.type==="select"||o.type==="multiselect")&&o.required){const e=(o.options||[])[0];e&&(i[o.name]=typeof e=="object"?e.value:e)}}),i}function f(t,i){(i||[]).forEach(o=>{if(o.type!=="date"||!o.name)return;const e=t.querySelector(`[name="${o.name}"]`);e&&e.type!=="date"&&(e.type="date")})}function a(t,i,o){let e=t.querySelector(".cms-form-message");e||(e=document.createElement("p"),e.className="cms-form-message",t.appendChild(e)),e.textContent=i,e.style.cssText=o?"color:var(--dm-danger);margin-top:.75rem;":"color:var(--dm-success);margin-top:.75rem;"}y.forEach(t=>{let i;try{i=JSON.parse(atob(t.dataset.formInline))}catch{return}const o=i.fields||[],e=i.settings||{},s=Number(e.columns)===3?3:2,p=e.layout||"grid",g=o.some(r=>r.type==="page-break"),u=async r=>{if(t.dataset.triggerBlocked==="true"){const n=t._formLogicRuntime,l=n&&n.submitBlock?n.submitBlock().reasons:[];a(t,l.join(" ")||"This form cannot be submitted yet.",!0);const d=new Error("Blocked by a form trigger");throw d.formSubmitFailed=!0,d}try{const n=t.querySelector(".fb-form-honeypot"),l=n?.querySelector('input[type="text"]')||null,d=n?.querySelector('[name="_t"]')||null,w=Object.assign({},r);delete w._t,n?.dataset.hpName&&delete w[n.dataset.hpName],l!==null&&(w._hp=l.value),d!==null&&(w._t=d.value);const v=await H.post(`/api/forms/submit/${i.slug}`,w);if(v&&v.error){a(t,v.error,!0);const D=new Error(v.error);throw D.formSubmitFailed=!0,D}if(v?.redirect){window.location.href=v.redirect;return}for(;t.firstChild;)t.removeChild(t.firstChild);a(t,v?.message||e.successMessage||"Thank you for your submission.",!1)}catch(n){throw n.formSubmitFailed||a(t,n.message||"Submission failed. Please try again.",!0),n}},m=Date.now();function b(r){const n=r.querySelector("form");if(!n||n.querySelector(".fb-form-honeypot"))return;const l=document.createElement("div");l.className="fb-form-honeypot",l.setAttribute("aria-hidden","true");const d=document.createElement("input");d.name=n.querySelector('[name="website"]')?"_website":"website",l.dataset.hpName=d.name,d.type="text",d.tabIndex=-1,d.autocomplete="url",d.placeholder="https://",l.appendChild(d);const w=document.createElement("input");w.name="_t",w.type="hidden",w.value=m,l.appendChild(w),n.appendChild(l)}function c(r){if(!window.FormLogicEngine||!o.some(l=>l.logic||Array.isArray(l.triggers)&&l.triggers.length))return null;const n=new window.FormLogicEngine.FormLogicRuntime(i,t,r?{baseValues:r}:void 0);if(n.init(),t._formLogicRuntime=n,t._formLogicObserved)return n;if(t._formLogicObserved=!0,t.parentNode&&typeof MutationObserver<"u"){const l=new MutationObserver(function(d){for(const w of d)for(const v of w.removedNodes)if(v===t||v.nodeType===1&&v.contains&&v.contains(t)){t._formLogicRuntime&&t._formLogicRuntime.destroy(),l.disconnect();return}});l.observe(t.parentNode,{childList:!0,subtree:!1}),t._formLogicObserver=l}return n}let h=!1;if(t.addEventListener("dm-form-end",r=>{const n=r.detail?.validation;if(n&&!n.valid){R(t,n.missing),P(n);return}h||(h=!0,u(r.detail?.values||{}).catch(()=>{}).finally(()=>{h=!1}))}),g&&F.wizard){const r=U(o,i.title),n=r.map(k=>({title:k.title,description:k.description,fields:x(k.fields,s)})),l=r.map(k=>k.fields);let d=0,w=null,v=null;const D=k=>{const L=window.FormLogicEngine;if(!L||!L.validateFields)return;const q=L.validateFields(t,l[d]||[]);q.valid||(k.preventDefault(),k.stopImmediatePropagation(),R(t,q.missing),P(q))},M=()=>{const k=l[d]||o;Q(t,e),f(t,k),j(t,k),e.honeypot!==!1&&b(t),w&&w.destroy(),w=c(v?.getStepData?v.getStepData():void 0),t.querySelectorAll(".wizard-next, .wizard-submit").forEach(L=>L.addEventListener("click",D,!0))};v=F.wizard(t,{schema:{steps:n},layout:p,columns:s,finishText:e.submitText||"Finish",onSubmit:u,onStepChange:k=>{d=k,setTimeout(M,0)}}),t.addEventListener("dm-form-trigger",k=>{if(k.detail?.type!=="goto-step"||!v?.goToStep)return;const L=Number(k.detail.action.step);!Number.isFinite(L)||L<0||L>=n.length||L!==d&&v.goToStep(L)}),Promise.resolve(v).then(M)}else if(F.render){const r=F.render(t,x(o,s),A(o),{submitText:e.submitText||"Submit",layout:p,columns:s,onSubmit:u});Promise.resolve(r).then(function(){K(t,e,p),f(t,o),j(t,o),e.honeypot!==!1&&b(t),c()})}}),$(document).on("click",".dm-banner__dismiss",function(){$(this).closest(".dm-banner").remove()})})();
|
package/server/server.js
CHANGED
|
@@ -31,6 +31,7 @@ import {refreshComponentTagAllowlist} from './services/markdown.js';
|
|
|
31
31
|
import * as cache from './services/cache/index.js';
|
|
32
32
|
import {parseTrustProxy} from './services/network.js';
|
|
33
33
|
import {rateLimitOptions} from './services/rate-limit.js';
|
|
34
|
+
import {frameSources} from './services/embedCsp.js';
|
|
34
35
|
|
|
35
36
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
37
|
const ROOT = path.resolve(__dirname, '..');
|
|
@@ -122,6 +123,8 @@ await app.register(helmet, {
|
|
|
122
123
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
123
124
|
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
|
|
124
125
|
connectSrc: ["'self'", 'https://cdn.jsdelivr.net'],
|
|
126
|
+
// [embed] players and config/embeds.json hosts - without this, default-src blocked them all.
|
|
127
|
+
frameSrc: frameSources((() => { try { return getConfig('embeds')?.allowedHosts; } catch { return []; } })()),
|
|
125
128
|
upgradeInsecureRequests: null, // prevent browsers forcing HTTPS on HTTP deployments
|
|
126
129
|
}
|
|
127
130
|
},
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame-src the Content-Security-Policy needs for [embed].
|
|
3
|
+
*
|
|
4
|
+
* The CSP had no frame-src, so it fell back to default-src 'self' and the
|
|
5
|
+
* browser blocked every embedded player - YouTube and Vimeo included, which the
|
|
6
|
+
* [embed] shortcode documents as always allowed. The page rendered the iframe
|
|
7
|
+
* and the visitor saw a blocked frame.
|
|
8
|
+
*
|
|
9
|
+
* So: 'self' (the [demo] shortcode frames the site's own pages), the two players
|
|
10
|
+
* [embed] rewrites links to (youtube-nocookie.com, player.vimeo.com), and every
|
|
11
|
+
* host in config/embeds.json `allowedHosts` - the same list the renderer and the
|
|
12
|
+
* sanitiser already enforce (server.js passes getConfig('embeds'), as the
|
|
13
|
+
* renderer reads it). Read at boot: a change to embeds.json needs a restart to
|
|
14
|
+
* reach the header.
|
|
15
|
+
*/
|
|
16
|
+
/** The players [embed] always uses. */
|
|
17
|
+
// youtube.com too: the HTML sanitiser lets its iframes through, so the policy must agree.
|
|
18
|
+
export const ALWAYS_FRAMED = ['https://www.youtube-nocookie.com', 'https://www.youtube.com', 'https://youtube.com', 'https://player.vimeo.com'];
|
|
19
|
+
|
|
20
|
+
/** A bare hostname, or nothing: a host in embeds.json must never smuggle in a scheme, path or wildcard. */
|
|
21
|
+
const HOST_RE = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string[]} allowedHosts - config/embeds.json allowedHosts
|
|
25
|
+
* @returns {string[]} frame-src sources
|
|
26
|
+
*/
|
|
27
|
+
export function frameSources(allowedHosts = []) {
|
|
28
|
+
const extra = (Array.isArray(allowedHosts) ? allowedHosts : [])
|
|
29
|
+
.map(h => String(h).trim().toLowerCase())
|
|
30
|
+
.filter(h => HOST_RE.test(h))
|
|
31
|
+
.map(h => `https://${h}`);
|
|
32
|
+
return [...new Set(["'self'", ...ALWAYS_FRAMED, ...extra])];
|
|
33
|
+
}
|
|
@@ -694,7 +694,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
|
|
|
694
694
|
* @param {string} opts.titleField - entry data field used as item title
|
|
695
695
|
* @param {string} [opts.dateField] - optional field surfaced as data-date
|
|
696
696
|
* @param {string} [opts.statusField]- optional field surfaced as data-status
|
|
697
|
-
* @param {string} [opts.iconField] - optional field surfaced as data-icon
|
|
697
|
+
* @param {string} [opts.iconField] - optional field surfaced as data-event-icon
|
|
698
698
|
* @param {string} [opts.bodyField] - optional Markdown body field
|
|
699
699
|
* @param {string} opts.layout - vertical | centred | horizontal
|
|
700
700
|
* @param {string} opts.theme - minimal | corporate | modern
|
|
@@ -712,7 +712,9 @@ function renderCollectionTimeline(entries, opts, ctxOpts = {}) {
|
|
|
712
712
|
const title = escapeHtmlText(String(e.data?.[titleField] ?? e.id ?? '(untitled)'));
|
|
713
713
|
const date = dateField && e.data?.[dateField] != null ? ` data-date="${escapeAttr(String(e.data[dateField]))}"` : '';
|
|
714
714
|
const status = statusField && e.data?.[statusField] != null ? ` data-status="${escapeAttr(String(e.data[statusField]))}"` : '';
|
|
715
|
-
|
|
715
|
+
// data-event-icon, never data-icon: the icon scanner turns ANY [data-icon] element into an
|
|
716
|
+
// icon, replacing its children - which wiped each item's title and body before site.js read them.
|
|
717
|
+
const icon = iconField && e.data?.[iconField] != null ? ` data-event-icon="${escapeAttr(String(e.data[iconField]))}"` : '';
|
|
716
718
|
const bodyHtml = bodyField
|
|
717
719
|
? marked.parse(String(e.data?.[bodyField] ?? ''))
|
|
718
720
|
: '';
|
|
@@ -3178,7 +3180,8 @@ function processTimelineBlocks(markdown) {
|
|
|
3178
3180
|
const title = escapeAttr(ia.title || '');
|
|
3179
3181
|
const date = ia.date ? ` data-date="${escapeAttr(ia.date)}"` : '';
|
|
3180
3182
|
const status = ia.status ? ` data-status="${escapeAttr(ia.status)}"` : '';
|
|
3181
|
-
|
|
3183
|
+
// data-event-icon, never data-icon - see renderCollectionTimeline: the icon scanner would empty the item.
|
|
3184
|
+
const icon = ia.icon ? ` data-event-icon="${escapeAttr(ia.icon)}"` : '';
|
|
3182
3185
|
const descHtml = marked.parse(processCardBlocks(processGridBlocks(processInlineShortcodes(restore(itemBody.trim())))));
|
|
3183
3186
|
return `<div class="dm-progression-item"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${descHtml}</div></div>`;
|
|
3184
3187
|
}
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
</script>
|
|
137
137
|
|
|
138
138
|
<!-- Site initialisation -->
|
|
139
|
-
<script src="/public/js/site.js?v=
|
|
139
|
+
<script src="/public/js/site.js?v=20261004-countdown" type="module"></script>
|
|
140
140
|
<script src="/public/js/collection-context.js?v=20260927-ctx" type="module"></script>
|
|
141
141
|
{{ctxMenusModule}}
|
|
142
142
|
|