domma-cms 1.8.38 → 1.8.39
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 +6 -0
- package/admin/js/lib/site-settings-arrange.js +1 -1
- package/admin/js/templates/docs/api-settings.html +2 -0
- package/admin/js/templates/docs/usage-site-settings.html +31 -0
- package/admin/js/templates/settings.html +21 -0
- package/admin/js/views/settings.js +3 -3
- package/docs/api-reference.md +1 -1
- package/docs/configuration.md +18 -0
- package/package.json +1 -1
- package/public/js/cookie-consent.js +1 -1
- package/public/js/google-analytics.js +1 -0
- package/public/js/site.js +1 -1
- package/server/routes/api/settings.js +18 -2
- package/server/server.js +6 -3
- package/server/services/googleAnalytics.js +82 -0
- package/server/services/renderer.js +35 -3
- package/server/templates/page.html +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.8.39] - 2026-10-22
|
|
4
|
+
|
|
5
|
+
- **Google Analytics, with consent.** Paste your GA4 measurement ID at **Site Settings > Analytics** and Google Analytics runs on every page - but only for visitors who accept Analytics cookies in the cookie banner, which is shown whenever an ID is set. Visitors who reject them, or have not answered, are never sent to Google, and withdrawing consent switches it off and removes its cookies. Adding an ID asks every visitor again, since an earlier answer was not consent to Google. GA's cookies stay on your site's own address, never one shared with other sites. The site's security policy lets Google in only while an ID is set.
|
|
6
|
+
- **A "Cookie settings" link in the footer.** While the cookie banner is on, the footer's legal links end with **Cookie settings**, which opens the banner again so visitors can change their answer. Reword it or switch it off on the Cookie consent tab, or put a menu item linking to `#cookie-settings` wherever you like (the footer then leaves its own out).
|
|
7
|
+
- **The cookie banner shows again.** It looked for Domma's banner in the wrong place, so a site with the banner switched on showed nothing. It now appears, and on Tool pages such as blog posts too. A blank text setting now falls back to the default wording instead of an empty banner.
|
|
8
|
+
|
|
3
9
|
## [1.8.38] - 2026-10-22
|
|
4
10
|
|
|
5
11
|
- Add a compact admin top menu with nested folders, icons, Frequent shortcuts, shared context actions and live counts. Cache recent counts per account and permissions for immediate display while fresh counts load. Highlight Frequent and Documentation, show the current project as a badge, and open the configured admin home from the brand.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const TAB_FIELDS={general:["title","tagline","siteUrl","adminHome","spacerSize","bodyClass"],branding:["brandText","brandLogo","brandIcon","brandUrl","adminBrandTitle","adminBrandLogo","adminBrandIcon"],favicon:["favicon"],seo:["seoTitle","seoSeparator","seoDescription"],footer:["footerCopyright","footerLayout","footerBlurb","footerBlocks","footerMotionToggle","footerMotionDefault","footerMotionControl","footerClass","footerSocialClass","socialTwitter","socialFacebook","socialInstagram","socialLinkedin","socialGithub","socialYoutube"],email:["smtpHost","smtpPort","smtpUser","smtpPass","smtpSecure","smtpInsecureTLS","smtpFromAddress","smtpFromName"],pageNavigation:["bttEnabled","bttSmooth","bttLabel","bttThreshold","bttPosition","bttOffset","bttBottomOffset","gtbEnabled","gtbSmooth","gtbLabel","gtbThreshold","gtbPosition","gtbOffset","gtbBottomOffset"],cookies:["ccEnabled","ccMessage","ccAcceptAll","ccRejectAll","ccCustomize","ccSavePrefs","ccPrivacyText","ccPrivacyUrl","ccCookieText","ccCookieUrl","ccPosition","ccLayout","ccTheme","ccShowFunctional","ccShowAnalytics","ccShowMarketing","ccVersion"],breadcrumbs:["bcEnabled","bcHomeLabel","bcPosition","bcOffsetX","bcOffsetY"]},SECRET_FIELDS=["smtpPass"],BREADCRUMB_POSITIONS=["TL","TR","BL","BR"];export function footerBlockNames(
|
|
1
|
+
export const TAB_FIELDS={general:["title","tagline","siteUrl","adminHome","spacerSize","bodyClass"],branding:["brandText","brandLogo","brandIcon","brandUrl","adminBrandTitle","adminBrandLogo","adminBrandIcon"],favicon:["favicon"],seo:["seoTitle","seoSeparator","seoDescription"],footer:["footerCopyright","footerLayout","footerBlurb","footerBlocks","footerMotionToggle","footerMotionDefault","footerMotionControl","footerClass","footerSocialClass","socialTwitter","socialFacebook","socialInstagram","socialLinkedin","socialGithub","socialYoutube"],email:["smtpHost","smtpPort","smtpUser","smtpPass","smtpSecure","smtpInsecureTLS","smtpFromAddress","smtpFromName"],pageNavigation:["bttEnabled","bttSmooth","bttLabel","bttThreshold","bttPosition","bttOffset","bttBottomOffset","gtbEnabled","gtbSmooth","gtbLabel","gtbThreshold","gtbPosition","gtbOffset","gtbBottomOffset"],cookies:["ccEnabled","ccMessage","ccAcceptAll","ccRejectAll","ccCustomize","ccSavePrefs","ccPrivacyText","ccPrivacyUrl","ccCookieText","ccCookieUrl","ccPosition","ccLayout","ccTheme","ccShowFunctional","ccShowAnalytics","ccShowMarketing","ccVersion","ccSettingsLink","ccSettingsLinkText"],analytics:["gaId"],breadcrumbs:["bcEnabled","bcHomeLabel","bcPosition","bcOffsetX","bcOffsetY"]},SECRET_FIELDS=["smtpPass"],BREADCRUMB_POSITIONS=["TL","TR","BL","BR"];export function footerBlockNames(t){return[...new Set(String(t??"").split(/[\s,]+/).map(r=>r.trim().toLowerCase()).filter(Boolean))]}export function gaIdError(t){const r=o(t).trim();return!r||/^G-[A-Z0-9]{4,20}$/i.test(r)?"":"A measurement ID looks like G-XXXXXXXXXX (Google Analytics > Admin > Data streams)."}export const FOOTER_LAYOUTS=["auto","columns","row"];const o=(t,r="")=>t==null?r:String(t),n=(t,r)=>{const s=Number.parseInt(t,10);return Number.isFinite(s)?s:r};export function homeValue(t){return/^\/[A-Za-z0-9\-_/]*$/.test(t||"")&&String(t).replace(/\/+$/,"")||"/"}export function formFromSettings(t={}){const r=t.backToTop||{},s=t.goToBottom||{},e=t.cookieConsent||{},i=t.breadcrumbs||{};return{title:o(t.title),tagline:o(t.tagline),siteUrl:o(t.baseUrl),adminHome:homeValue(t.adminHome),spacerSize:o(t.layoutOptions?.spacerSize??40),bodyClass:o(t.bodyClass),brandText:o(t.brand?.text),brandLogo:o(t.brand?.logo),brandIcon:o(t.brand?.icon),brandUrl:o(t.brand?.url,"/"),favicon:o(t.favicon),adminBrandTitle:o(t.adminBrand?.title),adminBrandLogo:o(t.adminBrand?.logo),adminBrandIcon:o(t.adminBrand?.icon),seoTitle:o(t.seo?.defaultTitle),seoSeparator:o(t.seo?.titleSeparator," | "),seoDescription:o(t.seo?.defaultDescription),footerCopyright:o(t.footer?.copyright),footerLayout:FOOTER_LAYOUTS.includes(t.footer?.layout)?t.footer.layout:"auto",footerBlurb:o(t.footer?.blurb),footerBlocks:Array.isArray(t.footer?.blocks)?t.footer.blocks.filter(c=>typeof c=="string").join(", "):"",footerMotionToggle:t.footer?.showMotionToggle!==!1,footerMotionDefault:["off","on"].includes(t.footer?.motionDefault)?t.footer.motionDefault:"system",footerMotionControl:t.footer?.motionControl==="checkbox"?"checkbox":"switch",footerClass:o(t.footer?.class),footerSocialClass:o(t.footer?.socialClass),socialTwitter:o(t.social?.twitter),socialFacebook:o(t.social?.facebook),socialInstagram:o(t.social?.instagram),socialLinkedin:o(t.social?.linkedin),socialGithub:o(t.social?.github),socialYoutube:o(t.social?.youtube),smtpHost:o(t.smtp?.host),smtpPort:o(t.smtp?.port||587),smtpUser:o(t.smtp?.user),smtpPass:"",smtpSecure:t.smtp?.secure===!0,smtpInsecureTLS:t.smtp?.allowInsecureTLS===!0,smtpFromAddress:o(t.smtp?.fromAddress),smtpFromName:o(t.smtp?.fromName),bttEnabled:r.enabled!==!1,bttSmooth:r.smooth!==!1,bttLabel:o(r.label),bttThreshold:o(r.scrollThreshold??300),bttPosition:o(r.position,"bottom-right"),bttOffset:o(r.offset??32),bttBottomOffset:o(r.bottomOffset??r.offset??32),gtbEnabled:s.enabled===!0,gtbSmooth:s.smooth!==!1,gtbLabel:o(s.label),gtbThreshold:o(s.scrollThreshold??300),gtbPosition:o(s.position,"bottom-right"),gtbOffset:o(s.offset??32),gtbBottomOffset:o(s.bottomOffset??s.offset??32),ccEnabled:e.enabled!==!1,ccMessage:o(e.message),ccAcceptAll:o(e.acceptAllText,"Accept All"),ccRejectAll:o(e.rejectAllText,"Reject All"),ccCustomize:o(e.customizeText,"Customize"),ccSavePrefs:o(e.savePreferencesText,"Save Preferences"),ccPrivacyText:o(e.privacyPolicyText,"Privacy Policy"),ccPrivacyUrl:o(e.privacyPolicyUrl),ccCookieText:o(e.cookiePolicyText,"Cookie Policy"),ccCookieUrl:o(e.cookiePolicyUrl),ccPosition:o(e.position,"bottom"),ccLayout:o(e.layout,"bar"),ccTheme:o(e.theme,"dark"),ccShowFunctional:e.showFunctional!==!1,ccShowAnalytics:e.showAnalytics!==!1,ccShowMarketing:e.showMarketing!==!1,ccVersion:o(e.consentVersion,"1.0"),ccSettingsLink:e.showSettingsLink!==!1,ccSettingsLinkText:o(e.settingsLinkText,"Cookie settings"),gaId:o(t.googleAnalytics?.measurementId),bcEnabled:i.enabled===!0,bcHomeLabel:o(i.homeLabel,"Home"),bcPosition:BREADCRUMB_POSITIONS.includes(i.position)?i.position:"TL",bcOffsetX:o(i.offsetX??8),bcOffsetY:o(i.offsetY??8)}}export function payloadFromForm(t,r={},{adminHomeReady:s=!0}={}){const e=i=>o(i).trim();return{title:e(t.title),tagline:e(t.tagline),baseUrl:e(t.siteUrl).replace(/\/+$/,""),...s&&{adminHome:e(t.adminHome)||"/"},layoutOptions:{spacerSize:n(t.spacerSize,40)||40},bodyClass:e(t.bodyClass),brand:{...r.brand,text:e(t.brandText),logo:e(t.brandLogo),icon:e(t.brandIcon),url:e(t.brandUrl)||"/"},favicon:e(t.favicon),adminBrand:{...r.adminBrand,title:e(t.adminBrandTitle),logo:e(t.adminBrandLogo),icon:e(t.adminBrandIcon)},seo:{defaultTitle:e(t.seoTitle),titleSeparator:o(t.seoSeparator)||" | ",defaultDescription:e(t.seoDescription)},footer:{copyright:e(t.footerCopyright),layout:FOOTER_LAYOUTS.includes(t.footerLayout)?t.footerLayout:"auto",blurb:e(t.footerBlurb),blocks:footerBlockNames(t.footerBlocks),showMotionToggle:t.footerMotionToggle===!0,links:r.footer?.links||[],motionDefault:["off","on"].includes(t.footerMotionDefault)?t.footerMotionDefault:"system",motionControl:t.footerMotionControl==="checkbox"?"checkbox":"switch",class:e(t.footerClass),socialClass:e(t.footerSocialClass)},social:{twitter:e(t.socialTwitter),facebook:e(t.socialFacebook),instagram:e(t.socialInstagram),linkedin:e(t.socialLinkedin),github:e(t.socialGithub),youtube:e(t.socialYoutube)},smtp:{host:e(t.smtpHost),port:n(t.smtpPort,587)||587,user:e(t.smtpUser),pass:o(t.smtpPass),secure:t.smtpSecure===!0,allowInsecureTLS:t.smtpInsecureTLS===!0,fromAddress:e(t.smtpFromAddress),fromName:e(t.smtpFromName)},backToTop:{enabled:t.bttEnabled===!0,scrollThreshold:n(t.bttThreshold,300),position:o(t.bttPosition)||"bottom-right",offset:n(t.bttOffset,32),bottomOffset:n(t.bttBottomOffset,32),label:e(t.bttLabel),smooth:t.bttSmooth===!0},goToBottom:{enabled:t.gtbEnabled===!0,scrollThreshold:n(t.gtbThreshold,300),position:o(t.gtbPosition)||"bottom-right",offset:n(t.gtbOffset,32),bottomOffset:n(t.gtbBottomOffset,32),label:e(t.gtbLabel),smooth:t.gtbSmooth===!0},cookieConsent:{enabled:t.ccEnabled===!0,message:e(t.ccMessage),acceptAllText:e(t.ccAcceptAll),rejectAllText:e(t.ccRejectAll),customizeText:e(t.ccCustomize),savePreferencesText:e(t.ccSavePrefs),privacyPolicyText:e(t.ccPrivacyText),privacyPolicyUrl:e(t.ccPrivacyUrl),cookiePolicyText:e(t.ccCookieText),cookiePolicyUrl:e(t.ccCookieUrl),position:o(t.ccPosition)||"bottom",layout:o(t.ccLayout)||"bar",theme:o(t.ccTheme)||"dark",showFunctional:t.ccShowFunctional===!0,showAnalytics:t.ccShowAnalytics===!0,showMarketing:t.ccShowMarketing===!0,consentVersion:e(t.ccVersion)||"1.0",showSettingsLink:t.ccSettingsLink===!0,settingsLinkText:e(t.ccSettingsLinkText)||"Cookie settings"},googleAnalytics:{measurementId:e(t.gaId).toUpperCase()},breadcrumbs:{enabled:t.bcEnabled===!0,homeLabel:e(t.bcHomeLabel)||"Home",position:BREADCRUMB_POSITIONS.includes(t.bcPosition)?t.bcPosition:"TL",offsetX:n(t.bcOffsetX,8),offsetY:n(t.bcOffsetY,8)}}}export function siteUrlError(t){const r=o(t).trim().replace(/\/+$/,"");return r?/^https?:\/\//i.test(r)?/^https?:\/\/([a-z0-9.\-]+|\[[0-9a-f:.]+\])(:\d{1,5})?$/i.test(r)?"":"Just the address, like https://example.com - no page path after it.":"Start with https:// (or http://).":""}const a=t=>typeof t=="boolean"?t:o(t);export function snapshot(t){return JSON.stringify(Object.keys(t).sort().map(r=>[r,a(t[r])]))}export function changedTabs(t,r){const s=[];for(const[e,i]of Object.entries(TAB_FIELDS))i.some(c=>a(t[c])!==a(r[c]))&&s.push(e);return s}export function draftOf(t){return Object.fromEntries(Object.entries(t).filter(([r])=>!SECRET_FIELDS.includes(r)))}export function settingsProblems(t){const r=[];return o(t.smtpHost).trim()?o(t.smtpFromAddress).trim()||r.push({tone:"warn",tab:"email",text:"No From address - mail has no sender and many servers refuse it."}):r.push({tone:"danger",tab:"email",text:"No SMTP host - mail goes to a throwaway test inbox and reaches no one (password resets, form notifications)."}),(t.ccEnabled===!0||o(t.gaId).trim())&&!o(t.ccPrivacyUrl).trim()&&r.push({tone:"warn",tab:"cookies",text:"The cookie banner is on but links to no privacy policy."}),r}export function describeCacheKey(t){const r=/^page:(.*?):m\d+:role([^:]*):/.exec(String(t||""));return r?{path:r[1]||"/",role:r[2]||"anon",page:!0}:{path:String(t||""),role:"",page:!1}}export function expiresIn(t,r=Date.now()){if(t==null)return"no expiry";const s=t-r;return s<=0?"expired":s<6e4?`${Math.round(s/1e3)}s`:s<36e5?`${Math.round(s/6e4)}m`:`${Math.round(s/36e5)}h`}
|
|
@@ -99,6 +99,8 @@
|
|
|
99
99
|
<tr><td><code>seo</code>, <code>footer</code>, <code>social</code>, <code>backToTop</code>,
|
|
100
100
|
<code>cookieConsent</code>, <code>breadcrumbs</code>, <code>layoutOptions</code></td><td>The Site Settings
|
|
101
101
|
tabs of the same names</td></tr>
|
|
102
|
+
<tr><td><code>googleAnalytics</code></td><td><code>{"measurementId": "G-XXXXXXXXXX"}</code> - the Analytics tab;
|
|
103
|
+
an empty ID switches it off, and anything that is not a GA4 ID is refused (400)</td></tr>
|
|
102
104
|
<tr><td><code>theme</code>, <code>adminTheme</code>, <code>autoTheme</code>, <code>fontFamily</code>,
|
|
103
105
|
<code>fontSize</code>, <code>adminBrand</code>, <code>locale</code>, <code>analytics</code>, <code>brand</code>,
|
|
104
106
|
<code>url</code>, <code>baseTheme</code></td><td>Also accepted; most are set from Theme or kept for older
|
|
@@ -54,6 +54,11 @@
|
|
|
54
54
|
<td>Back to top, Cookie consent, Breadcrumbs</td>
|
|
55
55
|
<td>Switch each on or off and set its wording and position.</td>
|
|
56
56
|
</tr>
|
|
57
|
+
<tr>
|
|
58
|
+
<td>Analytics</td>
|
|
59
|
+
<td>Your Google Analytics 4 measurement ID - see <a href="#google-analytics">Google
|
|
60
|
+
Analytics</a> below. Empty means Google Analytics is off.</td>
|
|
61
|
+
</tr>
|
|
57
62
|
<tr>
|
|
58
63
|
<td>Custom CSS</td>
|
|
59
64
|
<td><code>content/custom.css</code>, applied to every public page after the theme.</td>
|
|
@@ -65,6 +70,32 @@
|
|
|
65
70
|
</tbody>
|
|
66
71
|
</table>
|
|
67
72
|
|
|
73
|
+
<h3 id="google-analytics">Google Analytics</h3>
|
|
74
|
+
<p>To use Google Analytics, copy the measurement ID of your website's data stream from Google
|
|
75
|
+
Analytics (<strong>Admin > Data streams</strong>, it looks like <code>G-XXXXXXXXXX</code>),
|
|
76
|
+
paste it into <strong>Site Settings > Analytics</strong> and save. It applies to every
|
|
77
|
+
public page at once - there is nothing to paste into pages or templates.</p>
|
|
78
|
+
<ul>
|
|
79
|
+
<li><strong>Visitors are asked first.</strong> Google Analytics sets cookies, so it loads only
|
|
80
|
+
for visitors who accept <strong>Analytics</strong> cookies in the cookie banner. Visitors
|
|
81
|
+
who reject them, or have not answered yet, are never sent to Google, and so do not
|
|
82
|
+
appear in your Google Analytics reports.</li>
|
|
83
|
+
<li><strong>The cookie banner is shown while an ID is set,</strong> with its Analytics
|
|
84
|
+
choice, even if it is switched off under Cookie consent. Adding an ID asks every visitor
|
|
85
|
+
again, because an answer given before was not consent to Google. Set a privacy policy
|
|
86
|
+
address on the Cookie consent tab, and say there that you use Google Analytics.</li>
|
|
87
|
+
<li><strong>Changing their mind:</strong> while the banner is on, the footer has a
|
|
88
|
+
<strong>Cookie settings</strong> link that opens it again. A visitor who withdraws consent
|
|
89
|
+
there stops being tracked and has Google Analytics' cookies removed. The link's wording,
|
|
90
|
+
and whether it is shown, are on the Cookie consent tab. To put it somewhere else, add a
|
|
91
|
+
menu item whose address is <code>#cookie-settings</code> - the footer then leaves its own
|
|
92
|
+
out.</li>
|
|
93
|
+
<li>Previews of unsaved pages are never sent to Google.</li>
|
|
94
|
+
<li>The built-in <strong>Analytics</strong> Tool is separate: it needs no cookies, counts
|
|
95
|
+
every visitor, and keeps the figures on your own site. You can use both; expect Google
|
|
96
|
+
Analytics to show fewer visitors, since it only counts those who said yes.</li>
|
|
97
|
+
</ul>
|
|
98
|
+
|
|
68
99
|
<h3>Theme</h3>
|
|
69
100
|
<p><strong>Content > Theme</strong> (stored in <code>config/theme.json</code>) sets the public
|
|
70
101
|
theme, the admin theme, fonts, the visitor theme switcher, automatic day and night themes (at
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
<button type="button" class="tab-item">Email <i class="stx-dot" data-bind-hidden="!tabs.value.email" title="Changed"></i><i class="stx-dot is-bad" data-bind-hidden="!noSmtp.value || tabs.value.email" title="No SMTP host"></i></button>
|
|
24
24
|
<button type="button" class="tab-item">Page navigation <i class="stx-dot" data-bind-hidden="!tabs.value.pageNavigation" title="Changed"></i></button>
|
|
25
25
|
<button type="button" class="tab-item">Cookie consent <i class="stx-dot" data-bind-hidden="!tabs.value.cookies" title="Changed"></i></button>
|
|
26
|
+
<button type="button" class="tab-item">Analytics <i class="stx-dot" data-bind-hidden="!tabs.value.analytics" title="Changed"></i></button>
|
|
26
27
|
<button type="button" class="tab-item">Breadcrumbs <i class="stx-dot" data-bind-hidden="!tabs.value.breadcrumbs" title="Changed"></i></button>
|
|
27
28
|
<button type="button" class="tab-item">Cache</button>
|
|
28
29
|
</div>
|
|
@@ -407,6 +408,11 @@
|
|
|
407
408
|
<div class="stx-grid">
|
|
408
409
|
<div class="stx-wide stx-setting"><label class="form-label" for="stx-cc-on">Show the banner</label>
|
|
409
410
|
<input id="stx-cc-on" type="checkbox" class="form-switch" data-model="ccEnabled.value"></div>
|
|
411
|
+
<div class="stx-setting"><label class="form-label" for="stx-cc-link">"Cookie settings" link in the footer
|
|
412
|
+
<span data-help="Lets visitors reopen the banner and change their answer. You can also add a link to #cookie-settings to any menu instead - the footer link is then left out."></span></label>
|
|
413
|
+
<input id="stx-cc-link" type="checkbox" class="form-switch" data-model="ccSettingsLink.value"></div>
|
|
414
|
+
<div><label class="form-label" for="stx-cc-lt">Link text</label>
|
|
415
|
+
<input id="stx-cc-lt" type="text" class="form-input" placeholder="Cookie settings" data-model="ccSettingsLinkText.value"></div>
|
|
410
416
|
<div class="stx-wide">
|
|
411
417
|
<label class="form-label" for="stx-cc-msg">Message</label>
|
|
412
418
|
<textarea id="stx-cc-msg" class="form-input" rows="3" placeholder="We use cookies…" data-model="ccMessage.value"></textarea>
|
|
@@ -459,6 +465,21 @@
|
|
|
459
465
|
</div>
|
|
460
466
|
</div>
|
|
461
467
|
|
|
468
|
+
<!-- Analytics -->
|
|
469
|
+
<div class="tab-panel">
|
|
470
|
+
<div class="stx-card">
|
|
471
|
+
<div class="stx-card-title">Google Analytics
|
|
472
|
+
<span data-help-title="Google Analytics" data-help="Paste the measurement ID of your Google Analytics 4 web stream (Admin > Data streams). Google Analytics sets cookies, so it loads only for visitors who accept Analytics cookies in the cookie banner - the banner is shown whenever an ID is set. Leave it empty to switch Google Analytics off. The built-in Analytics Tool is separate and needs no cookies."></span></div>
|
|
473
|
+
<div class="stx-grid">
|
|
474
|
+
<div class="stx-wide"><label class="form-label" for="stx-ga-id">Measurement ID</label>
|
|
475
|
+
<input id="stx-ga-id" type="text" class="form-input" placeholder="G-XXXXXXXXXX" autocomplete="off" spellcheck="false" data-model="gaId.value">
|
|
476
|
+
<small class="text-danger" data-bind-hidden="!gaIdError.value" data-bind-text="gaIdError.value"></small></div>
|
|
477
|
+
</div>
|
|
478
|
+
<p class="stx-note">Loads only after a visitor accepts Analytics cookies. Visitors who reject them, or have not answered yet, are not sent to Google.</p>
|
|
479
|
+
<p class="stx-note" data-bind-hidden="!gaForcesBanner.value">The cookie banner is switched off under Cookie consent, but it will still be shown while Google Analytics is set - it is how visitors give consent.</p>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
462
483
|
<!-- Breadcrumbs -->
|
|
463
484
|
<div class="tab-panel">
|
|
464
485
|
<div class="stx-card-columns">
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as B,apiRequest as x}from"../api.js";import{placeTools as Re}from"../lib/sidebar-grouping.js";import{addBannerAction as pe}from"../lib/plugin-chrome.js";import{helpPopovers as Ie}from"../lib/help-popover.js";import{openMenuAt as W,sidebarCount as me}from"../lib/tool-kit.js";import{changedTabs as Ae,describeCacheKey as Y,draftOf as $e,expiresIn as Pe,formFromSettings as je,gaIdError as fe,homeValue as Be,payloadFromForm as De,siteUrlError as he,settingsProblems as He,snapshot as ve,TAB_FIELDS as qe}from"../lib/site-settings-arrange.js";import{pickMedia as Fe}from"../lib/media-picker.js";import{faviconGenerator as Ue}from"../lib/favicon-generator.js";import{makeIconInput as _e}from"../lib/shortcode-modal.js";import{fmtDateTime as Ke}from"../lib/dates.js";const J=/^\/settings(\?.*)?$/,be="#/settings",w="settings.draft",Ne=`
|
|
2
2
|
<div class="stx-problem">
|
|
3
3
|
<span class="stx-chip" data-bind-class="tone === 'danger' ? 'is-bad' : 'is-warn'">{{label}}</span>
|
|
4
4
|
<span>{{text}}</span>
|
|
5
5
|
<button type="button" class="btn btn-sm btn-ghost" data-go="{{tab}}">Go there</button>
|
|
6
|
-
</div>`,
|
|
6
|
+
</div>`,ze=`
|
|
7
7
|
<div class="stx-row" data-id="{{id}}" tabindex="0">
|
|
8
8
|
<span class="stx-main"><strong>{{path}}</strong><small>{{meta}}</small></span>
|
|
9
9
|
<span class="stx-chip">{{expires}}</span>
|
|
10
10
|
<button type="button" class="stx-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
|
|
11
|
-
</div>`,Ne=["general","branding","favicon","seo","footer","email","pageNavigation","cookies","breadcrumbs","cache"];async function ze(l,u){const[r,k]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),n=new Map([["/","Dashboard (built in)"]]),i=(b,d)=>{for(const o of b||[]){if(!o||o.hidden||o.restricted)continue;const m=typeof o.url=="string"?o.url:"";m.startsWith("#/")&&m.length>2&&!n.has(m.slice(1))&&n.set(m.slice(1),`${d?`${d} \u203A `:""}${o.text||m}`),i(o.items,o.url?d:o.text||d)}};i(Le(r?.items||[],k||[]),""),n.has(u)||n.set(u,`${u} (not in the sidebar)`),l.replaceChildren();for(const[b,d]of n){const o=document.createElement("option");o.value=b,o.textContent=d,l.appendChild(o)}}let h=null,be=!1;function Ve(){be||!window.R?.use||(be=!0,R.use((l,u,r)=>!(J.test(u?.path||"")&&!J.test(l?.path||""))||!h?.dirty()?r():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(n=>{n?r():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:l}={})=>{if(h&&!J.test(l?.path||"")){const u=h;h=null,u.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(l){if(Ve(),h){const e=h;h=null,e.dispose()}const u=[],r=e=>(u.push(e),e);let k=!1;const n=l.get(0).querySelector(".stx")||l.get(0),i=()=>!k&&n.isConnected,b=E.loader(l.get(0),{type:"dots"});let d;try{d=await j.settings.get()}catch(e){b.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(b.destroy(),!n.isConnected)return;const o=M.observable(d||{}),m=$e(d||{}),a={};for(const[e,t]of Object.entries(m))a[e]=M.observable(t);const D=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].value])),Q=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].peek()])),g=M.observable(m),B=M.computed(()=>he(g.value)),x=M.computed(()=>he(D())!==B.value),T=M.observable(!1),L=M.observable(""),X=M.observable(!1);j.plugins.policy().then(e=>{i()&&(X.value=e?.signedOnly===!1)}).catch(()=>{});const q=M.observable(!1),Z=M.computed(()=>{const e=new Set(Re(D(),g.value));return Object.fromEntries(Object.keys(He).map(t=>[t,e.has(t)]))}),U=M.observable([]);r(M.effect(()=>{U.value=De(D()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const ee=M.observable(""),O=M.observable(!1),A=M.observable(""),$=M.observable(""),p=M.observable(null),_=M.observable([]),F=M.observable(""),K=M.observable(!0),N=M.observable(""),te=M.observable([]);r(M.effect(()=>{const e=F.value.trim().toLowerCase(),t=Date.now();te.value=[..._.value].reverse().filter(s=>!e||String(s.key).toLowerCase().includes(e)||(s.tags||[]).some(c=>String(c).toLowerCase().includes(e))).map(s=>{const c=G(s.key);return{id:s.key,path:c.path,meta:[c.page?`as ${c.role}`:"not a page",(s.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:Ae(s.expiresAt,t)}})}));const se=qe(a.favicon,a.brandLogo,i),ge={...se.data,...a,saving:T,dirty:x,tabs:Z,problems:U,testTo:ee,testing:O,testResult:A,testTone:$,cacheQuery:F,cacheLoading:K,cacheError:N,cacheRows:te,errorText:L,problemsText:M.computed(()=>U.value.length?"yes":""),siteUrlError:M.computed(()=>fe(a.siteUrl.value)),noSmtp:M.computed(()=>!String(a.smtpHost.value||"").trim()),mayAllowInsecureTLS:X,dirtyEmail:M.computed(()=>Z.value.email),seoLength:M.computed(()=>{const e=String(a.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cacheOn:M.computed(()=>p.value?.enabled===!0),cacheState:M.computed(()=>{const e=p.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=p.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=p.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>p.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>F.value.trim()?"Nothing matches.":p.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function z(){if(T.peek())return;const e=Q(),t=g.peek(),s=fe(e.siteUrl);if(s){E.toast(`Site URL: ${s}`,{type:"error"});return}T.value=!0,L.value="";const c=String(e.title).trim()!==String(t.title).trim();try{await j.settings.save(je(e,o.peek(),{adminHomeReady:q.peek()}));const f=await j.settings.get().catch(()=>null);if(!i())return;f&&(o.value=f);const W={...e,smtpPass:""};a.smtpPass.value="",g.value=W;try{S.remove?S.remove(w):S.set(w,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),(c||["adminBrandTitle","adminBrandLogo","adminBrandIcon"].some(de=>e[de]!==t[de]))&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),me(ve),E.toast("Settings saved.",{type:"success"})}catch(f){L.value=f.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{T.value=!1}}async function ye(){await E.confirm("Throw away your unsaved settings?")&&(ae(g.peek()),L.value="")}function ae(e){for(const[t,s]of Object.entries(e))a[t]&&(a[t].value=s)}async function we(){if(!O.peek()){O.value=!0,$.value="",A.value="Sending\u2026";try{const e=await C("/settings/test-email",{method:"POST",body:JSON.stringify({to:ee.peek().trim()||void 0})});if(!i())return;A.value=e?.message||"Test email sent.",$.value="is-ok"}catch(e){if(!i())return;A.value=e.message||"Could not send the test email.",$.value="is-bad"}finally{O.value=!1}}}async function P(){K.value=!0;try{const[e,t]=await Promise.all([C("/cache/status").catch(()=>null),C("/cache/keys")]);if(!i())return;p.value=e,_.value=Array.isArray(t?.entries)?t.entries:[],N.value=""}catch(e){if(!i())return;N.value=`Could not read the cache: ${e.message||e}`}finally{K.value=!1}}async function ke(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await C("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}i()&&await P()}}async function Ee(){const e=p.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{p.value=await C("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),me(ve)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}i()&&await P()}}const ne=e=>_.peek().find(t=>t.key===e);function Se(e){const t=ne(e);if(!t)return[];const s=G(t.key);return[{header:s.path},{label:"Open the page",icon:"external-link",disabled:!s.page,action:()=>window.open(s.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...s.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(s.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}n.querySelector(".stx-problems").innerHTML=Fe;const y=n.querySelector(".stx-list");y.innerHTML=Ke;const Me=E.tabs(n.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Ce=M.applyBindings(ge,n,{methods:{...se.methods,save:z,discard:ye,sendTest:we,refreshCache:P,clearCache:ke,toggleCache:Ee,pickBrandImage:async e=>{const t=await Be({title:"Choose a branding image",current:a[e].peek()});t&&i()&&(a[e].value=t)},clearBrandImage:e=>{a[e].value=""},corner:e=>{a.bcPosition.value=e}}});r(()=>Ce.dispose());for(const e of n.querySelectorAll("[data-brand-icon]")){const t=e.dataset.brandIcon,s=Ue("Choose an icon",a[t].peek());s.input.id=`stx-${t}`,s.input.classList.add("form-input");const c=()=>{a[t].value=s.input.value};s.input.addEventListener("input",c),r(()=>s.input.removeEventListener("input",c)),r(M.effect(()=>{s.input.value=a[t].value})),e.appendChild(s.el)}r(Oe(n,{position:"bottom"}));const xe=e=>{const t=e.target.closest("[data-go]");if(!t)return;const s=Ne.indexOf(t.dataset.go);s>=0&&Me.activate(s)};n.querySelector(".stx-problems").addEventListener("click",xe);const V=n.querySelector("#stx-admin-home");ze(V,Pe(a.adminHome.peek())).then(()=>{i()&&(V.value=a.adminHome.peek(),q.value=!0)}).catch(()=>{});const Te=E.contextMenu(y,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Se(e.dataset.id)});r(()=>Te?.destroy?.());const oe=e=>{const t=ne(e.dataset.id),s=t&&G(t.key);s?.page?window.open(s.path,"_blank","noopener"):Y(e)},re=e=>{const t=e.target.closest(".stx-row");if(!t)return;const s=e.target.closest(".stx-kebab");if(s){Y(s);return}oe(t)},ie=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Y(t)):e.key==="Enter"&&e.target===t&&oe(t))};y.addEventListener("click",re),y.addEventListener("keydown",ie),r(()=>{y.removeEventListener("click",re),y.removeEventListener("keydown",ie)}),pe(l,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),pe(l,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>z()});const ce=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),z())};document.addEventListener("keydown",ce,!0),r(()=>document.removeEventListener("keydown",ce,!0));const le=()=>{if(x.peek())try{S.set(w,{form:Ie(Q()),base:B.peek(),at:new Date().toISOString()})}catch{}},ue=e=>{x.peek()&&(le(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ue),r(()=>window.removeEventListener("beforeunload",ue)),h={dirty:()=>x.peek(),dispose(){k=!0,le(),u.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(n),P();let v=null;try{v=S.get(w)}catch{}if(v?.form){try{S.remove?S.remove(w):S.set(w,null)}catch{}const e=v.base&&v.base!==B.peek(),t=v.at?` (${_e(v.at)})`:"",s=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(s)&&i()){const c={...g.peek()};for(const[f,W]of Object.entries(v.form))f in c&&f!=="smtpPass"&&(c[f]=W);ae(c),q.peek()&&(V.value=a.adminHome.peek())}}}};
|
|
11
|
+
</div>`,Ge=["general","branding","favicon","seo","footer","email","pageNavigation","cookies","analytics","breadcrumbs","cache"];async function Ve(l,u){const[r,k]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),n=new Map([["/","Dashboard (built in)"]]),i=(v,d)=>{for(const o of v||[]){if(!o||o.hidden||o.restricted)continue;const m=typeof o.url=="string"?o.url:"";m.startsWith("#/")&&m.length>2&&!n.has(m.slice(1))&&n.set(m.slice(1),`${d?`${d} \u203A `:""}${o.text||m}`),i(o.items,o.url?d:o.text||d)}};i(Re(r?.items||[],k||[]),""),n.has(u)||n.set(u,`${u} (not in the sidebar)`),l.replaceChildren();for(const[v,d]of n){const o=document.createElement("option");o.value=v,o.textContent=d,l.appendChild(o)}}let f=null,ge=!1;function We(){ge||!window.R?.use||(ge=!0,R.use((l,u,r)=>!(J.test(u?.path||"")&&!J.test(l?.path||""))||!f?.dirty()?r():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(n=>{n?r():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:l}={})=>{if(f&&!J.test(l?.path||"")){const u=f;f=null,u.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(l){if(We(),f){const e=f;f=null,e.dispose()}const u=[],r=e=>(u.push(e),e);let k=!1;const n=l.get(0).querySelector(".stx")||l.get(0),i=()=>!k&&n.isConnected,v=E.loader(l.get(0),{type:"dots"});let d;try{d=await B.settings.get()}catch(e){v.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(v.destroy(),!n.isConnected)return;const o=M.observable(d||{}),m=je(d||{}),s={};for(const[e,t]of Object.entries(m))s[e]=M.observable(t);const D=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),Q=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),b=M.observable(m),q=M.computed(()=>ve(b.value)),T=M.computed(()=>ve(D())!==q.value),L=M.observable(!1),O=M.observable(""),X=M.observable(!1);B.plugins.policy().then(e=>{i()&&(X.value=e?.signedOnly===!1)}).catch(()=>{});const F=M.observable(!1),Z=M.computed(()=>{const e=new Set(Ae(D(),b.value));return Object.fromEntries(Object.keys(qe).map(t=>[t,e.has(t)]))}),U=M.observable([]);r(M.effect(()=>{U.value=He(D()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const ee=M.observable(""),A=M.observable(!1),$=M.observable(""),P=M.observable(""),p=M.observable(null),_=M.observable([]),K=M.observable(""),N=M.observable(!0),z=M.observable(""),te=M.observable([]);r(M.effect(()=>{const e=K.value.trim().toLowerCase(),t=Date.now();te.value=[..._.value].reverse().filter(a=>!e||String(a.key).toLowerCase().includes(e)||(a.tags||[]).some(c=>String(c).toLowerCase().includes(e))).map(a=>{const c=Y(a.key);return{id:a.key,path:c.path,meta:[c.page?`as ${c.role}`:"not a page",(a.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:Pe(a.expiresAt,t)}})}));const ae=Ue(s.favicon,s.brandLogo,i),ye={...ae.data,...s,saving:L,dirty:T,tabs:Z,problems:U,testTo:ee,testing:A,testResult:$,testTone:P,cacheQuery:K,cacheLoading:N,cacheError:z,cacheRows:te,errorText:O,problemsText:M.computed(()=>U.value.length?"yes":""),siteUrlError:M.computed(()=>he(s.siteUrl.value)),gaIdError:M.computed(()=>fe(s.gaId.value)),gaForcesBanner:M.computed(()=>!!String(s.gaId.value||"").trim()&&s.ccEnabled.value!==!0),noSmtp:M.computed(()=>!String(s.smtpHost.value||"").trim()),mayAllowInsecureTLS:X,dirtyEmail:M.computed(()=>Z.value.email),seoLength:M.computed(()=>{const e=String(s.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cacheOn:M.computed(()=>p.value?.enabled===!0),cacheState:M.computed(()=>{const e=p.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=p.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=p.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>p.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>K.value.trim()?"Nothing matches.":p.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function G(){if(L.peek())return;const e=Q(),t=b.peek(),a=he(e.siteUrl);if(a){E.toast(`Site URL: ${a}`,{type:"error"});return}const c=fe(e.gaId);if(c){E.toast(`Google Analytics: ${c}`,{type:"error"});return}L.value=!0,O.value="";const C=String(e.title).trim()!==String(t.title).trim();try{await B.settings.save(De(e,o.peek(),{adminHomeReady:F.peek()}));const y=await B.settings.get().catch(()=>null);if(!i())return;y&&(o.value=y);const Oe={...e,smtpPass:""};s.smtpPass.value="",b.value=Oe;try{S.remove?S.remove(w):S.set(w,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),(C||["adminBrandTitle","adminBrandLogo","adminBrandIcon"].some(de=>e[de]!==t[de]))&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),me(be),E.toast("Settings saved.",{type:"success"})}catch(y){O.value=y.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{L.value=!1}}async function we(){await E.confirm("Throw away your unsaved settings?")&&(se(b.peek()),O.value="")}function se(e){for(const[t,a]of Object.entries(e))s[t]&&(s[t].value=a)}async function ke(){if(!A.peek()){A.value=!0,P.value="",$.value="Sending\u2026";try{const e=await x("/settings/test-email",{method:"POST",body:JSON.stringify({to:ee.peek().trim()||void 0})});if(!i())return;$.value=e?.message||"Test email sent.",P.value="is-ok"}catch(e){if(!i())return;$.value=e.message||"Could not send the test email.",P.value="is-bad"}finally{A.value=!1}}}async function j(){N.value=!0;try{const[e,t]=await Promise.all([x("/cache/status").catch(()=>null),x("/cache/keys")]);if(!i())return;p.value=e,_.value=Array.isArray(t?.entries)?t.entries:[],z.value=""}catch(e){if(!i())return;z.value=`Could not read the cache: ${e.message||e}`}finally{N.value=!1}}async function Ee(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await x("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}i()&&await j()}}async function Se(){const e=p.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{p.value=await x("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),me(be)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}i()&&await j()}}const ne=e=>_.peek().find(t=>t.key===e);function Me(e){const t=ne(e);if(!t)return[];const a=Y(t.key);return[{header:a.path},{label:"Open the page",icon:"external-link",disabled:!a.page,action:()=>window.open(a.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...a.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(a.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}n.querySelector(".stx-problems").innerHTML=Ne;const g=n.querySelector(".stx-list");g.innerHTML=ze;const Ce=E.tabs(n.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),xe=M.applyBindings(ye,n,{methods:{...ae.methods,save:G,discard:we,sendTest:ke,refreshCache:j,clearCache:Ee,toggleCache:Se,pickBrandImage:async e=>{const t=await Fe({title:"Choose a branding image",current:s[e].peek()});t&&i()&&(s[e].value=t)},clearBrandImage:e=>{s[e].value=""},corner:e=>{s.bcPosition.value=e}}});r(()=>xe.dispose());for(const e of n.querySelectorAll("[data-brand-icon]")){const t=e.dataset.brandIcon,a=_e("Choose an icon",s[t].peek());a.input.id=`stx-${t}`,a.input.classList.add("form-input");const c=()=>{s[t].value=a.input.value};a.input.addEventListener("input",c),r(()=>a.input.removeEventListener("input",c)),r(M.effect(()=>{a.input.value=s[t].value})),e.appendChild(a.el)}r(Ie(n,{position:"bottom"}));const Te=e=>{const t=e.target.closest("[data-go]");if(!t)return;const a=Ge.indexOf(t.dataset.go);a>=0&&Ce.activate(a)};n.querySelector(".stx-problems").addEventListener("click",Te);const V=n.querySelector("#stx-admin-home");Ve(V,Be(s.adminHome.peek())).then(()=>{i()&&(V.value=s.adminHome.peek(),F.value=!0)}).catch(()=>{});const Le=E.contextMenu(g,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Me(e.dataset.id)});r(()=>Le?.destroy?.());const oe=e=>{const t=ne(e.dataset.id),a=t&&Y(t.key);a?.page?window.open(a.path,"_blank","noopener"):W(e)},re=e=>{const t=e.target.closest(".stx-row");if(!t)return;const a=e.target.closest(".stx-kebab");if(a){W(a);return}oe(t)},ie=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),W(t)):e.key==="Enter"&&e.target===t&&oe(t))};g.addEventListener("click",re),g.addEventListener("keydown",ie),r(()=>{g.removeEventListener("click",re),g.removeEventListener("keydown",ie)}),pe(l,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),pe(l,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>G()});const ce=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),G())};document.addEventListener("keydown",ce,!0),r(()=>document.removeEventListener("keydown",ce,!0));const le=()=>{if(T.peek())try{S.set(w,{form:$e(Q()),base:q.peek(),at:new Date().toISOString()})}catch{}},ue=e=>{T.peek()&&(le(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ue),r(()=>window.removeEventListener("beforeunload",ue)),f={dirty:()=>T.peek(),dispose(){k=!0,le(),u.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(n),j();let h=null;try{h=S.get(w)}catch{}if(h?.form){try{S.remove?S.remove(w):S.set(w,null)}catch{}const e=h.base&&h.base!==q.peek(),t=h.at?` (${Ke(h.at)})`:"",a=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(a)&&i()){const c={...b.peek()};for(const[C,y]of Object.entries(h.form))C in c&&C!=="smtpPass"&&(c[C]=y);se(c),F.peek()&&(V.value=s.adminHome.peek())}}}};
|
package/docs/api-reference.md
CHANGED
|
@@ -615,7 +615,7 @@ Return the site config. The SMTP password is stripped from the response.
|
|
|
615
615
|
|
|
616
616
|
### PUT `/api/settings`
|
|
617
617
|
|
|
618
|
-
Update site settings. Only the
|
|
618
|
+
Update site settings. Only the keys Site Settings uses are accepted (an unknown key is a 400), among them `title`, `tagline`, `description`, `logo`, `favicon`, `theme`, `smtp`, `footer`, `analytics`, `social`, `locale`, `layoutOptions`, `seo`, `backToTop`, `cookieConsent`, `breadcrumbs`, `baseUrl`, `adminHome`, `bodyClass` and `googleAnalytics` (`{"measurementId": "G-XXXXXXXXXX"}`; an empty ID switches Google Analytics off, anything that is not a GA4 ID is a 400).
|
|
619
619
|
|
|
620
620
|
**Auth required:** Yes (`settings:update`)
|
|
621
621
|
|
package/docs/configuration.md
CHANGED
|
@@ -196,11 +196,29 @@ The main site-wide configuration. This file is editable from the **Settings** ad
|
|
|
196
196
|
| `footer` | Footer copyright, legacy link list, and the "Reduce motion" switch - see below |
|
|
197
197
|
| `backToTop` | Scroll-to-top button configuration |
|
|
198
198
|
| `cookieConsent` | Cookie consent banner configuration |
|
|
199
|
+
| `googleAnalytics` | `{measurementId: "G-…"}` - Google Analytics 4, set at Site Settings > Analytics. See **Google Analytics** below |
|
|
199
200
|
| `breadcrumbs` | Breadcrumb navigation configuration |
|
|
200
201
|
| `bodyClass` | Your own CSS classes on the `<body>` of every public page (Site Settings > General > Body CSS classes) |
|
|
201
202
|
| `baseUrl` | **Optional override.** Absolute origin (e.g. `https://example.com`). By default the CMS derives the origin from the incoming request (`request.protocol` + `request.host`), so sitemap URLs and canonical tags automatically match whatever domain you serve from - set this only if you sit behind infrastructure that doesn't forward `Host` correctly. Password reset links always use it when it is set; without it they go only to an address someone has signed in at, never to whatever host a reset request claims. Editable as **Site URL** in Settings › General. |
|
|
202
203
|
| `seo` | SEO defaults applied when a page has no overrides - see below |
|
|
203
204
|
|
|
205
|
+
### Google Analytics
|
|
206
|
+
|
|
207
|
+
Paste a GA4 measurement ID (`G-XXXXXXXXXX`, from Google Analytics > Admin > Data streams) at
|
|
208
|
+
**Site Settings > Analytics**; empty switches it off. Only a real ID is saved.
|
|
209
|
+
|
|
210
|
+
- GA sets cookies, so it waits for consent: Google's tag is fetched only after a visitor accepts the cookie
|
|
211
|
+
banner's **Analytics** category. No answer, or a rejection, means nothing is sent to Google. Withdrawing
|
|
212
|
+
consent later switches GA off for the page and deletes its `_ga` cookies.
|
|
213
|
+
- Visitors change their answer with the footer's **Cookie settings** link, added to the legal links while the
|
|
214
|
+
banner is on (`cookieConsent.showSettingsLink: false` hides it, `settingsLinkText` words it). Any link to
|
|
215
|
+
`#cookie-settings` - a menu item, say - opens the banner too, and the footer then leaves its own out.
|
|
216
|
+
- While an ID is set the cookie banner is shown, with its Analytics category, even if its own switch is off -
|
|
217
|
+
it is the only way GA ever loads. Give it a privacy policy address (Site Settings flags one that is missing).
|
|
218
|
+
- The site's Content-Security-Policy gains Google's hosts (`*.googletagmanager.com`, `*.google-analytics.com`,
|
|
219
|
+
`*.analytics.google.com`) only while an ID is set, and loses them when it is cleared - no restart.
|
|
220
|
+
- Editor previews are never sent. The built-in Analytics Tool is separate and cookieless; both can run.
|
|
221
|
+
|
|
204
222
|
### Outgoing email
|
|
205
223
|
|
|
206
224
|
**Certificates are checked.** The mail server's TLS certificate must be valid for the name in
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
(function(){"use strict";
|
|
1
|
+
(function(){"use strict";var o=window.Domma,s=o&&(o.elements&&o.elements.cookieConsent||o.components&&o.components.cookieConsent);if(!s)return;var e=window.__CMS_SITE__&&window.__CMS_SITE__.cookieConsent;if(!e)return;var i={necessary:{label:"Necessary Cookies",description:"These cookies are essential for the website to function properly.",required:!0}};e.showFunctional!==!1&&(i.functional={label:"Functional Cookies",description:"These cookies enable personalised features and functionality.",required:!1}),e.showAnalytics!==!1&&(i.analytics={label:"Analytics Cookies",description:"These cookies help us understand how visitors interact with our website.",required:!1}),e.showMarketing!==!1&&(i.marketing={label:"Marketing Cookies",description:"These cookies are used to deliver relevant ads and marketing campaigns.",required:!1});var n={message:e.message,acceptAllText:e.acceptAllText,rejectAllText:e.rejectAllText,customizeText:e.customizeText,savePreferencesText:e.savePreferencesText,privacyPolicyText:e.privacyPolicyText,privacyPolicyUrl:e.privacyPolicyUrl||null,cookiePolicyText:e.cookiePolicyText,cookiePolicyUrl:e.cookiePolicyUrl||null,position:e.position||"bottom",layout:e.layout||"bar",theme:e.theme||"dark",categories:i,consentVersion:e.consentVersion||"1.0",autoShow:!0,animation:!0,reopenSelector:'[data-cookie-consent-open], a[href="#cookie-settings"]',onChange:function(t,c){a(t,c)}};Object.keys(n).forEach(function(t){(n[t]===void 0||n[t]==="")&&delete n[t]});function a(t,c){document.dispatchEvent(new CustomEvent("dm-cookie-consent",{detail:{state:t,preferences:c||{}}}))}window.__CMS_CONSENT__=s.call(o.elements&&o.elements.cookieConsent===s?o.elements:o.components,n)})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){"use strict";var i=window.__CMS_SITE__||{},n=i.googleAnalytics&&i.googleAnalytics.measurementId;if(!n||!/^G-[A-Z0-9]{4,20}$/.test(n)||window.__CMS_PREVIEW__)return;var t=!1;function d(){if(window["ga-disable-"+n]=!1,!t){t=!0,window.dataLayer=window.dataLayer||[],window.gtag=window.gtag||function(){window.dataLayer.push(arguments)},window.gtag("js",new Date),window.gtag("config",n,{cookie_domain:"none"});var e=document.createElement("script");e.async=!0,e.src="https://www.googletagmanager.com/gtag/js?id="+encodeURIComponent(n),document.head.appendChild(e)}}function c(e){return e==="_ga"||e.indexOf("_ga_")===0||e==="_gid"||e==="_gat"}function a(){return document.cookie.split(";").map(function(e){return e.split("=")[0].trim()}).filter(c)}function s(){a().forEach(function(e){document.cookie=e+"=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/",document.cookie=e+"=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain="+location.hostname})}function u(){window["ga-disable-"+n]=!0,s()}function r(e){e&&e.analytics===!0?d():(t||a().length)&&u()}var o=window.__CMS_CONSENT__;o&&typeof o.getPreferences=="function"&&r(o.getPreferences()),document.addEventListener("dm-cookie-consent",function(e){r(e.detail&&e.detail.preferences)})})();
|
package/public/js/site.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{applyMobileMenu as ne,initCarouselPopup as ae}from"/public/js/mobile-ui.mjs?v=20261016-mobile-options";import{installSmoothCarets as se}from"/public/js/smooth-caret.js";import{decodeBase64Json as R}from"/public/js/utf8-base64.mjs";import{makeBadgeEl as X,applyColour as U,applyPill as ie,applyFloat as re,applyHighlight as W,applyItemHighlight as ce,itemHighlightAttrs as le,isCurrentItem as G,menuAlign as de,applyClasses as O,cssClasses as V,applyEntrance as Y}from"/public/js/menu-decor.mjs?v=20261014-menu-entrance";import{initFloatingMenus as me}from"/public/js/menu-float.js?v=20260829-float-panel";import{enhancePickerFields as K,markInvalidFields as Q,showValidationPopup as Z}from"/public/js/forms.js?v=20261015-smooth-caret";import{buildWizardSteps as ue}from"/public/js/form-steps.mjs";import{applyFormClasses as ee,applySubmitButtons as pe,applyWizardButtons as fe}from"/public/js/form-submit.mjs";document.addEventListener("click",p=>{const r=p.target?.closest?.(".dm-banner__dismiss");r&&(p.preventDefault(),r.closest(".dm-banner")?.remove())}),$(()=>{se(document);const p=window.__CMS_NAV__||{},r=window.__CMS_SITE__||{};if(r.autoTheme?.enabled){let o=function(e){const a=(e||"07:00").split(":");return+a[0]*60+(+a[1]||0)},s=function(){const e=new Date,a=e.getHours()*60+e.getMinutes();return a>=o(n.dayStart)&&a<o(n.nightStart)?n.dayTheme:n.nightTheme},t=function(e){const a=n.apply&&n.apply[e]||{theme:e,custom:""};Domma.theme.set(a.theme);for(const l of[...document.body.classList])l.startsWith("dm-custom-")&&document.body.classList.remove(l);a.custom&&document.body.classList.add(a.custom)};const n=r.autoTheme;t(s()),setInterval(()=>t(s()),6e4)}const x=$("#site-navbar");if(x.length&&p.brand){let s=function(u,f=0){return Array.isArray(u)?u.filter(m=>!(m&&(m.type==="spacer"||m.type==="theme-switcher"))).filter(m=>!(m&&m.type==="separator"&&f===0)).map(m=>m&&m.type==="separator"?{divider:!0}:Array.isArray(m?.items)?{...m,items:s(m.items,f+1)}:m):u};const n={...p.brand},o=n.size&&n.size!=="md"?` navbar-brand-${n.size}`:"";if(n.logo||n.icon||n.tagline){let u="";n.logo?u+=`<img src="${n.logo}" class="navbar-brand-logo" alt="${n.text||""}">`:n.icon&&(u+=`<span data-icon="${n.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),n.text&&(u+=`<span class="navbar-brand-text${o}">${n.text}</span>`),n.tagline&&(u+=`<small class="navbar-brand-tagline">${n.tagline}</small>`),n.html=u}else o&&n.text&&(n.html=`<span class="navbar-brand-text${o}">${n.text}</span>`);const t=s(p.items||[]),e=p.position==="floating",a=p.orientation==="vertical";x.get(0).querySelector(":scope > .cms-nojs-links")?.remove(),Domma.elements.navbar("#site-navbar",{brand:n,items:t,variant:p.variant||"dark",position:e?"static":p.position||"sticky",collapsible:!0,appearOnHover:!!p.appearOnHover}),Domma.icons.scan("#site-navbar");const l=x.get(0);ne(l,p.mobile);const h=()=>{if(!a)return;const f=l.querySelector('a[aria-current="page"]')?.closest(".navbar-dropdown");f&&!f.classList.contains("open")&&f.querySelector(":scope > .navbar-dropdown-toggle")?.click()};l.classList.add(`dm-nav-align-${de(p)||"centre"}`),a&&x.get(0).classList.add("dm-nav-vertical",p.side==="right"?"dm-nav-vertical-right":"dm-nav-vertical-left");const i=p.dock||{};a&&!e&&he(l,p.side==="right"),!a&&i.hideOnScroll&&ge(l),W(l,p),Y(l,p),O(l,p.class),e&&(re(x.get(0),p),me(x.get(0).parentNode||document));const c=l.querySelector(".navbar-toggle"),g=l.querySelector(".navbar-collapse");if(c&&g){let u=null;const f=()=>{g.classList.contains("show")&&c.click()},m=()=>{u&&(u.remove(),u=null),document.body.classList.remove("dm-drawer-open")},v=()=>{u||(u=document.createElement("div"),u.className="dm-navbar-backdrop",u.addEventListener("click",f),document.body.appendChild(u),document.body.classList.add("dm-drawer-open"))};c.addEventListener("click",()=>{requestAnimationFrame(()=>{g.classList.contains("show")?(h(),v()):m()})}),g.addEventListener("click",C=>{C.target.closest("a")&&requestAnimationFrame(m)}),document.addEventListener("keydown",C=>{C.key==="Escape"&&g.classList.contains("show")&&f()})}const b=l.querySelector(".navbar-nav");if(b){const u=Array.from(b.children);let f=0;for(const m of p.items||[])if(!(m&&m.type==="separator")){if(m&&m.type==="spacer"){const v=document.createElement("li"),C=["xs","sm","md","lg","xl","flex"].includes(m.size)?m.size:"md";v.className=`dm-menu-spacer dm-menu-spacer--${C}`,v.setAttribute("aria-hidden","true"),b.insertBefore(v,u[f]||null);continue}if(m&&m.type==="theme-switcher"){const v=document.createElement("li");v.className="dm-menu-theme-switcher";const C=document.createElement("span");C.dataset.themeSwitcher="menu",C.dataset.position="inline",v.appendChild(C),b.insertBefore(v,u[f]||null);continue}f++}window.CMSThemeSwitcher?.scan(b)}l.querySelectorAll("[data-path]").forEach(u=>{const f=u.dataset.path.split(".").map(Number),m=f.length>1,v=f.reduce((q,L)=>q?.items?.[L],{items:t});if(!v)return;v.colour&&U(u,v.colour),v.pill&&!m&&ie(u,v.pill),ce(u,v),O(u.closest("li"),v.class);const C=X(v.badge);C&&u.appendChild(C),G(v.url,location.pathname)&&(u.classList.add("active"),u.setAttribute("aria-current","page"))}),h()}const y=$("#site-footer");if(y.length){let e=function(d){return String(d??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},a=function(d){return e(d).replace(/'/g,"'")},l=function(d){if(d&&d.type==="separator")return'<li class="footer-sep"><hr></li>';if(d&&d.type==="spacer")return`<li class="dm-menu-spacer dm-menu-spacer--${["xs","sm","md","lg","xl","flex"].includes(d.size)?d.size:"md"}" aria-hidden="true"></li>`;if(d&&d.type==="theme-switcher")return'<li class="dm-menu-theme-switcher"><span data-theme-switcher="menu" data-position="inline"></span></li>';const I=a(d.url||"#"),A=e(d.text||""),D=Array.isArray(d.items)&&d.items.length?`<ul>${d.items.map(l).join("")}</ul>`:"",z=le(d),te=[z.hover&&` data-menu-item-hover="${a(z.hover)}"`,z.active&&` data-menu-item-active="${a(z.active)}"`].filter(Boolean).join(""),oe=z.accent?` style="--dm-menu-highlight:${a(z.accent)}"`:"",J=V(d.class).join(" ");return`<li${J?` class="${a(J)}"`:""}><a href="${I}"${te}${oe}>${A}</a>${D}</li>`},h=function(){if(!r.footer)return"";const d=Object.keys(o).filter(A=>n[A]);if(!d.length)return"";const I=V(r.footer.socialClass).join(" ");return`<div class="footer-social">${d.map(A=>{const{label:D,svg:z}=o[A];return`<a href="${n[A]}" target="_blank" rel="noopener noreferrer" aria-label="${D}" class="footer-social-link${I?` ${a(I)}`:""}">${z}</a>`}).join("")}</div>`},b=function(d){if(!d||d.type==="separator"||d.type==="spacer")return"";if(d.type==="theme-switcher")return`<li class="cms-footer-col">${l(d).replace(/^<li[^>]*>|<\/li>$/g,"")}</li>`;const I=Array.isArray(d.items)&&d.items.length?`<ul>${d.items.map(l).join("")}</ul>`:"",A=g(d)?`<span class="cms-footer-col-title">${e(d.text||"")}</span>`:`<a class="cms-footer-col-title" href="${a(d.url||"#")}">${e(d.text||"")}</a>`,D=["cms-footer-col",...V(d.class)].join(" ");return`<li class="${a(D)}">${A}${I}</li>`},m=function(d,I,A=!1){return(d||[]).forEach(D=>{D&&(D.type==="separator"||D.type==="spacer"||D.type==="theme-switcher")||(A&&g(D)||I.push(D),Array.isArray(D.items)&&D.items.length&&m(D.items,I))}),I};const n=r.social||{},o={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 s='<div class="footer-inner container">';const t=window.__CMS_FOOTER__||{primary:[],legal:[],copyright:""},i=["columns","row"].includes(r.footer?.layout)?r.footer.layout:"auto",c=i==="columns"||i==="auto"&&t.primary.some(d=>Array.isArray(d?.items)&&d.items.length),g=d=>c&&!d.url&&Array.isArray(d.items)&&d.items.length>0;if(c){const d=p.brand||{},I=d.text||r.title||"",A=d.logo?`<img src="${a(d.logo)}" class="cms-footer-brand-logo" alt="">`:d.icon?'<span class="cms-footer-brand-icon"></span>':"",D=String(r.footer?.blurb||"").trim();s='<div class="footer-inner footer-inner--columns container"><div class="cms-footer-top">',s+=`<div class="cms-footer-brand">${I||A?`<a class="cms-footer-brand-name" href="${a(d.url||"/")}">${A}<span>${e(I)}</span></a>`:""}${D?`<p class="cms-footer-blurb">${e(D)}</p>`:""}${h()}</div>`,t.primary.length&&(s+=`<nav class="footer-nav footer-nav--primary footer-nav--columns" aria-label="Footer"><ul>${t.primary.map(b).join("")}</ul></nav>`),s+='</div><div class="cms-footer-bottom">',t.copyright&&(s+=`<p class="cms-footer-copyright">${e(t.copyright)}</p>`),t.legal.length&&(s+=`<nav class="footer-nav footer-nav--legal" aria-label="Legal"><ul>${t.legal.map(l).join("")}</ul></nav>`),s+="</div>"}else t.copyright&&(s+=`<p>${e(t.copyright)}</p>`),t.primary.length&&(s+=`<nav class="footer-nav footer-nav--primary"><ul>${t.primary.map(l).join("")}</ul></nav>`),t.legal.length&&(s+=`<nav class="footer-nav footer-nav--legal"><ul>${t.legal.map(l).join("")}</ul></nav>`),s+=h();s+="</div>",y.get(0).querySelector(":scope > .cms-nojs-links")?.remove();const u=y.get(0).querySelector(":scope > .cms-footer-blocks");u?.remove(),y.html(s),u&&y.get(0).prepend(u),window.CMSThemeSwitcher?.scan(y.get(0));const f=y.get(0).querySelector(".cms-footer-brand-icon");f&&p.brand?.icon&&f.setAttribute("data-icon",p.brand.icon),O(y.get(0),r.footer?.class);const v=[...m(t.primary,[],!0),...m(t.legal,[])];y.get(0).querySelectorAll(".footer-nav a").forEach((d,I)=>{const A=v[I];if(!A)return;A.colour&&U(d,A.colour);const D=X(A.badge);D&&d.appendChild(D),G(A.url,location.pathname)&&(d.classList.add("is-active"),d.setAttribute("aria-current","page"))}),W(y.get(0).querySelector(".footer-nav--primary"),{highlight:t.primaryHighlight}),W(y.get(0).querySelector(".footer-nav--legal"),{highlight:t.legalHighlight}),Y(y.get(0).querySelector(".footer-nav--primary"),{entrance:t.primaryEntrance}),Y(y.get(0).querySelector(".footer-nav--legal"),{entrance:t.legalEntrance}),O(y.get(0).querySelector(".footer-nav--primary"),t.primaryClass),O(y.get(0).querySelector(".footer-nav--legal"),t.legalClass);const q=r.footer?.showMotionToggle!==!1,L=S.get("reduced_motion"),B=r.footer?.motionDefault,P=L!=null?!!L:B==="on"?!0:B==="off"?!1:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),M=r.footer?.motionControl==="checkbox",N=y.get(0).querySelector(".cms-footer-bottom")||y.get(0).querySelector(".footer-inner");if(q&&N){const d=document.createElement("input");d.type="checkbox",d.className=M?"footer-motion-checkbox":"form-switch-input",d.id="dm-motion-switch",d.checked=P,d.addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()});const I=document.createElement("span");I.className=M?"footer-motion-label":"form-switch-label",I.textContent="Reduce motion";const A=document.createElement("label");A.className=M?"footer-motion-check":"form-switch footer-motion-switch",A.title="Reduce motion",A.appendChild(d),A.appendChild(I),N.appendChild(A)}}const k=document.getElementById("site-sidebar");k&&ye(k),Domma.icons.scan(),Domma.syntax?.scan&&Domma.syntax.scan();const w=$(".page-body");if(w.length){w.find(".accordion").each(function(){Domma.elements.accordion(this,{allowMultiple:this.dataset.multi==="true"})}),w.find(".tabs").each(function(){Domma.elements.tabs(this)}),w.find(".carousel").each(function(){const n={primary:"--dm-primary",secondary:"--dm-secondary",accent:"--dm-primary",text:"--dm-text",surface:"--dm-surface",background:"--dm-background",success:"--dm-success",warning:"--dm-warning",danger:"--dm-danger",info:"--dm-info"},o=h=>{const i=String(h||"").trim();return i?n[i.toLowerCase()]?`var(${n[i.toLowerCase()]})`:CSS.supports("color",i)?i:"":""},s=o(this.dataset.background),t=o(this.dataset.arrowBackground),e=o(this.dataset.arrowColor);s&&(this.style.setProperty("--dm-carousel-background",s),this.classList.add("dm-carousel-custom-background")),t&&this.style.setProperty("--dm-carousel-arrow-background",t),e&&this.style.setProperty("--dm-carousel-arrow-color",e);const a=["top","middle","bottom"].includes(this.dataset.arrowPosition)?this.dataset.arrowPosition:"middle";this.classList.add(`dm-carousel-arrows-${a}`);const l={autoplay:this.dataset.autoplay==="true",interval:parseInt(this.dataset.interval,10)||5e3,loop:this.dataset.loop!=="false",animation:this.dataset.animation||"slide",heightMode:this.dataset.heightMode==="average"?"average":"crop"};if(this.dataset.height){const h=Number(this.dataset.height);Number.isFinite(h)&&h>0&&(l.height=h)}if(this.dataset.animationDuration){const h=parseInt(this.dataset.animationDuration,10);Number.isFinite(h)&&(l.animationDuration=h)}this.dataset.animationEasing&&(l.animationEasing=this.dataset.animationEasing),this.dataset.ctx&&(this._dmCtxSlides=Array.from(this.querySelectorAll(".carousel-slide")).map(h=>h.cloneNode(!0)),this._dmCarouselOpts=l),this._dmCarousel=Domma.elements.carousel(this,l),ae(this)}),w.find(".dm-countdown").each(function(){const n={autoStart:!0},o=String(this.dataset.format||"").trim(),s=String(this.dataset.to||"").trim(),t=/^(\d{4})-(\d{2})-(\d{2})$/.exec(s),e=t?new Date(+t[1],+t[2]-1,+t[3]).getTime():s?new Date(s).getTime():NaN;Number.isFinite(e)?n.duration=Math.max(0,e-Date.now()):this.dataset.duration&&(n.duration=(parseInt(this.dataset.duration,10)||0)*1e3);const a=/^dd:/i.test(o);n.format=a?"hh:mm:ss":o||(n.duration>=36e5?"hh:mm:ss":"mm:ss");const l=this,h=c=>String(c).padStart(2,"0"),i=c=>{const g=Math.max(0,Math.floor(c/1e3)),b=l.querySelector(".dm-timer-time");b&&(b.textContent=`${h(Math.floor(g/86400))}:${h(Math.floor(g%86400/3600))}:${h(Math.floor(g%3600/60))}:${h(g%60)}`)};a&&(n.onTick=i),Domma.elements.timer(this,n),a&&i(n.duration||0)}),w.find("[data-tooltip]").each(function(){Domma.elements.tooltip(this,{content:$(this).data("tooltip"),position:$(this).data("tooltip-position")||"top"})}),w.find(".dm-progression").each(function(){const n=Array.from(this.querySelectorAll(".dm-progression-item")).map(t=>{const e={title:t.querySelector(".dm-progression-item-title")?.textContent?.trim()||"",description:t.querySelector(".dm-progression-item-body")?.innerHTML?.trim()||""};return t.dataset.date&&(e.date=t.dataset.date),t.dataset.status&&(e.status=t.dataset.status),t.dataset.eventIcon&&(e.icon=t.dataset.eventIcon),t.dataset.entryId&&(e._entryId=t.dataset.entryId),e}),o=this.dataset.layout==="centred"?"centered":this.dataset.layout||"vertical",s=Domma.elements.progression(this,{layout:o,theme:this.dataset.theme||"minimal",mode:this.dataset.mode||"timeline",statusIcons:!0,allowHtml:!0,...n.length?{items:n}:{}});if((this.dataset.mode||"timeline")==="timeline"){const t={completed:{label:"Completed",icon:"check-circle",tone:"success"},planned:{label:"Planned",icon:"circle",tone:"secondary"},"in-progress":{label:"In progress",icon:"clock",tone:"primary"},blocked:{label:"Blocked",icon:"alert-circle",tone:"danger"}};this.querySelectorAll(".dm-progression-item").forEach((e,a)=>{const l=n[a];if(!l)return;const h=e.querySelector(".dm-progression-title");if(l.icon&&h){const c=document.createElement("span");c.dataset.icon=l.icon,c.style.cssText="display:inline-flex;vertical-align:middle;margin-right:.4em",c.setAttribute("aria-hidden","true"),h.prepend(c)}const i=t[l.status];if(i&&h){const c=document.createElement("span");c.className=`badge badge-${i.tone} cms-timeline-status`,c.style.cssText="display:inline-flex;align-items:center;gap:.35em;margin:.4rem 0";const g=document.createElement("span");g.dataset.icon=i.icon,g.dataset.iconSize="14",g.setAttribute("aria-hidden","true"),c.append(g,document.createTextNode(i.label)),h.after(c)}})}Domma.icons.scan(this),n.length&&(this._dmCtx={instance:s,items:n})});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(n=>{const o=n.dataset.revealMode||"stagger",s=n.dataset.revealAnimation||"slide-up",t=parseInt(n.dataset.revealDuration,10)||400,e=parseInt(n.dataset.revealStagger,10)||60,a=parseInt(n.dataset.revealDelay,10)||0,l=n.dataset.revealDirection==="rtl",h=Array.from(n.children),i={"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)"};h.forEach((c,g)=>{c.style.opacity="0",c.style.transform=i[s]||"",c.style.transition=`opacity ${t}ms ease, transform ${t}ms ease`;const b=l?h.length-1-g:g;c.style.transitionDelay=o==="stagger"?`${a+b*e}ms`:`${a}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const c=new IntersectionObserver(g=>{g.forEach(b=>{b.isIntersecting&&(b.target.offsetWidth,b.target.style.opacity="1",b.target.style.transform="none",c.unobserve(b.target))})},{threshold:.1});h.forEach(g=>c.observe(g))}))}),w.find(".card[data-collapsible]").each(function(){const n=this.querySelector(".card-header");n&&n.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),w.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const n=this.dataset.soTarget,o=document.getElementById(n);if(!o)return;const s=E.slideover({title:o.dataset.soTitle||"",size:o.dataset.soSize||"md",position:o.dataset.soPosition||"right"});o.style.display="",s.setContent(o),s.open()})})}if(typeof $.setup=="function"){const n=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(o=>{try{const s=R(o.dataset.config);Object.assign(n,s)}catch{}}),Object.keys(n).length>0){const o={};for(const[s,t]of Object.entries(n)){const e=t?.events?.click,{confirm:a,toast:l,alert:h,prompt:i,...c}=e||{};a||l||h||i?($(s).on("click",async function(b){if(b.preventDefault(),a&&!await E.confirm(a))return;let u=null;if(!(i&&(u=await E.prompt(i,{inputPlaceholder:c.promptPlaceholder||"",inputValue:c.promptDefault||""}),u===null))){if(c.target){const f=$(c.target);c.toggleClass&&f.toggleClass(c.toggleClass),c.addClass&&f.addClass(c.addClass),c.removeClass&&f.removeClass(c.removeClass),u!==null&&(c.setText&&f.text(u),c.setVal&&f.val(u),c.setAttr&&f.attr(c.setAttr,u))}c.href&&(window.location.href=c.href),l&&E.toast(l,{type:c.toastType||"success"}),h&&E.alert(h)}}),Object.keys(c).length&&(o[s]={...t,events:{...t.events,click:c}})):o[s]=t}$.setup(o)}}w.length&&j(w.get(0))}),window.wireCTAButtons=j;function j(p){p.querySelectorAll(".dm-cta-trigger").forEach(r=>{r.__ctaWired||(r.__ctaWired=!0,r.addEventListener("click",async()=>{const x=r.dataset.action,y=r.dataset.entry,k=r.dataset.confirm;let w=S.get("auth_token");if(!w){E.toast("Please log in to perform this action.",{type:"warning"});return}if(k&&!await E.confirm(k))return;const n=Array.from(r.childNodes).map(s=>s.cloneNode(!0));r.disabled=!0,r.textContent="Running\u2026";const o=s=>fetch(`/api/actions/${x}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${s}`},body:JSON.stringify(r.dataset.scope?{entryId:y,scope:r.dataset.scope}:{entryId:y})});try{let s=await o(w);if(s.status===401){const e=S.get("auth_refresh_token");if(e){const a=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(a.ok){const{token:l}=await a.json();S.set("auth_token",l),w=l,s=await o(w)}}}const t=await s.json().catch(()=>({}));if(!s.ok||t.success===!1)throw new Error(t.error||t.message||`Error ${s.status}`);E.toast(t.message||"Action completed.",{type:"success"})}catch(s){E.toast(s.message||"Action failed.",{type:"error"})}finally{r.disabled=!1,r.textContent="",n.forEach(s=>r.appendChild(s)),Domma.icons.scan(r)}}))})}(function(){const r=document.querySelectorAll('.dm-collection-hydrate[data-collection-scope="mine"]');if(!r.length)return;const x=S.get("auth_token");function y(w,n){const o=document.createElement("div");o.className="dm-collection-display dm-collection-empty";const s=document.createElement("p");if(n){s.appendChild(document.createTextNode("Please "));const t=document.createElement("a");t.href="/login",t.textContent="sign in",s.appendChild(t),s.appendChild(document.createTextNode(" to view your entries."))}else s.textContent=w;return o.appendChild(s),o}function k(w,n){const o=document.createElement("template");o.innerHTML=n||"",w.replaceWith(o.content)}r.forEach(async w=>{const n=w.dataset.collectionAttrs;if(!n)return;if(!x){w.replaceChildren(y("",!0));return}const o=s=>fetch("/api/collections/render-scope",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${s}`},body:JSON.stringify({attrs:n})});try{let s=x,t=await o(s);if(t.status===401){const a=S.get("auth_refresh_token");if(a){const l=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:a})});if(l.ok){const{token:h}=await l.json();S.set("auth_token",h),s=h,t=await o(s)}}}if(!t.ok){const{error:a}=await t.json().catch(()=>({}));w.replaceChildren(y(a||"Unable to load entries.",!1));return}const{html:e}=await t.json();k(w,e),window.DommaCollectionBrowser?.hydrate&&window.DommaCollectionBrowser.hydrate(document)}catch{w.replaceChildren(y("Unable to load entries.",!1))}}),setTimeout(()=>{typeof Domma<"u"&&Domma.icons?.scan&&Domma.icons.scan(),typeof Domma<"u"&&Domma.syntax?.scan&&Domma.syntax.scan(),j(document.body)},100)})(),(function(){const r=document.querySelectorAll("[data-collection-table]");!r.length||typeof T>"u"||!T.create||r.forEach(x=>{let y;try{y=R(x.dataset.payload)}catch{return}const{columns:k,rows:w,search:n,sortable:o,exportable:s,pageSize:t,empty:e,ctaConfig:a,slug:l,exportCfg:h}=y;if(!k?.length)return;if(a){const g=k.findIndex(b=>b.key==="_cta");g!==-1&&(k[g]={key:"_cta",title:"",render:(b,u)=>{const f=document.createElement("button");if(f.className=`btn btn-${a.style||"primary"} dm-cta-trigger`,f.dataset.action=a.action||"",a.scope&&(f.dataset.scope=a.scope),f.dataset.entry=u._entryId||"",a.confirm&&(f.dataset.confirm=a.confirm),a.icon){const m=document.createElement("span");m.dataset.icon=a.icon,f.appendChild(m),f.appendChild(document.createTextNode(" "))}return f.appendChild(document.createTextNode(a.label||"Run")),f}})}const i="col-table-"+Math.random().toString(36).slice(2,7),c=document.createElement("div");c.id=i,x.replaceChildren(c),T.create("#"+i,{data:w,columns:k,search:n,sortable:o,exportable:s,pageSize:t,emptyMessage:e}),a&&j(c),x._dmTable={columns:k,rows:w,slug:l,exportCfg:h,render(g){T.create("#"+i,{data:g,columns:k,search:n,sortable:o,exportable:s,pageSize:t,emptyMessage:e}),a&&j(c)}}})})(),(function(){const r=document.querySelectorAll("[data-form-inline]");if(!r.length||typeof F>"u")return;function x(o){const s=o&&o.logic;return!!(s&&(s.visibility||s.requirement))}function y(o,s){const t={};return(o||[]).forEach(e=>{if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const a=e.type==="chooser"||!!(e.variant&&["select","radio","checkbox-group"].includes(e.type)),l=e.type==="checkbox"?"boolean":e.type==="date"||e.type==="icon"||e.type==="image"?"string":a?"chooser":e.type,h={...e.formConfig||{}};h.span==="full"&&s&&(h.span=s);const i={type:l,required:x(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},...h}};e.minLength!==void 0&&(i.minLength=e.minLength),e.maxLength!==void 0&&(i.maxLength=e.maxLength),e.min!==void 0&&(i.min=e.min),e.max!==void 0&&(i.max=e.max),a&&(e.variant&&(i.variant=e.variant),(e.multiple||e.type==="checkbox-group")&&(i.multiple=!0),e.density&&(i.density=e.density),e.columns&&(i.columns=e.columns),e.accent&&(i.accent=e.accent),e.accentStyle&&(i.accentStyle=e.accentStyle),e.glow&&(i.glow=!0),e.glowColour&&(i.glowColour=e.glowColour),e.shadow&&(i.shadow=e.shadow),e.shadowColour&&(i.shadowColour=e.shadowColour)),t[e.name]=i}),t}function k(o){const s={};return(o||[]).forEach(t=>{if(!(!t.name||t.type==="page-break"||t.type==="spacer")&&(t.type==="select"||t.type==="multiselect")&&t.required){const e=(t.options||[])[0];e&&(s[t.name]=typeof e=="object"?e.value:e)}}),s}function w(o,s){(s||[]).forEach(t=>{if(t.type!=="date"||!t.name)return;const e=o.querySelector(`[name="${t.name}"]`);e&&e.type!=="date"&&(e.type="date")})}function n(o,s,t){let e=o.querySelector(".cms-form-message");e||(e=document.createElement("p"),e.className="cms-form-message",o.appendChild(e)),e.textContent=s,e.style.cssText=t?"color:var(--dm-danger);margin-top:.75rem;":"color:var(--dm-success);margin-top:.75rem;"}r.forEach(o=>{let s;try{s=R(o.dataset.formInline)}catch{return}o.querySelector(":scope > .cms-form-embed-summary")?.remove();const t=s.fields||[],e=s.settings||{},a=Number(e.columns)===3?3:2,l=e.layout||"grid",h=t.some(f=>f.type==="page-break"),i=async f=>{if(o.dataset.triggerBlocked==="true"){const m=o._formLogicRuntime,v=m&&m.submitBlock?m.submitBlock().reasons:[];n(o,v.join(" ")||"This form cannot be submitted yet.",!0);const C=new Error("Blocked by a form trigger");throw C.formSubmitFailed=!0,C}try{const m=o.querySelector(".fb-form-honeypot"),v=m?.querySelector('input[type="text"]')||null,C=m?.querySelector('[name="_t"]')||null,q=Object.assign({},f);delete q._t,m?.dataset.hpName&&delete q[m.dataset.hpName],v!==null&&(q._hp=v.value),C!==null&&(q._t=C.value);const L=await H.post(`/api/forms/submit/${s.slug}`,q);if(L&&L.error){n(o,L.error,!0);const B=new Error(L.error);throw B.formSubmitFailed=!0,B}if(L?.redirect){window.location.href=L.redirect;return}for(;o.firstChild;)o.removeChild(o.firstChild);n(o,L?.message||e.successMessage||"Thank you for your submission.",!1)}catch(m){throw m.formSubmitFailed||n(o,m.message||"Submission failed. Please try again.",!0),m}},c=Date.now();function g(f){const m=f.querySelector("form");if(!m||m.querySelector(".fb-form-honeypot"))return;const v=document.createElement("div");v.className="fb-form-honeypot",v.setAttribute("aria-hidden","true");const C=document.createElement("input");C.name=m.querySelector('[name="website"]')?"_website":"website",v.dataset.hpName=C.name,C.type="text",C.tabIndex=-1,C.autocomplete="url",C.placeholder="https://",v.appendChild(C);const q=document.createElement("input");q.name="_t",q.type="hidden",q.value=c,v.appendChild(q),m.appendChild(v)}function b(f){if(!window.FormLogicEngine||!t.some(v=>v.logic||Array.isArray(v.triggers)&&v.triggers.length))return null;const m=new window.FormLogicEngine.FormLogicRuntime(s,o,f?{baseValues:f}:void 0);if(m.init(),o._formLogicRuntime=m,o._formLogicObserved)return m;if(o._formLogicObserved=!0,o.parentNode&&typeof MutationObserver<"u"){const v=new MutationObserver(function(C){for(const q of C)for(const L of q.removedNodes)if(L===o||L.nodeType===1&&L.contains&&L.contains(o)){o._formLogicRuntime&&o._formLogicRuntime.destroy(),v.disconnect();return}});v.observe(o.parentNode,{childList:!0,subtree:!1}),o._formLogicObserver=v}return m}let u=!1;if(o.addEventListener("dm-form-end",f=>{const m=f.detail?.validation;if(m&&!m.valid){Q(o,m.missing),Z(m);return}u||(u=!0,i(f.detail?.values||{}).catch(()=>{}).finally(()=>{u=!1}))}),h&&F.wizard){const f=ue(t,s.title),m=f.map(M=>({title:M.title,description:M.description,fields:y(M.fields,a)})),v=f.map(M=>M.fields);let C=0,q=null,L=null;const B=M=>{const N=window.FormLogicEngine;if(!N||!N.validateFields)return;const d=N.validateFields(o,v[C]||[]);d.valid||(M.preventDefault(),M.stopImmediatePropagation(),Q(o,d.missing),Z(d))},P=()=>{const M=v[C]||t;fe(o,e),ee(o,e,M),w(o,M),K(o,M),e.honeypot!==!1&&g(o),q&&q.destroy(),q=b(L?.getStepData?L.getStepData():void 0),o.querySelectorAll(".wizard-next, .wizard-submit").forEach(N=>N.addEventListener("click",B,!0))};L=F.wizard(o,{schema:{steps:m},layout:l,columns:a,finishText:e.submitText||"Finish",onSubmit:i,onStepChange:M=>{C=M,setTimeout(P,0)}}),o.addEventListener("dm-form-trigger",M=>{if(M.detail?.type!=="goto-step"||!L?.goToStep)return;const N=Number(M.detail.action.step);!Number.isFinite(N)||N<0||N>=m.length||N!==C&&L.goToStep(N)}),Promise.resolve(L).then(P)}else if(F.render){const f=F.render(o,y(t,a),k(t),{submitText:e.submitText||"Submit",layout:l,columns:a,onSubmit:i});Promise.resolve(f).then(function(){pe(o,e,l),ee(o,e,t),w(o,t),K(o,t),e.honeypot!==!1&&g(o),b()})}})})();function he(p,r){const x=document.body,y=x.classList,k=new Set((x.dataset.railSite||"").split(" ").filter(Boolean)),w=()=>window.matchMedia("(min-width: 993px)").matches;p.querySelectorAll(".navbar-link, .navbar-dropdown-toggle").forEach(a=>{const l=a.textContent.trim();l&&(a.setAttribute("title",l),a.querySelector("svg, [data-icon]")||(a.dataset.initial=l.charAt(0).toUpperCase()))});const n=document.createElement("button");n.type="button",n.className="dm-nav-collapse-toggle";const o=()=>{const a=y.contains("dm-vnav-collapsed");n.setAttribute("aria-pressed",String(a)),n.setAttribute("aria-label",a?"Expand menu":"Collapse menu"),n.title=n.getAttribute("aria-label");const l=r?"chevrons-left":"chevrons-right",h=r?"chevrons-right":"chevrons-left";n.innerHTML=`<span data-icon="${a?l:h}"></span>`,window.Domma&&Domma.icons&&Domma.icons.scan(n)},s=a=>{y.toggle("dm-vnav-collapsed",a),S.set("nav_collapsed",a),o()};n.addEventListener("click",()=>s(!y.contains("dm-vnav-collapsed"))),o();const t=p.querySelector(".navbar-container")||p;t.insertBefore(n,t.firstChild?.nextSibling||null);const e=a=>{y.toggle("dm-vnav-autohide",a),y.toggle("dm-vnav-collapsible",!a&&k.has("collapsible")),a?y.remove("dm-vnav-collapsed"):k.has("collapsible")&&S.get("nav_collapsed")===!0&&y.add("dm-vnav-collapsed"),p.classList.remove("dm-nav-revealed"),o()};p.addEventListener("pointerdown",a=>{y.contains("dm-vnav-autohide")&&a.pointerType!=="mouse"&&!p.classList.contains("dm-nav-revealed")&&(p.classList.add("dm-nav-revealed"),a.preventDefault())}),document.addEventListener("pointerdown",a=>{p.contains(a.target)||p.classList.remove("dm-nav-revealed")}),window.E&&typeof E.contextMenu=="function"&&E.contextMenu(p,{items:()=>{const a=y.contains("dm-vnav-autohide"),l=y.contains("dm-vnav-collapsed"),h=S.has("nav_autohide")||S.has("nav_collapsed");return[{header:"Menu"},{label:l?"Expand menu":"Collapse to icons",icon:l?"maximize":"minimize",disabled:!w()||a||!k.has("collapsible"),action:()=>s(!l)},{label:a?"Keep on screen":"Hide when not in use",icon:a?"eye":"eye-off",disabled:!w(),action:()=>{S.set("nav_autohide",!a),e(!a)}},{type:"divider"},{label:"Reset to the site's setting",icon:"refresh",disabled:!h,action:()=>{S.remove("nav_autohide"),S.remove("nav_collapsed"),e(k.has("autohide")),y.toggle("dm-vnav-collapsed",!k.has("autohide")&&k.has("start-collapsed")),o()}}]}})}function ge(p){p.classList.add("dm-nav-scroll-hide");let r=window.scrollY,x=!1;window.addEventListener("scroll",()=>{x||(x=!0,requestAnimationFrame(()=>{x=!1;const y=window.scrollY,k=p.matches(":focus-within")||p.querySelector(".navbar-dropdown.open, .navbar-collapse.show");y>r&&y>p.offsetHeight&&!k?p.classList.add("dm-nav-scrolled-away"):y<r&&p.classList.remove("dm-nav-scrolled-away"),r=y}))},{passive:!0})}function ye(p){let r={};try{r=JSON.parse(p.dataset.contents||"{}")}catch{}const x=p.closest(".site-main"),y=document.querySelector(".site-content"),k=new Set(String(r.exclude||"").split(",").map(i=>i.trim().toLowerCase()).filter(Boolean)),w=i=>!i.closest(".hero, .card, .accordion, .tabs, figure, #site-sidebar"),n=i=>(y?[...y.querySelectorAll(i)]:[]).filter(w).filter(c=>!k.has(c.textContent.trim().toLowerCase())),o=r.headings==="h2"?n("h2"):r.headings==="h3"?n("h3"):r.headings==="h2h3"?n("h2, h3"):n("h2").length?n("h2"):n("h3");if(!o.length){x?.classList.add("no-sidebar-items");return}const s=new Set([...document.querySelectorAll("[id]")].map(i=>i.id)),t=document.createElement("nav");t.className="dm-contents",t.setAttribute("aria-label",r.title||"Page contents"),r.hover&&(t.dataset.menuHover=r.hover),r.selected&&(t.dataset.menuActive=r.selected),r.accent&&t.style.setProperty("--dm-menu-highlight",r.accent.startsWith("#")?r.accent:`var(--dm-${r.accent})`),r.font&&(t.style.fontFamily=r.font),r.fontSize&&(t.style.fontSize=r.fontSize),r.fontWeight&&(t.style.fontWeight=r.fontWeight);const e=document.createElement("div");e.className="dm-contents-head";const a=document.createElement("p");a.className="dm-contents-title",a.textContent=r.title||"",e.appendChild(a),t.appendChild(e);const l=document.createElement("ol");l.className="dm-contents-list"+(r.numbered?" is-numbered":"");const h=[];for(const i of o){if(!i.id){const b=(i.textContent||"").toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"section";let u=b;for(let f=2;s.has(u);f++)u=`${b}-${f}`;s.add(u),i.id=u}const c=document.createElement("li");c.className=`dm-contents-item dm-contents-${i.tagName.toLowerCase()}`;const g=document.createElement("a");g.href=`#${i.id}`,g.textContent=i.textContent.trim(),c.appendChild(g),l.appendChild(c),h.push([i,g])}if(t.appendChild(l),p.replaceChildren(t),r.scrollSpy!==!1&&"IntersectionObserver"in window){const i=new Map,c=()=>{const b=h.find(([u])=>i.get(u))||null;h.forEach(([,u])=>{const f=b&&u===b[1];u.classList.toggle("is-active",!!f),f?u.setAttribute("aria-current","location"):u.removeAttribute("aria-current")})},g=new IntersectionObserver(b=>{b.forEach(u=>i.set(u.target,u.isIntersecting)),c()},{rootMargin:"-80px 0px -60% 0px"});h.forEach(([b])=>g.observe(b))}if(x?.classList.contains("contents-collapsible")){const i=document.createElement("button");i.type="button",i.className="dm-contents-toggle";const c=()=>{const g=x.classList.contains("contents-collapsed");i.setAttribute("aria-expanded",String(!g)),i.setAttribute("aria-label",g?"Show the contents":"Hide the contents"),i.title=i.getAttribute("aria-label"),i.innerHTML=`<span data-icon="${g?"list":"x"}"></span>`,window.Domma?.icons&&Domma.icons.scan(i)};i.addEventListener("click",()=>{S.set("contents_collapsed",x.classList.toggle("contents-collapsed")),c()}),c(),e.insertBefore(i,e.firstChild)}if(x?.classList.contains("contents-floating")&&r.movable){t.classList.add("is-movable");const i=S.get("contents_position");i&&Number.isFinite(i.x)&&Object.assign(p.style,{left:`${i.x}px`,top:`${i.y}px`,right:"auto",bottom:"auto"}),e.addEventListener("pointerdown",c=>{if(c.target.closest("button"))return;const g=p.getBoundingClientRect(),b=c.clientX-g.left,u=c.clientY-g.top,f=v=>{const C=Math.max(0,Math.min(window.innerWidth-g.width,v.clientX-b)),q=Math.max(0,Math.min(window.innerHeight-40,v.clientY-u));Object.assign(p.style,{left:`${C}px`,top:`${q}px`,right:"auto",bottom:"auto"})},m=()=>{document.removeEventListener("pointermove",f),S.set("contents_position",{x:parseInt(p.style.left,10),y:parseInt(p.style.top,10)})};document.addEventListener("pointermove",f),document.addEventListener("pointerup",m,{once:!0})})}window.E&&typeof E.contextMenu=="function"&&E.contextMenu(p,{items:()=>{const i=x?.classList.contains("contents-collapsible"),c=x?.classList.contains("contents-collapsed"),g=h.find(([,b])=>b.classList.contains("is-active"));return[{header:"Contents"},...i?[{label:c?"Show the contents":"Collapse to a button",icon:c?"list":"minimize",action:()=>p.querySelector(".dm-contents-toggle")?.click()}]:[],{label:"Copy a link to this section",icon:"link",disabled:!g,action:async()=>{const b=`${location.origin}${location.pathname}#${g[0].id}`;try{await _.copyToClipboard(b),E.toast("Link copied.",{type:"success"})}catch{E.toast("Could not copy the link.",{type:"error"})}}},{label:"Back to the top",icon:"arrow-up",action:()=>window.scrollTo({top:0,behavior:"smooth"})}]}}),x?.classList.contains("contents-autohide")&&(p.addEventListener("pointerdown",i=>{i.pointerType!=="mouse"&&!p.classList.contains("is-revealed")&&(p.classList.add("is-revealed"),i.preventDefault())}),document.addEventListener("pointerdown",i=>{p.contains(i.target)||p.classList.remove("is-revealed")}))}
|
|
1
|
+
import{applyMobileMenu as ae,initCarouselPopup as se}from"/public/js/mobile-ui.mjs?v=20261016-mobile-options";import{installSmoothCarets as ie}from"/public/js/smooth-caret.js";import{decodeBase64Json as W}from"/public/js/utf8-base64.mjs";import{makeBadgeEl as U,applyColour as G,applyPill as re,applyFloat as ce,applyHighlight as V,applyItemHighlight as le,itemHighlightAttrs as de,isCurrentItem as K,menuAlign as me,applyClasses as P,cssClasses as Y,applyEntrance as J}from"/public/js/menu-decor.mjs?v=20261014-menu-entrance";import{initFloatingMenus as ue}from"/public/js/menu-float.js?v=20260829-float-panel";import{enhancePickerFields as Q,markInvalidFields as Z,showValidationPopup as ee}from"/public/js/forms.js?v=20261015-smooth-caret";import{buildWizardSteps as pe}from"/public/js/form-steps.mjs";import{applyFormClasses as te,applySubmitButtons as fe,applyWizardButtons as he}from"/public/js/form-submit.mjs";document.addEventListener("click",p=>{const r=p.target?.closest?.(".dm-banner__dismiss");r&&(p.preventDefault(),r.closest(".dm-banner")?.remove())}),$(()=>{ie(document);const p=window.__CMS_NAV__||{},r=window.__CMS_SITE__||{};if(r.autoTheme?.enabled){let o=function(e){const a=(e||"07:00").split(":");return+a[0]*60+(+a[1]||0)},s=function(){const e=new Date,a=e.getHours()*60+e.getMinutes();return a>=o(n.dayStart)&&a<o(n.nightStart)?n.dayTheme:n.nightTheme},t=function(e){const a=n.apply&&n.apply[e]||{theme:e,custom:""};Domma.theme.set(a.theme);for(const l of[...document.body.classList])l.startsWith("dm-custom-")&&document.body.classList.remove(l);a.custom&&document.body.classList.add(a.custom)};const n=r.autoTheme;t(s()),setInterval(()=>t(s()),6e4)}const x=$("#site-navbar");if(x.length&&p.brand){let s=function(f,u=0){return Array.isArray(f)?f.filter(m=>!(m&&(m.type==="spacer"||m.type==="theme-switcher"))).filter(m=>!(m&&m.type==="separator"&&u===0)).map(m=>m&&m.type==="separator"?{divider:!0}:Array.isArray(m?.items)?{...m,items:s(m.items,u+1)}:m):f};const n={...p.brand},o=n.size&&n.size!=="md"?` navbar-brand-${n.size}`:"";if(n.logo||n.icon||n.tagline){let f="";n.logo?f+=`<img src="${n.logo}" class="navbar-brand-logo" alt="${n.text||""}">`:n.icon&&(f+=`<span data-icon="${n.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),n.text&&(f+=`<span class="navbar-brand-text${o}">${n.text}</span>`),n.tagline&&(f+=`<small class="navbar-brand-tagline">${n.tagline}</small>`),n.html=f}else o&&n.text&&(n.html=`<span class="navbar-brand-text${o}">${n.text}</span>`);const t=s(p.items||[]),e=p.position==="floating",a=p.orientation==="vertical";x.get(0).querySelector(":scope > .cms-nojs-links")?.remove(),Domma.elements.navbar("#site-navbar",{brand:n,items:t,variant:p.variant||"dark",position:e?"static":p.position||"sticky",collapsible:!0,appearOnHover:!!p.appearOnHover}),Domma.icons.scan("#site-navbar");const l=x.get(0);ae(l,p.mobile);const h=()=>{if(!a)return;const u=l.querySelector('a[aria-current="page"]')?.closest(".navbar-dropdown");u&&!u.classList.contains("open")&&u.querySelector(":scope > .navbar-dropdown-toggle")?.click()};l.classList.add(`dm-nav-align-${me(p)||"centre"}`),a&&x.get(0).classList.add("dm-nav-vertical",p.side==="right"?"dm-nav-vertical-right":"dm-nav-vertical-left");const i=p.dock||{};a&&!e&&ge(l,p.side==="right"),!a&&i.hideOnScroll&&ye(l),V(l,p),J(l,p),P(l,p.class),e&&(ce(x.get(0),p),ue(x.get(0).parentNode||document));const c=l.querySelector(".navbar-toggle"),g=l.querySelector(".navbar-collapse");if(c&&g){let f=null;const u=()=>{g.classList.contains("show")&&c.click()},m=()=>{f&&(f.remove(),f=null),document.body.classList.remove("dm-drawer-open")},v=()=>{f||(f=document.createElement("div"),f.className="dm-navbar-backdrop",f.addEventListener("click",u),document.body.appendChild(f),document.body.classList.add("dm-drawer-open"))};c.addEventListener("click",()=>{requestAnimationFrame(()=>{g.classList.contains("show")?(h(),v()):m()})}),g.addEventListener("click",C=>{C.target.closest("a")&&requestAnimationFrame(m)}),document.addEventListener("keydown",C=>{C.key==="Escape"&&g.classList.contains("show")&&u()})}const b=l.querySelector(".navbar-nav");if(b){const f=Array.from(b.children);let u=0;for(const m of p.items||[])if(!(m&&m.type==="separator")){if(m&&m.type==="spacer"){const v=document.createElement("li"),C=["xs","sm","md","lg","xl","flex"].includes(m.size)?m.size:"md";v.className=`dm-menu-spacer dm-menu-spacer--${C}`,v.setAttribute("aria-hidden","true"),b.insertBefore(v,f[u]||null);continue}if(m&&m.type==="theme-switcher"){const v=document.createElement("li");v.className="dm-menu-theme-switcher";const C=document.createElement("span");C.dataset.themeSwitcher="menu",C.dataset.position="inline",v.appendChild(C),b.insertBefore(v,f[u]||null);continue}u++}window.CMSThemeSwitcher?.scan(b)}l.querySelectorAll("[data-path]").forEach(f=>{const u=f.dataset.path.split(".").map(Number),m=u.length>1,v=u.reduce((D,q)=>D?.items?.[q],{items:t});if(!v)return;v.colour&&G(f,v.colour),v.pill&&!m&&re(f,v.pill),le(f,v),P(f.closest("li"),v.class);const C=U(v.badge);C&&f.appendChild(C),K(v.url,location.pathname)&&(f.classList.add("active"),f.setAttribute("aria-current","page"))}),h()}const y=$("#site-footer");if(y.length){let e=function(d){return String(d??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},a=function(d){return e(d).replace(/'/g,"'")},l=function(d){if(d&&d.type==="separator")return'<li class="footer-sep"><hr></li>';if(d&&d.type==="spacer")return`<li class="dm-menu-spacer dm-menu-spacer--${["xs","sm","md","lg","xl","flex"].includes(d.size)?d.size:"md"}" aria-hidden="true"></li>`;if(d&&d.type==="theme-switcher")return'<li class="dm-menu-theme-switcher"><span data-theme-switcher="menu" data-position="inline"></span></li>';const M=a(d.url||"#"),L=e(d.text||""),A=Array.isArray(d.items)&&d.items.length?`<ul>${d.items.map(l).join("")}</ul>`:"",B=de(d),oe=[B.hover&&` data-menu-item-hover="${a(B.hover)}"`,B.active&&` data-menu-item-active="${a(B.active)}"`].filter(Boolean).join(""),ne=B.accent?` style="--dm-menu-highlight:${a(B.accent)}"`:"",X=Y(d.class).join(" ");return`<li${X?` class="${a(X)}"`:""}><a href="${M}"${oe}${ne}>${L}</a>${A}</li>`},i=function(){if(!r.footer)return"";const d=Object.keys(o).filter(L=>n[L]);if(!d.length)return"";const M=Y(r.footer.socialClass).join(" ");return`<div class="footer-social">${d.map(L=>{const{label:A,svg:B}=o[L];return`<a href="${n[L]}" target="_blank" rel="noopener noreferrer" aria-label="${A}" class="footer-social-link${M?` ${a(M)}`:""}">${B}</a>`}).join("")}</div>`},f=function(d){if(!d||d.type==="separator"||d.type==="spacer")return"";if(d.type==="theme-switcher")return`<li class="cms-footer-col">${l(d).replace(/^<li[^>]*>|<\/li>$/g,"")}</li>`;const M=Array.isArray(d.items)&&d.items.length?`<ul>${d.items.map(l).join("")}</ul>`:"",L=b(d)?`<span class="cms-footer-col-title">${e(d.text||"")}</span>`:`<a class="cms-footer-col-title" href="${a(d.url||"#")}">${e(d.text||"")}</a>`,A=["cms-footer-col",...Y(d.class)].join(" ");return`<li class="${a(A)}">${L}${M}</li>`},v=function(d,M,L=!1){return(d||[]).forEach(A=>{A&&(A.type==="separator"||A.type==="spacer"||A.type==="theme-switcher")||(L&&b(A)||M.push(A),Array.isArray(A.items)&&A.items.length&&v(A.items,M))}),M};const n=r.social||{},o={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 s='<div class="footer-inner container">';const t=window.__CMS_FOOTER__||{primary:[],legal:[],copyright:""},h=(()=>{const d=r.cookieConsent;if(!d||d.showSettingsLink===!1)return"";const M=L=>(L||[]).some(A=>A&&(A.url==="#cookie-settings"||M(A.items)));return M(t.primary)||M(t.legal)?"":`<li class="cms-cookie-settings"><a href="#cookie-settings">${e(d.settingsLinkText||"Cookie settings")}</a></li>`})(),c=["columns","row"].includes(r.footer?.layout)?r.footer.layout:"auto",g=c==="columns"||c==="auto"&&t.primary.some(d=>Array.isArray(d?.items)&&d.items.length),b=d=>g&&!d.url&&Array.isArray(d.items)&&d.items.length>0;if(g){const d=p.brand||{},M=d.text||r.title||"",L=d.logo?`<img src="${a(d.logo)}" class="cms-footer-brand-logo" alt="">`:d.icon?'<span class="cms-footer-brand-icon"></span>':"",A=String(r.footer?.blurb||"").trim();s='<div class="footer-inner footer-inner--columns container"><div class="cms-footer-top">',s+=`<div class="cms-footer-brand">${M||L?`<a class="cms-footer-brand-name" href="${a(d.url||"/")}">${L}<span>${e(M)}</span></a>`:""}${A?`<p class="cms-footer-blurb">${e(A)}</p>`:""}${i()}</div>`,t.primary.length&&(s+=`<nav class="footer-nav footer-nav--primary footer-nav--columns" aria-label="Footer"><ul>${t.primary.map(f).join("")}</ul></nav>`),s+='</div><div class="cms-footer-bottom">',t.copyright&&(s+=`<p class="cms-footer-copyright">${e(t.copyright)}</p>`),(t.legal.length||h)&&(s+=`<nav class="footer-nav footer-nav--legal" aria-label="Legal"><ul>${t.legal.map(l).join("")}${h}</ul></nav>`),s+="</div>"}else t.copyright&&(s+=`<p>${e(t.copyright)}</p>`),t.primary.length&&(s+=`<nav class="footer-nav footer-nav--primary"><ul>${t.primary.map(l).join("")}</ul></nav>`),(t.legal.length||h)&&(s+=`<nav class="footer-nav footer-nav--legal"><ul>${t.legal.map(l).join("")}${h}</ul></nav>`),s+=i();s+="</div>",y.get(0).querySelector(":scope > .cms-nojs-links")?.remove();const u=y.get(0).querySelector(":scope > .cms-footer-blocks");u?.remove(),y.html(s),u&&y.get(0).prepend(u),window.CMSThemeSwitcher?.scan(y.get(0));const m=y.get(0).querySelector(".cms-footer-brand-icon");m&&p.brand?.icon&&m.setAttribute("data-icon",p.brand.icon),P(y.get(0),r.footer?.class);const C=[...v(t.primary,[],!0),...v(t.legal,[])];y.get(0).querySelectorAll(".footer-nav a").forEach((d,M)=>{const L=C[M];if(!L)return;L.colour&&G(d,L.colour);const A=U(L.badge);A&&d.appendChild(A),K(L.url,location.pathname)&&(d.classList.add("is-active"),d.setAttribute("aria-current","page"))}),V(y.get(0).querySelector(".footer-nav--primary"),{highlight:t.primaryHighlight}),V(y.get(0).querySelector(".footer-nav--legal"),{highlight:t.legalHighlight}),J(y.get(0).querySelector(".footer-nav--primary"),{entrance:t.primaryEntrance}),J(y.get(0).querySelector(".footer-nav--legal"),{entrance:t.legalEntrance}),P(y.get(0).querySelector(".footer-nav--primary"),t.primaryClass),P(y.get(0).querySelector(".footer-nav--legal"),t.legalClass);const q=r.footer?.showMotionToggle!==!1,z=S.get("reduced_motion"),j=r.footer?.motionDefault,I=z!=null?!!z:j==="on"?!0:j==="off"?!1:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),N=r.footer?.motionControl==="checkbox",O=y.get(0).querySelector(".cms-footer-bottom")||y.get(0).querySelector(".footer-inner");if(q&&O){const d=document.createElement("input");d.type="checkbox",d.className=N?"footer-motion-checkbox":"form-switch-input",d.id="dm-motion-switch",d.checked=I,d.addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()});const M=document.createElement("span");M.className=N?"footer-motion-label":"form-switch-label",M.textContent="Reduce motion";const L=document.createElement("label");L.className=N?"footer-motion-check":"form-switch footer-motion-switch",L.title="Reduce motion",L.appendChild(d),L.appendChild(M),O.appendChild(L)}}const k=document.getElementById("site-sidebar");k&&ve(k),Domma.icons.scan(),Domma.syntax?.scan&&Domma.syntax.scan();const w=$(".page-body");if(w.length){w.find(".accordion").each(function(){Domma.elements.accordion(this,{allowMultiple:this.dataset.multi==="true"})}),w.find(".tabs").each(function(){Domma.elements.tabs(this)}),w.find(".carousel").each(function(){const n={primary:"--dm-primary",secondary:"--dm-secondary",accent:"--dm-primary",text:"--dm-text",surface:"--dm-surface",background:"--dm-background",success:"--dm-success",warning:"--dm-warning",danger:"--dm-danger",info:"--dm-info"},o=h=>{const i=String(h||"").trim();return i?n[i.toLowerCase()]?`var(${n[i.toLowerCase()]})`:CSS.supports("color",i)?i:"":""},s=o(this.dataset.background),t=o(this.dataset.arrowBackground),e=o(this.dataset.arrowColor);s&&(this.style.setProperty("--dm-carousel-background",s),this.classList.add("dm-carousel-custom-background")),t&&this.style.setProperty("--dm-carousel-arrow-background",t),e&&this.style.setProperty("--dm-carousel-arrow-color",e);const a=["top","middle","bottom"].includes(this.dataset.arrowPosition)?this.dataset.arrowPosition:"middle";this.classList.add(`dm-carousel-arrows-${a}`);const l={autoplay:this.dataset.autoplay==="true",interval:parseInt(this.dataset.interval,10)||5e3,loop:this.dataset.loop!=="false",animation:this.dataset.animation||"slide",heightMode:this.dataset.heightMode==="average"?"average":"crop"};if(this.dataset.height){const h=Number(this.dataset.height);Number.isFinite(h)&&h>0&&(l.height=h)}if(this.dataset.animationDuration){const h=parseInt(this.dataset.animationDuration,10);Number.isFinite(h)&&(l.animationDuration=h)}this.dataset.animationEasing&&(l.animationEasing=this.dataset.animationEasing),this.dataset.ctx&&(this._dmCtxSlides=Array.from(this.querySelectorAll(".carousel-slide")).map(h=>h.cloneNode(!0)),this._dmCarouselOpts=l),this._dmCarousel=Domma.elements.carousel(this,l),se(this)}),w.find(".dm-countdown").each(function(){const n={autoStart:!0},o=String(this.dataset.format||"").trim(),s=String(this.dataset.to||"").trim(),t=/^(\d{4})-(\d{2})-(\d{2})$/.exec(s),e=t?new Date(+t[1],+t[2]-1,+t[3]).getTime():s?new Date(s).getTime():NaN;Number.isFinite(e)?n.duration=Math.max(0,e-Date.now()):this.dataset.duration&&(n.duration=(parseInt(this.dataset.duration,10)||0)*1e3);const a=/^dd:/i.test(o);n.format=a?"hh:mm:ss":o||(n.duration>=36e5?"hh:mm:ss":"mm:ss");const l=this,h=c=>String(c).padStart(2,"0"),i=c=>{const g=Math.max(0,Math.floor(c/1e3)),b=l.querySelector(".dm-timer-time");b&&(b.textContent=`${h(Math.floor(g/86400))}:${h(Math.floor(g%86400/3600))}:${h(Math.floor(g%3600/60))}:${h(g%60)}`)};a&&(n.onTick=i),Domma.elements.timer(this,n),a&&i(n.duration||0)}),w.find("[data-tooltip]").each(function(){Domma.elements.tooltip(this,{content:$(this).data("tooltip"),position:$(this).data("tooltip-position")||"top"})}),w.find(".dm-progression").each(function(){const n=Array.from(this.querySelectorAll(".dm-progression-item")).map(t=>{const e={title:t.querySelector(".dm-progression-item-title")?.textContent?.trim()||"",description:t.querySelector(".dm-progression-item-body")?.innerHTML?.trim()||""};return t.dataset.date&&(e.date=t.dataset.date),t.dataset.status&&(e.status=t.dataset.status),t.dataset.eventIcon&&(e.icon=t.dataset.eventIcon),t.dataset.entryId&&(e._entryId=t.dataset.entryId),e}),o=this.dataset.layout==="centred"?"centered":this.dataset.layout||"vertical",s=Domma.elements.progression(this,{layout:o,theme:this.dataset.theme||"minimal",mode:this.dataset.mode||"timeline",statusIcons:!0,allowHtml:!0,...n.length?{items:n}:{}});if((this.dataset.mode||"timeline")==="timeline"){const t={completed:{label:"Completed",icon:"check-circle",tone:"success"},planned:{label:"Planned",icon:"circle",tone:"secondary"},"in-progress":{label:"In progress",icon:"clock",tone:"primary"},blocked:{label:"Blocked",icon:"alert-circle",tone:"danger"}};this.querySelectorAll(".dm-progression-item").forEach((e,a)=>{const l=n[a];if(!l)return;const h=e.querySelector(".dm-progression-title");if(l.icon&&h){const c=document.createElement("span");c.dataset.icon=l.icon,c.style.cssText="display:inline-flex;vertical-align:middle;margin-right:.4em",c.setAttribute("aria-hidden","true"),h.prepend(c)}const i=t[l.status];if(i&&h){const c=document.createElement("span");c.className=`badge badge-${i.tone} cms-timeline-status`,c.style.cssText="display:inline-flex;align-items:center;gap:.35em;margin:.4rem 0";const g=document.createElement("span");g.dataset.icon=i.icon,g.dataset.iconSize="14",g.setAttribute("aria-hidden","true"),c.append(g,document.createTextNode(i.label)),h.after(c)}})}Domma.icons.scan(this),n.length&&(this._dmCtx={instance:s,items:n})});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(n=>{const o=n.dataset.revealMode||"stagger",s=n.dataset.revealAnimation||"slide-up",t=parseInt(n.dataset.revealDuration,10)||400,e=parseInt(n.dataset.revealStagger,10)||60,a=parseInt(n.dataset.revealDelay,10)||0,l=n.dataset.revealDirection==="rtl",h=Array.from(n.children),i={"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)"};h.forEach((c,g)=>{c.style.opacity="0",c.style.transform=i[s]||"",c.style.transition=`opacity ${t}ms ease, transform ${t}ms ease`;const b=l?h.length-1-g:g;c.style.transitionDelay=o==="stagger"?`${a+b*e}ms`:`${a}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const c=new IntersectionObserver(g=>{g.forEach(b=>{b.isIntersecting&&(b.target.offsetWidth,b.target.style.opacity="1",b.target.style.transform="none",c.unobserve(b.target))})},{threshold:.1});h.forEach(g=>c.observe(g))}))}),w.find(".card[data-collapsible]").each(function(){const n=this.querySelector(".card-header");n&&n.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),w.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const n=this.dataset.soTarget,o=document.getElementById(n);if(!o)return;const s=E.slideover({title:o.dataset.soTitle||"",size:o.dataset.soSize||"md",position:o.dataset.soPosition||"right"});o.style.display="",s.setContent(o),s.open()})})}if(typeof $.setup=="function"){const n=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(o=>{try{const s=W(o.dataset.config);Object.assign(n,s)}catch{}}),Object.keys(n).length>0){const o={};for(const[s,t]of Object.entries(n)){const e=t?.events?.click,{confirm:a,toast:l,alert:h,prompt:i,...c}=e||{};a||l||h||i?($(s).on("click",async function(b){if(b.preventDefault(),a&&!await E.confirm(a))return;let f=null;if(!(i&&(f=await E.prompt(i,{inputPlaceholder:c.promptPlaceholder||"",inputValue:c.promptDefault||""}),f===null))){if(c.target){const u=$(c.target);c.toggleClass&&u.toggleClass(c.toggleClass),c.addClass&&u.addClass(c.addClass),c.removeClass&&u.removeClass(c.removeClass),f!==null&&(c.setText&&u.text(f),c.setVal&&u.val(f),c.setAttr&&u.attr(c.setAttr,f))}c.href&&(window.location.href=c.href),l&&E.toast(l,{type:c.toastType||"success"}),h&&E.alert(h)}}),Object.keys(c).length&&(o[s]={...t,events:{...t.events,click:c}})):o[s]=t}$.setup(o)}}w.length&&R(w.get(0))}),window.wireCTAButtons=R;function R(p){p.querySelectorAll(".dm-cta-trigger").forEach(r=>{r.__ctaWired||(r.__ctaWired=!0,r.addEventListener("click",async()=>{const x=r.dataset.action,y=r.dataset.entry,k=r.dataset.confirm;let w=S.get("auth_token");if(!w){E.toast("Please log in to perform this action.",{type:"warning"});return}if(k&&!await E.confirm(k))return;const n=Array.from(r.childNodes).map(s=>s.cloneNode(!0));r.disabled=!0,r.textContent="Running\u2026";const o=s=>fetch(`/api/actions/${x}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${s}`},body:JSON.stringify(r.dataset.scope?{entryId:y,scope:r.dataset.scope}:{entryId:y})});try{let s=await o(w);if(s.status===401){const e=S.get("auth_refresh_token");if(e){const a=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(a.ok){const{token:l}=await a.json();S.set("auth_token",l),w=l,s=await o(w)}}}const t=await s.json().catch(()=>({}));if(!s.ok||t.success===!1)throw new Error(t.error||t.message||`Error ${s.status}`);E.toast(t.message||"Action completed.",{type:"success"})}catch(s){E.toast(s.message||"Action failed.",{type:"error"})}finally{r.disabled=!1,r.textContent="",n.forEach(s=>r.appendChild(s)),Domma.icons.scan(r)}}))})}(function(){const r=document.querySelectorAll('.dm-collection-hydrate[data-collection-scope="mine"]');if(!r.length)return;const x=S.get("auth_token");function y(w,n){const o=document.createElement("div");o.className="dm-collection-display dm-collection-empty";const s=document.createElement("p");if(n){s.appendChild(document.createTextNode("Please "));const t=document.createElement("a");t.href="/login",t.textContent="sign in",s.appendChild(t),s.appendChild(document.createTextNode(" to view your entries."))}else s.textContent=w;return o.appendChild(s),o}function k(w,n){const o=document.createElement("template");o.innerHTML=n||"",w.replaceWith(o.content)}r.forEach(async w=>{const n=w.dataset.collectionAttrs;if(!n)return;if(!x){w.replaceChildren(y("",!0));return}const o=s=>fetch("/api/collections/render-scope",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${s}`},body:JSON.stringify({attrs:n})});try{let s=x,t=await o(s);if(t.status===401){const a=S.get("auth_refresh_token");if(a){const l=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:a})});if(l.ok){const{token:h}=await l.json();S.set("auth_token",h),s=h,t=await o(s)}}}if(!t.ok){const{error:a}=await t.json().catch(()=>({}));w.replaceChildren(y(a||"Unable to load entries.",!1));return}const{html:e}=await t.json();k(w,e),window.DommaCollectionBrowser?.hydrate&&window.DommaCollectionBrowser.hydrate(document)}catch{w.replaceChildren(y("Unable to load entries.",!1))}}),setTimeout(()=>{typeof Domma<"u"&&Domma.icons?.scan&&Domma.icons.scan(),typeof Domma<"u"&&Domma.syntax?.scan&&Domma.syntax.scan(),R(document.body)},100)})(),(function(){const r=document.querySelectorAll("[data-collection-table]");!r.length||typeof T>"u"||!T.create||r.forEach(x=>{let y;try{y=W(x.dataset.payload)}catch{return}const{columns:k,rows:w,search:n,sortable:o,exportable:s,pageSize:t,empty:e,ctaConfig:a,slug:l,exportCfg:h}=y;if(!k?.length)return;if(a){const g=k.findIndex(b=>b.key==="_cta");g!==-1&&(k[g]={key:"_cta",title:"",render:(b,f)=>{const u=document.createElement("button");if(u.className=`btn btn-${a.style||"primary"} dm-cta-trigger`,u.dataset.action=a.action||"",a.scope&&(u.dataset.scope=a.scope),u.dataset.entry=f._entryId||"",a.confirm&&(u.dataset.confirm=a.confirm),a.icon){const m=document.createElement("span");m.dataset.icon=a.icon,u.appendChild(m),u.appendChild(document.createTextNode(" "))}return u.appendChild(document.createTextNode(a.label||"Run")),u}})}const i="col-table-"+Math.random().toString(36).slice(2,7),c=document.createElement("div");c.id=i,x.replaceChildren(c),T.create("#"+i,{data:w,columns:k,search:n,sortable:o,exportable:s,pageSize:t,emptyMessage:e}),a&&R(c),x._dmTable={columns:k,rows:w,slug:l,exportCfg:h,render(g){T.create("#"+i,{data:g,columns:k,search:n,sortable:o,exportable:s,pageSize:t,emptyMessage:e}),a&&R(c)}}})})(),(function(){const r=document.querySelectorAll("[data-form-inline]");if(!r.length||typeof F>"u")return;function x(o){const s=o&&o.logic;return!!(s&&(s.visibility||s.requirement))}function y(o,s){const t={};return(o||[]).forEach(e=>{if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const a=e.type==="chooser"||!!(e.variant&&["select","radio","checkbox-group"].includes(e.type)),l=e.type==="checkbox"?"boolean":e.type==="date"||e.type==="icon"||e.type==="image"?"string":a?"chooser":e.type,h={...e.formConfig||{}};h.span==="full"&&s&&(h.span=s);const i={type:l,required:x(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},...h}};e.minLength!==void 0&&(i.minLength=e.minLength),e.maxLength!==void 0&&(i.maxLength=e.maxLength),e.min!==void 0&&(i.min=e.min),e.max!==void 0&&(i.max=e.max),a&&(e.variant&&(i.variant=e.variant),(e.multiple||e.type==="checkbox-group")&&(i.multiple=!0),e.density&&(i.density=e.density),e.columns&&(i.columns=e.columns),e.accent&&(i.accent=e.accent),e.accentStyle&&(i.accentStyle=e.accentStyle),e.glow&&(i.glow=!0),e.glowColour&&(i.glowColour=e.glowColour),e.shadow&&(i.shadow=e.shadow),e.shadowColour&&(i.shadowColour=e.shadowColour)),t[e.name]=i}),t}function k(o){const s={};return(o||[]).forEach(t=>{if(!(!t.name||t.type==="page-break"||t.type==="spacer")&&(t.type==="select"||t.type==="multiselect")&&t.required){const e=(t.options||[])[0];e&&(s[t.name]=typeof e=="object"?e.value:e)}}),s}function w(o,s){(s||[]).forEach(t=>{if(t.type!=="date"||!t.name)return;const e=o.querySelector(`[name="${t.name}"]`);e&&e.type!=="date"&&(e.type="date")})}function n(o,s,t){let e=o.querySelector(".cms-form-message");e||(e=document.createElement("p"),e.className="cms-form-message",o.appendChild(e)),e.textContent=s,e.style.cssText=t?"color:var(--dm-danger);margin-top:.75rem;":"color:var(--dm-success);margin-top:.75rem;"}r.forEach(o=>{let s;try{s=W(o.dataset.formInline)}catch{return}o.querySelector(":scope > .cms-form-embed-summary")?.remove();const t=s.fields||[],e=s.settings||{},a=Number(e.columns)===3?3:2,l=e.layout||"grid",h=t.some(u=>u.type==="page-break"),i=async u=>{if(o.dataset.triggerBlocked==="true"){const m=o._formLogicRuntime,v=m&&m.submitBlock?m.submitBlock().reasons:[];n(o,v.join(" ")||"This form cannot be submitted yet.",!0);const C=new Error("Blocked by a form trigger");throw C.formSubmitFailed=!0,C}try{const m=o.querySelector(".fb-form-honeypot"),v=m?.querySelector('input[type="text"]')||null,C=m?.querySelector('[name="_t"]')||null,D=Object.assign({},u);delete D._t,m?.dataset.hpName&&delete D[m.dataset.hpName],v!==null&&(D._hp=v.value),C!==null&&(D._t=C.value);const q=await H.post(`/api/forms/submit/${s.slug}`,D);if(q&&q.error){n(o,q.error,!0);const z=new Error(q.error);throw z.formSubmitFailed=!0,z}if(q?.redirect){window.location.href=q.redirect;return}for(;o.firstChild;)o.removeChild(o.firstChild);n(o,q?.message||e.successMessage||"Thank you for your submission.",!1)}catch(m){throw m.formSubmitFailed||n(o,m.message||"Submission failed. Please try again.",!0),m}},c=Date.now();function g(u){const m=u.querySelector("form");if(!m||m.querySelector(".fb-form-honeypot"))return;const v=document.createElement("div");v.className="fb-form-honeypot",v.setAttribute("aria-hidden","true");const C=document.createElement("input");C.name=m.querySelector('[name="website"]')?"_website":"website",v.dataset.hpName=C.name,C.type="text",C.tabIndex=-1,C.autocomplete="url",C.placeholder="https://",v.appendChild(C);const D=document.createElement("input");D.name="_t",D.type="hidden",D.value=c,v.appendChild(D),m.appendChild(v)}function b(u){if(!window.FormLogicEngine||!t.some(v=>v.logic||Array.isArray(v.triggers)&&v.triggers.length))return null;const m=new window.FormLogicEngine.FormLogicRuntime(s,o,u?{baseValues:u}:void 0);if(m.init(),o._formLogicRuntime=m,o._formLogicObserved)return m;if(o._formLogicObserved=!0,o.parentNode&&typeof MutationObserver<"u"){const v=new MutationObserver(function(C){for(const D of C)for(const q of D.removedNodes)if(q===o||q.nodeType===1&&q.contains&&q.contains(o)){o._formLogicRuntime&&o._formLogicRuntime.destroy(),v.disconnect();return}});v.observe(o.parentNode,{childList:!0,subtree:!1}),o._formLogicObserver=v}return m}let f=!1;if(o.addEventListener("dm-form-end",u=>{const m=u.detail?.validation;if(m&&!m.valid){Z(o,m.missing),ee(m);return}f||(f=!0,i(u.detail?.values||{}).catch(()=>{}).finally(()=>{f=!1}))}),h&&F.wizard){const u=pe(t,s.title),m=u.map(I=>({title:I.title,description:I.description,fields:y(I.fields,a)})),v=u.map(I=>I.fields);let C=0,D=null,q=null;const z=I=>{const N=window.FormLogicEngine;if(!N||!N.validateFields)return;const O=N.validateFields(o,v[C]||[]);O.valid||(I.preventDefault(),I.stopImmediatePropagation(),Z(o,O.missing),ee(O))},j=()=>{const I=v[C]||t;he(o,e),te(o,e,I),w(o,I),Q(o,I),e.honeypot!==!1&&g(o),D&&D.destroy(),D=b(q?.getStepData?q.getStepData():void 0),o.querySelectorAll(".wizard-next, .wizard-submit").forEach(N=>N.addEventListener("click",z,!0))};q=F.wizard(o,{schema:{steps:m},layout:l,columns:a,finishText:e.submitText||"Finish",onSubmit:i,onStepChange:I=>{C=I,setTimeout(j,0)}}),o.addEventListener("dm-form-trigger",I=>{if(I.detail?.type!=="goto-step"||!q?.goToStep)return;const N=Number(I.detail.action.step);!Number.isFinite(N)||N<0||N>=m.length||N!==C&&q.goToStep(N)}),Promise.resolve(q).then(j)}else if(F.render){const u=F.render(o,y(t,a),k(t),{submitText:e.submitText||"Submit",layout:l,columns:a,onSubmit:i});Promise.resolve(u).then(function(){fe(o,e,l),te(o,e,t),w(o,t),Q(o,t),e.honeypot!==!1&&g(o),b()})}})})();function ge(p,r){const x=document.body,y=x.classList,k=new Set((x.dataset.railSite||"").split(" ").filter(Boolean)),w=()=>window.matchMedia("(min-width: 993px)").matches;p.querySelectorAll(".navbar-link, .navbar-dropdown-toggle").forEach(a=>{const l=a.textContent.trim();l&&(a.setAttribute("title",l),a.querySelector("svg, [data-icon]")||(a.dataset.initial=l.charAt(0).toUpperCase()))});const n=document.createElement("button");n.type="button",n.className="dm-nav-collapse-toggle";const o=()=>{const a=y.contains("dm-vnav-collapsed");n.setAttribute("aria-pressed",String(a)),n.setAttribute("aria-label",a?"Expand menu":"Collapse menu"),n.title=n.getAttribute("aria-label");const l=r?"chevrons-left":"chevrons-right",h=r?"chevrons-right":"chevrons-left";n.innerHTML=`<span data-icon="${a?l:h}"></span>`,window.Domma&&Domma.icons&&Domma.icons.scan(n)},s=a=>{y.toggle("dm-vnav-collapsed",a),S.set("nav_collapsed",a),o()};n.addEventListener("click",()=>s(!y.contains("dm-vnav-collapsed"))),o();const t=p.querySelector(".navbar-container")||p;t.insertBefore(n,t.firstChild?.nextSibling||null);const e=a=>{y.toggle("dm-vnav-autohide",a),y.toggle("dm-vnav-collapsible",!a&&k.has("collapsible")),a?y.remove("dm-vnav-collapsed"):k.has("collapsible")&&S.get("nav_collapsed")===!0&&y.add("dm-vnav-collapsed"),p.classList.remove("dm-nav-revealed"),o()};p.addEventListener("pointerdown",a=>{y.contains("dm-vnav-autohide")&&a.pointerType!=="mouse"&&!p.classList.contains("dm-nav-revealed")&&(p.classList.add("dm-nav-revealed"),a.preventDefault())}),document.addEventListener("pointerdown",a=>{p.contains(a.target)||p.classList.remove("dm-nav-revealed")}),window.E&&typeof E.contextMenu=="function"&&E.contextMenu(p,{items:()=>{const a=y.contains("dm-vnav-autohide"),l=y.contains("dm-vnav-collapsed"),h=S.has("nav_autohide")||S.has("nav_collapsed");return[{header:"Menu"},{label:l?"Expand menu":"Collapse to icons",icon:l?"maximize":"minimize",disabled:!w()||a||!k.has("collapsible"),action:()=>s(!l)},{label:a?"Keep on screen":"Hide when not in use",icon:a?"eye":"eye-off",disabled:!w(),action:()=>{S.set("nav_autohide",!a),e(!a)}},{type:"divider"},{label:"Reset to the site's setting",icon:"refresh",disabled:!h,action:()=>{S.remove("nav_autohide"),S.remove("nav_collapsed"),e(k.has("autohide")),y.toggle("dm-vnav-collapsed",!k.has("autohide")&&k.has("start-collapsed")),o()}}]}})}function ye(p){p.classList.add("dm-nav-scroll-hide");let r=window.scrollY,x=!1;window.addEventListener("scroll",()=>{x||(x=!0,requestAnimationFrame(()=>{x=!1;const y=window.scrollY,k=p.matches(":focus-within")||p.querySelector(".navbar-dropdown.open, .navbar-collapse.show");y>r&&y>p.offsetHeight&&!k?p.classList.add("dm-nav-scrolled-away"):y<r&&p.classList.remove("dm-nav-scrolled-away"),r=y}))},{passive:!0})}function ve(p){let r={};try{r=JSON.parse(p.dataset.contents||"{}")}catch{}const x=p.closest(".site-main"),y=document.querySelector(".site-content"),k=new Set(String(r.exclude||"").split(",").map(i=>i.trim().toLowerCase()).filter(Boolean)),w=i=>!i.closest(".hero, .card, .accordion, .tabs, figure, #site-sidebar"),n=i=>(y?[...y.querySelectorAll(i)]:[]).filter(w).filter(c=>!k.has(c.textContent.trim().toLowerCase())),o=r.headings==="h2"?n("h2"):r.headings==="h3"?n("h3"):r.headings==="h2h3"?n("h2, h3"):n("h2").length?n("h2"):n("h3");if(!o.length){x?.classList.add("no-sidebar-items");return}const s=new Set([...document.querySelectorAll("[id]")].map(i=>i.id)),t=document.createElement("nav");t.className="dm-contents",t.setAttribute("aria-label",r.title||"Page contents"),r.hover&&(t.dataset.menuHover=r.hover),r.selected&&(t.dataset.menuActive=r.selected),r.accent&&t.style.setProperty("--dm-menu-highlight",r.accent.startsWith("#")?r.accent:`var(--dm-${r.accent})`),r.font&&(t.style.fontFamily=r.font),r.fontSize&&(t.style.fontSize=r.fontSize),r.fontWeight&&(t.style.fontWeight=r.fontWeight);const e=document.createElement("div");e.className="dm-contents-head";const a=document.createElement("p");a.className="dm-contents-title",a.textContent=r.title||"",e.appendChild(a),t.appendChild(e);const l=document.createElement("ol");l.className="dm-contents-list"+(r.numbered?" is-numbered":"");const h=[];for(const i of o){if(!i.id){const b=(i.textContent||"").toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"section";let f=b;for(let u=2;s.has(f);u++)f=`${b}-${u}`;s.add(f),i.id=f}const c=document.createElement("li");c.className=`dm-contents-item dm-contents-${i.tagName.toLowerCase()}`;const g=document.createElement("a");g.href=`#${i.id}`,g.textContent=i.textContent.trim(),c.appendChild(g),l.appendChild(c),h.push([i,g])}if(t.appendChild(l),p.replaceChildren(t),r.scrollSpy!==!1&&"IntersectionObserver"in window){const i=new Map,c=()=>{const b=h.find(([f])=>i.get(f))||null;h.forEach(([,f])=>{const u=b&&f===b[1];f.classList.toggle("is-active",!!u),u?f.setAttribute("aria-current","location"):f.removeAttribute("aria-current")})},g=new IntersectionObserver(b=>{b.forEach(f=>i.set(f.target,f.isIntersecting)),c()},{rootMargin:"-80px 0px -60% 0px"});h.forEach(([b])=>g.observe(b))}if(x?.classList.contains("contents-collapsible")){const i=document.createElement("button");i.type="button",i.className="dm-contents-toggle";const c=()=>{const g=x.classList.contains("contents-collapsed");i.setAttribute("aria-expanded",String(!g)),i.setAttribute("aria-label",g?"Show the contents":"Hide the contents"),i.title=i.getAttribute("aria-label"),i.innerHTML=`<span data-icon="${g?"list":"x"}"></span>`,window.Domma?.icons&&Domma.icons.scan(i)};i.addEventListener("click",()=>{S.set("contents_collapsed",x.classList.toggle("contents-collapsed")),c()}),c(),e.insertBefore(i,e.firstChild)}if(x?.classList.contains("contents-floating")&&r.movable){t.classList.add("is-movable");const i=S.get("contents_position");i&&Number.isFinite(i.x)&&Object.assign(p.style,{left:`${i.x}px`,top:`${i.y}px`,right:"auto",bottom:"auto"}),e.addEventListener("pointerdown",c=>{if(c.target.closest("button"))return;const g=p.getBoundingClientRect(),b=c.clientX-g.left,f=c.clientY-g.top,u=v=>{const C=Math.max(0,Math.min(window.innerWidth-g.width,v.clientX-b)),D=Math.max(0,Math.min(window.innerHeight-40,v.clientY-f));Object.assign(p.style,{left:`${C}px`,top:`${D}px`,right:"auto",bottom:"auto"})},m=()=>{document.removeEventListener("pointermove",u),S.set("contents_position",{x:parseInt(p.style.left,10),y:parseInt(p.style.top,10)})};document.addEventListener("pointermove",u),document.addEventListener("pointerup",m,{once:!0})})}window.E&&typeof E.contextMenu=="function"&&E.contextMenu(p,{items:()=>{const i=x?.classList.contains("contents-collapsible"),c=x?.classList.contains("contents-collapsed"),g=h.find(([,b])=>b.classList.contains("is-active"));return[{header:"Contents"},...i?[{label:c?"Show the contents":"Collapse to a button",icon:c?"list":"minimize",action:()=>p.querySelector(".dm-contents-toggle")?.click()}]:[],{label:"Copy a link to this section",icon:"link",disabled:!g,action:async()=>{const b=`${location.origin}${location.pathname}#${g[0].id}`;try{await _.copyToClipboard(b),E.toast("Link copied.",{type:"success"})}catch{E.toast("Could not copy the link.",{type:"error"})}}},{label:"Back to the top",icon:"arrow-up",action:()=>window.scrollTo({top:0,behavior:"smooth"})}]}}),x?.classList.contains("contents-autohide")&&(p.addEventListener("pointerdown",i=>{i.pointerType!=="mouse"&&!p.classList.contains("is-revealed")&&(p.classList.add("is-revealed"),i.preventDefault())}),document.addEventListener("pointerdown",i=>{p.contains(i.target)||p.classList.remove("is-revealed")}))}
|
|
@@ -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 {invalidateMeasurementId, measurementIdOf, normaliseMeasurementId} from '../../services/googleAnalytics.js';
|
|
23
24
|
|
|
24
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
|
|
@@ -46,7 +47,8 @@ export function settingsBadge(site = {}, cacheStats = {}) {
|
|
|
46
47
|
} else if (!String(smtp.fromAddress || '').trim()) {
|
|
47
48
|
problems.push({tone: 'warn', text: 'No From address', meta: 'mail has no sender; many servers refuse it'});
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
+
// Google Analytics shows the banner whatever its own switch says (services/googleAnalytics.js).
|
|
51
|
+
if ((cc.enabled !== false || measurementIdOf(site)) && !String(cc.privacyPolicyUrl || '').trim()) {
|
|
50
52
|
problems.push({tone: 'warn', text: 'Cookie banner without a privacy policy', meta: 'it links to nothing'});
|
|
51
53
|
}
|
|
52
54
|
const danger = problems.some(p => p.tone === 'danger');
|
|
@@ -120,7 +122,9 @@ export async function settingsRoutes(fastify) {
|
|
|
120
122
|
// payload into a save gets a 400 for keys it never touched.
|
|
121
123
|
'brand', 'url', 'baseTheme',
|
|
122
124
|
// Designer classes on <body>, every page (Site Settings > Appearance).
|
|
123
|
-
'bodyClass'
|
|
125
|
+
'bodyClass',
|
|
126
|
+
// Google Analytics (Site Settings > Analytics) - services/googleAnalytics.js.
|
|
127
|
+
'googleAnalytics'
|
|
124
128
|
]);
|
|
125
129
|
const unknownKeys = Object.keys(data).filter(k => !ALLOWED_KEYS.has(k));
|
|
126
130
|
if (unknownKeys.length > 0) {
|
|
@@ -149,6 +153,17 @@ export async function settingsRoutes(fastify) {
|
|
|
149
153
|
}
|
|
150
154
|
data.footer = {...data.footer, blocks: [...new Set(list)]};
|
|
151
155
|
}
|
|
156
|
+
if ('googleAnalytics' in data) {
|
|
157
|
+
// A GA4 measurement ID or nothing: it is written into every page and the security policy.
|
|
158
|
+
const ga = data.googleAnalytics;
|
|
159
|
+
if (!ga || typeof ga !== 'object' || Array.isArray(ga)) {
|
|
160
|
+
return reply.status(400).send({error: 'Google Analytics: send {measurementId}.'});
|
|
161
|
+
}
|
|
162
|
+
const raw = String(ga.measurementId ?? '').trim();
|
|
163
|
+
const id = normaliseMeasurementId(raw);
|
|
164
|
+
if (raw && !id) return reply.status(400).send({error: 'Google Analytics: a measurement ID looks like G-XXXXXXXXXX.'});
|
|
165
|
+
data.googleAnalytics = {measurementId: id};
|
|
166
|
+
}
|
|
152
167
|
if ('adminHome' in data) {
|
|
153
168
|
// An admin route, or blank for the built-in dashboard.
|
|
154
169
|
const home = data.adminHome == null || data.adminHome === '' ? '/' : normaliseAdminPath(data.adminHome);
|
|
@@ -185,6 +200,7 @@ export async function settingsRoutes(fastify) {
|
|
|
185
200
|
}
|
|
186
201
|
}
|
|
187
202
|
saveConfig('site', merged);
|
|
203
|
+
invalidateMeasurementId();
|
|
188
204
|
await cache.invalidateTags(['site']);
|
|
189
205
|
return { success: true };
|
|
190
206
|
});
|
package/server/server.js
CHANGED
|
@@ -34,6 +34,7 @@ import {parseTrustProxy} from './services/network.js';
|
|
|
34
34
|
import {requestSerializer} from './services/request-log.js';
|
|
35
35
|
import {rateLimitOptions} from './services/rate-limit.js';
|
|
36
36
|
import {connectSources, frameSources} from './services/embedCsp.js';
|
|
37
|
+
import {cspSources as gaSources} from './services/googleAnalytics.js';
|
|
37
38
|
|
|
38
39
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
40
|
const ROOT = path.resolve(__dirname, '..');
|
|
@@ -123,12 +124,14 @@ await app.register(helmet, {
|
|
|
123
124
|
defaultSrc: ["'self'"],
|
|
124
125
|
// 'wasm-unsafe-eval' lets a page compile WebAssembly (the call page's background blur) - it allows
|
|
125
126
|
// nothing for JavaScript: eval and new Function stay blocked.
|
|
126
|
-
|
|
127
|
+
// Google Analytics' hosts are added per response, and only while a measurement ID is set
|
|
128
|
+
// (services/googleAnalytics.js) - a site without one keeps exactly this policy.
|
|
129
|
+
scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", 'https://cdn.jsdelivr.net', gaSources('script')],
|
|
127
130
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net', 'https://fonts.googleapis.com'],
|
|
128
|
-
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
131
|
+
imgSrc: ["'self'", 'data:', 'blob:', gaSources('img')],
|
|
129
132
|
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
|
|
130
133
|
// config/server.json csp.connectHosts: other sites a page may read from (services/embedCsp.js).
|
|
131
|
-
connectSrc: connectSources(serverConfig.csp?.connectHosts),
|
|
134
|
+
connectSrc: [...connectSources(serverConfig.csp?.connectHosts), gaSources('connect')],
|
|
132
135
|
// [embed] players and config/embeds.json hosts - without this, default-src blocked them all.
|
|
133
136
|
frameSrc: frameSources((() => { try { return getConfig('embeds')?.allowedHosts; } catch { return []; } })()),
|
|
134
137
|
upgradeInsecureRequests: null, // prevent browsers forcing HTTPS on HTTP deployments
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Analytics (GA4) - Site Settings > Analytics, stored as site.json `googleAnalytics.measurementId`.
|
|
3
|
+
*
|
|
4
|
+
* Off until a measurement ID is set, and then still off for each visitor until they accept analytics
|
|
5
|
+
* cookies: the renderer turns the cookie banner on (with its Analytics category) whenever an ID is set,
|
|
6
|
+
* and public/js/google-analytics.js loads Google's tag only after that consent. GA sets cookies, so
|
|
7
|
+
* under UK GDPR/PECR it may not run first and ask afterwards - unlike the built-in Analytics Tool,
|
|
8
|
+
* which is cookieless.
|
|
9
|
+
*
|
|
10
|
+
* The Content-Security-Policy gains Google's hosts only while an ID is set: server.js hands helmet the
|
|
11
|
+
* cspSources() functions, which helmet calls per response.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import {getConfig} from '../config.js';
|
|
16
|
+
|
|
17
|
+
/** A GA4 measurement ID: "G-" and letters/digits. Nothing else reaches the page or the policy. */
|
|
18
|
+
export const MEASUREMENT_ID_RE = /^G-[A-Z0-9]{4,20}$/;
|
|
19
|
+
|
|
20
|
+
/** Google's hosts for GA4 (its own CSP guidance), per directive. */
|
|
21
|
+
export const GA_SOURCES = {
|
|
22
|
+
script: ['https://*.googletagmanager.com'],
|
|
23
|
+
img: ['https://*.google-analytics.com', 'https://*.googletagmanager.com'],
|
|
24
|
+
connect: ['https://*.google-analytics.com', 'https://*.analytics.google.com', 'https://*.googletagmanager.com']
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Typed value -> the ID, or '' when it is empty or not one (" g-abc123 " -> "G-ABC123"). */
|
|
28
|
+
export function normaliseMeasurementId(value) {
|
|
29
|
+
const v = String(value ?? '').trim().toUpperCase();
|
|
30
|
+
return MEASUREMENT_ID_RE.test(v) ? v : '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The site's measurement ID, or '' - re-checked here because site.json can be edited by hand. */
|
|
34
|
+
export function measurementIdOf(site = {}) {
|
|
35
|
+
return normaliseMeasurementId(site?.googleAnalytics?.measurementId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The cookie banner the page gets: the site's own while it is on; while GA is set it is on regardless,
|
|
40
|
+
* with its Analytics category offered, since that category is the only way GA ever loads. Its consent
|
|
41
|
+
* version gains "+ga" then, so every visitor is asked again: an answer given before GA existed (or while
|
|
42
|
+
* the Analytics category was hidden) was not consent to it.
|
|
43
|
+
*
|
|
44
|
+
* @returns {object|null} null = no banner
|
|
45
|
+
*/
|
|
46
|
+
export function consentSettingsFor(site = {}) {
|
|
47
|
+
const cc = site.cookieConsent || {};
|
|
48
|
+
if (measurementIdOf(site)) {
|
|
49
|
+
return {...cc, enabled: true, showAnalytics: true, consentVersion: `${cc.consentVersion || '1.0'}+ga`};
|
|
50
|
+
}
|
|
51
|
+
return cc.enabled ? cc : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// site.json read once per change, not three times per response (one per directive).
|
|
55
|
+
const SITE_JSON = path.resolve('config', 'site.json');
|
|
56
|
+
let memo = {key: '', id: ''};
|
|
57
|
+
|
|
58
|
+
/** Forget the memo - the settings save calls it, since two writes can share an mtime tick. */
|
|
59
|
+
export function invalidateMeasurementId() {
|
|
60
|
+
memo = {key: '', id: ''};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function currentMeasurementId() {
|
|
64
|
+
try {
|
|
65
|
+
const {mtimeMs, size} = fs.statSync(SITE_JSON);
|
|
66
|
+
const key = `${mtimeMs}:${size}`;
|
|
67
|
+
if (key !== memo.key) memo = {key, id: measurementIdOf(getConfig('site'))};
|
|
68
|
+
return memo.id;
|
|
69
|
+
} catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A helmet directive entry: Google's hosts for `kind` while an ID is set, nothing otherwise.
|
|
76
|
+
*
|
|
77
|
+
* @param {'script'|'img'|'connect'} kind
|
|
78
|
+
* @returns {() => string}
|
|
79
|
+
*/
|
|
80
|
+
export function cspSources(kind) {
|
|
81
|
+
return () => (currentMeasurementId() ? GA_SOURCES[kind].join(' ') : '');
|
|
82
|
+
}
|
|
@@ -17,6 +17,7 @@ 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
19
|
import {isToolEnabled} from './tools.js';
|
|
20
|
+
import {consentSettingsFor, measurementIdOf} from './googleAnalytics.js';
|
|
20
21
|
import {fmtDateTime} from './dates.js';
|
|
21
22
|
import {cssClasses} from '../../public/js/css-classes.mjs';
|
|
22
23
|
import {parseMarkdown} from './markdown.js';
|
|
@@ -82,6 +83,8 @@ function buildSearchAssets() {
|
|
|
82
83
|
|
|
83
84
|
const THEME_SWITCHER_ASSET_V = '20261011-ts-inline';
|
|
84
85
|
const ANALYTICS_ASSET_V = '20260922-analytics';
|
|
86
|
+
const CONSENT_ASSET_V = '20261022-consent-reopen';
|
|
87
|
+
const GOOGLE_ANALYTICS_ASSET_V = '20261022-ga4';
|
|
85
88
|
|
|
86
89
|
/**
|
|
87
90
|
* The page-view beacon.
|
|
@@ -95,6 +98,30 @@ const ANALYTICS_ASSET_V = '20260922-analytics';
|
|
|
95
98
|
*
|
|
96
99
|
* @returns {string} the body tag
|
|
97
100
|
*/
|
|
101
|
+
/**
|
|
102
|
+
* The cookie banner and Google Analytics: what they add to window.__CMS_SITE__ and their scripts.
|
|
103
|
+
*
|
|
104
|
+
* GA turns the banner on (services/googleAnalytics.js consentSettingsFor) and its loader runs after the
|
|
105
|
+
* banner's script, so it can read the visitor's answer. Both render paths - the standing trap.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} site - config/site.json
|
|
108
|
+
* @returns {{siteFields: object, bodyTag: string}}
|
|
109
|
+
*/
|
|
110
|
+
function buildConsentAssets(site) {
|
|
111
|
+
const consent = consentSettingsFor(site);
|
|
112
|
+
const measurementId = measurementIdOf(site);
|
|
113
|
+
return {
|
|
114
|
+
siteFields: {
|
|
115
|
+
...(consent ? {cookieConsent: consent} : {}),
|
|
116
|
+
...(measurementId ? {googleAnalytics: {measurementId}} : {})
|
|
117
|
+
},
|
|
118
|
+
bodyTag: [
|
|
119
|
+
consent ? `<script src="/public/js/cookie-consent.js?v=${CONSENT_ASSET_V}" defer></script>` : '',
|
|
120
|
+
measurementId ? `<script src="/public/js/google-analytics.js?v=${GOOGLE_ANALYTICS_ASSET_V}" defer></script>` : ''
|
|
121
|
+
].filter(Boolean).join('\n')
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
98
125
|
function buildAnalyticsTag() {
|
|
99
126
|
// Unless the site has switched Analytics off (config/tools.json).
|
|
100
127
|
if (!isToolEnabled('analytics')) return '';
|
|
@@ -319,6 +346,7 @@ export async function renderPage(page, opts = {}) {
|
|
|
319
346
|
resolveLocation('footer-legal', opts.user || null, menuCtx)
|
|
320
347
|
]);
|
|
321
348
|
const baseUrl = opts.baseUrl || site.baseUrl || '';
|
|
349
|
+
const consentAssets = buildConsentAssets(site);
|
|
322
350
|
// The page's own navbar settings ([navbar ... /]) over the menu's, for this page only.
|
|
323
351
|
const navMenu = applyMenuOverride(navMenuOwn, readNavbarOverride(page.html));
|
|
324
352
|
|
|
@@ -524,7 +552,7 @@ export async function renderPage(page, opts = {}) {
|
|
|
524
552
|
},
|
|
525
553
|
site.backToTop?.enabled ? { backToTop: site.backToTop } : {},
|
|
526
554
|
site.goToBottom?.enabled ? { goToBottom: site.goToBottom } : {},
|
|
527
|
-
|
|
555
|
+
consentAssets.siteFields,
|
|
528
556
|
autoTheme ? {autoTheme} : {}
|
|
529
557
|
)).replace(/<\/script>/gi, '<\\/script>'),
|
|
530
558
|
headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
|
|
@@ -547,7 +575,7 @@ export async function renderPage(page, opts = {}) {
|
|
|
547
575
|
switcherAssets.bodyTag,
|
|
548
576
|
buildAnalyticsTag(),
|
|
549
577
|
(site.backToTop?.enabled || site.goToBottom?.enabled) ? '<script src="/public/js/btt.js?v=20261022-page-navigation" defer></script>' : '',
|
|
550
|
-
|
|
578
|
+
consentAssets.bodyTag,
|
|
551
579
|
(page.usedComponents || [])
|
|
552
580
|
.map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
|
|
553
581
|
.join('\n')
|
|
@@ -1137,6 +1165,8 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
1137
1165
|
resolveLocation('footer-legal', user, menuCtx)
|
|
1138
1166
|
]);
|
|
1139
1167
|
const baseUrl = seoMeta.baseUrl || site.baseUrl || '';
|
|
1168
|
+
// The cookie banner on these pages too (it was missing here before GA needed it).
|
|
1169
|
+
const consentAssets = buildConsentAssets(site);
|
|
1140
1170
|
|
|
1141
1171
|
// Resolve live badge counts (countFrom) before the menus are injected as
|
|
1142
1172
|
// globals. Static badges/colours/pills pass through unchanged.
|
|
@@ -1308,7 +1338,8 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
1308
1338
|
links: (site.footer.links || []).filter(l => !l.hidden)
|
|
1309
1339
|
}
|
|
1310
1340
|
: site.footer,
|
|
1311
|
-
social: site.social || null
|
|
1341
|
+
social: site.social || null,
|
|
1342
|
+
...(bare ? {} : consentAssets.siteFields)
|
|
1312
1343
|
}).replace(/<\/script>/gi, '<\\/script>'),
|
|
1313
1344
|
headInject: [bare ? '' : injection.head, navbarFontLink].filter(Boolean).join('\n'),
|
|
1314
1345
|
// Theme overrides land BEFORE content/custom.css: the overrides are
|
|
@@ -1322,6 +1353,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
1322
1353
|
searchAssets.bodyTag,
|
|
1323
1354
|
switcherAssets.bodyTag,
|
|
1324
1355
|
bare ? '' : buildAnalyticsTag(),
|
|
1356
|
+
bare ? '' : consentAssets.bodyTag,
|
|
1325
1357
|
(seoMeta.usedComponents || [])
|
|
1326
1358
|
.map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
|
|
1327
1359
|
.join('\n')
|
|
@@ -183,7 +183,7 @@
|
|
|
183
183
|
<script src="/public/js/form-logic-engine.js?v=20260927-submit-btn" defer></script>
|
|
184
184
|
|
|
185
185
|
<!-- Site initialisation -->
|
|
186
|
-
<script src="/public/js/site.js?v=20261022-
|
|
186
|
+
<script src="/public/js/site.js?v=20261022-cookie-settings" type="module"></script>
|
|
187
187
|
<script src="/public/js/collection-context.js?v=20261010-utf8-payloads" type="module"></script>
|
|
188
188
|
{{ctxMenusModule}}
|
|
189
189
|
|