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
package/public/js/site.js CHANGED
@@ -1 +1 @@
1
- $(()=>{const w=window.__CMS_NAV__||{},l=window.__CMS_SITE__||{};if(l.autoTheme?.enabled){let t=function(s){const h=(s||"07:00").split(":");return+h[0]*60+(+h[1]||0)},o=function(){const s=new Date,h=s.getHours()*60+s.getMinutes();return h>=t(e.dayStart)&&h<t(e.nightStart)?e.dayTheme:e.nightTheme};var i=t,d=o;const e=l.autoTheme;Domma.theme.set(o()),setInterval(()=>Domma.theme.set(o()),6e4)}if($("#site-navbar").length&&w.brand){const e={...w.brand},t=e.size&&e.size!=="md"?` navbar-brand-${e.size}`:"";if(e.logo||e.icon||e.tagline){let o="";e.logo?o+=`<img src="${e.logo}" class="navbar-brand-logo" alt="${e.text||""}">`:e.icon&&(o+=`<span data-icon="${e.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),e.text&&(o+=`<span class="navbar-brand-text${t}">${e.text}</span>`),e.tagline&&(o+=`<small class="navbar-brand-tagline">${e.tagline}</small>`),e.html=o}else t&&e.text&&(e.html=`<span class="navbar-brand-text${t}">${e.text}</span>`);Domma.elements.navbar("#site-navbar",{brand:e,items:w.items||[],variant:w.variant||"dark",position:w.position||"sticky",collapsible:!0}),Domma.icons.scan("#site-navbar")}const v=$("#site-footer");if(v.length){const e=l.social||{},t={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 o='<div class="footer-inner container">';if(l.footer){const m=l.footer;o+=`<p>${m.copyright||""}</p>`,m.links?.length&&(o+='<nav class="footer-links">',m.links.forEach(n=>{o+=`<a href="${n.url}">${n.text}</a>`}),o+="</nav>");const f=Object.keys(t).filter(n=>e[n]);f.length&&(o+='<div class="footer-social">',f.forEach(n=>{const{label:a,svg:r}=t[n];o+=`<a href="${e[n]}" target="_blank" rel="noopener noreferrer" aria-label="${a}" class="footer-social-link">${r}</a>`}),o+="</div>")}o+="</div>",v.html(o);const s=S.get("reduced_motion"),h=s!==null?!!s:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),g=v.get(0).querySelector(".footer-inner");if(g){const m=document.createElement("input");m.type="checkbox",m.className="form-switch-input",m.id="dm-motion-switch",m.checked=h,m.addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()});const f=document.createElement("span");f.className="form-switch-label",f.textContent="Reduce motion";const n=document.createElement("label");n.className="form-switch footer-motion-switch",n.title="Reduce motion",n.appendChild(m),n.appendChild(f),g.appendChild(n)}}$("#site-sidebar").length&&Domma.elements.sidebar("#site-sidebar",{autoGenerate:!0,selector:"h2, h3",collapsible:!1,push:!0,contentSelector:".site-content"}),Domma.icons.scan();const p=$(".page-body");if(p.length){p.find(".accordion").each(function(){Domma.elements.accordion(this,{allowMultiple:this.dataset.multi==="true"})}),p.find(".tabs").each(function(){Domma.elements.tabs(this)}),p.find(".carousel").each(function(){Domma.elements.carousel(this,{autoplay:this.dataset.autoplay==="true",interval:parseInt(this.dataset.interval,10)||5e3,loop:this.dataset.loop!=="false",animation:this.dataset.animation||"slide"})}),p.find(".dm-countdown").each(function(){const e={autoStart:!0};this.dataset.to&&(e.targetDate=new Date(this.dataset.to)),this.dataset.duration&&(e.duration=parseInt(this.dataset.duration,10)),this.dataset.format&&(e.format=this.dataset.format),Domma.elements.timer(this,e)}),p.find("[data-tooltip]").each(function(){Domma.elements.tooltip(this,{content:$(this).data("tooltip"),position:$(this).data("tooltip-position")||"top"})}),p.find(".dm-progression").each(function(){Domma.elements.progression(this,{layout:this.dataset.layout||"vertical",theme:this.dataset.theme||"minimal",mode:this.dataset.mode||"timeline",statusIcons:!0})});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(e=>{const t=e.dataset.revealMode||"stagger",o=e.dataset.revealAnimation||"slide-up",s=parseInt(e.dataset.revealDuration,10)||400,h=parseInt(e.dataset.revealStagger,10)||60,g=parseInt(e.dataset.revealDelay,10)||0,m=e.dataset.revealDirection==="rtl",f=Array.from(e.children),n={"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)"};f.forEach((a,r)=>{a.style.opacity="0",a.style.transform=n[o]||"",a.style.transition=`opacity ${s}ms ease, transform ${s}ms ease`;const c=m?f.length-1-r:r;a.style.transitionDelay=t==="stagger"?`${g+c*h}ms`:`${g}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const a=new IntersectionObserver(r=>{r.forEach(c=>{c.isIntersecting&&(c.target.offsetWidth,c.target.style.opacity="1",c.target.style.transform="none",a.unobserve(c.target))})},{threshold:.1});f.forEach(r=>a.observe(r))}))}),p.find(".card[data-collapsible]").each(function(){const e=this.querySelector(".card-header");e&&e.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),p.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const e=this.dataset.soTarget,t=document.getElementById(e);if(!t)return;const o=E.slideover({title:t.dataset.soTitle||"",size:t.dataset.soSize||"md",position:t.dataset.soPosition||"right"});t.style.display="",o.setContent(t),o.open()})})}if(typeof $.setup=="function"){const e=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(t=>{try{const o=atob(t.dataset.config),s=JSON.parse(o);Object.assign(e,s)}catch{}}),Object.keys(e).length>0){const t={};for(const[o,s]of Object.entries(e)){const h=s?.events?.click,{confirm:g,toast:m,alert:f,prompt:n,...a}=h||{};g||m||f||n?($(o).on("click",async function(c){if(c.preventDefault(),g&&!await E.confirm(g))return;let u=null;if(!(n&&(u=await E.prompt(n,{inputPlaceholder:a.promptPlaceholder||"",inputValue:a.promptDefault||""}),u===null))){if(a.target){const y=$(a.target);a.toggleClass&&y.toggleClass(a.toggleClass),a.addClass&&y.addClass(a.addClass),a.removeClass&&y.removeClass(a.removeClass),u!==null&&(a.setText&&y.text(u),a.setVal&&y.val(u),a.setAttr&&y.attr(a.setAttr,u))}a.href&&(window.location.href=a.href),m&&E.toast(m,{type:a.toastType||"success"}),f&&E.alert(f)}}),Object.keys(a).length&&(t[o]={...s,events:{...s.events,click:a}})):t[o]=s}$.setup(t)}}p.length&&wireCTAButtons(p.get(0))});function wireCTAButtons(w){w.querySelectorAll(".dm-cta-trigger").forEach(l=>{l.addEventListener("click",async()=>{const C=l.dataset.action,v=l.dataset.entry,b=l.dataset.confirm;let p=S.get("auth_token");if(!p){E.toast("Please log in to perform this action.",{type:"warning"});return}if(b&&!await E.confirm(b))return;const i=Array.from(l.childNodes).map(e=>e.cloneNode(!0));l.disabled=!0,l.textContent="Running\u2026";const d=e=>fetch(`/api/actions/${C}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e}`},body:JSON.stringify({entryId:v})});try{let e=await d(p);if(e.status===401){const o=S.get("auth_refresh_token");if(o){const s=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:o})});if(s.ok){const{token:h}=await s.json();S.set("auth_token",h),p=h,e=await d(p)}}}const t=await e.json().catch(()=>({}));if(!e.ok)throw new Error(t.error||t.message||`Error ${e.status}`);E.toast(t.message||"Action completed.",{type:"success"})}catch(e){E.toast(e.message||"Action failed.",{type:"error"})}finally{l.disabled=!1,l.textContent="",i.forEach(e=>l.appendChild(e)),Domma.icons.scan(l)}})})}(function(){const l=document.querySelectorAll("[data-collection-table]");!l.length||typeof T>"u"||!T.create||l.forEach(C=>{let v;try{v=JSON.parse(atob(C.dataset.payload))}catch{return}const{columns:b,rows:p,search:i,sortable:d,exportable:e,pageSize:t,empty:o,ctaConfig:s}=v;if(!b?.length)return;if(s){const m=b.findIndex(f=>f.key==="_cta");m!==-1&&(b[m]={key:"_cta",title:"",render:(f,n)=>{const a=document.createElement("button");if(a.className=`btn btn-${s.style||"primary"} dm-cta-trigger`,a.dataset.action=s.action||"",a.dataset.entry=n._entryId||"",s.confirm&&(a.dataset.confirm=s.confirm),s.icon){const r=document.createElement("span");r.dataset.icon=s.icon,a.appendChild(r),a.appendChild(document.createTextNode(" "))}return a.appendChild(document.createTextNode(s.label||"Run")),a}})}const h="col-table-"+Math.random().toString(36).slice(2,7),g=document.createElement("div");g.id=h,C.replaceChildren(g),T.create("#"+h,{data:p,columns:b,search:i,sortable:d,exportable:e,pageSize:t,emptyMessage:o}),s&&wireCTAButtons(g)})})(),(function(){const l=document.querySelectorAll("[data-form-inline]");if(!l.length||typeof F>"u")return;function C(i,d){const e={};return(i||[]).forEach(t=>{if(t.type==="page-break"||t.type==="spacer"||!t.name)return;const o=t.type==="checkbox"?"boolean":t.type==="date"?"string":t.type,s={...t.formConfig||{}};s.span==="full"&&d&&(s.span=d),e[t.name]={type:o,label:t.label,required:t.required,options:t.options,formConfig:{...t.placeholder&&{placeholder:t.placeholder},...t.helper&&{hint:t.helper},...s}}}),e}function v(i){const d={};return(i||[]).forEach(e=>{if(!(!e.name||e.type==="page-break"||e.type==="spacer")&&(e.type==="select"||e.type==="multiselect")&&e.required){const t=(e.options||[])[0];t&&(d[e.name]=typeof t=="object"?t.value:t)}}),d}function b(i,d){(d||[]).forEach(e=>{if(e.type!=="date"||!e.name)return;const t=i.querySelector(`[name="${e.name}"]`);t&&t.type!=="date"&&(t.type="date")})}function p(i,d,e){let t=i.querySelector(".cms-form-message");t||(t=document.createElement("p"),t.className="cms-form-message",i.appendChild(t)),t.textContent=d,t.style.cssText=e?"color:var(--danger,#f87171);margin-top:.75rem;":"color:var(--success,#4ade80);margin-top:.75rem;"}l.forEach(i=>{let d;try{d=JSON.parse(atob(i.dataset.formInline))}catch{return}const e=d.fields||[],t=d.settings||{},o=t.columns||1,s=t.layout||"stacked",h=e.some(n=>n.type==="page-break"),g=async n=>{try{const a=i.querySelector('[name="website"]'),r=i.querySelector('[name="_t"]'),c=Object.assign({},n);a!==null&&(c._hp=a.value),r!==null&&(c._t=r.value);const u=await H.post(`/api/forms/submit/${d.slug}`,c);if(u?.redirect){window.location.href=u.redirect;return}for(;i.firstChild;)i.removeChild(i.firstChild);p(i,u?.message||t.successMessage||"Thank you for your submission.",!1)}catch(a){throw p(i,a.message||"Submission failed. Please try again.",!0),a}};function m(n){const a=n.querySelector("form");if(!a)return;const r=document.createElement("div");r.className="fb-form-honeypot",r.setAttribute("aria-hidden","true");const c=document.createElement("input");c.name="website",c.type="text",c.tabIndex=-1,c.autocomplete="url",c.placeholder="https://",r.appendChild(c);const u=document.createElement("input");u.name="_t",u.type="hidden",u.value=Date.now(),r.appendChild(u),a.appendChild(r)}function f(){if(!window.FormLogicEngine||!e.some(a=>a.logic))return;const n=new window.FormLogicEngine.FormLogicRuntime(d,i);if(n.init(),i._formLogicRuntime=n,i.parentNode&&typeof MutationObserver<"u"){const a=new MutationObserver(function(r){for(const c of r)for(const u of c.removedNodes)if(u===i||u.nodeType===1&&u.contains&&u.contains(i)){n.destroy(),a.disconnect();return}});a.observe(i.parentNode,{childList:!0,subtree:!1})}}if(h&&F.wizard){const n=[];let a=[],r=d.title||"Step 1",c="";e.forEach(y=>{y.type==="page-break"?(n.push({title:r,description:c,fields:C(a,o)}),a=[],r=y.label||`Step ${n.length+1}`,c=y.description||""):y.type!=="spacer"&&a.push(y)}),n.push({title:r,description:c,fields:C(a,o)});const u=F.wizard(i,{schema:{steps:n},onSubmit:g});Promise.resolve(u).then(function(){b(i,e),t.honeypot!==!1&&m(i),f()})}else if(F.render){const n=F.render(i,C(e,o),v(e),{submitText:t.submitText||"Submit",layout:s,columns:o,onSubmit:g});Promise.resolve(n).then(function(){if(s==="grid"&&t.submitSpan==="full"){const a=i.querySelector(".form-buttons");a&&a.classList.add("col-span-full")}b(i,e),t.honeypot!==!1&&m(i),f()})}}),$(document).on("click",".dm-banner__dismiss",function(){$(this).closest(".dm-banner").remove()})})();
1
+ $(()=>{const w=window.__CMS_NAV__||{},d=window.__CMS_SITE__||{};if(d.autoTheme?.enabled){let e=function(s){const r=(s||"07:00").split(":");return+r[0]*60+(+r[1]||0)},o=function(){const s=new Date,r=s.getHours()*60+s.getMinutes();return r>=e(t.dayStart)&&r<e(t.nightStart)?t.dayTheme:t.nightTheme};var i=e,m=o;const t=d.autoTheme;Domma.theme.set(o()),setInterval(()=>Domma.theme.set(o()),6e4)}if($("#site-navbar").length&&w.brand){const t={...w.brand},e=t.size&&t.size!=="md"?` navbar-brand-${t.size}`:"";if(t.logo||t.icon||t.tagline){let o="";t.logo?o+=`<img src="${t.logo}" class="navbar-brand-logo" alt="${t.text||""}">`:t.icon&&(o+=`<span data-icon="${t.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),t.text&&(o+=`<span class="navbar-brand-text${e}">${t.text}</span>`),t.tagline&&(o+=`<small class="navbar-brand-tagline">${t.tagline}</small>`),t.html=o}else e&&t.text&&(t.html=`<span class="navbar-brand-text${e}">${t.text}</span>`);Domma.elements.navbar("#site-navbar",{brand:t,items:w.items||[],variant:w.variant||"dark",position:w.position||"sticky",collapsible:!0}),Domma.icons.scan("#site-navbar")}const b=$("#site-footer");if(b.length){const t=d.social||{},e={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 o='<div class="footer-inner container">';if(d.footer){const u=d.footer;o+=`<p>${u.copyright||""}</p>`,u.links?.length&&(o+='<nav class="footer-links">',u.links.forEach(n=>{o+=`<a href="${n.url}">${n.text}</a>`}),o+="</nav>");const f=Object.keys(e).filter(n=>t[n]);f.length&&(o+='<div class="footer-social">',f.forEach(n=>{const{label:a,svg:c}=e[n];o+=`<a href="${t[n]}" target="_blank" rel="noopener noreferrer" aria-label="${a}" class="footer-social-link">${c}</a>`}),o+="</div>")}o+="</div>",b.html(o);const s=S.get("reduced_motion"),r=s!==null?!!s:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),g=b.get(0).querySelector(".footer-inner");if(g){const u=document.createElement("input");u.type="checkbox",u.className="form-switch-input",u.id="dm-motion-switch",u.checked=r,u.addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()});const f=document.createElement("span");f.className="form-switch-label",f.textContent="Reduce motion";const n=document.createElement("label");n.className="form-switch footer-motion-switch",n.title="Reduce motion",n.appendChild(u),n.appendChild(f),g.appendChild(n)}}$("#site-sidebar").length&&Domma.elements.sidebar("#site-sidebar",{autoGenerate:!0,selector:"h2, h3",collapsible:!1,push:!0,contentSelector:".site-content"}),Domma.icons.scan();const h=$(".page-body");if(h.length){h.find(".accordion").each(function(){Domma.elements.accordion(this,{allowMultiple:this.dataset.multi==="true"})}),h.find(".tabs").each(function(){Domma.elements.tabs(this)}),h.find(".carousel").each(function(){const t={autoplay:this.dataset.autoplay==="true",interval:parseInt(this.dataset.interval,10)||5e3,loop:this.dataset.loop!=="false",animation:this.dataset.animation||"slide"};if(this.dataset.animationDuration){const e=parseInt(this.dataset.animationDuration,10);Number.isFinite(e)&&(t.animationDuration=e)}this.dataset.animationEasing&&(t.animationEasing=this.dataset.animationEasing),Domma.elements.carousel(this,t)}),h.find(".dm-countdown").each(function(){const t={autoStart:!0};this.dataset.to&&(t.targetDate=new Date(this.dataset.to)),this.dataset.duration&&(t.duration=parseInt(this.dataset.duration,10)),this.dataset.format&&(t.format=this.dataset.format),Domma.elements.timer(this,t)}),h.find("[data-tooltip]").each(function(){Domma.elements.tooltip(this,{content:$(this).data("tooltip"),position:$(this).data("tooltip-position")||"top"})}),h.find(".dm-progression").each(function(){Domma.elements.progression(this,{layout:this.dataset.layout||"vertical",theme:this.dataset.theme||"minimal",mode:this.dataset.mode||"timeline",statusIcons:!0})});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(t=>{const e=t.dataset.revealMode||"stagger",o=t.dataset.revealAnimation||"slide-up",s=parseInt(t.dataset.revealDuration,10)||400,r=parseInt(t.dataset.revealStagger,10)||60,g=parseInt(t.dataset.revealDelay,10)||0,u=t.dataset.revealDirection==="rtl",f=Array.from(t.children),n={"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)"};f.forEach((a,c)=>{a.style.opacity="0",a.style.transform=n[o]||"",a.style.transition=`opacity ${s}ms ease, transform ${s}ms ease`;const l=u?f.length-1-c:c;a.style.transitionDelay=e==="stagger"?`${g+l*r}ms`:`${g}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const a=new IntersectionObserver(c=>{c.forEach(l=>{l.isIntersecting&&(l.target.offsetWidth,l.target.style.opacity="1",l.target.style.transform="none",a.unobserve(l.target))})},{threshold:.1});f.forEach(c=>a.observe(c))}))}),h.find(".card[data-collapsible]").each(function(){const t=this.querySelector(".card-header");t&&t.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),h.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const t=this.dataset.soTarget,e=document.getElementById(t);if(!e)return;const o=E.slideover({title:e.dataset.soTitle||"",size:e.dataset.soSize||"md",position:e.dataset.soPosition||"right"});e.style.display="",o.setContent(e),o.open()})})}if(typeof $.setup=="function"){const t=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(e=>{try{const o=atob(e.dataset.config),s=JSON.parse(o);Object.assign(t,s)}catch{}}),Object.keys(t).length>0){const e={};for(const[o,s]of Object.entries(t)){const r=s?.events?.click,{confirm:g,toast:u,alert:f,prompt:n,...a}=r||{};g||u||f||n?($(o).on("click",async function(l){if(l.preventDefault(),g&&!await E.confirm(g))return;let p=null;if(!(n&&(p=await E.prompt(n,{inputPlaceholder:a.promptPlaceholder||"",inputValue:a.promptDefault||""}),p===null))){if(a.target){const y=$(a.target);a.toggleClass&&y.toggleClass(a.toggleClass),a.addClass&&y.addClass(a.addClass),a.removeClass&&y.removeClass(a.removeClass),p!==null&&(a.setText&&y.text(p),a.setVal&&y.val(p),a.setAttr&&y.attr(a.setAttr,p))}a.href&&(window.location.href=a.href),u&&E.toast(u,{type:a.toastType||"success"}),f&&E.alert(f)}}),Object.keys(a).length&&(e[o]={...s,events:{...s.events,click:a}})):e[o]=s}$.setup(e)}}h.length&&wireCTAButtons(h.get(0))});function wireCTAButtons(w){w.querySelectorAll(".dm-cta-trigger").forEach(d=>{d.addEventListener("click",async()=>{const C=d.dataset.action,b=d.dataset.entry,v=d.dataset.confirm;let h=S.get("auth_token");if(!h){E.toast("Please log in to perform this action.",{type:"warning"});return}if(v&&!await E.confirm(v))return;const i=Array.from(d.childNodes).map(t=>t.cloneNode(!0));d.disabled=!0,d.textContent="Running\u2026";const m=t=>fetch(`/api/actions/${C}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${t}`},body:JSON.stringify({entryId:b})});try{let t=await m(h);if(t.status===401){const o=S.get("auth_refresh_token");if(o){const s=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:o})});if(s.ok){const{token:r}=await s.json();S.set("auth_token",r),h=r,t=await m(h)}}}const e=await t.json().catch(()=>({}));if(!t.ok)throw new Error(e.error||e.message||`Error ${t.status}`);E.toast(e.message||"Action completed.",{type:"success"})}catch(t){E.toast(t.message||"Action failed.",{type:"error"})}finally{d.disabled=!1,d.textContent="",i.forEach(t=>d.appendChild(t)),Domma.icons.scan(d)}})})}(function(){const d=document.querySelectorAll("[data-collection-table]");!d.length||typeof T>"u"||!T.create||d.forEach(C=>{let b;try{b=JSON.parse(atob(C.dataset.payload))}catch{return}const{columns:v,rows:h,search:i,sortable:m,exportable:t,pageSize:e,empty:o,ctaConfig:s}=b;if(!v?.length)return;if(s){const u=v.findIndex(f=>f.key==="_cta");u!==-1&&(v[u]={key:"_cta",title:"",render:(f,n)=>{const a=document.createElement("button");if(a.className=`btn btn-${s.style||"primary"} dm-cta-trigger`,a.dataset.action=s.action||"",a.dataset.entry=n._entryId||"",s.confirm&&(a.dataset.confirm=s.confirm),s.icon){const c=document.createElement("span");c.dataset.icon=s.icon,a.appendChild(c),a.appendChild(document.createTextNode(" "))}return a.appendChild(document.createTextNode(s.label||"Run")),a}})}const r="col-table-"+Math.random().toString(36).slice(2,7),g=document.createElement("div");g.id=r,C.replaceChildren(g),T.create("#"+r,{data:h,columns:v,search:i,sortable:m,exportable:t,pageSize:e,emptyMessage:o}),s&&wireCTAButtons(g)})})(),(function(){const d=document.querySelectorAll("[data-form-inline]");if(!d.length||typeof F>"u")return;function C(i,m){const t={};return(i||[]).forEach(e=>{if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const o=e.type==="checkbox"?"boolean":e.type==="date"?"string":e.type,s={...e.formConfig||{}};s.span==="full"&&m&&(s.span=m);const r={type:o,label:e.label,required:e.required,options:e.options,formConfig:{...e.placeholder&&{placeholder:e.placeholder},...e.helper&&{hint:e.helper},...s}};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 b(i){const m={};return(i||[]).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&&(m[t.name]=typeof e=="object"?e.value:e)}}),m}function v(i,m){(m||[]).forEach(t=>{if(t.type!=="date"||!t.name)return;const e=i.querySelector(`[name="${t.name}"]`);e&&e.type!=="date"&&(e.type="date")})}function h(i,m,t){let e=i.querySelector(".cms-form-message");e||(e=document.createElement("p"),e.className="cms-form-message",i.appendChild(e)),e.textContent=m,e.style.cssText=t?"color:var(--danger,#f87171);margin-top:.75rem;":"color:var(--success,#4ade80);margin-top:.75rem;"}d.forEach(i=>{let m;try{m=JSON.parse(atob(i.dataset.formInline))}catch{return}const t=m.fields||[],e=m.settings||{},o=e.columns||1,s=e.layout||"stacked",r=t.some(n=>n.type==="page-break"),g=async n=>{try{const a=i.querySelector('[name="website"]'),c=i.querySelector('[name="_t"]'),l=Object.assign({},n);a!==null&&(l._hp=a.value),c!==null&&(l._t=c.value);const p=await H.post(`/api/forms/submit/${m.slug}`,l);if(p?.redirect){window.location.href=p.redirect;return}for(;i.firstChild;)i.removeChild(i.firstChild);h(i,p?.message||e.successMessage||"Thank you for your submission.",!1)}catch(a){throw h(i,a.message||"Submission failed. Please try again.",!0),a}};function u(n){const a=n.querySelector("form");if(!a)return;const c=document.createElement("div");c.className="fb-form-honeypot",c.setAttribute("aria-hidden","true");const l=document.createElement("input");l.name="website",l.type="text",l.tabIndex=-1,l.autocomplete="url",l.placeholder="https://",c.appendChild(l);const p=document.createElement("input");p.name="_t",p.type="hidden",p.value=Date.now(),c.appendChild(p),a.appendChild(c)}function f(){if(!window.FormLogicEngine||!t.some(a=>a.logic))return;const n=new window.FormLogicEngine.FormLogicRuntime(m,i);if(n.init(),i._formLogicRuntime=n,i.parentNode&&typeof MutationObserver<"u"){const a=new MutationObserver(function(c){for(const l of c)for(const p of l.removedNodes)if(p===i||p.nodeType===1&&p.contains&&p.contains(i)){n.destroy(),a.disconnect();return}});a.observe(i.parentNode,{childList:!0,subtree:!1})}}if(r&&F.wizard){const n=[];let a=[],c=m.title||"Step 1",l="";t.forEach(y=>{y.type==="page-break"?(n.push({title:c,description:l,fields:C(a,o)}),a=[],c=y.label||`Step ${n.length+1}`,l=y.description||""):y.type!=="spacer"&&a.push(y)}),n.push({title:c,description:l,fields:C(a,o)});const p=F.wizard(i,{schema:{steps:n},onSubmit:g});Promise.resolve(p).then(function(){v(i,t),e.honeypot!==!1&&u(i),f()})}else if(F.render){const n=F.render(i,C(t,o),b(t),{submitText:e.submitText||"Submit",layout:s,columns:o,onSubmit:g});Promise.resolve(n).then(function(){if(s==="grid"&&e.submitSpan==="full"){const a=i.querySelector(".form-buttons");a&&a.classList.add("col-span-full")}v(i,t),e.honeypot!==!1&&u(i),f()})}}),$(document).on("click",".dm-banner__dismiss",function(){$(this).closest(".dm-banner").remove()})})();
package/server/config.js CHANGED
@@ -49,8 +49,19 @@ export function saveConfig(name, data) {
49
49
  const serverConfig = loadJson('server');
50
50
  if (process.env.PORT) serverConfig.port = parseInt(process.env.PORT, 10);
51
51
 
52
+ const cacheDefaults = {
53
+ enabled: process.env.NODE_ENV === 'production',
54
+ driver: 'memory',
55
+ memory: {maxItems: 1000, defaultTtlSeconds: 3600}
56
+ };
57
+ const cacheFile = path.join(CONFIG_DIR, 'cache.json');
58
+ const cacheConfig = fs.existsSync(cacheFile)
59
+ ? {...cacheDefaults, ...JSON.parse(fs.readFileSync(cacheFile, 'utf8'))}
60
+ : cacheDefaults;
61
+
52
62
  export const config = {
53
63
  server: serverConfig,
54
64
  auth: loadJson('auth'),
55
- content: loadJson('content')
65
+ content: loadJson('content'),
66
+ cache: cacheConfig
56
67
  };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Cache API
3
+ * GET /api/cache/status - report enabled flag plus live stats
4
+ * GET /api/cache/keys - list cached keys with their tags (no values)
5
+ * POST /api/cache/toggle - flip enabled on/off, persist to config/cache.json
6
+ * POST /api/cache/clear - flush every cached entry
7
+ *
8
+ * All endpoints are admin-only.
9
+ */
10
+ import fs from 'fs/promises';
11
+ import path from 'path';
12
+ import {fileURLToPath} from 'url';
13
+ import * as cache from '../../services/cache/index.js';
14
+ import {authenticate, requireAdmin} from '../../middleware/auth.js';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const CACHE_CONFIG_FILE = path.resolve(__dirname, '../../../config/cache.json');
18
+
19
+ const CACHE_DEFAULTS = {
20
+ enabled: process.env.NODE_ENV === 'production',
21
+ driver: 'memory',
22
+ memory: {maxItems: 1000, defaultTtlSeconds: 3600}
23
+ };
24
+
25
+ async function readCacheConfig() {
26
+ try {
27
+ return {...CACHE_DEFAULTS, ...JSON.parse(await fs.readFile(CACHE_CONFIG_FILE, 'utf8'))};
28
+ } catch {
29
+ return {...CACHE_DEFAULTS};
30
+ }
31
+ }
32
+
33
+ async function writeCacheConfig(cfg) {
34
+ await fs.writeFile(CACHE_CONFIG_FILE, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
35
+ }
36
+
37
+ export async function cacheRoutes(fastify) {
38
+ const adminOnly = {preHandler: [authenticate, requireAdmin]};
39
+
40
+ fastify.get('/cache/status', adminOnly, async () => cache.getStats());
41
+
42
+ fastify.get('/cache/keys', adminOnly, async () => ({entries: cache.listEntries()}));
43
+
44
+ fastify.post('/cache/toggle', adminOnly, async (request, reply) => {
45
+ const enabled = !!request.body?.enabled;
46
+ const cfg = await readCacheConfig();
47
+ cfg.enabled = enabled;
48
+ await writeCacheConfig(cfg);
49
+ await cache.setEnabled(enabled);
50
+ return reply.send(cache.getStats());
51
+ });
52
+
53
+ fastify.post('/cache/clear', adminOnly, async () => {
54
+ await cache.clear();
55
+ return cache.getStats();
56
+ });
57
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Dashboard API
3
+ * Aggregates traffic, journeys, spikes, health, and activity for the admin dashboard.
4
+ *
5
+ * GET /api/dashboard/summary full payload
6
+ * GET /api/dashboard/summary?lite=1 KPI strip + spikes + health.status only
7
+ */
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ import {fileURLToPath} from 'url';
11
+ import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
12
+ import {getHealth} from '../../services/health.js';
13
+ import {listVersions} from '../../services/versions.js';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
17
+
18
+ const ACTIVITY_LIMIT = 10;
19
+ const VERSIONS_PER_PAGE = 3;
20
+ const ENTRIES_PER_COLLECTION = 3;
21
+
22
+ /**
23
+ * @param {Date} [d]
24
+ * @returns {string}
25
+ */
26
+ function todayKey(d = new Date()) {
27
+ return d.toISOString().slice(0, 10);
28
+ }
29
+
30
+ /**
31
+ * @param {number} n
32
+ * @returns {string}
33
+ */
34
+ function isoDaysAgo(n) {
35
+ const d = new Date();
36
+ d.setUTCHours(0, 0, 0, 0);
37
+ d.setUTCDate(d.getUTCDate() - n);
38
+ return todayKey(d);
39
+ }
40
+
41
+ /**
42
+ * @param {object} daily
43
+ * @param {string} key
44
+ * @returns {number}
45
+ */
46
+ function sumDay(daily, key) {
47
+ return Object.values(daily[key] || {}).reduce((a, n) => a + n, 0);
48
+ }
49
+
50
+ /**
51
+ * @param {object} daily
52
+ * @param {string} fromKey
53
+ * @param {string} toKey
54
+ * @returns {number}
55
+ */
56
+ function sumRange(daily, fromKey, toKey) {
57
+ let total = 0;
58
+ for (const [day, urls] of Object.entries(daily)) {
59
+ if (day < fromKey || day > toKey) continue;
60
+ for (const n of Object.values(urls)) total += n;
61
+ }
62
+ return total;
63
+ }
64
+
65
+ /**
66
+ * Build the top-pages widget data, including a per-day sparkline and a
67
+ * percentage delta versus the previous comparable window.
68
+ *
69
+ * @param {object} daily
70
+ * @param {number} [days]
71
+ * @returns {Array<{url: string, hits: number, deltaPct: number|null, spark: number[]}>}
72
+ */
73
+ function buildTopPages(daily, days = 7) {
74
+ const totals = {};
75
+ const perDay = {};
76
+ for (let i = days - 1; i >= 0; i -= 1) {
77
+ const k = isoDaysAgo(i);
78
+ for (const [url, n] of Object.entries(daily[k] || {})) {
79
+ totals[url] = (totals[url] || 0) + n;
80
+ if (!perDay[url]) perDay[url] = new Array(days).fill(0);
81
+ perDay[url][days - 1 - i] = n;
82
+ }
83
+ }
84
+ const prevTotals = {};
85
+ for (let i = 2 * days - 1; i >= days; i -= 1) {
86
+ const k = isoDaysAgo(i);
87
+ for (const [url, n] of Object.entries(daily[k] || {})) {
88
+ prevTotals[url] = (prevTotals[url] || 0) + n;
89
+ }
90
+ }
91
+ return Object.entries(totals)
92
+ .map(([url, hits]) => {
93
+ const prev = prevTotals[url] || 0;
94
+ const deltaPct = prev === 0 ? null : +(((hits - prev) / prev) * 100).toFixed(1);
95
+ return { url, hits, deltaPct, spark: perDay[url] };
96
+ })
97
+ .sort((a, b) => b.hits - a.hits)
98
+ .slice(0, 5);
99
+ }
100
+
101
+ /**
102
+ * Recent activity feed — combines page version events with recent collection
103
+ * entries (form submissions). Returns at most 10 items, newest first.
104
+ *
105
+ * @returns {Promise<Array<object>>}
106
+ */
107
+ async function buildActivity() {
108
+ const items = [];
109
+
110
+ // Versions — read each page's _meta.json via the service so the schema
111
+ // stays in one place. listVersions returns newest-first.
112
+ const versionsDir = path.join(ROOT, 'content', 'versions');
113
+ try {
114
+ const dirs = await fs.readdir(versionsDir, { withFileTypes: true });
115
+ for (const d of dirs) {
116
+ if (!d.isDirectory()) continue;
117
+ const urlPath = '/' + (d.name === '_index' ? '' : d.name);
118
+ let versions = [];
119
+ try { versions = await listVersions(urlPath); }
120
+ catch { continue; }
121
+ for (const v of versions.slice(0, VERSIONS_PER_PAGE)) {
122
+ items.push({
123
+ type: v.type === 'manual' ? 'publish' : 'edit',
124
+ at: v.createdAt,
125
+ actor: v.author || 'unknown',
126
+ target: urlPath
127
+ });
128
+ }
129
+ }
130
+ } catch { /* no versions dir */ }
131
+
132
+ // Collection entries — each slug has a single data.json with an array of
133
+ // entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt.
134
+ const collectionsDir = path.join(ROOT, 'content', 'collections');
135
+ try {
136
+ const slugs = await fs.readdir(collectionsDir, { withFileTypes: true });
137
+ for (const slug of slugs) {
138
+ if (!slug.isDirectory()) continue;
139
+ const dataFile = path.join(collectionsDir, slug.name, 'data.json');
140
+ let entries = [];
141
+ try { entries = JSON.parse(await fs.readFile(dataFile, 'utf8')); }
142
+ catch { continue; }
143
+ if (!Array.isArray(entries)) continue;
144
+
145
+ const sorted = entries
146
+ .filter(e => e && e.meta && e.meta.createdAt)
147
+ .sort((a, b) => (b.meta.createdAt || '').localeCompare(a.meta.createdAt || ''))
148
+ .slice(0, ENTRIES_PER_COLLECTION);
149
+
150
+ for (const e of sorted) {
151
+ items.push({
152
+ type: 'form',
153
+ at: e.meta.createdAt,
154
+ form: slug.name
155
+ });
156
+ }
157
+ }
158
+ } catch { /* no collections dir */ }
159
+
160
+ return items
161
+ .filter(i => i.at)
162
+ .sort((a, b) => (b.at || '').localeCompare(a.at || ''))
163
+ .slice(0, ACTIVITY_LIMIT);
164
+ }
165
+
166
+ /**
167
+ * Register the dashboard routes.
168
+ *
169
+ * Auth middlewares are accepted as options so tests can supply no-ops without
170
+ * needing to register `@fastify/jwt` on the test instance. In production the
171
+ * defaults are the real `authenticate` / `requireAdmin` from middleware/auth.js.
172
+ *
173
+ * @param {import('fastify').FastifyInstance} fastify
174
+ * @param {{authenticate?: Function, requireAdmin?: Function}} [opts]
175
+ * @returns {Promise<void>}
176
+ */
177
+ export async function dashboardRoutes(fastify, opts = {}) {
178
+ const authenticate = opts.authenticate || defaultAuthenticate;
179
+ const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
180
+ const guard = { preHandler: [authenticate, requireAdmin] };
181
+
182
+ fastify.get('/dashboard/summary', guard, async (request) => {
183
+ const lite = request.query.lite === '1' || request.query.lite === 'true';
184
+ const warnings = [];
185
+ const safe = async (label, fn) => {
186
+ try { return await fn(); }
187
+ catch (e) {
188
+ // Strip absolute paths from error messages so we don't disclose deployment layout.
189
+ const msg = String(e.message || e).replace(/(\/|[A-Za-z]:\\)[^\s'"]+/g, '<path>');
190
+ warnings.push(`${label}: ${msg}`);
191
+ return null;
192
+ }
193
+ };
194
+
195
+ const analytics = fastify.analytics;
196
+ const daily = analytics ? (await safe('analytics.daily', () => analytics.getDaily())) || {} : {};
197
+
198
+ const today = todayKey();
199
+ const yesterday = isoDaysAgo(1);
200
+ const todayHits = sumDay(daily, today);
201
+ const yesterdayHits = sumDay(daily, yesterday);
202
+ const deltaPct = yesterdayHits === 0
203
+ ? null
204
+ : +(((todayHits - yesterdayHits) / yesterdayHits) * 100).toFixed(1);
205
+ const traffic = {
206
+ today: todayHits,
207
+ yesterday: yesterdayHits,
208
+ deltaPct,
209
+ weekToDate: sumRange(daily, isoDaysAgo(6), today),
210
+ previousWeek: sumRange(daily, isoDaysAgo(13), isoDaysAgo(7))
211
+ };
212
+
213
+ const realtime = analytics
214
+ ? (await safe('analytics.realtime', () => analytics.getRealtime())) || { activeSessions: 0 }
215
+ : { activeSessions: 0 };
216
+ const spikes = analytics
217
+ ? ((await safe('analytics.spikes', () => analytics.getSpikes())) || { items: [] }).items
218
+ : [];
219
+ const health = await safe('health', () => getHealth()) || { status: 'ok', checks: [] };
220
+
221
+ if (lite) {
222
+ return {
223
+ traffic: { today: traffic.today, yesterday: traffic.yesterday, deltaPct: traffic.deltaPct },
224
+ realtime,
225
+ spikes,
226
+ health: { status: health.status },
227
+ warnings
228
+ };
229
+ }
230
+
231
+ const topPages = buildTopPages(daily);
232
+ const journeys = analytics
233
+ ? (await safe('analytics.journeys', () => analytics.getJourneys({ range: '7d' }))) || null
234
+ : null;
235
+ const activity = (await safe('activity', () => buildActivity())) || [];
236
+
237
+ return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };
238
+ });
239
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import {getConfig, saveConfig} from '../../config.js';
7
7
  import {authenticate, requirePermission} from '../../middleware/auth.js';
8
+ import * as cache from '../../services/cache/index.js';
8
9
 
9
10
  export async function navigationRoutes(fastify) {
10
11
  const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
@@ -35,6 +36,7 @@ export async function navigationRoutes(fastify) {
35
36
  });
36
37
  }
37
38
  saveConfig('navigation', data);
39
+ await cache.invalidateTags(['nav']);
38
40
  return { success: true };
39
41
  });
40
42
  }
@@ -10,6 +10,7 @@ import nodemailer from 'nodemailer';
10
10
  import fs from 'fs/promises';
11
11
  import path from 'path';
12
12
  import {fileURLToPath} from 'url';
13
+ import * as cache from '../../services/cache/index.js';
13
14
 
14
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
16
  const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
@@ -64,6 +65,7 @@ export async function settingsRoutes(fastify) {
64
65
  merged.smtp = {...merged.smtp, pass: existing.smtp.pass};
65
66
  }
66
67
  saveConfig('site', merged);
68
+ await cache.invalidateTags(['site']);
67
69
  return { success: true };
68
70
  });
