domma-cms 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CLAUDE.md +2 -0
  2. package/admin/css/dashboard.css +1 -0
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +4 -4
  5. package/admin/index.html +2 -1
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +1 -1
  8. package/admin/js/lib/card-builder.js +3 -3
  9. package/admin/js/lib/effects-builder.js +1 -1
  10. package/admin/js/lib/markdown-toolbar.js +5 -5
  11. package/admin/js/templates/dashboard/activity-feed.html +3 -0
  12. package/admin/js/templates/dashboard/cache.html +32 -0
  13. package/admin/js/templates/dashboard/health-detail.html +2 -0
  14. package/admin/js/templates/dashboard/journeys.html +17 -0
  15. package/admin/js/templates/dashboard/kpi-strip.html +34 -0
  16. package/admin/js/templates/dashboard/spike-feed.html +3 -0
  17. package/admin/js/templates/dashboard/top-pages.html +3 -0
  18. package/admin/js/templates/dashboard/traffic-chart.html +3 -0
  19. package/admin/js/templates/dashboard.html +26 -44
  20. package/admin/js/templates/settings.html +26 -0
  21. package/admin/js/views/block-editor-enhance.js +1 -1
  22. package/admin/js/views/dashboard/lib/escape.js +1 -0
  23. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
  24. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  25. package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
  26. package/admin/js/views/dashboard/widgets/journeys.js +1 -0
  27. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
  28. package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
  29. package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
  30. package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
  31. package/admin/js/views/dashboard.js +1 -1
  32. package/admin/js/views/form-editor.js +7 -7
  33. package/admin/js/views/index.js +1 -1
  34. package/admin/js/views/page-editor.js +42 -37
  35. package/admin/js/views/settings.js +3 -3
  36. package/config/cache.json +4 -0
  37. package/config/cache.json.example +12 -0
  38. package/config/plugins.json +3 -0
  39. package/package.json +2 -2
  40. package/plugins/analytics/daily.json +5 -0
  41. package/plugins/analytics/journeys.json +10 -0
  42. package/plugins/analytics/lifetime.json +25 -0
  43. package/plugins/analytics/plugin.js +231 -16
  44. package/plugins/analytics/public/inject-body.html +26 -2
  45. package/public/js/forms.js +1 -1
  46. package/public/js/site.js +1 -1
  47. package/server/config.js +12 -1
  48. package/server/routes/api/cache.js +57 -0
  49. package/server/routes/api/dashboard.js +239 -0
  50. package/server/routes/api/navigation.js +2 -0
  51. package/server/routes/api/settings.js +3 -0
  52. package/server/routes/public.js +11 -3
  53. package/server/server.js +18 -3
  54. package/server/services/blocks.js +3 -0
  55. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  56. package/server/services/cache/drivers/NoneDriver.js +12 -0
  57. package/server/services/cache/index.js +229 -0
  58. package/server/services/cache/lru.js +61 -0
  59. package/server/services/collections.js +17 -4
  60. package/server/services/content.js +7 -2
  61. package/server/services/email.js +60 -20
  62. package/server/services/forms.js +3 -0
  63. package/server/services/health.js +282 -0
  64. package/server/services/markdown.js +25 -15
  65. package/server/services/plugins.js +37 -5
  66. package/server/services/views.js +4 -0
  67. package/server/templates/page.html +130 -130
