domma-cms 0.17.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 (43) hide show
  1. package/CLAUDE.md +2 -0
  2. package/admin/css/dashboard.css +1 -1
  3. package/admin/index.html +2 -2
  4. package/admin/js/app.js +1 -1
  5. package/admin/js/lib/card-builder.js +3 -3
  6. package/admin/js/lib/effects-builder.js +1 -1
  7. package/admin/js/lib/markdown-toolbar.js +5 -5
  8. package/admin/js/templates/dashboard/cache.html +32 -0
  9. package/admin/js/templates/dashboard.html +4 -0
  10. package/admin/js/templates/settings.html +26 -0
  11. package/admin/js/views/block-editor-enhance.js +1 -1
  12. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
  13. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  14. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  15. package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
  16. package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
  17. package/admin/js/views/dashboard.js +1 -1
  18. package/admin/js/views/form-editor.js +6 -6
  19. package/admin/js/views/index.js +1 -1
  20. package/admin/js/views/page-editor.js +42 -37
  21. package/admin/js/views/settings.js +3 -3
  22. package/config/cache.json +4 -0
  23. package/config/cache.json.example +12 -0
  24. package/package.json +1 -1
  25. package/public/js/forms.js +1 -1
  26. package/public/js/site.js +1 -1
  27. package/server/config.js +12 -1
  28. package/server/routes/api/cache.js +57 -0
  29. package/server/routes/api/navigation.js +2 -0
  30. package/server/routes/api/settings.js +3 -0
  31. package/server/routes/public.js +11 -3
  32. package/server/server.js +16 -3
  33. package/server/services/blocks.js +3 -0
  34. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  35. package/server/services/cache/drivers/NoneDriver.js +12 -0
  36. package/server/services/cache/index.js +229 -0
  37. package/server/services/cache/lru.js +61 -0
  38. package/server/services/collections.js +17 -4
  39. package/server/services/content.js +7 -2
  40. package/server/services/forms.js +3 -0
  41. package/server/services/markdown.js +25 -15
  42. package/server/services/views.js +4 -0
  43. 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.17.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",
@@ -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)})}
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
+ }
@@ -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,
@@ -250,6 +261,7 @@ 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');
252
263
  const {dashboardRoutes} = await import('./routes/api/dashboard.js');
264
+ const {cacheRoutes} = await import('./routes/api/cache.js');
253
265
 
254
266
  await app.register(pagesRoutes, { prefix: '/api' });
255
267
  await app.register(settingsRoutes, { prefix: '/api' });
@@ -269,6 +281,7 @@ await app.register(versionsRoutes, {prefix: '/api'});
269
281
  await app.register(effectsRoutes, {prefix: '/api'});
270
282
  await app.register(notificationsRoutes, {prefix: '/api'});
271
283
  await app.register(dashboardRoutes, {prefix: '/api'});
284
+ await app.register(cacheRoutes, {prefix: '/api'});
272
285
 
273
286
  // ---------------------------------------------------------------------------
274
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
  // ---------------------------------------------------------------------------
@@ -0,0 +1,118 @@
1
+ import {LRU} from '../lru.js';
2
+
3
+ /**
4
+ * In-memory cache driver with TTL and tag invalidation.
5
+ *
6
+ * Each entry is stored as {value, expiresAt, tags}. TTL is lazy — entries
7
+ * past their expiresAt are returned as undefined on the next get and dropped.
8
+ *
9
+ * Tag index: a Map<tag, Set<key>>. set() with {tags} adds the key to each
10
+ * tag's set; invalidateTags() iterates the listed tags and deletes every
11
+ * key in their sets. The forward index (entry.tags) lets us clean up old
12
+ * links when a key is re-set with a different tag list.
13
+ */
14
+ export class MemoryDriver {
15
+ /**
16
+ * @param {{maxItems?: number, defaultTtlSeconds?: number}} options
17
+ */
18
+ constructor(options = {}) {
19
+ const maxItems = options.maxItems ?? 1000;
20
+ this._defaultTtlSeconds = options.defaultTtlSeconds ?? null;
21
+ this._lru = new LRU(maxItems);
22
+ /** @type {Map<string, Set<string>>} */
23
+ this._tagIndex = new Map();
24
+ this._now = () => Date.now();
25
+ }
26
+
27
+ async get(key) {
28
+ const entry = this._lru.get(key);
29
+ if (!entry) return undefined;
30
+ if (entry.expiresAt !== null && entry.expiresAt <= this._now()) {
31
+ this._removeKey(key);
32
+ return undefined;
33
+ }
34
+ return entry.value;
35
+ }
36
+
37
+ async set(key, value, opts = {}) {
38
+ const existing = this._lru.get(key);
39
+ if (existing && existing.tags) {
40
+ for (const t of existing.tags) {
41
+ const set = this._tagIndex.get(t);
42
+ if (set) {
43
+ set.delete(key);
44
+ if (set.size === 0) this._tagIndex.delete(t);
45
+ }
46
+ }
47
+ }
48
+
49
+ const ttl = opts.ttlSeconds ?? this._defaultTtlSeconds;
50
+ const expiresAt = ttl ? this._now() + ttl * 1000 : null;
51
+ const tags = opts.tags && opts.tags.length ? [...opts.tags] : null;
52
+
53
+ this._lru.set(key, {value, expiresAt, tags});
54
+
55
+ if (tags) {
56
+ for (const t of tags) {
57
+ let set = this._tagIndex.get(t);
58
+ if (!set) {
59
+ set = new Set();
60
+ this._tagIndex.set(t, set);
61
+ }
62
+ set.add(key);
63
+ }
64
+ }
65
+ }
66
+
67
+ async invalidateTags(tags) {
68
+ for (const t of tags) {
69
+ const set = this._tagIndex.get(t);
70
+ if (!set) continue;
71
+ for (const key of [...set]) {
72
+ this._removeKey(key);
73
+ }
74
+ }
75
+ }
76
+
77
+ async clear() {
78
+ this._lru.clear();
79
+ this._tagIndex.clear();
80
+ }
81
+
82
+ /** Current entry count (sync — for the dashboard widget). */
83
+ size() {
84
+ return this._lru.size;
85
+ }
86
+
87
+ /**
88
+ * Snapshot the currently cached entries as `{key, tags, expiresAt}`.
89
+ * No values — they can be megabytes of HTML. Uses `entries()` (non-mutating)
90
+ * so the LRU order is preserved through the iteration.
91
+ */
92
+ listEntries() {
93
+ const out = [];
94
+ for (const [key, entry] of this._lru.entries()) {
95
+ if (!entry) continue;
96
+ out.push({
97
+ key,
98
+ tags: entry.tags ? [...entry.tags] : [],
99
+ expiresAt: entry.expiresAt
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ _removeKey(key) {
106
+ const entry = this._lru.get(key);
107
+ if (entry && entry.tags) {
108
+ for (const t of entry.tags) {
109
+ const set = this._tagIndex.get(t);
110
+ if (set) {
111
+ set.delete(key);
112
+ if (set.size === 0) this._tagIndex.delete(t);
113
+ }
114
+ }
115
+ }
116
+ this._lru.delete(key);
117
+ }
118
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Null driver — used when the cache is disabled.
3
+ * Every read misses; every write is dropped on the floor.
4
+ */
5
+ export class NoneDriver {
6
+ async get() { return undefined; }
7
+ async set() { /* no-op */ }
8
+ async invalidateTags() { /* no-op */ }
9
+ async clear() { /* no-op */ }
10
+ size() { return 0; }
11
+ listEntries() { return []; }
12
+ }