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.
Files changed (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/lib/admin-brand.js +1 -1
  4. package/admin/js/lib/google-analytics-panel.js +9 -0
  5. package/admin/js/lib/google-business-callback.js +1 -0
  6. package/admin/js/lib/google-business-import.js +3 -0
  7. package/admin/js/lib/google-business-panel.js +32 -0
  8. package/admin/js/lib/seo-defaults.js +1 -0
  9. package/admin/js/lib/sidebar-renderer.js +5 -5
  10. package/admin/js/lib/site-search-section.js +1 -0
  11. package/admin/js/lib/site-settings-arrange.js +3 -3
  12. package/admin/js/lib/topbar-account.js +1 -1
  13. package/admin/js/lib/topbar-cache.js +6 -0
  14. package/admin/js/lib/topbar-notifications.js +4 -4
  15. package/admin/js/templates/docs/usage-site-settings.html +20 -7
  16. package/admin/js/templates/menu-editor.html +1 -1
  17. package/admin/js/templates/settings.html +104 -63
  18. package/admin/js/views/analytics.js +8 -6
  19. package/admin/js/views/search.js +2 -2
  20. package/admin/js/views/seo.js +50 -42
  21. package/admin/js/views/settings.js +3 -3
  22. package/docs/DommaFeatures.md +3 -2
  23. package/docs/configuration.md +53 -8
  24. package/docs/google-business-profile.md +44 -0
  25. package/docs/screenshots/google-business-profile-review.png +0 -0
  26. package/docs/screenshots/notifications-cache.png +0 -0
  27. package/docs/screenshots/site-settings-search.png +0 -0
  28. package/package.json +1 -1
  29. package/public/css/motion.css +1 -0
  30. package/public/js/motion-control.mjs +1 -0
  31. package/public/js/site.js +1 -1
  32. package/server/routes/api/analytics.js +19 -0
  33. package/server/routes/api/google-business.js +64 -0
  34. package/server/routes/api/seo.js +14 -3
  35. package/server/routes/api/settings.js +23 -2
  36. package/server/services/adminBrand.js +1 -0
  37. package/server/services/googleBusiness.js +225 -0
  38. package/server/services/motionSettings.js +107 -0
  39. package/server/services/renderer.js +36 -6
  40. 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
- const d = site?.footer?.motionDefault;
215
- return d === 'off' || d === 'on' ? d : 'system';
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(/<\/script>/gi, '<\\/script>'),
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(/<\/script>/gi, '<\\/script>'),
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 > Footer) -
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-glider-layout" type="module"></script>
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