domma-cms 1.14.0 → 1.16.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/CHANGELOG.md +12 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/admin-brand.js +1 -1
- package/admin/js/lib/google-analytics-panel.js +9 -0
- package/admin/js/lib/google-business-callback.js +1 -0
- package/admin/js/lib/google-business-import.js +3 -0
- package/admin/js/lib/google-business-panel.js +32 -0
- package/admin/js/lib/seo-defaults.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +5 -5
- package/admin/js/lib/site-search-section.js +1 -0
- package/admin/js/lib/site-settings-arrange.js +3 -3
- package/admin/js/lib/topbar-account.js +1 -1
- package/admin/js/lib/topbar-cache.js +6 -0
- package/admin/js/lib/topbar-notifications.js +4 -4
- package/admin/js/templates/docs/usage-site-settings.html +20 -7
- package/admin/js/templates/menu-editor.html +1 -1
- package/admin/js/templates/settings.html +104 -63
- package/admin/js/views/analytics.js +8 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +50 -42
- package/admin/js/views/settings.js +3 -3
- package/docs/DommaFeatures.md +3 -2
- package/docs/configuration.md +53 -8
- package/docs/google-business-profile.md +44 -0
- package/docs/screenshots/google-business-profile-review.png +0 -0
- package/docs/screenshots/notifications-cache.png +0 -0
- package/docs/screenshots/site-settings-search.png +0 -0
- package/package.json +1 -1
- package/public/css/motion.css +1 -0
- package/public/js/motion-control.mjs +1 -0
- package/public/js/site.js +1 -1
- package/server/routes/api/analytics.js +19 -0
- package/server/routes/api/google-business.js +64 -0
- package/server/routes/api/seo.js +14 -3
- package/server/routes/api/settings.js +23 -2
- package/server/services/adminBrand.js +1 -0
- package/server/services/googleBusiness.js +225 -0
- package/server/services/motionSettings.js +107 -0
- package/server/services/renderer.js +36 -6
- package/server/templates/page.html +2 -2
|
@@ -20,6 +20,7 @@ import {fileURLToPath} from 'url';
|
|
|
20
20
|
import * as cache from '../../services/cache/index.js';
|
|
21
21
|
import {normaliseAdminPath} from '../../services/roles.js';
|
|
22
22
|
import {cssClassProblems} from '../../../public/js/css-classes.mjs';
|
|
23
|
+
import {motionFooterMirror, normaliseMotionSettings} from '../../services/motionSettings.js';
|
|
23
24
|
import {invalidateMeasurementId, measurementIdOf, normaliseMeasurementId} from '../../services/googleAnalytics.js';
|
|
24
25
|
|
|
25
26
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -124,7 +125,9 @@ export async function settingsRoutes(fastify) {
|
|
|
124
125
|
// Designer classes on <body>, every page (Site Settings > Appearance).
|
|
125
126
|
'bodyClass',
|
|
126
127
|
// Google Analytics (Site Settings > Analytics) - services/googleAnalytics.js.
|
|
127
|
-
'googleAnalytics'
|
|
128
|
+
'googleAnalytics',
|
|
129
|
+
// The "Reduce motion" control (Site Settings > Reduce motion) - services/motionSettings.js.
|
|
130
|
+
'motion'
|
|
128
131
|
]);
|
|
129
132
|
const unknownKeys = Object.keys(data).filter(k => !ALLOWED_KEYS.has(k));
|
|
130
133
|
if (unknownKeys.length > 0) {
|
|
@@ -141,7 +144,7 @@ export async function settingsRoutes(fastify) {
|
|
|
141
144
|
}
|
|
142
145
|
// Designer classes - the same rules as menus and forms (css-classes.mjs).
|
|
143
146
|
for (const [label, value] of [['Body CSS classes', data.bodyClass], ['Footer CSS classes', data.footer?.class],
|
|
144
|
-
['Social link CSS classes', data.footer?.socialClass]]) {
|
|
147
|
+
['Social link CSS classes', data.footer?.socialClass], ['Reduce motion CSS classes', data.motion?.class]]) {
|
|
145
148
|
const problems = cssClassProblems(value);
|
|
146
149
|
if (problems.length) return reply.status(400).send({error: `${label}: ${problems[0]}.`});
|
|
147
150
|
}
|
|
@@ -184,6 +187,24 @@ export async function settingsRoutes(fastify) {
|
|
|
184
187
|
merged[k] = v;
|
|
185
188
|
}
|
|
186
189
|
}
|
|
190
|
+
// The Reduce motion control: stored whitelisted, with the footer keys it used to live
|
|
191
|
+
// under kept in step for older readers. A client that still writes only those footer
|
|
192
|
+
// keys (fleet tooling) moves the block with them, so its change is not overruled.
|
|
193
|
+
if ('motion' in data) {
|
|
194
|
+
if (data.motion !== null && (typeof data.motion !== 'object' || Array.isArray(data.motion))) {
|
|
195
|
+
return reply.status(400).send({error: 'Reduce motion: send an object.'});
|
|
196
|
+
}
|
|
197
|
+
merged.motion = normaliseMotionSettings(merged.motion, merged.footer);
|
|
198
|
+
merged.footer = {...merged.footer, ...motionFooterMirror(merged.motion)};
|
|
199
|
+
} else if (merged.motion && data.footer && typeof data.footer === 'object') {
|
|
200
|
+
const legacy = {showMotionToggle: 'enabled', motionDefault: 'default', motionControl: 'control'};
|
|
201
|
+
const moved = Object.entries(legacy).filter(([k]) => k in data.footer);
|
|
202
|
+
if (moved.length) {
|
|
203
|
+
const next = {...merged.motion};
|
|
204
|
+
for (const [k, key] of moved) next[key] = k === 'showMotionToggle' ? data.footer[k] !== false : data.footer[k];
|
|
205
|
+
merged.motion = normaliseMotionSettings(next, merged.footer);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
187
208
|
// Never erase smtp.pass with the empty string that the GET endpoint injects for safety
|
|
188
209
|
if (merged.smtp && merged.smtp.pass === '' && existing.smtp?.pass) {
|
|
189
210
|
merged.smtp = {...merged.smtp, pass: existing.smtp.pass};
|
|
@@ -24,6 +24,7 @@ export function adminBrandFor(site = {}) {
|
|
|
24
24
|
const str = (v) => (typeof v === 'string' ? v.replace(/[ \t\n\f\r]+/g, ' ').trim() : '');
|
|
25
25
|
const logo = str(own.logo);
|
|
26
26
|
return {
|
|
27
|
+
enabled: own.enabled !== false,
|
|
27
28
|
title: (str(own.title) || str(site?.title) || DEFAULT_ADMIN_TITLE).slice(0, 120),
|
|
28
29
|
// Same rule as the sidebar header: a site path or an http(s) address, nothing else.
|
|
29
30
|
logo: /^(?:\/(?!\/)|https?:\/\/)/i.test(logo) ? logo : '',
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/** Google Business Profile: one site connection, encrypted outside authored content.
|
|
2
|
+
* Remote business data is fetched on demand. Applying a preview is an explicit SEO form edit.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
export const BUSINESS_SCOPE = 'https://www.googleapis.com/auth/business.manage';
|
|
9
|
+
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
10
|
+
const ACCOUNT_URL = 'https://mybusinessaccountmanagement.googleapis.com/v1/accounts';
|
|
11
|
+
const LOCATION_URL = 'https://mybusinessbusinessinformation.googleapis.com/v1/';
|
|
12
|
+
const READ_MASK = 'name,title,storefrontAddress,phoneNumbers,websiteUri,metadata';
|
|
13
|
+
const TTL = 600_000;
|
|
14
|
+
const hash = value => createHash('sha256').update(String(value)).digest();
|
|
15
|
+
const text = (v, max = 300) => String(v ?? '').trim().slice(0, max);
|
|
16
|
+
const webAddress = v => /^https?:\/\/[^\s\\]+$/i.test(text(v, 500)) ? text(v, 500) : '';
|
|
17
|
+
|
|
18
|
+
export class GoogleBusinessError extends Error {
|
|
19
|
+
constructor(message, statusCode = 400) { super(message); this.statusCode = statusCode; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Only fields supported by the SEO organisation form; absent Google fields never clear site data. */
|
|
23
|
+
export function businessPreview(location = {}) {
|
|
24
|
+
const a = location.storefrontAddress || {};
|
|
25
|
+
return {name: text(location.name), title: text(location.title),
|
|
26
|
+
organisation: {type: 'LocalBusiness', name: text(location.title), url: webAddress(location.websiteUri),
|
|
27
|
+
phone: text(location.phoneNumbers?.primaryPhone, 80),
|
|
28
|
+
sameAs: [webAddress(location.metadata?.mapsUri)].filter(Boolean),
|
|
29
|
+
address: {street: (Array.isArray(a.addressLines) ? a.addressLines : []).map(v => text(v, 200)).filter(Boolean).join(', ').slice(0, 200),
|
|
30
|
+
locality: text(a.locality, 200), region: text(a.administrativeArea, 200), postcode: text(a.postalCode, 200), country: text(a.regionCode, 200)}}};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createGoogleBusiness({directory = path.resolve('content/sessions/google-business'), fetch: fetcher = globalThis.fetch, now = Date.now} = {}) {
|
|
34
|
+
const file = path.join(directory, 'connection.json');
|
|
35
|
+
const keyFile = path.join(directory, '.key');
|
|
36
|
+
const pending = new Map();
|
|
37
|
+
let generation = 0;
|
|
38
|
+
let refreshing = null;
|
|
39
|
+
|
|
40
|
+
function key() {
|
|
41
|
+
fs.mkdirSync(directory, {recursive: true, mode: 0o700});
|
|
42
|
+
try { return fs.readFileSync(keyFile); }
|
|
43
|
+
catch (e) {
|
|
44
|
+
if (e.code !== 'ENOENT') throw e;
|
|
45
|
+
const value = randomBytes(32);
|
|
46
|
+
try { fs.writeFileSync(keyFile, value, {mode: 0o600, flag: 'wx'}); return value; }
|
|
47
|
+
catch (err) { if (err.code !== 'EEXIST') throw err; return fs.readFileSync(keyFile); }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function seal(value) {
|
|
51
|
+
const iv = randomBytes(12);
|
|
52
|
+
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
|
53
|
+
const bytes = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
|
|
54
|
+
return [iv, cipher.getAuthTag(), bytes].map(b => b.toString('base64url')).join('.');
|
|
55
|
+
}
|
|
56
|
+
function open(value) {
|
|
57
|
+
const [iv, tag, bytes] = value.split('.').map(b => Buffer.from(b, 'base64url'));
|
|
58
|
+
const cipher = createDecipheriv('aes-256-gcm', key(), iv);
|
|
59
|
+
cipher.setAuthTag(tag);
|
|
60
|
+
return JSON.parse(Buffer.concat([cipher.update(bytes), cipher.final()]).toString('utf8'));
|
|
61
|
+
}
|
|
62
|
+
function read() {
|
|
63
|
+
try { return open(JSON.parse(fs.readFileSync(file, 'utf8')).sealed); }
|
|
64
|
+
catch (e) {
|
|
65
|
+
if (e.code === 'ENOENT') return null;
|
|
66
|
+
throw new GoogleBusinessError('The saved Google connection could not be opened. Disconnect it and connect again.', 409);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function write(value) {
|
|
70
|
+
const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`;
|
|
71
|
+
try {
|
|
72
|
+
fs.writeFileSync(temporary, JSON.stringify({version: 1, sealed: seal(value)}) + '\n', {mode: 0o600});
|
|
73
|
+
fs.renameSync(temporary, file);
|
|
74
|
+
} finally { fs.rmSync(temporary, {force: true}); }
|
|
75
|
+
}
|
|
76
|
+
function status() {
|
|
77
|
+
let c;
|
|
78
|
+
try { c = read(); }
|
|
79
|
+
catch {
|
|
80
|
+
return {connected: false, needsReconnect: true, clientId: '', hasClientSecret: false, location: null};
|
|
81
|
+
}
|
|
82
|
+
return {connected: Boolean(c?.refreshToken), needsReconnect: c?.needsReconnect === true,
|
|
83
|
+
clientId: c?.clientId || '', hasClientSecret: Boolean(c?.clientSecret), location: c?.location || null};
|
|
84
|
+
}
|
|
85
|
+
function assertGeneration(expected) {
|
|
86
|
+
if (generation !== expected) throw new GoogleBusinessError('The Google connection changed. Open its settings again.', 409);
|
|
87
|
+
}
|
|
88
|
+
async function remote(url, options = {}) {
|
|
89
|
+
let response;
|
|
90
|
+
try { response = await fetcher(url, {...options, redirect: 'error', signal: AbortSignal.timeout(15_000)}); }
|
|
91
|
+
catch { throw new GoogleBusinessError('Google could not be reached. Try again.', 502); }
|
|
92
|
+
const body = await response.json().catch(() => ({}));
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
if (body.error === 'invalid_grant' || response.status === 401) throw new GoogleBusinessError('Google access has expired or was revoked. Connect again.', 401);
|
|
95
|
+
if (body.error === 'invalid_client') throw new GoogleBusinessError('Google rejected the client ID or secret. Check the app credentials.');
|
|
96
|
+
if (response.status === 403) throw new GoogleBusinessError('Google refused access. Check Business Profile API approval, enabled APIs and permission to manage this business.', 403);
|
|
97
|
+
if (response.status === 429) throw new GoogleBusinessError('Google Business Profile has reached its request limit. Try again later.', 429);
|
|
98
|
+
throw new GoogleBusinessError('Google could not complete the request. Check the selected business and app setup.', 502);
|
|
99
|
+
}
|
|
100
|
+
return body;
|
|
101
|
+
}
|
|
102
|
+
const tokenRequest = params => remote(TOKEN_URL, {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams(params).toString()});
|
|
103
|
+
function begin({clientId, clientSecret, redirectUri, userId}) {
|
|
104
|
+
let previous = null;
|
|
105
|
+
try { previous = read(); } catch { /* new credentials can repair an unreadable connection */ }
|
|
106
|
+
clientId = text(clientId, 300);
|
|
107
|
+
clientSecret = text(clientSecret, 2000) || (previous?.clientId === clientId ? previous.clientSecret : '');
|
|
108
|
+
if (!clientId || !clientSecret) throw new GoogleBusinessError('Enter the Google app client ID and client secret first.');
|
|
109
|
+
const uri = new URL(redirectUri);
|
|
110
|
+
if (uri.protocol !== 'https:' && !(uri.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(uri.hostname))) {
|
|
111
|
+
throw new GoogleBusinessError('Google sign-in needs an HTTPS site address, or localhost for development.');
|
|
112
|
+
}
|
|
113
|
+
for (const [k, t] of pending) if (t.expires <= now() || t.userId === userId) pending.delete(k);
|
|
114
|
+
if (pending.size >= 200) throw new GoogleBusinessError('Too many sign-ins are pending. Try again in a few minutes.', 429);
|
|
115
|
+
const state = randomBytes(32).toString('base64url');
|
|
116
|
+
const binding = randomBytes(32).toString('base64url');
|
|
117
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
118
|
+
pending.set(state, {userId, redirectUri, credentials: seal({clientId, clientSecret}), verifier, bindingHash: hash(binding), expires: now() + TTL, generation});
|
|
119
|
+
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
120
|
+
url.search = new URLSearchParams({client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: BUSINESS_SCOPE,
|
|
121
|
+
state, access_type: 'offline', prompt: 'consent', code_challenge: hash(verifier).toString('base64url'), code_challenge_method: 'S256'}).toString();
|
|
122
|
+
return {url: url.href, state, binding};
|
|
123
|
+
}
|
|
124
|
+
function take(state, binding) {
|
|
125
|
+
const t = pending.get(state);
|
|
126
|
+
pending.delete(state);
|
|
127
|
+
if (!t || t.expires <= now()) throw new GoogleBusinessError('This sign-in expired or was already used. Press Connect again.');
|
|
128
|
+
if (!binding || !timingSafeEqual(hash(binding), t.bindingHash)) throw new GoogleBusinessError('Finish sign-in in the browser that pressed Connect.');
|
|
129
|
+
assertGeneration(t.generation);
|
|
130
|
+
return t;
|
|
131
|
+
}
|
|
132
|
+
async function complete(ticket, code) {
|
|
133
|
+
if (!text(code, 4000)) throw new GoogleBusinessError('Google did not return a sign-in code. Connect again.');
|
|
134
|
+
const credentials = open(ticket.credentials);
|
|
135
|
+
const t = await tokenRequest({client_id: credentials.clientId, client_secret: credentials.clientSecret,
|
|
136
|
+
grant_type: 'authorization_code', code: text(code, 4000), redirect_uri: ticket.redirectUri, code_verifier: ticket.verifier});
|
|
137
|
+
if (!t.access_token || !t.refresh_token || !String(t.scope || '').split(' ').includes(BUSINESS_SCOPE)) {
|
|
138
|
+
throw new GoogleBusinessError('Google did not grant Business Profile access for this connection. Connect again and allow access.');
|
|
139
|
+
}
|
|
140
|
+
assertGeneration(ticket.generation);
|
|
141
|
+
write({...credentials, accessToken: t.access_token, refreshToken: t.refresh_token,
|
|
142
|
+
expiresAt: now() + (Number(t.expires_in) || 3600) * 1000, needsReconnect: false, location: null});
|
|
143
|
+
generation++;
|
|
144
|
+
pending.clear();
|
|
145
|
+
refreshing = null;
|
|
146
|
+
return status();
|
|
147
|
+
}
|
|
148
|
+
async function accessToken() {
|
|
149
|
+
const c = read();
|
|
150
|
+
if (!c?.refreshToken || c.needsReconnect) throw new GoogleBusinessError('Connect Google Business Profile first.', 409);
|
|
151
|
+
if (c.expiresAt > now() + 60_000) return c.accessToken;
|
|
152
|
+
if (refreshing) return refreshing;
|
|
153
|
+
const expected = generation;
|
|
154
|
+
const task = (async () => {
|
|
155
|
+
try {
|
|
156
|
+
const t = await tokenRequest({client_id: c.clientId, client_secret: c.clientSecret, refresh_token: c.refreshToken, grant_type: 'refresh_token'});
|
|
157
|
+
assertGeneration(expected);
|
|
158
|
+
if (!t.access_token) throw new GoogleBusinessError('Google did not renew access. Connect again.', 502);
|
|
159
|
+
write({...read(), accessToken: t.access_token, refreshToken: t.refresh_token || c.refreshToken,
|
|
160
|
+
expiresAt: now() + (Number(t.expires_in) || 3600) * 1000});
|
|
161
|
+
return t.access_token;
|
|
162
|
+
} catch (e) {
|
|
163
|
+
if (e.statusCode === 401 && generation === expected) write({...read(), needsReconnect: true});
|
|
164
|
+
throw e;
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
refreshing = task;
|
|
168
|
+
try { return await task; } finally { if (refreshing === task) refreshing = null; }
|
|
169
|
+
}
|
|
170
|
+
async function api(url) {
|
|
171
|
+
const expected = generation;
|
|
172
|
+
const token = await accessToken();
|
|
173
|
+
assertGeneration(expected);
|
|
174
|
+
try {
|
|
175
|
+
const result = await remote(url, {headers: {Authorization: `Bearer ${token}`}});
|
|
176
|
+
assertGeneration(expected);
|
|
177
|
+
return result;
|
|
178
|
+
} catch (e) {
|
|
179
|
+
if (e.statusCode === 401 && generation === expected) write({...read(), needsReconnect: true});
|
|
180
|
+
throw e;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function listUrl(base, pageToken, extra = {}) {
|
|
184
|
+
const url = new URL(base);
|
|
185
|
+
url.search = new URLSearchParams({...extra, ...(pageToken ? {pageToken: text(pageToken, 2000)} : {})}).toString();
|
|
186
|
+
return url.href;
|
|
187
|
+
}
|
|
188
|
+
async function accounts(pageToken) {
|
|
189
|
+
const r = await api(listUrl(ACCOUNT_URL, pageToken, {pageSize: '20'}));
|
|
190
|
+
return {accounts: (r.accounts || []).filter(a => /^accounts\/[A-Za-z0-9_-]+$/.test(a.name)).map(a => ({name: a.name, title: text(a.accountName) || a.name})), nextPageToken: r.nextPageToken || ''};
|
|
191
|
+
}
|
|
192
|
+
async function locations(account, pageToken) {
|
|
193
|
+
if (!/^accounts\/[A-Za-z0-9_-]+$/.test(account || '')) throw new GoogleBusinessError('Choose a business account.');
|
|
194
|
+
const r = await api(listUrl(`${LOCATION_URL}${account}/locations`, pageToken, {pageSize: '100', readMask: READ_MASK}));
|
|
195
|
+
return {locations: (r.locations || []).filter(l => /^locations\/[A-Za-z0-9_-]+$/.test(l.name)).map(businessPreview), nextPageToken: r.nextPageToken || ''};
|
|
196
|
+
}
|
|
197
|
+
async function select(name) {
|
|
198
|
+
if (!/^locations\/[A-Za-z0-9_-]+$/.test(name || '')) throw new GoogleBusinessError('Choose a business location.');
|
|
199
|
+
const expected = generation;
|
|
200
|
+
const location = await api(listUrl(`${LOCATION_URL}${name}`, '', {readMask: READ_MASK}));
|
|
201
|
+
if (location.name !== name) throw new GoogleBusinessError('Google returned a different business location.', 502);
|
|
202
|
+
assertGeneration(expected);
|
|
203
|
+
const preview = businessPreview(location);
|
|
204
|
+
write({...read(), location: {name: preview.name, title: preview.title}});
|
|
205
|
+
return preview;
|
|
206
|
+
}
|
|
207
|
+
async function disconnect() {
|
|
208
|
+
generation++;
|
|
209
|
+
pending.clear();
|
|
210
|
+
refreshing = null;
|
|
211
|
+
let c;
|
|
212
|
+
try { c = read(); } catch { /* forgetting an unreadable connection still works */ }
|
|
213
|
+
fs.rmSync(file, {force: true});
|
|
214
|
+
let revoked = false;
|
|
215
|
+
if (c?.refreshToken) {
|
|
216
|
+
try {
|
|
217
|
+
const r = await fetcher('https://oauth2.googleapis.com/revoke', {method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
218
|
+
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({token: c.refreshToken}).toString()});
|
|
219
|
+
revoked = r.ok;
|
|
220
|
+
} catch { /* the site's credential is already removed */ }
|
|
221
|
+
}
|
|
222
|
+
return {connected: false, revoked};
|
|
223
|
+
}
|
|
224
|
+
return {status, begin, take, complete, accounts, locations, select, disconnect};
|
|
225
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Reduce motion" control - `site.json` `motion` (Site Settings > Reduce motion).
|
|
3
|
+
*
|
|
4
|
+
* One pure normaliser, shared by the renderer (both render paths: the page's
|
|
5
|
+
* bootstrap default and the object site.js draws the control from) and the
|
|
6
|
+
* settings route (what is stored). Whitelisted and clamped rather than merged,
|
|
7
|
+
* because the result is written into the source of every public page.
|
|
8
|
+
*
|
|
9
|
+
* Until it had a tab of its own the control lived in the footer settings: `footer.showMotionToggle`,
|
|
10
|
+
* `footer.motionDefault` and `footer.motionControl`. A site with no `motion`
|
|
11
|
+
* block - or one missing a key - falls back to those, key by key, so every
|
|
12
|
+
* existing site renders exactly as before with no migration. The admin keeps
|
|
13
|
+
* writing them as a mirror for older readers and fleet tooling.
|
|
14
|
+
*
|
|
15
|
+
* No I/O here; tested in tests/services/motion-settings.test.js.
|
|
16
|
+
*/
|
|
17
|
+
import {cssClasses} from '../../public/js/css-classes.mjs';
|
|
18
|
+
|
|
19
|
+
/** Today's behaviour: shown, in the footer, as a switch, following the device. */
|
|
20
|
+
export const MOTION_DEFAULTS = Object.freeze({
|
|
21
|
+
enabled: true,
|
|
22
|
+
default: 'system',
|
|
23
|
+
placement: 'footer',
|
|
24
|
+
control: 'switch',
|
|
25
|
+
triggerStyle: 'icon',
|
|
26
|
+
label: 'Reduce motion',
|
|
27
|
+
// Bottom left: clear of Search (right-centre) and Back to top (bottom right)
|
|
28
|
+
// at their defaults. public/js/motion-control.mjs stacks it clear of them
|
|
29
|
+
// anyway when a site moves one into the same corner.
|
|
30
|
+
position: 'BL',
|
|
31
|
+
offsetX: 32,
|
|
32
|
+
offsetY: 32,
|
|
33
|
+
iconSize: 52,
|
|
34
|
+
iconRadius: 26,
|
|
35
|
+
class: ''
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const MOTION_DEFAULT_CHOICES = ['system', 'off', 'on'];
|
|
39
|
+
export const MOTION_PLACEMENTS = ['footer', 'floating', 'navbar'];
|
|
40
|
+
export const MOTION_CONTROLS = ['switch', 'checkbox'];
|
|
41
|
+
export const MOTION_TRIGGER_STYLES = ['icon', 'icon-text'];
|
|
42
|
+
/** The breadcrumb corners - the Placement card's corner picker. */
|
|
43
|
+
export const MOTION_POSITIONS = ['TL', 'TR', 'BL', 'BR'];
|
|
44
|
+
export const MOTION_LABEL_MAX = 40;
|
|
45
|
+
/** The same limits as Search's floating disc (services/search.js). */
|
|
46
|
+
export const MOTION_ICON_SIZE_MIN = 32;
|
|
47
|
+
export const MOTION_ICON_SIZE_MAX = 96;
|
|
48
|
+
export const MOTION_OFFSET_MAX = 500;
|
|
49
|
+
|
|
50
|
+
const pick = (value, list, fallback) => (list.includes(value) ? value : fallback);
|
|
51
|
+
|
|
52
|
+
function num(value, fallback, min, max) {
|
|
53
|
+
const n = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() ? Number(value) : NaN);
|
|
54
|
+
if (!Number.isFinite(n)) return fallback;
|
|
55
|
+
return Math.min(max, Math.max(min, Math.round(n)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The settings a page and the admin act on.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} [raw] - site.json `motion` (absent or partial is fine)
|
|
62
|
+
* @param {object} [footer] - site.json `footer`, for the keys the control used to live under
|
|
63
|
+
* @returns {object} every key of MOTION_DEFAULTS, nothing else
|
|
64
|
+
*/
|
|
65
|
+
export function normaliseMotionSettings(raw, footer) {
|
|
66
|
+
const m = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
67
|
+
const f = footer && typeof footer === 'object' ? footer : {};
|
|
68
|
+
const has = (k) => m[k] !== undefined && m[k] !== null;
|
|
69
|
+
|
|
70
|
+
const enabledRaw = has('enabled') ? m.enabled : f.showMotionToggle;
|
|
71
|
+
const defaultRaw = has('default') ? m.default : f.motionDefault;
|
|
72
|
+
const controlRaw = has('control') ? m.control : f.motionControl;
|
|
73
|
+
|
|
74
|
+
const label = typeof m.label === 'string' && m.label.trim()
|
|
75
|
+
? m.label.trim().replace(/\s+/g, ' ').slice(0, MOTION_LABEL_MAX)
|
|
76
|
+
: MOTION_DEFAULTS.label;
|
|
77
|
+
|
|
78
|
+
// The radius is clamped against the size (half of it is already a circle) - Search's rule.
|
|
79
|
+
const iconSize = num(m.iconSize, MOTION_DEFAULTS.iconSize, MOTION_ICON_SIZE_MIN, MOTION_ICON_SIZE_MAX);
|
|
80
|
+
const maxRadius = Math.floor(iconSize / 2);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
// Absent means shown, as footer.showMotionToggle always did.
|
|
84
|
+
enabled: enabledRaw !== false,
|
|
85
|
+
default: pick(defaultRaw, MOTION_DEFAULT_CHOICES, MOTION_DEFAULTS.default),
|
|
86
|
+
placement: pick(m.placement, MOTION_PLACEMENTS, MOTION_DEFAULTS.placement),
|
|
87
|
+
control: pick(controlRaw, MOTION_CONTROLS, MOTION_DEFAULTS.control),
|
|
88
|
+
triggerStyle: pick(m.triggerStyle, MOTION_TRIGGER_STYLES, MOTION_DEFAULTS.triggerStyle),
|
|
89
|
+
label,
|
|
90
|
+
position: pick(m.position, MOTION_POSITIONS, MOTION_DEFAULTS.position),
|
|
91
|
+
offsetX: num(m.offsetX, MOTION_DEFAULTS.offsetX, 0, MOTION_OFFSET_MAX),
|
|
92
|
+
offsetY: num(m.offsetY, MOTION_DEFAULTS.offsetY, 0, MOTION_OFFSET_MAX),
|
|
93
|
+
iconSize,
|
|
94
|
+
iconRadius: Math.min(maxRadius, num(m.iconRadius, MOTION_DEFAULTS.iconRadius, 0, maxRadius)),
|
|
95
|
+
class: cssClasses(typeof m.class === 'string' ? m.class : '').join(' ')
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The settings for a site.json. */
|
|
100
|
+
export function motionSettingsOf(site) {
|
|
101
|
+
return normaliseMotionSettings(site?.motion, site?.footer);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The footer keys older readers look at, kept in step with a `motion` block. */
|
|
105
|
+
export function motionFooterMirror(settings) {
|
|
106
|
+
return {showMotionToggle: settings.enabled, motionDefault: settings.default, motionControl: settings.control};
|
|
107
|
+
}
|
|
@@ -16,6 +16,7 @@ import {getProjectForPage} from './projects.js';
|
|
|
16
16
|
import {resolveContextMenusForPage} from './contextMenus.js';
|
|
17
17
|
import {buildCustomThemeStyleTag, buildOverrideStyleTag, getThemeTokenMap, listThemeIds, loadThemeConfig, resolveThemeClasses} from './themeSettings.js';
|
|
18
18
|
import {getSearchSettings} from './search.js';
|
|
19
|
+
import {motionSettingsOf} from './motionSettings.js';
|
|
19
20
|
import {isToolEnabled} from './tools.js';
|
|
20
21
|
import {consentSettingsFor, measurementIdOf} from './googleAnalytics.js';
|
|
21
22
|
import {fmtDateTime} from './dates.js';
|
|
@@ -209,10 +210,30 @@ export async function buildFooterBlocks(site, ctx = {}) {
|
|
|
209
210
|
return cols.length ? `<div class="cms-footer-blocks">${cols.join('')}</div>` : '';
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
const MOTION_ASSET_V = '20261029-motion-under-switcher';
|
|
214
|
+
|
|
212
215
|
/** The site's reduced-motion default for visitors who have not chosen: 'system', 'off' or 'on'. */
|
|
213
216
|
function motionDefaultOf(site) {
|
|
214
|
-
|
|
215
|
-
|
|
217
|
+
return motionSettingsOf(site).default;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The "Reduce motion" control (site.json `motion`, Site Settings > Reduce motion):
|
|
222
|
+
* the whitelisted settings for window.__CMS_SITE__.motion - site.js draws the
|
|
223
|
+
* control from them (public/js/motion-control.mjs) - and its stylesheet. The
|
|
224
|
+
* old footer keys still count where `motion` says nothing (motionSettings.js).
|
|
225
|
+
*
|
|
226
|
+
* Both render paths need this - the standing trap in this file.
|
|
227
|
+
*
|
|
228
|
+
* @param {object} site - site.json
|
|
229
|
+
* @returns {{siteFields: {motion: object}, headTag: string}}
|
|
230
|
+
*/
|
|
231
|
+
export function buildMotionAssets(site) {
|
|
232
|
+
const motion = motionSettingsOf(site);
|
|
233
|
+
return {
|
|
234
|
+
siteFields: {motion},
|
|
235
|
+
headTag: motion.enabled ? `<link rel="stylesheet" href="/public/css/motion.css?v=${MOTION_ASSET_V}">` : ''
|
|
236
|
+
};
|
|
216
237
|
}
|
|
217
238
|
|
|
218
239
|
/** The override a page's [navbar] tag left in its html (the first one), or null. */
|
|
@@ -450,6 +471,7 @@ export async function renderPage(page, opts = {}) {
|
|
|
450
471
|
|
|
451
472
|
const themeView = await buildThemeView(page);
|
|
452
473
|
const searchAssets = buildSearchAssets();
|
|
474
|
+
const motionAssets = buildMotionAssets(site);
|
|
453
475
|
const footerBlocksHtml = preset.footer !== false ? await buildFooterBlocks(site, {user: opts.user || null, urlPath: menuCtx.urlPath, project: menuCtx.project, tags: opts.cacheTags}) : '';
|
|
454
476
|
const switcherAssets = buildThemeSwitcherAssets(themeView.cfg, {
|
|
455
477
|
html: `${page.html || ''}${overlayMenus}${footerBlocksHtml}`,
|
|
@@ -547,9 +569,12 @@ export async function renderPage(page, opts = {}) {
|
|
|
547
569
|
footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
|
|
548
570
|
ctxMenusScript: ctxMenusScript,
|
|
549
571
|
ctxMenusModule: ctxMenusModule,
|
|
572
|
+
// Every `<` escaped, not only `</script>`: a label holding `<!--<script` would
|
|
573
|
+
// otherwise put the parser in script-data-escaped state and swallow the page.
|
|
550
574
|
siteJson: JSON.stringify(Object.assign(
|
|
551
575
|
{
|
|
552
576
|
title: site.title,
|
|
577
|
+
...(site.brand?.enabled === false ? {brandingEnabled: false} : {}),
|
|
553
578
|
footer: site.footer
|
|
554
579
|
? {
|
|
555
580
|
...site.footer,
|
|
@@ -561,13 +586,14 @@ export async function renderPage(page, opts = {}) {
|
|
|
561
586
|
site.backToTop?.enabled ? { backToTop: site.backToTop } : {},
|
|
562
587
|
site.goToBottom?.enabled ? { goToBottom: site.goToBottom } : {},
|
|
563
588
|
consentAssets.siteFields,
|
|
589
|
+
motionAssets.siteFields,
|
|
564
590
|
autoTheme ? {autoTheme} : {}
|
|
565
|
-
)).replace(
|
|
591
|
+
)).replace(/</g, '\\u003c'),
|
|
566
592
|
headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
|
|
567
593
|
// Theme overrides land BEFORE content/custom.css: the overrides are
|
|
568
594
|
// generated, custom.css is hand-written, and the hand-written one stays
|
|
569
595
|
// the last word.
|
|
570
|
-
headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
596
|
+
headInjectLate: [injection.headLate, searchAssets.headTag, motionAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
571
597
|
.filter(Boolean).join('\n'),
|
|
572
598
|
bodyEndInject: [
|
|
573
599
|
// Draft banner - first, so it is the topmost fixed element and a
|
|
@@ -1256,6 +1282,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
1256
1282
|
// other is the standing trap in this file.
|
|
1257
1283
|
const themeView = await buildThemeView({});
|
|
1258
1284
|
const searchAssets = buildSearchAssets();
|
|
1285
|
+
const motionAssets = buildMotionAssets(site);
|
|
1259
1286
|
const footerBlocksHtml = await buildFooterBlocks(site, {user, urlPath: seoMeta.urlPath || '/', project: menuCtx.project ?? null});
|
|
1260
1287
|
const switcherAssets = buildThemeSwitcherAssets(themeView.cfg, {
|
|
1261
1288
|
html: `${renderedFragment || ''}${overlayMenus}${footerBlocksHtml}`,
|
|
@@ -1340,8 +1367,10 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
1340
1367
|
footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
|
|
1341
1368
|
ctxMenusScript: ctxMenusScript,
|
|
1342
1369
|
ctxMenusModule: ctxMenusModule,
|
|
1370
|
+
// Every `<` escaped, as on the page path above.
|
|
1343
1371
|
siteJson: JSON.stringify({
|
|
1344
1372
|
title: site.title,
|
|
1373
|
+
...(site.brand?.enabled === false ? {brandingEnabled: false} : {}),
|
|
1345
1374
|
footer: site.footer
|
|
1346
1375
|
? {
|
|
1347
1376
|
...site.footer,
|
|
@@ -1349,13 +1378,14 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
1349
1378
|
}
|
|
1350
1379
|
: site.footer,
|
|
1351
1380
|
social: site.social || null,
|
|
1381
|
+
...motionAssets.siteFields,
|
|
1352
1382
|
...(bare ? {} : consentAssets.siteFields)
|
|
1353
|
-
}).replace(
|
|
1383
|
+
}).replace(/</g, '\\u003c'),
|
|
1354
1384
|
headInject: [bare ? '' : injection.head, navbarFontLink].filter(Boolean).join('\n'),
|
|
1355
1385
|
// Theme overrides land BEFORE content/custom.css: the overrides are
|
|
1356
1386
|
// generated, custom.css is hand-written, and the hand-written one stays
|
|
1357
1387
|
// the last word.
|
|
1358
|
-
headInjectLate: [bare ? '' : injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
1388
|
+
headInjectLate: [bare ? '' : injection.headLate, searchAssets.headTag, motionAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
1359
1389
|
.filter(Boolean).join('\n'),
|
|
1360
1390
|
bodyEndInject: [
|
|
1361
1391
|
overlayMenus,
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
_stored = JSON.parse(localStorage.getItem('domma:reduced_motion'));
|
|
113
113
|
} catch (e) {
|
|
114
114
|
}
|
|
115
|
-
// No choice of the visitor's yet: the site's default (Site Settings >
|
|
115
|
+
// No choice of the visitor's yet: the site's default (Site Settings > Reduce motion) -
|
|
116
116
|
// 'on' or 'off' decide it; 'system' (or nothing) leaves it to the device.
|
|
117
117
|
if (_stored === null || _stored === undefined) {
|
|
118
118
|
var _def = '{{motionDefault}}';
|
|
@@ -188,7 +188,7 @@
|
|
|
188
188
|
<script src="/public/js/form-logic-engine.js?v=20261025-dropzone-disable" defer></script>
|
|
189
189
|
|
|
190
190
|
<!-- Site initialisation -->
|
|
191
|
-
<script src="/public/js/site.js?v=20261029-
|
|
191
|
+
<script src="/public/js/site.js?v=20261029-site-branding" type="module"></script>
|
|
192
192
|
<script src="/public/js/collection-context.js?v=20261010-utf8-payloads" type="module"></script>
|
|
193
193
|
{{ctxMenusModule}}
|
|
194
194
|
|