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 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(o){return[...new Set(String(o??"").split(/[\s,]+/).map(r=>r.trim().toLowerCase()).filter(Boolean))]}export const FOOTER_LAYOUTS=["auto","columns","row"];const t=(o,r="")=>o==null?r:String(o),c=(o,r)=>{const s=Number.parseInt(o,10);return Number.isFinite(s)?s:r};export function homeValue(o){return/^\/[A-Za-z0-9\-_/]*$/.test(o||"")&&String(o).replace(/\/+$/,"")||"/"}export function formFromSettings(o={}){const r=o.backToTop||{},s=o.goToBottom||{},e=o.cookieConsent||{},i=o.breadcrumbs||{};return{title:t(o.title),tagline:t(o.tagline),siteUrl:t(o.baseUrl),adminHome:homeValue(o.adminHome),spacerSize:t(o.layoutOptions?.spacerSize??40),bodyClass:t(o.bodyClass),brandText:t(o.brand?.text),brandLogo:t(o.brand?.logo),brandIcon:t(o.brand?.icon),brandUrl:t(o.brand?.url,"/"),favicon:t(o.favicon),adminBrandTitle:t(o.adminBrand?.title),adminBrandLogo:t(o.adminBrand?.logo),adminBrandIcon:t(o.adminBrand?.icon),seoTitle:t(o.seo?.defaultTitle),seoSeparator:t(o.seo?.titleSeparator," | "),seoDescription:t(o.seo?.defaultDescription),footerCopyright:t(o.footer?.copyright),footerLayout:FOOTER_LAYOUTS.includes(o.footer?.layout)?o.footer.layout:"auto",footerBlurb:t(o.footer?.blurb),footerBlocks:Array.isArray(o.footer?.blocks)?o.footer.blocks.filter(a=>typeof a=="string").join(", "):"",footerMotionToggle:o.footer?.showMotionToggle!==!1,footerMotionDefault:["off","on"].includes(o.footer?.motionDefault)?o.footer.motionDefault:"system",footerMotionControl:o.footer?.motionControl==="checkbox"?"checkbox":"switch",footerClass:t(o.footer?.class),footerSocialClass:t(o.footer?.socialClass),socialTwitter:t(o.social?.twitter),socialFacebook:t(o.social?.facebook),socialInstagram:t(o.social?.instagram),socialLinkedin:t(o.social?.linkedin),socialGithub:t(o.social?.github),socialYoutube:t(o.social?.youtube),smtpHost:t(o.smtp?.host),smtpPort:t(o.smtp?.port||587),smtpUser:t(o.smtp?.user),smtpPass:"",smtpSecure:o.smtp?.secure===!0,smtpInsecureTLS:o.smtp?.allowInsecureTLS===!0,smtpFromAddress:t(o.smtp?.fromAddress),smtpFromName:t(o.smtp?.fromName),bttEnabled:r.enabled!==!1,bttSmooth:r.smooth!==!1,bttLabel:t(r.label),bttThreshold:t(r.scrollThreshold??300),bttPosition:t(r.position,"bottom-right"),bttOffset:t(r.offset??32),bttBottomOffset:t(r.bottomOffset??r.offset??32),gtbEnabled:s.enabled===!0,gtbSmooth:s.smooth!==!1,gtbLabel:t(s.label),gtbThreshold:t(s.scrollThreshold??300),gtbPosition:t(s.position,"bottom-right"),gtbOffset:t(s.offset??32),gtbBottomOffset:t(s.bottomOffset??s.offset??32),ccEnabled:e.enabled!==!1,ccMessage:t(e.message),ccAcceptAll:t(e.acceptAllText,"Accept All"),ccRejectAll:t(e.rejectAllText,"Reject All"),ccCustomize:t(e.customizeText,"Customize"),ccSavePrefs:t(e.savePreferencesText,"Save Preferences"),ccPrivacyText:t(e.privacyPolicyText,"Privacy Policy"),ccPrivacyUrl:t(e.privacyPolicyUrl),ccCookieText:t(e.cookiePolicyText,"Cookie Policy"),ccCookieUrl:t(e.cookiePolicyUrl),ccPosition:t(e.position,"bottom"),ccLayout:t(e.layout,"bar"),ccTheme:t(e.theme,"dark"),ccShowFunctional:e.showFunctional!==!1,ccShowAnalytics:e.showAnalytics!==!1,ccShowMarketing:e.showMarketing!==!1,ccVersion:t(e.consentVersion,"1.0"),bcEnabled:i.enabled===!0,bcHomeLabel:t(i.homeLabel,"Home"),bcPosition:BREADCRUMB_POSITIONS.includes(i.position)?i.position:"TL",bcOffsetX:t(i.offsetX??8),bcOffsetY:t(i.offsetY??8)}}export function payloadFromForm(o,r={},{adminHomeReady:s=!0}={}){const e=i=>t(i).trim();return{title:e(o.title),tagline:e(o.tagline),baseUrl:e(o.siteUrl).replace(/\/+$/,""),...s&&{adminHome:e(o.adminHome)||"/"},layoutOptions:{spacerSize:c(o.spacerSize,40)||40},bodyClass:e(o.bodyClass),brand:{...r.brand,text:e(o.brandText),logo:e(o.brandLogo),icon:e(o.brandIcon),url:e(o.brandUrl)||"/"},favicon:e(o.favicon),adminBrand:{...r.adminBrand,title:e(o.adminBrandTitle),logo:e(o.adminBrandLogo),icon:e(o.adminBrandIcon)},seo:{defaultTitle:e(o.seoTitle),titleSeparator:t(o.seoSeparator)||" | ",defaultDescription:e(o.seoDescription)},footer:{copyright:e(o.footerCopyright),layout:FOOTER_LAYOUTS.includes(o.footerLayout)?o.footerLayout:"auto",blurb:e(o.footerBlurb),blocks:footerBlockNames(o.footerBlocks),showMotionToggle:o.footerMotionToggle===!0,links:r.footer?.links||[],motionDefault:["off","on"].includes(o.footerMotionDefault)?o.footerMotionDefault:"system",motionControl:o.footerMotionControl==="checkbox"?"checkbox":"switch",class:e(o.footerClass),socialClass:e(o.footerSocialClass)},social:{twitter:e(o.socialTwitter),facebook:e(o.socialFacebook),instagram:e(o.socialInstagram),linkedin:e(o.socialLinkedin),github:e(o.socialGithub),youtube:e(o.socialYoutube)},smtp:{host:e(o.smtpHost),port:c(o.smtpPort,587)||587,user:e(o.smtpUser),pass:t(o.smtpPass),secure:o.smtpSecure===!0,allowInsecureTLS:o.smtpInsecureTLS===!0,fromAddress:e(o.smtpFromAddress),fromName:e(o.smtpFromName)},backToTop:{enabled:o.bttEnabled===!0,scrollThreshold:c(o.bttThreshold,300),position:t(o.bttPosition)||"bottom-right",offset:c(o.bttOffset,32),bottomOffset:c(o.bttBottomOffset,32),label:e(o.bttLabel),smooth:o.bttSmooth===!0},goToBottom:{enabled:o.gtbEnabled===!0,scrollThreshold:c(o.gtbThreshold,300),position:t(o.gtbPosition)||"bottom-right",offset:c(o.gtbOffset,32),bottomOffset:c(o.gtbBottomOffset,32),label:e(o.gtbLabel),smooth:o.gtbSmooth===!0},cookieConsent:{enabled:o.ccEnabled===!0,message:e(o.ccMessage),acceptAllText:e(o.ccAcceptAll),rejectAllText:e(o.ccRejectAll),customizeText:e(o.ccCustomize),savePreferencesText:e(o.ccSavePrefs),privacyPolicyText:e(o.ccPrivacyText),privacyPolicyUrl:e(o.ccPrivacyUrl),cookiePolicyText:e(o.ccCookieText),cookiePolicyUrl:e(o.ccCookieUrl),position:t(o.ccPosition)||"bottom",layout:t(o.ccLayout)||"bar",theme:t(o.ccTheme)||"dark",showFunctional:o.ccShowFunctional===!0,showAnalytics:o.ccShowAnalytics===!0,showMarketing:o.ccShowMarketing===!0,consentVersion:e(o.ccVersion)||"1.0"},breadcrumbs:{enabled:o.bcEnabled===!0,homeLabel:e(o.bcHomeLabel)||"Home",position:BREADCRUMB_POSITIONS.includes(o.bcPosition)?o.bcPosition:"TL",offsetX:c(o.bcOffsetX,8),offsetY:c(o.bcOffsetY,8)}}}export function siteUrlError(o){const r=t(o).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 n=o=>typeof o=="boolean"?o:t(o);export function snapshot(o){return JSON.stringify(Object.keys(o).sort().map(r=>[r,n(o[r])]))}export function changedTabs(o,r){const s=[];for(const[e,i]of Object.entries(TAB_FIELDS))i.some(a=>n(o[a])!==n(r[a]))&&s.push(e);return s}export function draftOf(o){return Object.fromEntries(Object.entries(o).filter(([r])=>!SECRET_FIELDS.includes(r)))}export function settingsProblems(o){const r=[];return t(o.smtpHost).trim()?t(o.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)."}),o.ccEnabled===!0&&!t(o.ccPrivacyUrl).trim()&&r.push({tone:"warn",tab:"cookies",text:"The cookie banner is on but links to no privacy policy."}),r}export function describeCacheKey(o){const r=/^page:(.*?):m\d+:role([^:]*):/.exec(String(o||""));return r?{path:r[1]||"/",role:r[2]||"anon",page:!0}:{path:String(o||""),role:"",page:!1}}export function expiresIn(o,r=Date.now()){if(o==null)return"no expiry";const s=o-r;return s<=0?"expired":s<6e4?`${Math.round(s/1e3)}s`:s<36e5?`${Math.round(s/6e4)}m`:`${Math.round(s/36e5)}h`}
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 &gt; Data streams</strong>, it looks like <code>G-XXXXXXXXXX</code>),
76
+ paste it into <strong>Site Settings &gt; 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 &gt; 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 j,apiRequest as C}from"../api.js";import{placeTools as Le}from"../lib/sidebar-grouping.js";import{addBannerAction as pe}from"../lib/plugin-chrome.js";import{helpPopovers as Oe}from"../lib/help-popover.js";import{openMenuAt as Y,sidebarCount as me}from"../lib/tool-kit.js";import{changedTabs as Re,describeCacheKey as G,draftOf as Ie,expiresIn as Ae,formFromSettings as $e,homeValue as Pe,payloadFromForm as je,siteUrlError as fe,settingsProblems as De,snapshot as he,TAB_FIELDS as He}from"../lib/site-settings-arrange.js";import{pickMedia as Be}from"../lib/media-picker.js";import{faviconGenerator as qe}from"../lib/favicon-generator.js";import{makeIconInput as Ue}from"../lib/shortcode-modal.js";import{fmtDateTime as _e}from"../lib/dates.js";const J=/^\/settings(\?.*)?$/,ve="#/settings",w="settings.draft",Fe=`
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>`,Ke=`
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())}}}};
@@ -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 following keys are accepted: `title`, `tagline`, `description`, `logo`, `favicon`, `theme`, `smtp`, `footer`, `analytics`, `social`, `locale`, `layoutOptions`.
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
 
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.38",
3
+ "version": "1.8.39",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -1 +1 @@
1
- (function(){"use strict";if(!(!window.Domma||!window.Domma.components||!window.Domma.components.cookieConsent)){var e=window.__CMS_SITE__&&window.__CMS_SITE__.cookieConsent;if(e){var o={necessary:{label:"Necessary Cookies",description:"These cookies are essential for the website to function properly.",required:!0}};e.showFunctional!==!1&&(o.functional={label:"Functional Cookies",description:"These cookies enable personalised features and functionality.",required:!1}),e.showAnalytics!==!1&&(o.analytics={label:"Analytics Cookies",description:"These cookies help us understand how visitors interact with our website.",required:!1}),e.showMarketing!==!1&&(o.marketing={label:"Marketing Cookies",description:"These cookies are used to deliver relevant ads and marketing campaigns.",required:!1});var i={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:o,consentVersion:e.consentVersion||"1.0",autoShow:!0,animation:!0};Domma.components.cookieConsent(i)}}})();
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")},a=function(d){return e(d).replace(/'/g,"&#39;")},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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")},a=function(d){return e(d).replace(/'/g,"&#39;")},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
- if (cc.enabled !== false && !String(cc.privacyPolicyUrl || '').trim()) {
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
- scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", 'https://cdn.jsdelivr.net'],
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
- site.cookieConsent?.enabled ? {cookieConsent: site.cookieConsent} : {},
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
- site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js" defer></script>' : '',
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-nojs-links" type="module"></script>
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