69
71
 
@@ -133,6 +135,7 @@ export async function settingsRoutes(fastify) {
133
135
  return reply.status(400).send({ error: 'CSS exceeds the 100 KB limit.' });
134
136
  }
135
137
  await fs.writeFile(CUSTOM_CSS_FILE, css, 'utf8');
138
+ await cache.invalidateTags(['site']);
136
139
  return { success: true };
137
140
  });
138
141
  }
@@ -8,6 +8,7 @@ import {getPage} from '../services/content.js';
8
8
  import {renderPage} from '../services/renderer.js';
9
9
  import {checkVisibility} from '../middleware/auth.js';
10
10
  import {hooks} from '../services/hooks.js';
11
+ import * as cache from '../services/cache/index.js';
11
12
 
12
13
  /**
13
14
  * Escape user-controlled strings before interpolating into HTML.
@@ -64,9 +65,10 @@ export async function publicRoutes(fastify) {
64
65
  return reply.type('text/html').send(await render404(urlPath));
65
66
  }
66
67
 
67
- // Enforce page visibility
68
+ // Enforce page visibility — role only resolved for gated pages,
69
+ // so public pages share a single cache entry keyed `roleanon`.
70
+ let userRole = null;
68
71
  if (page.visibility && page.visibility !== 'public') {
69
- let userRole = null;
70
72
  try {
71
73
  const decoded = await request.jwtVerify();
72
74
  if (decoded.type === 'access') userRole = decoded.role;
@@ -78,7 +80,13 @@ export async function publicRoutes(fastify) {
78
80
  }
79
81
  }
80
82
 
81
- const html = await renderPage(page);
83
+ const cacheKey = `page:${urlPath}:role${userRole ?? 'anon'}`;
84
+ const cacheTags = [`page:${urlPath}`, ...(page.tags || []), 'nav', 'site'];
85
+ const html = await cache.wrap(
86
+ cacheKey,
87
+ () => renderPage(page),
88
+ {tags: cacheTags}
89
+ );
82
90
  hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
83
91
  return reply.type('text/html').send(html);
84
92
  });
package/server/server.js CHANGED
@@ -24,6 +24,7 @@ import {seedAll as seedPresetCollections} from './services/presetCollections.js'
24
24
  import {seedDefaultBlocks} from './services/blocks.js';
25
25
  import {seedDefaultComponents} from './services/components.js';
26
26
  import {refreshComponentTagAllowlist} from './services/markdown.js';
27
+ import * as cache from './services/cache/index.js';
27
28
 
28
29
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
30
  const ROOT = path.resolve(__dirname, '..');
@@ -122,13 +123,19 @@ await app.register(staticPlugin, {
122
123
  decorateReply: false
123
124
  });
124
125
 
125
- // Serve admin panel assets — no-cache so JS/CSS changes are always picked up
126
+ // Serve admin panel assets — no-store on JS/CSS so ESM module URLs are never
127
+ // cached. `no-cache` revalidates but browsers may keep the parsed ESM module
128
+ // keyed by URL across reloads, which means a hard refresh can still load the
129
+ // stale code path. `no-store` skips the cache entirely. Other admin assets
130
+ // (images, html) keep no-cache. Local-only impact — admin is not public.
126
131
  await app.register(staticPlugin, {
127
132
  root: path.join(ROOT, 'admin'),
128
133
  prefix: '/admin/',
129
134
  decorateReply: false,
130
- setHeaders: (res) => {
131
- res.setHeader('Cache-Control', 'no-cache');
135
+ cacheControl: false,
136
+ setHeaders: (res, filePath) => {
137
+ const noStore = /\.(?:js|mjs|css)$/i.test(filePath);
138
+ res.setHeader('Cache-Control', noStore ? 'no-store' : 'no-cache');
132
139
  }
133
140
  });
134
141
 
@@ -172,6 +179,10 @@ await seedDefaultBlocks();
172
179
  await seedDefaultComponents();
173
180
  await refreshComponentTagAllowlist();
174
181
 
182
+ // Initialise response cache (Memory/None/Redis driver per config.cache).
183
+ await cache.initCache(config.cache);
184
+ console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
185
+
175
186
  // Serve uploaded media files — nosniff prevents browsers rendering spoofed content types
176
187
  await app.register(staticPlugin, {
177
188
  root: mediaDir,
@@ -249,6 +260,8 @@ const {componentsRoutes} = await import('./routes/api/components.js');
249
260
  const {versionsRoutes} = await import('./routes/api/versions.js');
250
261
  const {effectsRoutes} = await import('./routes/api/effects.js');
251
262
  const {notificationsRoutes} = await import('./routes/api/notifications.js');
263
+ const {dashboardRoutes} = await import('./routes/api/dashboard.js');
264
+ const {cacheRoutes} = await import('./routes/api/cache.js');
252
265
 
253
266
  await app.register(pagesRoutes, { prefix: '/api' });
254
267
  await app.register(settingsRoutes, { prefix: '/api' });
@@ -267,6 +280,8 @@ await app.register(componentsRoutes, {prefix: '/api'});
267
280
  await app.register(versionsRoutes, {prefix: '/api'});
268
281
  await app.register(effectsRoutes, {prefix: '/api'});
269
282
  await app.register(notificationsRoutes, {prefix: '/api'});
283
+ await app.register(dashboardRoutes, {prefix: '/api'});
284
+ await app.register(cacheRoutes, {prefix: '/api'});
270
285
 
271
286
  // ---------------------------------------------------------------------------
272
287
  // CMS Plugins (server-side Fastify plugins from plugins/ directory)
@@ -6,6 +6,7 @@
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
8
  import {fileURLToPath} from 'url';
9
+ import * as cache from './cache/index.js';
9
10
 
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  const BLOCKS_DIR = path.resolve(__dirname, '../../content/blocks');
@@ -341,6 +342,7 @@ export async function saveBlock(name, content, {bundled, css} = {}) {
341
342
  });
342
343
  }
343
344
  }
345
+ await cache.invalidateTags([`block:${name}`]);
344
346
  return {success: true, name};
345
347
  }
346
348
 
@@ -368,6 +370,7 @@ export async function deleteBlock(name) {
368
370
  });
369
371
  await fs.unlink(blockMetaPath(name)).catch(() => {
370
372
  });
373
+ await cache.invalidateTags([`block:${name}`]);
371
374
  }
372
375
 
373
376
  // ---------------------------------------------------------------------------