@@ -1,3 +1,3 @@
1
- import{api as v,apiRequest as u}from"../api.js";import{populateThemeSelect as x}from"../lib/themes.js";export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(e){E.tabs(e.find("#settings-tabs").get(0));const w=E.loader(e.get(0),{type:"dots"}),t=await v.settings.get().catch(()=>({}));if(w.destroy(),e.find("#field-site-title").val(t.title||""),e.find("#field-tagline").val(t.tagline||""),e.find("#field-font-family").val(t.fontFamily||"Roboto"),e.find("#field-font-size").val(t.fontSize||16),x(e.find("#field-theme").get(0)),x(e.find("#field-admin-theme").get(0)),e.find("#field-theme").val(t.baseTheme||t.theme||"charcoal-dark"),t.baseTheme){e.find("#field-theme").prop("disabled",!0);const l=document.createElement("p");l.className="form-hint",l.style.cssText="margin-top:.4rem;font-size:.8rem;color:var(--dm-info);";const s=document.createElement("a");s.href="#/plugins/theme-roller",s.textContent="Theme Roller",l.appendChild(document.createTextNode(`Custom theme \u201C${t.theme}\u201D is active (based on ${t.baseTheme}). Manage via `)),l.appendChild(s),l.appendChild(document.createTextNode(".")),e.find("#field-theme").get(0).closest(".col-6").appendChild(l)}e.find("#field-admin-theme").val(t.adminTheme||"charcoal-dark");const m=t.autoTheme||{},y=!!m.enabled,n=e.find("#field-auto-theme-enabled"),g=e.find("#auto-theme-fields"),b=e.find("#field-theme"),k=b.get(0).innerHTML;e.find("#field-day-theme").html(k),e.find("#field-night-theme").html(k),t.baseTheme?(n.prop("disabled",!0),e.find("#auto-theme-roller-hint").show()):(n.prop("checked",y),y&&(g.show(),b.prop("disabled",!0)),n.on("change",function(){const l=this.checked;g.toggle(l),b.prop("disabled",l)})),e.find("#field-day-theme").val(m.dayTheme||"charcoal-light"),e.find("#field-night-theme").val(m.nightTheme||"charcoal-dark"),e.find("#field-day-start").val(m.dayStart||"07:00"),e.find("#field-night-start").val(m.nightStart||"19:00"),e.find("#field-spacer-size").val(t.layoutOptions?.spacerSize??40),e.find("#field-seo-title").val(t.seo?.defaultTitle||""),e.find("#field-seo-separator").val(t.seo?.titleSeparator||" | "),e.find("#field-seo-desc").val(t.seo?.defaultDescription||""),e.find("#field-footer-copy").val(t.footer?.copyright||""),e.find("#field-social-twitter").val(t.social?.twitter||""),e.find("#field-social-facebook").val(t.social?.facebook||""),e.find("#field-social-instagram").val(t.social?.instagram||""),e.find("#field-social-linkedin").val(t.social?.linkedin||""),e.find("#field-social-github").val(t.social?.github||""),e.find("#field-social-youtube").val(t.social?.youtube||""),e.find("#field-smtp-host").val(t.smtp?.host||""),e.find("#field-smtp-port").val(t.smtp?.port||587),e.find("#field-smtp-user").val(t.smtp?.user||""),e.find("#field-smtp-pass").val(t.smtp?.pass||""),e.find("#field-smtp-secure").prop("checked",t.smtp?.secure||!1),e.find("#field-smtp-from-address").val(t.smtp?.fromAddress||""),e.find("#field-smtp-from-name").val(t.smtp?.fromName||"");const a=t.backToTop||{};e.find("#field-btt-enabled").prop("checked",a.enabled!==!1),e.find("#field-btt-threshold").val(a.scrollThreshold??300),e.find("#field-btt-position").val(a.position||"bottom-right"),e.find("#field-btt-offset").val(a.offset??32),e.find("#field-btt-bottom-offset").val(a.bottomOffset??a.offset??32),e.find("#field-btt-label").val(a.label||""),e.find("#field-btt-smooth").prop("checked",a.smooth!==!1);const i=t.cookieConsent||{};e.find("#field-cc-enabled").prop("checked",i.enabled!==!1),e.find("#field-cc-message").val(i.message||""),e.find("#field-cc-accept-all").val(i.acceptAllText||"Accept All"),e.find("#field-cc-reject-all").val(i.rejectAllText||"Reject All"),e.find("#field-cc-customize").val(i.customizeText||"Customize"),e.find("#field-cc-save-prefs").val(i.savePreferencesText||"Save Preferences"),e.find("#field-cc-privacy-text").val(i.privacyPolicyText||"Privacy Policy"),e.find("#field-cc-privacy-url").val(i.privacyPolicyUrl||""),e.find("#field-cc-cookie-text").val(i.cookiePolicyText||"Cookie Policy"),e.find("#field-cc-cookie-url").val(i.cookiePolicyUrl||""),e.find("#field-cc-position").val(i.position||"bottom"),e.find("#field-cc-layout").val(i.layout||"bar"),e.find("#field-cc-theme").val(i.theme||"dark"),e.find("#field-cc-show-functional").prop("checked",i.showFunctional!==!1),e.find("#field-cc-show-analytics").prop("checked",i.showAnalytics!==!1),e.find("#field-cc-show-marketing").prop("checked",i.showMarketing!==!1),e.find("#field-cc-version").val(i.consentVersion||"1.0");const c=t.breadcrumbs||{};e.find("#field-breadcrumbs-enabled").prop("checked",c.enabled===!0),e.find("#field-breadcrumbs-home-label").val(c.homeLabel||"Home"),e.find("#field-breadcrumbs-position").val(c.position||"TL"),e.find("#field-bc-offset-x").val(c.offsetX??8),e.find("#field-bc-offset-y").val(c.offsetY??8);function T(l){e.find(".bc-pos-btn").each(function(){const s=this.dataset.pos===l;this.style.background=s?"var(--primary, #5b8cff)":"",this.style.color=s?"#fff":""})}T(c.position||"TL"),e.find(".bc-pos-btn").on("click",function(){const l=this.dataset.pos;e.find("#field-breadcrumbs-position").val(l),T(l)});let o=null;try{const{css:l}=await u("/settings/custom-css");e.find("#field-custom-css").val(l||""),E.editor&&(o=E.editor(e.find("#field-custom-css").get(0),{mode:"code",language:"css",lineNumbers:!0,showToolbar:!1,minHeight:420,placeholder:"/* Add your custom CSS here */",characterCount:!0}))}catch{}o&&o._editorEl&&o._editorEl.addEventListener("keydown",l=>{if(!l.ctrlKey&&!l.metaKey)return;if(l.key==="s"){l.preventDefault(),e.find("#save-css-btn").get(0)?.click();return}const s=o._editorEl,f=s.selectionStart!==s.selectionEnd;if((l.key==="c"||l.key==="x")&&!f){l.preventDefault();const d=s.value,p=s.selectionStart,r=d.lastIndexOf(`
2
- `,p-1)+1,h=d.indexOf(`
3
- `,p),S=h===-1?d.slice(r):d.slice(r,h+1);if(navigator.clipboard.writeText(S),l.key==="x"){const C=d.slice(0,r),P=h===-1?"":d.slice(h+1);s.value=C+P,s.selectionStart=s.selectionEnd=r,s.dispatchEvent(new Event("input",{bubbles:!0})),E.toast("Line cut",{type:"info",duration:1500})}else E.toast("Line copied",{type:"info",duration:1500})}}),e.find("#save-settings-btn").on("click",async()=>{const l=e.find("#field-admin-theme").val(),s=!t.baseTheme&&e.find("#field-auto-theme-enabled").prop("checked"),f={enabled:s,dayTheme:e.find("#field-day-theme").val()||"charcoal-light",nightTheme:e.find("#field-night-theme").val()||"charcoal-dark",dayStart:e.find("#field-day-start").val()||"07:00",nightStart:e.find("#field-night-start").val()||"19:00"},d={title:e.find("#field-site-title").val().trim(),tagline:e.find("#field-tagline").val().trim(),fontFamily:e.find("#field-font-family").val()||"Roboto",fontSize:parseInt(e.find("#field-font-size").val(),10)||16,theme:t.baseTheme?t.theme:s?f.dayTheme:e.find("#field-theme").val(),...t.baseTheme?{baseTheme:t.baseTheme}:{},autoTheme:f,adminTheme:l,layoutOptions:{spacerSize:parseInt(e.find("#field-spacer-size").val(),10)||40},seo:{defaultTitle:e.find("#field-seo-title").val().trim(),titleSeparator:e.find("#field-seo-separator").val()||" | ",defaultDescription:e.find("#field-seo-desc").val().trim()},footer:{copyright:e.find("#field-footer-copy").val().trim(),links:t.footer?.links||[]},social:{twitter:e.find("#field-social-twitter").val().trim(),facebook:e.find("#field-social-facebook").val().trim(),instagram:e.find("#field-social-instagram").val().trim(),linkedin:e.find("#field-social-linkedin").val().trim(),github:e.find("#field-social-github").val().trim(),youtube:e.find("#field-social-youtube").val().trim()},smtp:{host:e.find("#field-smtp-host").val().trim(),port:parseInt(e.find("#field-smtp-port").val(),10)||587,user:e.find("#field-smtp-user").val().trim(),pass:e.find("#field-smtp-pass").val(),secure:e.find("#field-smtp-secure").prop("checked"),fromAddress:e.find("#field-smtp-from-address").val().trim(),fromName:e.find("#field-smtp-from-name").val().trim()},backToTop:{enabled:e.find("#field-btt-enabled").prop("checked"),scrollThreshold:parseInt(e.find("#field-btt-threshold").val(),10)||300,position:e.find("#field-btt-position").val()||"bottom-right",offset:parseInt(e.find("#field-btt-offset").val(),10)||32,bottomOffset:parseInt(e.find("#field-btt-bottom-offset").val(),10)||32,label:e.find("#field-btt-label").val().trim(),smooth:e.find("#field-btt-smooth").prop("checked")},cookieConsent:{enabled:e.find("#field-cc-enabled").prop("checked"),message:e.find("#field-cc-message").val().trim(),acceptAllText:e.find("#field-cc-accept-all").val().trim(),rejectAllText:e.find("#field-cc-reject-all").val().trim(),customizeText:e.find("#field-cc-customize").val().trim(),savePreferencesText:e.find("#field-cc-save-prefs").val().trim(),privacyPolicyText:e.find("#field-cc-privacy-text").val().trim(),privacyPolicyUrl:e.find("#field-cc-privacy-url").val().trim(),cookiePolicyText:e.find("#field-cc-cookie-text").val().trim(),cookiePolicyUrl:e.find("#field-cc-cookie-url").val().trim(),position:e.find("#field-cc-position").val(),layout:e.find("#field-cc-layout").val(),theme:e.find("#field-cc-theme").val(),showFunctional:e.find("#field-cc-show-functional").prop("checked"),showAnalytics:e.find("#field-cc-show-analytics").prop("checked"),showMarketing:e.find("#field-cc-show-marketing").prop("checked"),consentVersion:e.find("#field-cc-version").val().trim()||"1.0"}},p=e.find("#save-settings-btn");p.prop("disabled",!0);try{await v.settings.save(d),Domma.theme.set(l),E.toast("Settings saved.",{type:"success"})}catch{E.toast("Failed to save settings.",{type:"error"})}finally{p.prop("disabled",!1)}}),e.find("#send-test-email-btn").on("click",async()=>{const l=e.find("#field-test-email-to").val().trim(),s=e.find("#test-email-result").get(0),f=e.find("#send-test-email-btn");f.prop("disabled",!0),s&&(s.textContent="Sending\u2026",s.style.color="");try{const d=await u("/settings/test-email",{method:"POST",body:JSON.stringify({to:l||void 0})});s&&(s.textContent=d.message||"Test email sent.",s.style.color="var(--success,#4ade80)")}catch(d){s&&(s.textContent=d.message||"Failed to send test email.",s.style.color="var(--danger,#f87171)")}finally{f.prop("disabled",!1)}}),e.find("#save-breadcrumbs-btn").on("click",async()=>{const l=e.find("#save-breadcrumbs-btn");l.prop("disabled",!0);try{const s={enabled:e.find("#field-breadcrumbs-enabled").prop("checked"),homeLabel:e.find("#field-breadcrumbs-home-label").val().trim()||"Home",position:e.find("#field-breadcrumbs-position").val()||"TL",offsetX:parseInt(e.find("#field-bc-offset-x").val(),10)||8,offsetY:parseInt(e.find("#field-bc-offset-y").val(),10)||8};await v.settings.save({...t,breadcrumbs:s}),t.breadcrumbs=s,E.toast("Breadcrumbs settings saved.",{type:"success"})}catch{E.toast("Failed to save breadcrumbs settings.",{type:"error"})}finally{l.prop("disabled",!1)}}),e.find("#save-css-btn").on("click",async()=>{const l=o?o.getValue():e.find("#field-custom-css").val(),s=e.find("#save-css-btn");s.prop("disabled",!0);try{await u("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:l})}),E.toast("Custom CSS saved.",{type:"success"})}catch(f){E.toast(f.message||"Failed to save CSS.",{type:"error"})}finally{s.prop("disabled",!1)}})}};
1
+ import{api as C,apiRequest as v}from"../api.js";import{populateThemeSelect as w}from"../lib/themes.js";export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(e){E.tabs(e.find("#settings-tabs").get(0));const k=E.loader(e.get(0),{type:"dots"}),l=await C.settings.get().catch(()=>({}));if(k.destroy(),e.find("#field-site-title").val(l.title||""),e.find("#field-tagline").val(l.tagline||""),e.find("#field-font-family").val(l.fontFamily||"Roboto"),e.find("#field-font-size").val(l.fontSize||16),w(e.find("#field-theme").get(0)),w(e.find("#field-admin-theme").get(0)),e.find("#field-theme").val(l.baseTheme||l.theme||"charcoal-dark"),l.baseTheme){e.find("#field-theme").prop("disabled",!0);const t=document.createElement("p");t.className="form-hint",t.style.cssText="margin-top:.4rem;font-size:.8rem;color:var(--dm-info);";const s=document.createElement("a");s.href="#/plugins/theme-roller",s.textContent="Theme Roller",t.appendChild(document.createTextNode(`Custom theme \u201C${l.theme}\u201D is active (based on ${l.baseTheme}). Manage via `)),t.appendChild(s),t.appendChild(document.createTextNode(".")),e.find("#field-theme").get(0).closest(".col-6").appendChild(t)}e.find("#field-admin-theme").val(l.adminTheme||"charcoal-dark");const p=l.autoTheme||{},h=!!p.enabled,b=e.find("#field-auto-theme-enabled"),y=e.find("#auto-theme-fields"),u=e.find("#field-theme"),f=u.get(0).innerHTML;e.find("#field-day-theme").html(f),e.find("#field-night-theme").html(f),l.baseTheme?(b.prop("disabled",!0),e.find("#auto-theme-roller-hint").show()):(b.prop("checked",h),h&&(y.show(),u.prop("disabled",!0)),b.on("change",function(){const t=this.checked;y.toggle(t),u.prop("disabled",t)})),e.find("#field-day-theme").val(p.dayTheme||"charcoal-light"),e.find("#field-night-theme").val(p.nightTheme||"charcoal-dark"),e.find("#field-day-start").val(p.dayStart||"07:00"),e.find("#field-night-start").val(p.nightStart||"19:00"),e.find("#field-spacer-size").val(l.layoutOptions?.spacerSize??40),e.find("#field-seo-title").val(l.seo?.defaultTitle||""),e.find("#field-seo-separator").val(l.seo?.titleSeparator||" | "),e.find("#field-seo-desc").val(l.seo?.defaultDescription||""),e.find("#field-footer-copy").val(l.footer?.copyright||""),e.find("#field-social-twitter").val(l.social?.twitter||""),e.find("#field-social-facebook").val(l.social?.facebook||""),e.find("#field-social-instagram").val(l.social?.instagram||""),e.find("#field-social-linkedin").val(l.social?.linkedin||""),e.find("#field-social-github").val(l.social?.github||""),e.find("#field-social-youtube").val(l.social?.youtube||""),e.find("#field-smtp-host").val(l.smtp?.host||""),e.find("#field-smtp-port").val(l.smtp?.port||587),e.find("#field-smtp-user").val(l.smtp?.user||""),e.find("#field-smtp-pass").val(l.smtp?.pass||""),e.find("#field-smtp-secure").prop("checked",l.smtp?.secure||!1),e.find("#field-smtp-from-address").val(l.smtp?.fromAddress||""),e.find("#field-smtp-from-name").val(l.smtp?.fromName||"");const i=l.backToTop||{};e.find("#field-btt-enabled").prop("checked",i.enabled!==!1),e.find("#field-btt-threshold").val(i.scrollThreshold??300),e.find("#field-btt-position").val(i.position||"bottom-right"),e.find("#field-btt-offset").val(i.offset??32),e.find("#field-btt-bottom-offset").val(i.bottomOffset??i.offset??32),e.find("#field-btt-label").val(i.label||""),e.find("#field-btt-smooth").prop("checked",i.smooth!==!1);const d=l.cookieConsent||{};e.find("#field-cc-enabled").prop("checked",d.enabled!==!1),e.find("#field-cc-message").val(d.message||""),e.find("#field-cc-accept-all").val(d.acceptAllText||"Accept All"),e.find("#field-cc-reject-all").val(d.rejectAllText||"Reject All"),e.find("#field-cc-customize").val(d.customizeText||"Customize"),e.find("#field-cc-save-prefs").val(d.savePreferencesText||"Save Preferences"),e.find("#field-cc-privacy-text").val(d.privacyPolicyText||"Privacy Policy"),e.find("#field-cc-privacy-url").val(d.privacyPolicyUrl||""),e.find("#field-cc-cookie-text").val(d.cookiePolicyText||"Cookie Policy"),e.find("#field-cc-cookie-url").val(d.cookiePolicyUrl||""),e.find("#field-cc-position").val(d.position||"bottom"),e.find("#field-cc-layout").val(d.layout||"bar"),e.find("#field-cc-theme").val(d.theme||"dark"),e.find("#field-cc-show-functional").prop("checked",d.showFunctional!==!1),e.find("#field-cc-show-analytics").prop("checked",d.showAnalytics!==!1),e.find("#field-cc-show-marketing").prop("checked",d.showMarketing!==!1),e.find("#field-cc-version").val(d.consentVersion||"1.0");const m=l.breadcrumbs||{};e.find("#field-breadcrumbs-enabled").prop("checked",m.enabled===!0),e.find("#field-breadcrumbs-home-label").val(m.homeLabel||"Home"),e.find("#field-breadcrumbs-position").val(m.position||"TL"),e.find("#field-bc-offset-x").val(m.offsetX??8),e.find("#field-bc-offset-y").val(m.offsetY??8);function c(t){e.find(".bc-pos-btn").each(function(){const s=this.dataset.pos===t;this.style.background=s?"var(--primary, #5b8cff)":"",this.style.color=s?"#fff":""})}c(m.position||"TL"),e.find(".bc-pos-btn").on("click",function(){const t=this.dataset.pos;e.find("#field-breadcrumbs-position").val(t),c(t)});let a=null;try{const{css:t}=await v("/settings/custom-css");e.find("#field-custom-css").val(t||""),E.editor&&(a=E.editor(e.find("#field-custom-css").get(0),{mode:"code",language:"css",lineNumbers:!0,showToolbar:!1,minHeight:420,placeholder:"/* Add your custom CSS here */",characterCount:!0}))}catch{}a&&a._editorEl&&a._editorEl.addEventListener("keydown",t=>{if(!t.ctrlKey&&!t.metaKey)return;if(t.key==="s"){t.preventDefault(),e.find("#save-css-btn").get(0)?.click();return}const s=a._editorEl,n=s.selectionStart!==s.selectionEnd;if((t.key==="c"||t.key==="x")&&!n){t.preventDefault();const o=s.value,g=s.selectionStart,T=o.lastIndexOf(`
2
+ `,g-1)+1,x=o.indexOf(`
3
+ `,g),S=x===-1?o.slice(T):o.slice(T,x+1);if(navigator.clipboard.writeText(S),t.key==="x"){const z=o.slice(0,T),P=x===-1?"":o.slice(x+1);s.value=z+P,s.selectionStart=s.selectionEnd=T,s.dispatchEvent(new Event("input",{bubbles:!0})),E.toast("Line cut",{type:"info",duration:1500})}else E.toast("Line copied",{type:"info",duration:1500})}}),e.find("#save-settings-btn").on("click",async()=>{const t=e.find("#field-admin-theme").val(),s=!l.baseTheme&&e.find("#field-auto-theme-enabled").prop("checked"),n={enabled:s,dayTheme:e.find("#field-day-theme").val()||"charcoal-light",nightTheme:e.find("#field-night-theme").val()||"charcoal-dark",dayStart:e.find("#field-day-start").val()||"07:00",nightStart:e.find("#field-night-start").val()||"19:00"},o={title:e.find("#field-site-title").val().trim(),tagline:e.find("#field-tagline").val().trim(),fontFamily:e.find("#field-font-family").val()||"Roboto",fontSize:parseInt(e.find("#field-font-size").val(),10)||16,theme:l.baseTheme?l.theme:s?n.dayTheme:e.find("#field-theme").val(),...l.baseTheme?{baseTheme:l.baseTheme}:{},autoTheme:n,adminTheme:t,layoutOptions:{spacerSize:parseInt(e.find("#field-spacer-size").val(),10)||40},seo:{defaultTitle:e.find("#field-seo-title").val().trim(),titleSeparator:e.find("#field-seo-separator").val()||" | ",defaultDescription:e.find("#field-seo-desc").val().trim()},footer:{copyright:e.find("#field-footer-copy").val().trim(),links:l.footer?.links||[]},social:{twitter:e.find("#field-social-twitter").val().trim(),facebook:e.find("#field-social-facebook").val().trim(),instagram:e.find("#field-social-instagram").val().trim(),linkedin:e.find("#field-social-linkedin").val().trim(),github:e.find("#field-social-github").val().trim(),youtube:e.find("#field-social-youtube").val().trim()},smtp:{host:e.find("#field-smtp-host").val().trim(),port:parseInt(e.find("#field-smtp-port").val(),10)||587,user:e.find("#field-smtp-user").val().trim(),pass:e.find("#field-smtp-pass").val(),secure:e.find("#field-smtp-secure").prop("checked"),fromAddress:e.find("#field-smtp-from-address").val().trim(),fromName:e.find("#field-smtp-from-name").val().trim()},backToTop:{enabled:e.find("#field-btt-enabled").prop("checked"),scrollThreshold:parseInt(e.find("#field-btt-threshold").val(),10)||300,position:e.find("#field-btt-position").val()||"bottom-right",offset:parseInt(e.find("#field-btt-offset").val(),10)||32,bottomOffset:parseInt(e.find("#field-btt-bottom-offset").val(),10)||32,label:e.find("#field-btt-label").val().trim(),smooth:e.find("#field-btt-smooth").prop("checked")},cookieConsent:{enabled:e.find("#field-cc-enabled").prop("checked"),message:e.find("#field-cc-message").val().trim(),acceptAllText:e.find("#field-cc-accept-all").val().trim(),rejectAllText:e.find("#field-cc-reject-all").val().trim(),customizeText:e.find("#field-cc-customize").val().trim(),savePreferencesText:e.find("#field-cc-save-prefs").val().trim(),privacyPolicyText:e.find("#field-cc-privacy-text").val().trim(),privacyPolicyUrl:e.find("#field-cc-privacy-url").val().trim(),cookiePolicyText:e.find("#field-cc-cookie-text").val().trim(),cookiePolicyUrl:e.find("#field-cc-cookie-url").val().trim(),position:e.find("#field-cc-position").val(),layout:e.find("#field-cc-layout").val(),theme:e.find("#field-cc-theme").val(),showFunctional:e.find("#field-cc-show-functional").prop("checked"),showAnalytics:e.find("#field-cc-show-analytics").prop("checked"),showMarketing:e.find("#field-cc-show-marketing").prop("checked"),consentVersion:e.find("#field-cc-version").val().trim()||"1.0"}},g=e.find("#save-settings-btn");g.prop("disabled",!0);try{await C.settings.save(o),Domma.theme.set(t),E.toast("Settings saved.",{type:"success"})}catch{E.toast("Failed to save settings.",{type:"error"})}finally{g.prop("disabled",!1)}}),e.find("#send-test-email-btn").on("click",async()=>{const t=e.find("#field-test-email-to").val().trim(),s=e.find("#test-email-result").get(0),n=e.find("#send-test-email-btn");n.prop("disabled",!0),s&&(s.textContent="Sending\u2026",s.style.color="");try{const o=await v("/settings/test-email",{method:"POST",body:JSON.stringify({to:t||void 0})});s&&(s.textContent=o.message||"Test email sent.",s.style.color="var(--success,#4ade80)")}catch(o){s&&(s.textContent=o.message||"Failed to send test email.",s.style.color="var(--danger,#f87171)")}finally{n.prop("disabled",!1)}}),e.find("#save-breadcrumbs-btn").on("click",async()=>{const t=e.find("#save-breadcrumbs-btn");t.prop("disabled",!0);try{const s={enabled:e.find("#field-breadcrumbs-enabled").prop("checked"),homeLabel:e.find("#field-breadcrumbs-home-label").val().trim()||"Home",position:e.find("#field-breadcrumbs-position").val()||"TL",offsetX:parseInt(e.find("#field-bc-offset-x").val(),10)||8,offsetY:parseInt(e.find("#field-bc-offset-y").val(),10)||8};await C.settings.save({...l,breadcrumbs:s}),l.breadcrumbs=s,E.toast("Breadcrumbs settings saved.",{type:"success"})}catch{E.toast("Failed to save breadcrumbs settings.",{type:"error"})}finally{t.prop("disabled",!1)}}),e.find("#save-css-btn").on("click",async()=>{const t=a?a.getValue():e.find("#field-custom-css").val(),s=e.find("#save-css-btn");s.prop("disabled",!0);try{await v("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:t})}),E.toast("Custom CSS saved.",{type:"success"})}catch(n){E.toast(n.message||"Failed to save CSS.",{type:"error"})}finally{s.prop("disabled",!1)}});async function r(){try{const t=await v("/cache/status"),s=t.enabled?`enabled (${t.driver})`:"disabled",n=t.maxItems?` \xB7 ${t.size} / ${t.maxItems} entries`:"";e.find("#cache-status").text(s+n)}catch{e.find("#cache-status").text("unknown")}try{const{entries:t}=await v("/cache/keys");A(e.find("#cache-keys-table"),t),e.find("#cache-key-count").text(`(${t.length})`)}catch(t){e.find("#cache-keys-table").get(0).textContent="Failed to load keys: "+t.message}}await r(),e.find("#btn-clear-cache").on("click",async()=>{if(!await E.confirm("Clear the entire response cache?"))return;const t=e.find("#btn-clear-cache");t.prop("disabled",!0);try{await v("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"}),await r()}catch(s){E.toast(s.message||"Failed to clear cache.",{type:"error"})}finally{t.prop("disabled",!1)}}),e.find("#btn-refresh-cache-keys").on("click",r)}};function A(e,k){const l=e.get(0);if(!l)return;for(;l.firstChild;)l.removeChild(l.firstChild);if(!k.length){const f=document.createElement("p");f.className="text-muted",f.style.fontSize=".875rem",f.textContent="Cache is empty.",l.appendChild(f);return}const p=[...k].reverse(),h=document.createElement("table");h.className="table table-compact";const b=document.createElement("thead"),y=document.createElement("tr");for(const f of["Key","Tags","Expires"]){const i=document.createElement("th");i.textContent=f,y.appendChild(i)}b.appendChild(y),h.appendChild(b);const u=document.createElement("tbody");for(const f of p){const i=document.createElement("tr"),d=document.createElement("td");d.style.fontFamily="monospace",d.style.fontSize="12px",d.textContent=f.key,i.appendChild(d);const m=document.createElement("td");m.style.fontSize="12px";for(const a of f.tags){const r=document.createElement("span");r.className="badge",r.style.marginRight="4px",r.style.fontSize="11px",r.textContent=a,m.appendChild(r)}i.appendChild(m);const c=document.createElement("td");if(c.style.fontSize="12px",c.style.color="var(--dm-muted, #6b7280)",f.expiresAt===null)c.textContent="no expiry";else{const a=f.expiresAt-Date.now();a<=0?c.textContent="expired":a<6e4?c.textContent=`${Math.round(a/1e3)}s`:a<36e5?c.textContent=`${Math.round(a/6e4)}m`:c.textContent=`${Math.round(a/36e5)}h`}i.appendChild(c),u.appendChild(i)}h.appendChild(u),l.appendChild(h)}
@@ -0,0 +1,4 @@
1
+ {
2
+ "enabled": true,
3
+ "driver": "memory"
4
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "enabled": true,
3
+ "driver": "memory",
4
+ "memory": {
5
+ "maxItems": 1000,
6
+ "defaultTtlSeconds": 3600
7
+ },
8
+ "redis": {
9
+ "url": "redis://localhost:6379",
10
+ "keyPrefix": "domma:"
11
+ }
12
+ }
@@ -23,5 +23,8 @@
23
23
  "enabled": false,
24
24
  "bundled": false,
25
25
  "settings": {}
26
+ },
27
+ "analytics": {
28
+ "enabled": true
26
29
  }
27
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
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",
@@ -73,7 +73,7 @@
73
73
  "@fastify/rate-limit": "^10.3.0",
74
74
  "@fastify/static": "9.1.1",
75
75
  "bcryptjs": "^3.0.3",
76
- "domma-js": "^0.24.0",
76
+ "domma-js": "^0.25.1",
77
77
  "dotenv": "^17.2.3",
78
78
  "fastify": "5.8.5",
79
79
  "gray-matter": "^4.0.3",
@@ -0,0 +1,5 @@
1
+ {
2
+ "2026-05-06": {
3
+ "/test-from-controller": 1
4
+ }
5
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "2026-05-06": [
3
+ {
4
+ "sid": "test-controller",
5
+ "t": 1778084386121,
6
+ "url": "/test-from-controller",
7
+ "ref": "/"
8
+ }
9
+ ]
10
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "/": 162,
3
+ "/about": 88,
4
+ "/blog": 42,
5
+ "/contact": 31,
6
+ "/resources/typography": 4,
7
+ "/resources": 13,
8
+ "/resources/shortcodes": 14,
9
+ "/resources/cards": 19,
10
+ "/resources/interactive": 13,
11
+ "/resources/grid": 6,
12
+ "/forms": 14,
13
+ "/resources/effects": 6,
14
+ "/blog/hello-world": 28,
15
+ "/feedback": 42,
16
+ "/resources/dependencies": 2,
17
+ "/resources/components": 6,
18
+ "/gdpr": 3,
19
+ "/scratch": 84,
20
+ "/getting-started": 3,
21
+ "/resources/pro": 1,
22
+ "/todo": 23,
23
+ "/thank-you": 1,
24
+ "/test-from-controller": 1
25
+ }
@@ -22,6 +22,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
22
  const LIFETIME_FILE = path.join(__dirname, 'lifetime.json');
23
23
  const DAILY_FILE = path.join(__dirname, 'daily.json');
24
24
  const LEGACY_FILE = path.join(__dirname, 'stats.json');
25
+ const JOURNEYS_FILE = path.join(__dirname, 'journeys.json');
26
+ const JOURNEY_DAILY_CAP = 5000;
27
+ const REALTIME_WINDOW_MS = 5 * 60 * 1000;
25
28
 
26
29
  async function readJson(file, fallback) {
27
30
  try {
@@ -40,23 +43,30 @@ async function writeJson(file, data) {
40
43
  /**
41
44
  * One-shot migration from the legacy flat stats.json.
42
45
  * Preserves existing totals as lifetime numbers; daily history starts empty.
46
+ * Also seeds journeys.json if missing.
43
47
  */
44
48
  async function migrateLegacy() {
49
+ let lifetimeExists = false;
45
50
  try {
46
51
  await fs.access(LIFETIME_FILE);
47
- return; // already migrated
48
- } catch {
49
- // fall through
50
- }
51
- const legacy = await readJson(LEGACY_FILE, null);
52
- if (legacy && typeof legacy === 'object') {
53
- await writeJson(LIFETIME_FILE, legacy);
54
- await writeJson(DAILY_FILE, {});
55
- // keep stats.json on disk as a backup; the next reset will wipe it
56
- } else {
57
- await writeJson(LIFETIME_FILE, {});
52
+ lifetimeExists = true;
53
+ } catch { /* not migrated */ }
54
+
55
+ if (!lifetimeExists) {
56
+ const legacy = await readJson(LEGACY_FILE, null);
57
+ if (legacy && typeof legacy === 'object') {
58
+ await writeJson(LIFETIME_FILE, legacy);
59
+ } else {
60
+ await writeJson(LIFETIME_FILE, {});
61
+ }
58
62
  await writeJson(DAILY_FILE, {});
59
63
  }
64
+
65
+ try {
66
+ await fs.access(JOURNEYS_FILE);
67
+ } catch {
68
+ await writeJson(JOURNEYS_FILE, {});
69
+ }
60
70
  }
61
71
 
62
72
  function todayKey(date = new Date()) {
@@ -131,14 +141,164 @@ function escapeCsvCell(value) {
131
141
  return str;
132
142
  }
133
143
 
144
+ function eventsInRange(journeys, start, end) {
145
+ const startMs = start.getTime();
146
+ const endMs = end.getTime();
147
+ const out = [];
148
+ for (const [day, events] of Object.entries(journeys)) {
149
+ const dayMs = new Date(day + 'T00:00:00Z').getTime();
150
+ if (dayMs < startMs || dayMs > endMs) continue;
151
+ for (const ev of events) out.push(ev);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ function groupBySession(events) {
157
+ const sessions = new Map();
158
+ for (const ev of events) {
159
+ if (!sessions.has(ev.sid)) sessions.set(ev.sid, []);
160
+ sessions.get(ev.sid).push(ev);
161
+ }
162
+ for (const arr of sessions.values()) arr.sort((a, b) => a.t - b.t);
163
+ return sessions;
164
+ }
165
+
166
+ function aggregateJourneys(events) {
167
+ const sessions = groupBySession(events);
168
+ const entry = new Map();
169
+ const exit = new Map();
170
+ const paths = new Map(); // key: '\x00'-joined, value: { from, to, count }
171
+ let bouncedSessions = 0;
172
+
173
+ for (const arr of sessions.values()) {
174
+ if (arr.length === 0) continue;
175
+ entry.set(arr[0].url, (entry.get(arr[0].url) || 0) + 1);
176
+ exit.set(arr[arr.length - 1].url, (exit.get(arr[arr.length - 1].url) || 0) + 1);
177
+ if (arr.length === 1) bouncedSessions += 1;
178
+ for (let i = 0; i < arr.length - 1; i += 1) {
179
+ const from = arr[i].url;
180
+ const to = arr[i + 1].url;
181
+ const key = from + '\x00' + to; // NUL is invalid in URL pathnames, so unambiguous
182
+ const existing = paths.get(key);
183
+ if (existing) {
184
+ existing.count += 1;
185
+ } else {
186
+ paths.set(key, { from, to, count: 1 });
187
+ }
188
+ }
189
+ }
190
+
191
+ const toSorted = (m) => Array.from(m.entries())
192
+ .map(([url, count]) => ({ url, count }))
193
+ .sort((a, b) => b.count - a.count)
194
+ .slice(0, 10);
195
+
196
+ const pathsSorted = Array.from(paths.values())
197
+ .sort((a, b) => b.count - a.count)
198
+ .slice(0, 10);
199
+
200
+ const totalSessions = sessions.size;
201
+ return {
202
+ entry: toSorted(entry),
203
+ exit: toSorted(exit),
204
+ paths: pathsSorted,
205
+ bounceRate: totalSessions === 0 ? 0 : +(bouncedSessions / totalSessions).toFixed(3),
206
+ totalSessions
207
+ };
208
+ }
209
+
210
+ function detectSpikes(daily, now = new Date()) {
211
+ const todayStr = todayKey(now);
212
+ const currentHour = now.getUTCHours();
213
+
214
+ // Approximation: distribute today's hits evenly across elapsed hours.
215
+ const todayUrls = daily[todayStr] || {};
216
+ const elapsedHours = Math.max(1, currentHour + 1);
217
+
218
+ const flagged = [];
219
+ for (const [url, totalToday] of Object.entries(todayUrls)) {
220
+ const currentHits = totalToday / elapsedHours;
221
+
222
+ // Baseline: previous 7 days' same-hour share (assume 24-hour spread)
223
+ const samples = [];
224
+ for (let i = 1; i <= 7; i += 1) {
225
+ const prev = new Date(now);
226
+ prev.setUTCDate(prev.getUTCDate() - i);
227
+ const key = todayKey(prev);
228
+ const sample = (daily[key] && daily[key][url]) ? daily[key][url] / 24 : 0;
229
+ samples.push(sample);
230
+ }
231
+ const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
232
+ const variance = samples.reduce((acc, x) => acc + (x - mean) * (x - mean), 0) / samples.length;
233
+ const stddev = Math.sqrt(variance);
234
+
235
+ if (currentHits >= 5 && currentHits > mean + 2 * stddev && mean > 0) {
236
+ flagged.push({
237
+ url,
238
+ hits: Math.round(currentHits),
239
+ baseline: Math.round(mean),
240
+ ratio: +(currentHits / Math.max(mean, 0.5)).toFixed(2)
241
+ });
242
+ }
243
+ }
244
+
245
+ return flagged.sort((a, b) => b.ratio - a.ratio).slice(0, 5);
246
+ }
247
+
248
+ function shouldRecordJourney(dayEntries, cap = JOURNEY_DAILY_CAP) {
249
+ return Array.isArray(dayEntries) && dayEntries.length < cap;
250
+ }
251
+
134
252
  export default async function analyticsPlugin(fastify, options) {
135
253
  const { authenticate, requireAdmin } = options.auth;
136
254
  const settings = options.settings || {};
137
255
 
138
256
  await migrateLegacy();
139
257
 
258
+ fastify.decorate('analytics', {
259
+ async getStats({ range = '7d', from, to } = {}) {
260
+ const dates = rangeToDates(range, from, to);
261
+ const [lifetime, daily] = await Promise.all([
262
+ readJson(LIFETIME_FILE, {}),
263
+ readJson(DAILY_FILE, {})
264
+ ]);
265
+ let totals; let total;
266
+ if (!dates) {
267
+ totals = lifetime;
268
+ total = Object.values(lifetime).reduce((a, n) => a + n, 0);
269
+ } else {
270
+ const agg = aggregateDaily(daily, dates.start, dates.end);
271
+ totals = agg.totals;
272
+ total = agg.total;
273
+ }
274
+ const top = Object.entries(totals)
275
+ .map(([url, hits]) => ({ url, hits }))
276
+ .sort((a, b) => b.hits - a.hits);
277
+ return { total, top, lifetimeTotal: Object.values(lifetime).reduce((a, n) => a + n, 0) };
278
+ },
279
+ async getDaily() { return readJson(DAILY_FILE, {}); },
280
+ async getJourneys({ range = '7d', from, to } = {}) {
281
+ const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
282
+ const journeys = await readJson(JOURNEYS_FILE, {});
283
+ return aggregateJourneys(eventsInRange(journeys, dates.start, dates.end));
284
+ },
285
+ async getRealtime() {
286
+ const journeys = await readJson(JOURNEYS_FILE, {});
287
+ const today = todayKey();
288
+ const events = journeys[today] || [];
289
+ const cutoff = Date.now() - REALTIME_WINDOW_MS;
290
+ const sids = new Set();
291
+ for (const ev of events) if (ev.t >= cutoff) sids.add(ev.sid);
292
+ return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
293
+ },
294
+ async getSpikes() {
295
+ const daily = await readJson(DAILY_FILE, {});
296
+ return { items: detectSpikes(daily) };
297
+ }
298
+ });
299
+
140
300
  fastify.post('/hit', async (request, reply) => {
141
- const { url, dnt } = request.body || {};
301
+ const { url, dnt, sid, ref } = request.body || {};
142
302
  if (!url || typeof url !== 'string') {
143
303
  return reply.status(400).send({ error: 'url is required' });
144
304
  }
@@ -159,14 +319,31 @@ export default async function analyticsPlugin(fastify, options) {
159
319
  }
160
320
 
161
321
  const today = todayKey();
162
- const [lifetime, daily] = await Promise.all([
322
+ const [lifetime, daily, journeys] = await Promise.all([
163
323
  readJson(LIFETIME_FILE, {}),
164
- readJson(DAILY_FILE, {})
324
+ readJson(DAILY_FILE, {}),
325
+ readJson(JOURNEYS_FILE, {})
165
326
  ]);
327
+
166
328
  lifetime[normalised] = (lifetime[normalised] || 0) + 1;
167
329
  if (!daily[today]) daily[today] = {};
168
330
  daily[today][normalised] = (daily[today][normalised] || 0) + 1;
169
- await Promise.all([writeJson(LIFETIME_FILE, lifetime), writeJson(DAILY_FILE, daily)]);
331
+
332
+ // Journey event — only if sid is a string of reasonable shape, and under cap
333
+ const safeSid = typeof sid === 'string' && sid.length > 0 && sid.length <= 64 ? sid : null;
334
+ const safeRef = typeof ref === 'string' && ref.length <= 512 ? ref : '';
335
+ if (safeSid) {
336
+ if (!journeys[today]) journeys[today] = [];
337
+ if (shouldRecordJourney(journeys[today])) {
338
+ journeys[today].push({ sid: safeSid, t: Date.now(), url: normalised, ref: safeRef });
339
+ }
340
+ }
341
+
342
+ await Promise.all([
343
+ writeJson(LIFETIME_FILE, lifetime),
344
+ writeJson(DAILY_FILE, daily),
345
+ writeJson(JOURNEYS_FILE, journeys)
346
+ ]);
170
347
  return { ok: true };
171
348
  });
172
349
 
@@ -212,6 +389,37 @@ export default async function analyticsPlugin(fastify, options) {
212
389
  return { series: buildSeries(daily, dates.start, dates.end) };
213
390
  });
214
391
 
392
+ fastify.get('/journeys', { preHandler: [authenticate, requireAdmin] }, async (request) => {
393
+ const { range = '7d', from, to } = request.query || {};
394
+ const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
395
+ const journeys = await readJson(JOURNEYS_FILE, {});
396
+ const events = eventsInRange(journeys, dates.start, dates.end);
397
+ const agg = aggregateJourneys(events);
398
+ return {
399
+ range,
400
+ from: todayKey(dates.start),
401
+ to: todayKey(dates.end),
402
+ ...agg
403
+ };
404
+ });
405
+
406
+ fastify.get('/realtime', { preHandler: [authenticate, requireAdmin] }, async () => {
407
+ const journeys = await readJson(JOURNEYS_FILE, {});
408
+ const today = todayKey();
409
+ const events = journeys[today] || [];
410
+ const cutoff = Date.now() - REALTIME_WINDOW_MS;
411
+ const sids = new Set();
412
+ for (const ev of events) {
413
+ if (ev.t >= cutoff) sids.add(ev.sid);
414
+ }
415
+ return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
416
+ });
417
+
418
+ fastify.get('/spikes', { preHandler: [authenticate, requireAdmin] }, async () => {
419
+ const daily = await readJson(DAILY_FILE, {});
420
+ return { items: detectSpikes(daily) };
421
+ });
422
+
215
423
  fastify.get('/export.csv', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
216
424
  const { range = '30d', from, to } = request.query || {};
217
425
  const dates = rangeToDates(range, from, to);
@@ -236,7 +444,11 @@ export default async function analyticsPlugin(fastify, options) {
236
444
  });
237
445
 
238
446
  fastify.delete('/stats', { preHandler: [authenticate, requireAdmin] }, async () => {
239
- await Promise.all([writeJson(LIFETIME_FILE, {}), writeJson(DAILY_FILE, {})]);
447
+ await Promise.all([
448
+ writeJson(LIFETIME_FILE, {}),
449
+ writeJson(DAILY_FILE, {}),
450
+ writeJson(JOURNEYS_FILE, {})
451
+ ]);
240
452
  try {
241
453
  await fs.unlink(LEGACY_FILE);
242
454
  } catch {
@@ -245,3 +457,6 @@ export default async function analyticsPlugin(fastify, options) {
245
457
  return { ok: true };
246
458
  });
247
459
  }
460
+
461
+ export { aggregateJourneys, detectSpikes, shouldRecordJourney };
462
+
@@ -10,6 +10,21 @@
10
10
 
11
11
  var url = window.location.pathname;
12
12
 
13
+ // Session-scoped anonymous ID — clears with the tab. Cookie-less.
14
+ var sid = '';
15
+ try {
16
+ sid = sessionStorage.getItem('dm_sid') || '';
17
+ if (!sid) {
18
+ sid = (crypto && crypto.randomUUID)
19
+ ? crypto.randomUUID()
20
+ : (Date.now().toString(36) + Math.random().toString(36).slice(2, 10));
21
+ sessionStorage.setItem('dm_sid', sid);
22
+ }
23
+ } catch (e) {
24
+ // sessionStorage unavailable — proceed without sid
25
+ }
26
+
27
+ // Session dedup (one hit per URL per session)
13
28
  try {
14
29
  var key = 'dm_analytics_seen';
15
30
  var seen = sessionStorage.getItem(key);
@@ -18,14 +33,23 @@
18
33
  list.push(url);
19
34
  sessionStorage.setItem(key, list.join('|'));
20
35
  } catch (e) {
21
- // sessionStorage unavailable (e.g. private mode) — record anyway
36
+ // proceed anyway
22
37
  }
23
38
 
39
+ // Strip query/fragment from referrer; only keep same-origin or external origin+path
40
+ var ref = '';
41
+ try {
42
+ if (document.referrer) {
43
+ var r = new URL(document.referrer);
44
+ ref = r.origin === window.location.origin ? r.pathname : (r.origin + r.pathname);
45
+ }
46
+ } catch (e) { /* ignore */ }
47
+
24
48
  fetch('/api/plugins/analytics/hit', {
25
49
  method: 'POST',
26
50
  headers: { 'Content-Type': 'application/json' },
27
51
  keepalive: true,
28
- body: JSON.stringify({ url: url })
52
+ body: JSON.stringify({ url: url, sid: sid, ref: ref })
29
53
  }).catch(function () { /* silent */ });
30
54
  })();
31
55
  </script>
@@ -1 +1 @@
1
- const targets=document.querySelectorAll("[data-form]");targets.length&&targets.forEach(initFormTarget);function showMessage(i,r,t){const e=i.querySelector(".fb-form-success, .fb-form-error");e&&e.remove();const a=document.createElement("div");a.className=t==="success"?"fb-form-success":"fb-form-error",a.textContent=r,i.appendChild(a)}function attachRuntimeLifecycle(i,r){if(i._formLogicRuntime=r,!i.parentNode||typeof MutationObserver>"u")return;const t=new MutationObserver(function(e){for(const a of e)for(const n of a.removedNodes)if(n===i||n.nodeType===1&&n.contains&&n.contains(i)){r.destroy(),t.disconnect();return}});t.observe(i.parentNode,{childList:!0,subtree:!1})}function buildBlueprintFromFields(i,r){const t={};return i.forEach(function(e){if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const a=e.type==="checkbox"?"boolean":e.type==="date"?"string":e.type,n={...e.formConfig||{}};n.span==="full"&&r&&(n.span=r),t[e.name]={type:a,label:e.label||e.name,required:e.required||!1,options:e.options,formConfig:{...e.placeholder&&{placeholder:e.placeholder},...e.helper&&{helperText:e.helper},...e.tooltip&&{tooltip:e.tooltip},...n}},e.minLength!==void 0&&(t[e.name].minLength=e.minLength),e.maxLength!==void 0&&(t[e.name].maxLength=e.maxLength),e.min!==void 0&&(t[e.name].min=e.min),e.max!==void 0&&(t[e.name].max=e.max)}),t}function buildInitialData(i){const r={};return i.forEach(function(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&&(r[t.name]=typeof e=="object"?e.value:e)}}),r}function patchDateInputs(i,r){(r||[]).forEach(function(t){if(t.type!=="date"||!t.name)return;const e=i.querySelector('[name="'+t.name+'"]');e&&e.type!=="date"&&(e.type="date")})}function buildWizardSteps(i,r){const t=[];let e=[],a=r||"Step 1",n="";return i.forEach(function(u){u.type==="page-break"?(t.push({title:a,description:n,fieldGroup:e}),e=[],a=u.label||"Step "+(t.length+1),n=u.description||""):u.type!=="spacer"&&e.push(u)}),t.push({title:a,description:n,fieldGroup:e}),t}function injectHoneypot(i){const r=document.createElement("div");r.className="fb-form-honeypot",r.setAttribute("aria-hidden","true");const t=document.createElement("input");t.name="website",t.type="text",t.tabIndex=-1,t.autocomplete="url",t.placeholder="https://",r.appendChild(t);const e=document.createElement("input");e.name="_t",e.type="hidden",e.value=Date.now(),r.appendChild(e),i.appendChild(r)}function injectSpacers(i,r){const t=i.querySelector("form");if(!t)return;const e=Array.from(t.querySelectorAll(".form-group"));let a=0;r.forEach(function(n){if(n.type==="spacer"){const u=document.createElement("div");u.className="fb-spacer";const s=e[a];if(s)t.insertBefore(u,s);else{const l=t.querySelector('[type="submit"]');l?t.insertBefore(u,l):t.appendChild(u)}}else n.type!=="page-break"&&a++})}function submitForm(i,r,t,e,a){const n=a||e,u=n.querySelector('[name="website"]')?.value||"",s=n.querySelector('[name="_t"]')?.value||"",l=Object.assign({},r,{_hp:u,_t:s});return fetch("/api/forms/submit/"+i,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)}).then(m=>m.json().then(c=>({ok:m.ok,body:c}))).then(m=>{m.ok&&m.body.ok?(e.textContent="",showMessage(e,m.body.message||t.successMessage||"Thank you.","success")):showMessage(e,m.body.error||"Something went wrong.","error")}).catch(()=>{showMessage(e,"Unable to submit. Please check your connection.","error")})}function renderManualForm(i,r,t,e,a){const n=document.createElement("form");n.noValidate=!0,r.forEach(function(s){const l=document.createElement("div");l.className="form-group",l.style.marginBottom="1.25rem";const m=document.createElement("label");if(m.className="form-label",m.textContent=s.label||s.name,s.required){const o=document.createElement("span");o.textContent=" *",o.style.color="#f87171",m.appendChild(o)}let c;s.type==="textarea"?(c=document.createElement("textarea"),c.rows=s.formConfig?.rows||4,c.className="form-input"):s.type==="select"?(c=document.createElement("select"),c.className="form-input",(s.options||[]).forEach(function(o){const p=document.createElement("option");p.value=typeof o=="string"?o:o.value??"",p.textContent=typeof o=="string"?o:o.label||p.value,c.appendChild(p)})):(c=document.createElement("input"),c.type=s.type||"text",c.className="form-input",s.placeholder&&(c.placeholder=s.placeholder)),c.name=s.name,c.required=s.required||!1,l.appendChild(m),l.appendChild(c),n.appendChild(l)}),t.honeypot&&injectHoneypot(n);const u=document.createElement("button");u.type="submit",u.className="btn btn-primary",u.textContent=t.submitText||"Submit",n.appendChild(u),n.addEventListener("submit",function(s){s.preventDefault();const l={};if(r.forEach(function(m){const c=n.querySelector('[name="'+m.name+'"]');c&&(l[m.name]=c.value)}),window.FormLogicEngine&&a){const m=window.FormLogicEngine,c=[],o=[];if(r.forEach(function(p){if(m.evaluateFieldVisibility(p,l)==="hidden"){delete l[p.name];return}const h=m.evaluateFieldRequirement(p,l),f=l[p.name];h&&(!f||!String(f).trim())&&c.push(p.label||p.name);const d=m.validateField(p,f||"",l);d.length&&o.push(d[0].message)}),c.length||o.length){const p=[];c.length&&p.push("Required: "+c.join(", ")),o.length&&p.push(o.join("; ")),showMessage(i,p.join(". "),"error");return}}i.classList.add("fb-form-loading"),u.disabled=!0,submitForm(e,l,t,i,n).finally(function(){i.classList.remove("fb-form-loading"),u.disabled=!1})}),i.appendChild(n),window.FormLogicEngine&&a&&r.some(s=>s.logic)&&new window.FormLogicEngine.FormLogicRuntime(a,i).init()}function initFormTarget(i){const r=i.getAttribute("data-form");r&&fetch("/api/forms/"+r+"/public").then(t=>{if(!t.ok)throw new Error("Form not found: "+r);return t.json()}).then(t=>{const e=t.fields||[],a=t.settings||{},n=document.createElement("div");n.className="fb-form-wrapper",i.appendChild(n);const u=e.some(s=>s.type==="page-break");if(typeof Domma<"u"&&Domma.forms){const s=a.columns||1;if(u&&Domma.forms.wizard){const m=buildWizardSteps(e,t.title).map(function(o){return{title:o.title,description:o.description,fields:buildBlueprintFromFields(o.fieldGroup,s)}}),c=Domma.forms.wizard(n,{schema:{steps:m},onSubmit:function(o){return submitForm(r,o,a,n,null)}});Promise.resolve(c).then(function(){if(patchDateInputs(n,e),window.FormLogicEngine&&e.some(o=>o.logic)){const o=new window.FormLogicEngine.FormLogicRuntime(t,n);o.init(),attachRuntimeLifecycle(n,o)}if(a.honeypot){const o=n.querySelector("form");o&&injectHoneypot(o)}})}else if(Domma.forms.render){const l=buildBlueprintFromFields(e,s),m=buildInitialData(e),c=Domma.forms.render(n,l,m,{submitText:a.submitText||"Submit",layout:a.layout||"stacked",columns:s,onSubmit:function(o){return submitForm(r,o,a,n,null)}});Promise.resolve(c).then(function(){if(patchDateInputs(n,e),window.FormLogicEngine&&e.some(o=>o.logic)){const o=new window.FormLogicEngine.FormLogicRuntime(t,n);o.init(),attachRuntimeLifecycle(n,o)}if(e.some(o=>o.type==="spacer")&&injectSpacers(n,e),a.honeypot){const o=n.querySelector("form");o&&injectHoneypot(o)}})}}else renderManualForm(n,e.filter(s=>s.type!=="page-break"&&s.type!=="spacer"),a,r,t)}).catch(t=>{const e=document.createElement("p");e.textContent="Form unavailable.",e.style.cssText="color:#f87171;font-style:italic;",i.appendChild(e),console.warn("[forms]",t.message)})}
1
+ const targets=document.querySelectorAll("[data-form]");targets.length&&targets.forEach(initFormTarget);function showMessage(c,s,t){const e=c.querySelector(".fb-form-success, .fb-form-error");e&&e.remove();const i=document.createElement("div");i.className=t==="success"?"fb-form-success":"fb-form-error",i.textContent=s,c.appendChild(i)}function attachRuntimeLifecycle(c,s){if(c._formLogicRuntime=s,!c.parentNode||typeof MutationObserver>"u")return;const t=new MutationObserver(function(e){for(const i of e)for(const n of i.removedNodes)if(n===c||n.nodeType===1&&n.contains&&n.contains(c)){s.destroy(),t.disconnect();return}});t.observe(c.parentNode,{childList:!0,subtree:!1})}function buildBlueprintFromFields(c,s){const t={};return c.forEach(function(e){if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const i=e.type==="checkbox"?"boolean":e.type==="date"?"string":e.type,n={...e.formConfig||{}};n.span==="full"&&s&&(n.span=s);const r={type:i,label:e.label||e.name,required:e.required||!1,options:e.options,formConfig:{...e.placeholder&&{placeholder:e.placeholder},...e.helper&&{helperText:e.helper},...e.tooltip&&{tooltip:e.tooltip},...n}};e.minLength!==void 0&&(r.minLength=e.minLength),e.maxLength!==void 0&&(r.maxLength=e.maxLength),e.min!==void 0&&(r.min=e.min),e.max!==void 0&&(r.max=e.max),e.type==="chooser"&&(e.variant&&(r.variant=e.variant),e.multiple&&(r.multiple=!0),e.density&&(r.density=e.density),e.columns&&(r.columns=e.columns),e.accent&&(r.accent=e.accent),e.accentStyle&&(r.accentStyle=e.accentStyle),e.glow&&(r.glow=!0),e.glowColour&&(r.glowColour=e.glowColour),e.shadow&&(r.shadow=e.shadow),e.shadowColour&&(r.shadowColour=e.shadowColour)),t[e.name]=r}),t}function buildInitialData(c){const s={};return c.forEach(function(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 patchDateInputs(c,s){(s||[]).forEach(function(t){if(t.type!=="date"||!t.name)return;const e=c.querySelector('[name="'+t.name+'"]');e&&e.type!=="date"&&(e.type="date")})}function buildWizardSteps(c,s){const t=[];let e=[],i=s||"Step 1",n="";return c.forEach(function(r){r.type==="page-break"?(t.push({title:i,description:n,fieldGroup:e}),e=[],i=r.label||"Step "+(t.length+1),n=r.description||""):r.type!=="spacer"&&e.push(r)}),t.push({title:i,description:n,fieldGroup:e}),t}function injectHoneypot(c){const s=document.createElement("div");s.className="fb-form-honeypot",s.setAttribute("aria-hidden","true");const t=document.createElement("input");t.name="website",t.type="text",t.tabIndex=-1,t.autocomplete="url",t.placeholder="https://",s.appendChild(t);const e=document.createElement("input");e.name="_t",e.type="hidden",e.value=Date.now(),s.appendChild(e),c.appendChild(s)}function injectSpacers(c,s){const t=c.querySelector("form");if(!t)return;const e=Array.from(t.querySelectorAll(".form-group"));let i=0;s.forEach(function(n){if(n.type==="spacer"){const r=document.createElement("div");r.className="fb-spacer";const a=e[i];if(a)t.insertBefore(r,a);else{const l=t.querySelector('[type="submit"]');l?t.insertBefore(r,l):t.appendChild(r)}}else n.type!=="page-break"&&i++})}function submitForm(c,s,t,e,i){const n=i||e,r=n.querySelector('[name="website"]')?.value||"",a=n.querySelector('[name="_t"]')?.value||"",l=Object.assign({},s,{_hp:r,_t:a});return fetch("/api/forms/submit/"+c,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)}).then(m=>m.json().then(u=>({ok:m.ok,body:u}))).then(m=>{m.ok&&m.body.ok?(e.textContent="",showMessage(e,m.body.message||t.successMessage||"Thank you.","success")):showMessage(e,m.body.error||"Something went wrong.","error")}).catch(()=>{showMessage(e,"Unable to submit. Please check your connection.","error")})}function renderManualForm(c,s,t,e,i){const n=document.createElement("form");n.noValidate=!0,s.forEach(function(a){const l=document.createElement("div");l.className="form-group",l.style.marginBottom="1.25rem";const m=document.createElement("label");if(m.className="form-label",m.textContent=a.label||a.name,a.required){const o=document.createElement("span");o.textContent=" *",o.style.color="#f87171",m.appendChild(o)}let u;a.type==="textarea"?(u=document.createElement("textarea"),u.rows=a.formConfig?.rows||4,u.className="form-input"):a.type==="select"?(u=document.createElement("select"),u.className="form-input",(a.options||[]).forEach(function(o){const p=document.createElement("option");p.value=typeof o=="string"?o:o.value??"",p.textContent=typeof o=="string"?o:o.label||p.value,u.appendChild(p)})):(u=document.createElement("input"),u.type=a.type||"text",u.className="form-input",a.placeholder&&(u.placeholder=a.placeholder)),u.name=a.name,u.required=a.required||!1,l.appendChild(m),l.appendChild(u),n.appendChild(l)}),t.honeypot&&injectHoneypot(n);const r=document.createElement("button");r.type="submit",r.className="btn btn-primary",r.textContent=t.submitText||"Submit",n.appendChild(r),n.addEventListener("submit",function(a){a.preventDefault();const l={};if(s.forEach(function(m){const u=n.querySelector('[name="'+m.name+'"]');u&&(l[m.name]=u.value)}),window.FormLogicEngine&&i){const m=window.FormLogicEngine,u=[],o=[];if(s.forEach(function(p){if(m.evaluateFieldVisibility(p,l)==="hidden"){delete l[p.name];return}const d=m.evaluateFieldRequirement(p,l),f=l[p.name];d&&(!f||!String(f).trim())&&u.push(p.label||p.name);const h=m.validateField(p,f||"",l);h.length&&o.push(h[0].message)}),u.length||o.length){const p=[];u.length&&p.push("Required: "+u.join(", ")),o.length&&p.push(o.join("; ")),showMessage(c,p.join(". "),"error");return}}c.classList.add("fb-form-loading"),r.disabled=!0,submitForm(e,l,t,c,n).finally(function(){c.classList.remove("fb-form-loading"),r.disabled=!1})}),c.appendChild(n),window.FormLogicEngine&&i&&s.some(a=>a.logic)&&new window.FormLogicEngine.FormLogicRuntime(i,c).init()}function initFormTarget(c){const s=c.getAttribute("data-form");s&&fetch("/api/forms/"+s+"/public").then(t=>{if(!t.ok)throw new Error("Form not found: "+s);return t.json()}).then(t=>{const e=t.fields||[],i=t.settings||{},n=document.createElement("div");n.className="fb-form-wrapper",c.appendChild(n);const r=e.some(a=>a.type==="page-break");if(typeof Domma<"u"&&Domma.forms){const a=i.columns||1;if(r&&Domma.forms.wizard){const m=buildWizardSteps(e,t.title).map(function(o){return{title:o.title,description:o.description,fields:buildBlueprintFromFields(o.fieldGroup,a)}}),u=Domma.forms.wizard(n,{schema:{steps:m},onSubmit:function(o){return submitForm(s,o,i,n,null)}});Promise.resolve(u).then(function(){if(patchDateInputs(n,e),window.FormLogicEngine&&e.some(o=>o.logic)){const o=new window.FormLogicEngine.FormLogicRuntime(t,n);o.init(),attachRuntimeLifecycle(n,o)}if(i.honeypot){const o=n.querySelector("form");o&&injectHoneypot(o)}})}else if(Domma.forms.render){const l=buildBlueprintFromFields(e,a),m=buildInitialData(e),u=Domma.forms.render(n,l,m,{submitText:i.submitText||"Submit",layout:i.layout||"stacked",columns:a,onSubmit:function(o){return submitForm(s,o,i,n,null)}});Promise.resolve(u).then(function(){if(patchDateInputs(n,e),window.FormLogicEngine&&e.some(o=>o.logic)){const o=new window.FormLogicEngine.FormLogicRuntime(t,n);o.init(),attachRuntimeLifecycle(n,o)}if(e.some(o=>o.type==="spacer")&&injectSpacers(n,e),i.honeypot){const o=n.querySelector("form");o&&injectHoneypot(o)}})}}else renderManualForm(n,e.filter(a=>a.type!=="page-break"&&a.type!=="spacer"),i,s,t)}).catch(t=>{const e=document.createElement("p");e.textContent="Form unavailable.",e.style.cssText="color:#f87171;font-style:italic;",c.appendChild(e),console.warn("[forms]",t.message)})}