domma-cms 0.25.16 → 0.31.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.
@@ -1,4 +1,4 @@
1
- import{api as c}from"../api.js";import{colourToCss as F}from"/public/js/menu-decor.mjs";import{groupPluginItems as W,stripItemByUrl as D,insertFoldersBeforeSystem as _,pruneEmptySynthesisedFolders as K,MANAGE_PLUGINS_URL as X}from"./sidebar-grouping.js";function f(e){return String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}const G=[{text:"Dashboard",url:"#/",icon:"home"},{text:"Menus",url:"#/menus",icon:"menu"}];function B(e,i){return i?!e||!e.length?!1:e.includes(i)?!0:["read","create","update","delete"].some(t=>e.includes(`${i}.${t}`)):!0}function I(e,i){const t=[];for(const n of e){if(n.hidden||n.permission&&!B(i,n.permission))continue;const s=Array.isArray(n.items)&&n.items.length?I(n.items,i):[];t.push({...n,items:s})}return t}function O(e,i){if(!i||!i.length)return e;const t=(n,s)=>{for(const d of n){if((d.text||"").toLowerCase()===s.toLowerCase())return d;if(Array.isArray(d.items)){const a=t(d.items,s);if(a)return a}}return null};for(const n of i)if(n.parent){const s=t(e,n.parent);s?(s.items=Array.isArray(s.items)?s.items:[],s.items.push(n.item)):e.push(n.item)}else e.push(n.item);return e}async function V(e){if(!B(e,"projects"))return[];let i=[];try{i=await c.projects.list()}catch{return[]}const t={};await Promise.all(i.map(async s=>{try{const d=await c.projects.artefacts(s.slug);t[s.slug]=d}catch{t[s.slug]={}}}));const n=[{key:"pages",text:"Pages",icon:"file-text",path:"/pages"},{key:"collections",text:"Collections",icon:"database",path:"/collections"},{key:"forms",text:"Forms",icon:"layout",path:"/forms"},{key:"actions",text:"Actions",icon:"zap",path:"/actions"},{key:"menus",text:"Menus",icon:"menu",path:"/menus"},{key:"blocks",text:"Blocks",icon:"box",path:"/blocks"},{key:"components",text:"Components",icon:"component",path:"/components"},{key:"views",text:"Views",icon:"eye",path:"/views"},{key:"roles",text:"Roles",icon:"shield",path:"/roles"},{key:"users",text:"Users",icon:"users",path:"/users"},{key:"apis",text:"APIs",icon:"code",path:"/apis"}];return i.map(s=>{const a="#/projects/"+encodeURIComponent(s.slug),p=t[s.slug]||{},u=[{text:"Overview",url:a,icon:s.icon||"folder"}];for(const l of n){const h=p[l.key],g=Array.isArray(h)?h.length:0;g<=0||u.push({text:`${l.text} (${g})`,url:a+l.path,icon:l.icon})}return u.push({text:"Settings",url:a+"/settings",icon:"settings"}),{text:s.name||s.slug,icon:s.icon||"folder",items:u}})}function Y(e,i){for(const t of e)if((t.text||"").toLowerCase()==="projects"){t.items=Array.isArray(t.items)?t.items.slice():[],t.items.push(...i);return}}function J(e,i){if(!e.url||!i)return"";const t=i[e.url];return t==null||t<=0?"":`<span class="sidebar-badge">${f(String(t))}</span>`}function Q(e){const i=(e.text||"").replace(/\s*\(\d+\)\s*$/,"").toLowerCase().replace(/\s+/g,"-"),t=(e.url||"").match(/^#\/projects\/([^/]+)/);return`sidebar.expanded.${t?`project-${decodeURIComponent(t[1])}.`:""}${i}`}function z(e,i){if(e&&e.type==="separator")return'<hr class="sidebar-divider">';const t=e.icon?`<span data-icon="${f(e.icon)}"></span>`:"",n=f(e.text||""),s=e.url?f(e.url):"",d=Array.isArray(e.items)&&e.items.length>0,a=e.colour?F(e.colour):"",p=a?` style="color:${f(a)}"`:"";let u="";if(e.badge&&e.badge.text!=null&&e.badge.text!==""){const l=e.badge.variant?F(e.badge.variant):"";u=`<span class="dm-menu-badge"${l?` style="background:${f(l)};color:#fff"`:""}>${f(String(e.badge.text))}</span>`}if(d){const l=Q(e),h=S.get(l)!==!1,g=e.items.map(y=>z(y,i)).join("");return`<details data-state-key="${f(l)}"${h?" open":""}>
2
- <summary${p}>${t} <span class="sidebar-text">${n}</span>${u}</summary>
3
- <div class="sidebar-children">${g}</div>
4
- </details>`}return`<a href="${s}" class="sidebar-link" data-url="${s}"${p}>${t} <span class="sidebar-text">${n}</span>${J(e,i)}${u}</a>`}async function Z(){const e=y=>y.then(b=>Array.isArray(b)?b.length:Array.isArray(b?.entries)?b.entries.length:0).catch(()=>0),[i,t,n,s,d,a,p,u,l,h,g]=await Promise.all([e(c.pages.list()),e(c.media.list()),e(c.collections.list()),e(c.forms.list()),e(c.views.list()),e(c.actions.list()),e(c.blocks.list()),e(c.components.list()),e(c.users.list()),e(c.plugins.list()),c.system?.notifications?.unreadCount?.().then(y=>y?.count??0).catch(()=>0)??Promise.resolve(0)]);return{"#/pages":i,"#/media":t,"#/collections":n,"#/forms":s,"#/views":d,"#/actions":a,"#/blocks":p,"#/components":u,"#/users":l,"#/plugins":h,"#/system/notifications":g}}async function ee(){try{const e=await c.settings.get();return e?.adminBrand?.title||e?.title||"Admin"}catch{return"Admin"}}export async function renderAdminSidebar({mount:e,permissions:i}){const t=$(e).get(0);if(!t)return;let n,s=null,d=null,a=null;try{const r=await c.menus.get("admin-sidebar");n=Array.isArray(r?.items)?r.items:null,s=r?.variant||null,d=r?.position||null,a=r?.style||null}catch{n=null}(!n||!n.length)&&(console.warn("[admin-sidebar] No admin-sidebar menu found; using fallback tree"),n=G.slice());let p=[];try{p=await H.get("/api/sidebar/registered-items")||[]}catch{}n=D(n,X);const{toolsFolder:u,pluginsFolder:l,parented:h}=W(p);n=O(n,h),n=_(n,[u,l]);const g=await V(i);g.length&&Y(n,g),n=I(n,i),n=K(n);const[y,b]=await Promise.all([Z().catch(()=>({})),ee()]),R=s?` dm-admin-sidebar--${f(s)}`:"";function w(r,m="px"){if(r==null)return r;const o=String(r).trim();return/^\d+(\.\d+)?$/.test(o)?`${o}${m}`:o}let C="";if(a){const r=[];a.fontFamily&&r.push(`font-family: ${a.fontFamily}, sans-serif`),a.fontSize&&r.push(`font-size: ${w(a.fontSize)}`),a.fontWeight&&r.push(`font-weight: ${a.fontWeight}`),a.letterSpacing&&r.push(`letter-spacing: ${w(a.letterSpacing,"em")}`);const m=[];if(r.length&&m.push(`#admin-sidebar .dm-admin-sidebar, #admin-sidebar .dm-admin-sidebar .sidebar-link, #admin-sidebar .dm-admin-sidebar summary, #admin-sidebar .dm-admin-sidebar .sidebar-text { ${r.join("; ")} }`),a.iconSize){const o=w(a.iconSize);m.push(`#admin-sidebar .dm-admin-sidebar [data-icon], #admin-sidebar .dm-admin-sidebar [data-icon] svg { width: ${o} !important; height: ${o} !important; }`)}m.length&&(C=`<style data-admin-sidebar-style>${m.join(" ")}</style>`)}const N=`<div class="dm-admin-sidebar-header"><span data-icon="layout"></span> ${f(b)}</div>`,U=`${C}<nav class="dm-admin-sidebar${R}">${N}${n.map(r=>z(r,y)).join("")}</nav>`,j=document.createRange();j.selectNodeContents(t);const q=j.createContextualFragment(U);t.replaceChildren(q),Domma.icons.scan(t);const x=Number.parseInt(S.get("sidebar.width"),10);Number.isFinite(x)&&x>=180&&x<=480&&(t.style.width=x+"px");const A=document.createElement("div");A.className="dm-admin-sidebar-handle",A.setAttribute("aria-label","Resize sidebar"),t.appendChild(A),t.querySelectorAll("details").forEach(r=>{r.addEventListener("toggle",function(){const m=this.getAttribute("data-state-key");m&&S.set(m,this.open);const o=this.querySelector(":scope > .sidebar-children");if(o)if(this.open){const k=o.scrollHeight;o.style.maxHeight="0",requestAnimationFrame(()=>{o.style.maxHeight=k+"px",o.addEventListener("transitionend",function T(){o.style.maxHeight="none",o.removeEventListener("transitionend",T)})})}else{const k=o.scrollHeight;o.style.maxHeight=k+"px",requestAnimationFrame(()=>{o.style.maxHeight="0"})}})});let v=!1,P=0,E=0;A.addEventListener("mousedown",r=>{v=!0,P=r.clientX,E=t.getBoundingClientRect().width,document.body.style.cursor="col-resize",document.body.style.userSelect="none",r.preventDefault()}),document.addEventListener("mousemove",r=>{if(!v)return;const m=Math.max(180,Math.min(480,E+(r.clientX-P)));t.style.width=m+"px"}),document.addEventListener("mouseup",()=>{v&&(v=!1,document.body.style.cursor="",document.body.style.userSelect="",S.set("sidebar.width",parseInt(t.style.width,10)))});function L(){const r=location.hash||"#/";$(t).find(".sidebar-link").removeClass("active"),$(t).find(`.sidebar-link[data-url="${r}"]`).addClass("active")}L(),M.subscribe("router:afterChange",L)}
1
+ import{api as c}from"../api.js";import{colourToCss as F}from"/public/js/menu-decor.mjs";import{groupPluginItems as W,stripItemByUrl as D,insertFoldersBeforeSystem as _,pruneEmptySynthesisedFolders as K,MANAGE_PLUGINS_URL as X}from"./sidebar-grouping.js";function h(e){return String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}const G=[{text:"Dashboard",url:"#/",icon:"home"},{text:"Menus",url:"#/menus",icon:"menu"}];function B(e,i){return i?!e||!e.length?!1:e.includes(i)?!0:["read","create","update","delete"].some(t=>e.includes(`${i}.${t}`)):!0}function I(e,i){const t=[];for(const n of e){if(n.hidden||n.permission&&!B(i,n.permission))continue;const s=Array.isArray(n.items)&&n.items.length?I(n.items,i):[];t.push({...n,items:s})}return t}function O(e,i){if(!i||!i.length)return e;const t=(n,s)=>{for(const l of n){if((l.text||"").toLowerCase()===s.toLowerCase())return l;if(Array.isArray(l.items)){const r=t(l.items,s);if(r)return r}}return null};for(const n of i)if(n.parent){const s=t(e,n.parent);s?(s.items=Array.isArray(s.items)?s.items:[],s.items.push(n.item)):e.push(n.item)}else e.push(n.item);return e}async function V(e){if(!B(e,"projects"))return[];let i=[];try{i=await c.projects.list()}catch{return[]}const t={};await Promise.all(i.map(async s=>{try{const l=await c.projects.artefacts(s.slug);t[s.slug]=l}catch{t[s.slug]={}}}));const n=[{key:"pages",text:"Pages",icon:"file-text",path:"/pages"},{key:"collections",text:"Collections",icon:"database",path:"/collections"},{key:"forms",text:"Forms",icon:"layout",path:"/forms"},{key:"actions",text:"Actions",icon:"zap",path:"/actions"},{key:"menus",text:"Menus",icon:"menu",path:"/menus"},{key:"blocks",text:"Blocks",icon:"box",path:"/blocks"},{key:"components",text:"Components",icon:"component",path:"/components"},{key:"views",text:"Views",icon:"eye",path:"/views"},{key:"roles",text:"Roles",icon:"shield",path:"/roles"},{key:"users",text:"Users",icon:"users",path:"/users"},{key:"apis",text:"APIs",icon:"code",path:"/apis"}];return i.map(s=>{const r="#/projects/"+encodeURIComponent(s.slug),g=t[s.slug]||{},d=[{text:"Overview",url:r,icon:s.icon||"folder"}];for(const m of n){const y=g[m.key],p=Array.isArray(y)?y.length:0;p<=0||d.push({text:`${m.text} (${p})`,url:r+m.path,icon:m.icon})}d.push({text:"Settings",url:r+"/settings",icon:"settings"});const u=s.enabled===!1;return{text:s.name||s.slug,icon:s.icon||"folder",items:d,...u&&{colour:"#9ca3af",badge:{text:"off"}}}})}function Y(e,i){for(const t of e)if((t.text||"").toLowerCase()==="projects"){t.items=Array.isArray(t.items)?t.items.slice():[],t.items.push(...i);return}}function J(e,i){if(!e.url||!i)return"";const t=i[e.url];return t==null||t<=0?"":`<span class="sidebar-badge">${h(String(t))}</span>`}function Q(e){const i=(e.text||"").replace(/\s*\(\d+\)\s*$/,"").toLowerCase().replace(/\s+/g,"-"),t=(e.url||"").match(/^#\/projects\/([^/]+)/);return`sidebar.expanded.${t?`project-${decodeURIComponent(t[1])}.`:""}${i}`}function z(e,i){if(e&&e.type==="separator")return'<hr class="sidebar-divider">';const t=e.icon?`<span data-icon="${h(e.icon)}"></span>`:"",n=h(e.text||""),s=e.url?h(e.url):"",l=Array.isArray(e.items)&&e.items.length>0,r=e.colour?F(e.colour):"",g=r?` style="color:${h(r)}"`:"";let d="";if(e.badge&&e.badge.text!=null&&e.badge.text!==""){const u=e.badge.variant?F(e.badge.variant):"";d=`<span class="dm-menu-badge"${u?` style="background:${h(u)};color:#fff"`:""}>${h(String(e.badge.text))}</span>`}if(l){const u=Q(e),m=S.get(u)!==!1,y=e.items.map(p=>z(p,i)).join("");return`<details data-state-key="${h(u)}"${m?" open":""}>
2
+ <summary${g}>${t} <span class="sidebar-text">${n}</span>${d}</summary>
3
+ <div class="sidebar-children">${y}</div>
4
+ </details>`}return`<a href="${s}" class="sidebar-link" data-url="${s}"${g}>${t} <span class="sidebar-text">${n}</span>${J(e,i)}${d}</a>`}async function Z(){const e=p=>p.then(b=>Array.isArray(b)?b.length:Array.isArray(b?.entries)?b.entries.length:0).catch(()=>0),[i,t,n,s,l,r,g,d,u,m,y]=await Promise.all([e(c.pages.list()),e(c.media.list()),e(c.collections.list()),e(c.forms.list()),e(c.views.list()),e(c.actions.list()),e(c.blocks.list()),e(c.components.list()),e(c.users.list()),e(c.plugins.list()),c.system?.notifications?.unreadCount?.().then(p=>p?.count??0).catch(()=>0)??Promise.resolve(0)]);return{"#/pages":i,"#/media":t,"#/collections":n,"#/forms":s,"#/views":l,"#/actions":r,"#/blocks":g,"#/components":d,"#/users":u,"#/plugins":m,"#/system/notifications":y}}async function ee(){try{const e=await c.settings.get();return e?.adminBrand?.title||e?.title||"Admin"}catch{return"Admin"}}export async function renderAdminSidebar({mount:e,permissions:i}){const t=$(e).get(0);if(!t)return;let n,s=null,l=null,r=null;try{const a=await c.menus.get("admin-sidebar");n=Array.isArray(a?.items)?a.items:null,s=a?.variant||null,l=a?.position||null,r=a?.style||null}catch{n=null}(!n||!n.length)&&(console.warn("[admin-sidebar] No admin-sidebar menu found; using fallback tree"),n=G.slice());let g=[];try{g=await H.get("/api/sidebar/registered-items")||[]}catch{}n=D(n,X);const{toolsFolder:d,pluginsFolder:u,parented:m}=W(g);n=O(n,m),n=_(n,[d,u]);const y=await V(i);y.length&&Y(n,y),n=I(n,i),n=K(n);const[p,b]=await Promise.all([Z().catch(()=>({})),ee()]),R=s?` dm-admin-sidebar--${h(s)}`:"";function w(a,f="px"){if(a==null)return a;const o=String(a).trim();return/^\d+(\.\d+)?$/.test(o)?`${o}${f}`:o}let C="";if(r){const a=[];r.fontFamily&&a.push(`font-family: ${r.fontFamily}, sans-serif`),r.fontSize&&a.push(`font-size: ${w(r.fontSize)}`),r.fontWeight&&a.push(`font-weight: ${r.fontWeight}`),r.letterSpacing&&a.push(`letter-spacing: ${w(r.letterSpacing,"em")}`);const f=[];if(a.length&&f.push(`#admin-sidebar .dm-admin-sidebar, #admin-sidebar .dm-admin-sidebar .sidebar-link, #admin-sidebar .dm-admin-sidebar summary, #admin-sidebar .dm-admin-sidebar .sidebar-text { ${a.join("; ")} }`),r.iconSize){const o=w(r.iconSize);f.push(`#admin-sidebar .dm-admin-sidebar [data-icon], #admin-sidebar .dm-admin-sidebar [data-icon] svg { width: ${o} !important; height: ${o} !important; }`)}f.length&&(C=`<style data-admin-sidebar-style>${f.join(" ")}</style>`)}const N=`<div class="dm-admin-sidebar-header"><span data-icon="layout"></span> ${h(b)}</div>`,U=`${C}<nav class="dm-admin-sidebar${R}">${N}${n.map(a=>z(a,p)).join("")}</nav>`,j=document.createRange();j.selectNodeContents(t);const q=j.createContextualFragment(U);t.replaceChildren(q),Domma.icons.scan(t);const x=Number.parseInt(S.get("sidebar.width"),10);Number.isFinite(x)&&x>=180&&x<=480&&(t.style.width=x+"px");const A=document.createElement("div");A.className="dm-admin-sidebar-handle",A.setAttribute("aria-label","Resize sidebar"),t.appendChild(A),t.querySelectorAll("details").forEach(a=>{a.addEventListener("toggle",function(){const f=this.getAttribute("data-state-key");f&&S.set(f,this.open);const o=this.querySelector(":scope > .sidebar-children");if(o)if(this.open){const k=o.scrollHeight;o.style.maxHeight="0",requestAnimationFrame(()=>{o.style.maxHeight=k+"px",o.addEventListener("transitionend",function T(){o.style.maxHeight="none",o.removeEventListener("transitionend",T)})})}else{const k=o.scrollHeight;o.style.maxHeight=k+"px",requestAnimationFrame(()=>{o.style.maxHeight="0"})}})});let v=!1,P=0,E=0;A.addEventListener("mousedown",a=>{v=!0,P=a.clientX,E=t.getBoundingClientRect().width,document.body.style.cursor="col-resize",document.body.style.userSelect="none",a.preventDefault()}),document.addEventListener("mousemove",a=>{if(!v)return;const f=Math.max(180,Math.min(480,E+(a.clientX-P)));t.style.width=f+"px"}),document.addEventListener("mouseup",()=>{v&&(v=!1,document.body.style.cursor="",document.body.style.userSelect="",S.set("sidebar.width",parseInt(t.style.width,10)))});function L(){const a=location.hash||"#/";$(t).find(".sidebar-link").removeClass("active"),$(t).find(`.sidebar-link[data-url="${a}"]`).addClass("active")}L(),M.subscribe("router:afterChange",L)}
@@ -40,6 +40,13 @@
40
40
  <input type="number" id="ps-sortOrder" name="sortOrder" class="form-input" value="0">
41
41
  <small class="form-hint">Lower = higher in the sidebar</small>
42
42
  </div>
43
+ <div class="form-group col-span-2" id="ps-enabled-group">
44
+ <label class="dm-checkbox-label" style="display:inline-flex;align-items:center;gap:.5rem;">
45
+ <input type="checkbox" id="ps-enabled" name="enabled" checked>
46
+ <span>Enabled</span>
47
+ </label>
48
+ <small class="form-hint">Off = hard kill-switch: public pages 404 and the external API stops responding. Admin editing of its artefacts is unaffected.</small>
49
+ </div>
43
50
  </div>
44
51
  </div>
45
52
  </div>
@@ -1 +1 @@
1
- import{api as c}from"../api.js";import{makeIconInput as g}from"../lib/shortcode-modal.js";export const projectSettingsView={templateUrl:"/admin/js/templates/project-settings.html",async onMount(e){const i=location.hash.split("?")[0].match(/^#\/projects\/([^/]+)\/settings$/),o=i?decodeURIComponent(i[1]):null;if(!o){location.hash="#/projects";return}let s;try{s=await c.projects.get(o)}catch(t){E.toast(`Failed to load project: ${t.message||t}`,{type:"error"}),location.hash="#/projects";return}const m=encodeURIComponent(o);e.find("#ps-title").text(`Settings \u2014 ${s.name||o}`),e.find("#ps-back").attr("href","#/projects/"+m);const f=e.find("#ps-slug"),l=e.find("#ps-name"),p=e.find("#ps-description"),a=e.find("#ps-rootUrl"),d=e.find("#ps-sortOrder");if(f.val(s.slug||""),l.val(s.name||""),p.val(s.description||""),a.val(s.rootUrl||""),d.val(s.sortOrder??0),s.protected){a.attr("disabled","disabled");const t=a.get(0)?.nextElementSibling;t?.classList.contains("form-hint")&&(t.textContent="Locked \u2014 built-in project"),e.find("#ps-danger-zone").css("display","none")}const u=e.find("#ps-icon-mount").get(0),n=g("e.g. folder, users, box",s.icon||"folder");n.input.id="ps-icon",n.input.classList.add("form-input"),u.appendChild(n.el),Domma.icons.scan(e.get(0)),e.find("#ps-save").on("click",async()=>{const t={name:l.val().trim(),description:p.val().trim(),icon:n.input.value.trim()||"folder",sortOrder:Number.parseInt(d.val(),10)||0};if(!s.protected){const r=a.val().trim();r&&(t.rootUrl=r)}try{await c.projects.update(o,t),E.toast("Saved.",{type:"success"})}catch(r){E.toast(`Save failed: ${r.message||r}`,{type:"error"})}}),e.find("#ps-untag-all").on("click",async()=>{if(await E.confirm(`Untag all artefacts from project "${o}"? They will become site-wide.`))try{const t=await c.projects.untagAll(o);E.toast(`Untagged: ${JSON.stringify(t.untagged)}`,{type:"success"}),setTimeout(()=>location.reload(),600)}catch(t){E.toast(`Untag failed: ${t.message||t}`,{type:"error"})}}),e.find("#ps-delete").on("click",async()=>{if(await E.confirm(`Delete project "${o}"? The server refuses if any artefacts are still tagged.`))try{await c.projects.remove(o),E.toast("Project deleted.",{type:"success"}),location.hash="#/projects"}catch(t){E.toast(`Delete refused: ${t.message||t}`,{type:"error"})}})}};
1
+ import{api as c}from"../api.js";import{makeIconInput as h}from"../lib/shortcode-modal.js";export const projectSettingsView={templateUrl:"/admin/js/templates/project-settings.html",async onMount(e){const i=location.hash.split("?")[0].match(/^#\/projects\/([^/]+)\/settings$/),o=i?decodeURIComponent(i[1]):null;if(!o){location.hash="#/projects";return}let s;try{s=await c.projects.get(o)}catch(t){E.toast(`Failed to load project: ${t.message||t}`,{type:"error"}),location.hash="#/projects";return}const m=encodeURIComponent(o);e.find("#ps-title").text(`Settings \u2014 ${s.name||o}`),e.find("#ps-back").attr("href","#/projects/"+m);const u=e.find("#ps-slug"),l=e.find("#ps-name"),p=e.find("#ps-description"),a=e.find("#ps-rootUrl"),d=e.find("#ps-sortOrder"),f=e.find("#ps-enabled");if(u.val(s.slug||""),l.val(s.name||""),p.val(s.description||""),a.val(s.rootUrl||""),d.val(s.sortOrder??0),f.prop("checked",s.enabled!==!1),s.protected){a.attr("disabled","disabled");const t=a.get(0)?.nextElementSibling;t?.classList.contains("form-hint")&&(t.textContent="Locked \u2014 built-in project"),e.find("#ps-danger-zone").css("display","none"),e.find("#ps-enabled-group").css("display","none")}const g=e.find("#ps-icon-mount").get(0),n=h("e.g. folder, users, box",s.icon||"folder");n.input.id="ps-icon",n.input.classList.add("form-input"),g.appendChild(n.el),Domma.icons.scan(e.get(0)),e.find("#ps-save").on("click",async()=>{const t={name:l.val().trim(),description:p.val().trim(),icon:n.input.value.trim()||"folder",sortOrder:Number.parseInt(d.val(),10)||0};if(!s.protected){const r=a.val().trim();r&&(t.rootUrl=r),t.enabled=f.prop("checked")}try{await c.projects.update(o,t),E.toast("Saved.",{type:"success"})}catch(r){E.toast(`Save failed: ${r.message||r}`,{type:"error"})}}),e.find("#ps-untag-all").on("click",async()=>{if(await E.confirm(`Untag all artefacts from project "${o}"? They will become site-wide.`))try{const t=await c.projects.untagAll(o);E.toast(`Untagged: ${JSON.stringify(t.untagged)}`,{type:"success"}),setTimeout(()=>location.reload(),600)}catch(t){E.toast(`Untag failed: ${t.message||t}`,{type:"error"})}}),e.find("#ps-delete").on("click",async()=>{if(await E.confirm(`Delete project "${o}"? The server refuses if any artefacts are still tagged.`))try{await c.projects.remove(o),E.toast("Project deleted.",{type:"success"}),location.hash="#/projects"}catch(t){E.toast(`Delete refused: ${t.message||t}`,{type:"error"})}})}};
@@ -1,7 +1,3 @@
1
- import{api as s}from"../api.js";function o(a){return String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const projectsView={templateUrl:"/admin/js/templates/projects.html",async onMount(a){const n=await s.projects.list().catch(()=>[]),c=await Promise.all(n.map(t=>s.projects.artefacts(t.slug).then(e=>Object.values(e).reduce((l,p)=>l+p.length,0)).catch(()=>0))),r=n.map((t,e)=>({...t,artefactCount:c[e]}));T.create("#projects-table",{data:r,emptyMessage:'No projects yet. Click "New project" to create one.',columns:[{key:"slug",title:"Slug",sortable:!0},{key:"name",title:"Name",sortable:!0,render:(t,e)=>o(t)+(e.protected?' <span class="badge badge-secondary" data-tooltip="Built-in project \u2014 cannot be deleted">System</span>':"")},{key:"icon",title:"Icon",render:t=>t?`<span data-icon="${o(t)}"></span>`:""},{key:"rootUrl",title:"Root URL",render:t=>t?`<code>${o(t)}</code>`:'<span class="text-muted">\u2014</span>'},{key:"artefactCount",title:"Artefacts",render:t=>`<span class="badge badge-info">${t??0}</span>`},{key:"_actions",title:"Actions",render:(t,e)=>`
2
- <span style="display:inline-flex;gap:0.25rem;">
3
- <a class="btn btn-sm btn-ghost" href="#/projects/${encodeURIComponent(e.slug)}" data-tooltip="Overview"><span data-icon="eye"></span></a>
4
- <a class="btn btn-sm btn-ghost" href="#/projects/${encodeURIComponent(e.slug)}/settings" data-tooltip="Settings"><span data-icon="settings"></span></a>
5
- ${e.protected?"":`<button class="btn btn-sm btn-danger btn-delete-project" data-slug="${o(e.slug)}" data-tooltip="Delete (refused while tagged)"><span data-icon="trash"></span></button>`}
6
- </span>
7
- `}]}),Domma.icons.scan(a.get(0)),document.querySelectorAll("#projects-table [data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})}),a.off("click",".btn-delete-project").on("click",".btn-delete-project",async function(){const t=$(this).data("slug");if(await E.confirm(`Delete project "${t}"? This cannot be undone.`))try{await s.projects.remove(t),E.toast("Project deleted.",{type:"success"}),location.reload()}catch(e){E.toast(`Delete refused: ${e.message||e}`,{type:"error"})}})}};
1
+ import{api as l}from"../api.js";function s(n){return String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const projectsView={templateUrl:"/admin/js/templates/projects.html",async onMount(n){const r=await l.projects.list().catch(()=>[]),p=await Promise.all(r.map(t=>l.projects.artefacts(t.slug).then(e=>Object.values(e).reduce((a,c)=>a+c.length,0)).catch(()=>0))),d=r.map((t,e)=>({...t,artefactCount:p[e]})),o=t=>t.enabled===!1;T.create("#projects-table",{data:d,emptyMessage:'No projects yet. Click "New project" to create one.',columns:[{key:"slug",title:"Slug",sortable:!0,render:(t,e)=>o(e)?`<span class="text-muted">${s(t)}</span>`:s(t)},{key:"name",title:"Name",sortable:!0,render:(t,e)=>{const a=o(e)?`<span class="text-muted">${s(t)}</span>`:s(t),c=e.protected?' <span class="badge badge-secondary" data-tooltip="Built-in project \u2014 cannot be deleted">System</span>':"",i=o(e)?' <span class="badge badge-warning" data-tooltip="Disabled \u2014 public pages and external API are off">Disabled</span>':"";return a+c+i}},{key:"icon",title:"Icon",render:(t,e)=>t?`<span class="${o(e)?"text-muted":""}" data-icon="${s(t)}"></span>`:""},{key:"rootUrl",title:"Root URL",render:t=>t?`<code>${s(t)}</code>`:'<span class="text-muted">\u2014</span>'},{key:"artefactCount",title:"Artefacts",render:t=>`<span class="badge badge-info">${t??0}</span>`},{key:"_actions",title:"Actions",render:(t,e)=>{const a=encodeURIComponent(e.slug),c=o(e)?`<span class="btn btn-sm btn-ghost disabled" aria-disabled="true" style="opacity:.4;pointer-events:none;"><span data-icon="eye"></span></span>
2
+ <span class="btn btn-sm btn-ghost disabled" aria-disabled="true" style="opacity:.4;pointer-events:none;"><span data-icon="settings"></span></span>`:`<a class="btn btn-sm btn-ghost" href="#/projects/${a}" data-tooltip="Overview"><span data-icon="eye"></span></a>
3
+ <a class="btn btn-sm btn-ghost" href="#/projects/${a}/settings" data-tooltip="Settings"><span data-icon="settings"></span></a>`,b=e.slug==="core"?"":o(e)?`<button class="btn btn-sm btn-ghost btn-toggle-project" data-slug="${s(e.slug)}" data-enable="1" data-tooltip="Enable"><span class="text-success" data-icon="play"></span></button>`:`<button class="btn btn-sm btn-ghost btn-toggle-project" data-slug="${s(e.slug)}" data-enable="0" data-tooltip="Disable"><span class="text-warning" data-icon="pause"></span></button>`,g=e.protected?"":`<button class="btn btn-sm btn-danger btn-delete-project" data-slug="${s(e.slug)}" data-tooltip="Delete (refused while tagged)"><span data-icon="trash"></span></button>`;return`<span style="display:inline-flex;gap:0.25rem;">${c}${b}${g}</span>`}}]}),Domma.icons.scan(n.get(0)),document.querySelectorAll("#projects-table [data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})}),n.off("click",".btn-toggle-project").on("click",".btn-toggle-project",async function(){const t=$(this).data("slug"),e=String($(this).data("enable"))==="1";if(!(!e&&!await E.confirm(`Disable project "${t}"? Its public pages will 404 and its external API will stop responding. Admin editing is unaffected.`)))try{await l.projects.update(t,{enabled:e}),E.toast(e?"Project enabled.":"Project disabled.",{type:"success"}),location.reload()}catch(a){E.toast(`Failed: ${a.message||a}`,{type:"error"})}}),n.off("click",".btn-delete-project").on("click",".btn-delete-project",async function(){const t=$(this).data("slug");if(await E.confirm(`Delete project "${t}"? This cannot be undone.`))try{await l.projects.remove(t),E.toast("Project deleted.",{type:"success"}),location.reload()}catch(e){E.toast(`Delete refused: ${e.message||e}`,{type:"error"})}})}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.25.16",
3
+ "version": "0.31.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",
package/public/js/site.js CHANGED
@@ -1 +1 @@
1
- import{makeBadgeEl as _,applyColour as A,applyPill as I}from"/public/js/menu-decor.mjs";$(()=>{const C=window.__CMS_NAV__||{},g=window.__CMS_SITE__||{};if(g.autoTheme?.enabled){let o=function(e){const f=(e||"07:00").split(":");return+f[0]*60+(+f[1]||0)},a=function(){const e=new Date,f=e.getHours()*60+e.getMinutes();return f>=o(t.dayStart)&&f<o(t.nightStart)?t.dayTheme:t.nightTheme};const t=g.autoTheme;Domma.theme.set(a()),setInterval(()=>Domma.theme.set(a()),6e4)}const b=$("#site-navbar");if(b.length&&C.brand){let a=function(i,n=0){return Array.isArray(i)?i.filter(c=>!(c&&c.type==="separator"&&n===0)).map(c=>c&&c.type==="separator"?{divider:!0}:Array.isArray(c?.items)?{...c,items:a(c.items,n+1)}:c):i},e=function(i){return Array.isArray(i)?i.filter(n=>!(n&&n.type==="separator")):[]};const t={...C.brand},o=t.size&&t.size!=="md"?` navbar-brand-${t.size}`:"";if(t.logo||t.icon||t.tagline){let i="";t.logo?i+=`<img src="${t.logo}" class="navbar-brand-logo" alt="${t.text||""}">`:t.icon&&(i+=`<span data-icon="${t.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),t.text&&(i+=`<span class="navbar-brand-text${o}">${t.text}</span>`),t.tagline&&(i+=`<small class="navbar-brand-tagline">${t.tagline}</small>`),t.html=i}else o&&t.text&&(t.html=`<span class="navbar-brand-text${o}">${t.text}</span>`);Domma.elements.navbar("#site-navbar",{brand:t,items:a(C.items||[]),variant:C.variant||"dark",position:C.position||"sticky",collapsible:!0}),Domma.icons.scan("#site-navbar");const f=b.get(0);if(C.appearOnHover){const i=window.matchMedia("(min-width: 993px)");f.querySelectorAll(".navbar-dropdown").forEach(n=>{n.addEventListener("mouseenter",()=>{i.matches&&n.classList.add("open")}),n.addEventListener("mouseleave",()=>{i.matches&&n.classList.remove("open")})})}const d=f.querySelector(".navbar-toggle"),l=f.querySelector(".navbar-collapse");if(d&&l){let i=null;const n=()=>{l.classList.contains("show")&&d.click()},c=()=>{i&&(i.remove(),i=null),document.body.classList.remove("dm-drawer-open")},s=()=>{i||(i=document.createElement("div"),i.className="dm-navbar-backdrop",i.addEventListener("click",n),document.body.appendChild(i),document.body.classList.add("dm-drawer-open"))};d.addEventListener("click",()=>{requestAnimationFrame(()=>{l.classList.contains("show")?s():c()})}),l.addEventListener("click",u=>{u.target.closest("a")&&requestAnimationFrame(c)}),document.addEventListener("keydown",u=>{u.key==="Escape"&&l.classList.contains("show")&&n()})}const y=e(C.items||[]);f.querySelectorAll(".navbar-link[data-index], .navbar-dropdown-item[data-index]").forEach(i=>{const n=y[parseInt(i.dataset.index,10)];if(!n)return;const c=i.dataset.subindex!=null,s=c&&Array.isArray(n.items)?n.items[parseInt(i.dataset.subindex,10)]:n;if(!s)return;s.colour&&A(i,s.colour),s.pill&&!c&&I(i,s.pill);const u=_(s.badge);u&&i.appendChild(u)})}const v=$("#site-footer");if(v.length){let f=function(r){return String(r??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")},d=function(r){return f(r).replace(/'/g,"&#39;")},l=function(r){if(r&&r.type==="separator")return'<li class="footer-sep"><hr></li>';const m=d(r.url||"#"),h=f(r.text||""),k=Array.isArray(r.items)&&r.items.length?`<ul>${r.items.map(l).join("")}</ul>`:"";return`<li><a href="${m}">${h}</a>${k}</li>`},y=function(r,m){return(r||[]).forEach(h=>{h&&h.type==="separator"||(m.push(h),Array.isArray(h.items)&&h.items.length&&y(h.items,m))}),m};const t=g.social||{},o={twitter:{label:"X / Twitter",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.742l7.73-8.835L1.254 2.25H8.08l4.259 5.629L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>'},facebook:{label:"Facebook",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'},instagram:{label:"Instagram",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>'},linkedin:{label:"LinkedIn",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'},github:{label:"GitHub",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>'},youtube:{label:"YouTube",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.495 6.205a3.007 3.007 0 00-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 00.527 6.205a31.247 31.247 0 00-.522 5.805 31.247 31.247 0 00.522 5.783 3.007 3.007 0 002.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 002.088-2.088 31.247 31.247 0 00.5-5.783 31.247 31.247 0 00-.5-5.805zM9.609 15.601V8.408l6.264 3.602z"/></svg>'}};let a='<div class="footer-inner container">';const e=window.__CMS_FOOTER__||{primary:[],legal:[],copyright:""};if(e.copyright&&(a+=`<p>${f(e.copyright)}</p>`),e.primary.length&&(a+=`<nav class="footer-nav footer-nav--primary"><ul>${e.primary.map(l).join("")}</ul></nav>`),e.legal.length&&(a+=`<nav class="footer-nav footer-nav--legal"><ul>${e.legal.map(l).join("")}</ul></nav>`),g.footer){const r=Object.keys(o).filter(m=>t[m]);r.length&&(a+='<div class="footer-social">',r.forEach(m=>{const{label:h,svg:k}=o[m];a+=`<a href="${t[m]}" target="_blank" rel="noopener noreferrer" aria-label="${h}" class="footer-social-link">${k}</a>`}),a+="</div>")}a+="</div>",v.html(a);const i=[...y(e.primary,[]),...y(e.legal,[])];v.get(0).querySelectorAll(".footer-nav a").forEach((r,m)=>{const h=i[m];if(!h)return;h.colour&&A(r,h.colour);const k=_(h.badge);k&&r.appendChild(k)});const c=S.get("reduced_motion"),s=c!==null?!!c:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),u=v.get(0).querySelector(".footer-inner");if(u){const r=document.createElement("input");r.type="checkbox",r.className="form-switch-input",r.id="dm-motion-switch",r.checked=s,r.addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()});const m=document.createElement("span");m.className="form-switch-label",m.textContent="Reduce motion";const h=document.createElement("label");h.className="form-switch footer-motion-switch",h.title="Reduce motion",h.appendChild(r),h.appendChild(m),u.appendChild(h)}}$("#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(){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 o=parseInt(this.dataset.animationDuration,10);Number.isFinite(o)&&(t.animationDuration=o)}this.dataset.animationEasing&&(t.animationEasing=this.dataset.animationEasing),Domma.elements.carousel(this,t)}),p.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)}),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(t=>{const o=t.dataset.revealMode||"stagger",a=t.dataset.revealAnimation||"slide-up",e=parseInt(t.dataset.revealDuration,10)||400,f=parseInt(t.dataset.revealStagger,10)||60,d=parseInt(t.dataset.revealDelay,10)||0,l=t.dataset.revealDirection==="rtl",y=Array.from(t.children),i={"slide-up":"translateY(30px)","slide-down":"translateY(-30px)","slide-left":"translateX(30px)","slide-right":"translateX(-30px)",zoom:"scale(0.85)",flip:"perspective(600px) rotateX(15deg)"};y.forEach((n,c)=>{n.style.opacity="0",n.style.transform=i[a]||"",n.style.transition=`opacity ${e}ms ease, transform ${e}ms ease`;const s=l?y.length-1-c:c;n.style.transitionDelay=o==="stagger"?`${d+s*f}ms`:`${d}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const n=new IntersectionObserver(c=>{c.forEach(s=>{s.isIntersecting&&(s.target.offsetWidth,s.target.style.opacity="1",s.target.style.transform="none",n.unobserve(s.target))})},{threshold:.1});y.forEach(c=>n.observe(c))}))}),p.find(".card[data-collapsible]").each(function(){const t=this.querySelector(".card-header");t&&t.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),p.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const t=this.dataset.soTarget,o=document.getElementById(t);if(!o)return;const a=E.slideover({title:o.dataset.soTitle||"",size:o.dataset.soSize||"md",position:o.dataset.soPosition||"right"});o.style.display="",a.setContent(o),a.open()})})}if(typeof $.setup=="function"){const t=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(o=>{try{const a=atob(o.dataset.config),e=JSON.parse(a);Object.assign(t,e)}catch{}}),Object.keys(t).length>0){const o={};for(const[a,e]of Object.entries(t)){const f=e?.events?.click,{confirm:d,toast:l,alert:y,prompt:i,...n}=f||{};d||l||y||i?($(a).on("click",async function(s){if(s.preventDefault(),d&&!await E.confirm(d))return;let u=null;if(!(i&&(u=await E.prompt(i,{inputPlaceholder:n.promptPlaceholder||"",inputValue:n.promptDefault||""}),u===null))){if(n.target){const r=$(n.target);n.toggleClass&&r.toggleClass(n.toggleClass),n.addClass&&r.addClass(n.addClass),n.removeClass&&r.removeClass(n.removeClass),u!==null&&(n.setText&&r.text(u),n.setVal&&r.val(u),n.setAttr&&r.attr(n.setAttr,u))}n.href&&(window.location.href=n.href),l&&E.toast(l,{type:n.toastType||"success"}),y&&E.alert(y)}}),Object.keys(n).length&&(o[a]={...e,events:{...e.events,click:n}})):o[a]=e}$.setup(o)}}p.length&&x(p.get(0))});function x(C){C.querySelectorAll(".dm-cta-trigger").forEach(g=>{g.__ctaWired||(g.__ctaWired=!0,g.addEventListener("click",async()=>{const b=g.dataset.action,v=g.dataset.entry,w=g.dataset.confirm;let p=S.get("auth_token");if(!p){E.toast("Please log in to perform this action.",{type:"warning"});return}if(w&&!await E.confirm(w))return;const t=Array.from(g.childNodes).map(a=>a.cloneNode(!0));g.disabled=!0,g.textContent="Running\u2026";const o=a=>fetch(`/api/actions/${b}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${a}`},body:JSON.stringify({entryId:v})});try{let a=await o(p);if(a.status===401){const f=S.get("auth_refresh_token");if(f){const d=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:f})});if(d.ok){const{token:l}=await d.json();S.set("auth_token",l),p=l,a=await o(p)}}}const e=await a.json().catch(()=>({}));if(!a.ok)throw new Error(e.error||e.message||`Error ${a.status}`);E.toast(e.message||"Action completed.",{type:"success"})}catch(a){E.toast(a.message||"Action failed.",{type:"error"})}finally{g.disabled=!1,g.textContent="",t.forEach(a=>g.appendChild(a)),Domma.icons.scan(g)}}))})}(function(){const g=document.querySelectorAll('.dm-collection-hydrate[data-collection-scope="mine"]');if(!g.length)return;const b=S.get("auth_token");function v(p,t){const o=document.createElement("div");o.className="dm-collection-display dm-collection-empty";const a=document.createElement("p");if(t){a.appendChild(document.createTextNode("Please "));const e=document.createElement("a");e.href="/login",e.textContent="sign in",a.appendChild(e),a.appendChild(document.createTextNode(" to view your entries."))}else a.textContent=p;return o.appendChild(a),o}function w(p,t){const o=document.createElement("template");o.innerHTML=t||"",p.replaceWith(o.content)}g.forEach(async p=>{const t=p.dataset.collectionAttrs;if(!t)return;if(!b){p.replaceChildren(v("",!0));return}const o=a=>fetch("/api/collections/render-scope",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${a}`},body:JSON.stringify({attrs:t})});try{let a=b,e=await o(a);if(e.status===401){const d=S.get("auth_refresh_token");if(d){const l=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:d})});if(l.ok){const{token:y}=await l.json();S.set("auth_token",y),a=y,e=await o(a)}}}if(!e.ok){const{error:d}=await e.json().catch(()=>({}));p.replaceChildren(v(d||"Unable to load entries.",!1));return}const{html:f}=await e.json();w(p,f)}catch{p.replaceChildren(v("Unable to load entries.",!1))}}),setTimeout(()=>{typeof Domma<"u"&&Domma.icons?.scan&&Domma.icons.scan(),x(document.body)},100)})(),(function(){const g=document.querySelectorAll("[data-collection-table]");!g.length||typeof T>"u"||!T.create||g.forEach(b=>{let v;try{v=JSON.parse(atob(b.dataset.payload))}catch{return}const{columns:w,rows:p,search:t,sortable:o,exportable:a,pageSize:e,empty:f,ctaConfig:d}=v;if(!w?.length)return;if(d){const i=w.findIndex(n=>n.key==="_cta");i!==-1&&(w[i]={key:"_cta",title:"",render:(n,c)=>{const s=document.createElement("button");if(s.className=`btn btn-${d.style||"primary"} dm-cta-trigger`,s.dataset.action=d.action||"",s.dataset.entry=c._entryId||"",d.confirm&&(s.dataset.confirm=d.confirm),d.icon){const u=document.createElement("span");u.dataset.icon=d.icon,s.appendChild(u),s.appendChild(document.createTextNode(" "))}return s.appendChild(document.createTextNode(d.label||"Run")),s}})}const l="col-table-"+Math.random().toString(36).slice(2,7),y=document.createElement("div");y.id=l,b.replaceChildren(y),T.create("#"+l,{data:p,columns:w,search:t,sortable:o,exportable:a,pageSize:e,emptyMessage:f}),d&&x(y)})})(),(function(){const g=document.querySelectorAll("[data-form-inline]");if(!g.length||typeof F>"u")return;function b(t,o){const a={};return(t||[]).forEach(e=>{if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const f=e.type==="checkbox"?"boolean":e.type==="date"?"string":e.type,d={...e.formConfig||{}};d.span==="full"&&o&&(d.span=o);const l={type:f,label:e.label,required:e.required,options:e.options,formConfig:{...e.placeholder&&{placeholder:e.placeholder},...e.helper&&{hint:e.helper},...d}};e.type==="chooser"&&(e.variant&&(l.variant=e.variant),e.multiple&&(l.multiple=!0),e.density&&(l.density=e.density),e.columns&&(l.columns=e.columns),e.accent&&(l.accent=e.accent),e.accentStyle&&(l.accentStyle=e.accentStyle),e.glow&&(l.glow=!0),e.glowColour&&(l.glowColour=e.glowColour),e.shadow&&(l.shadow=e.shadow),e.shadowColour&&(l.shadowColour=e.shadowColour)),a[e.name]=l}),a}function v(t){const o={};return(t||[]).forEach(a=>{if(!(!a.name||a.type==="page-break"||a.type==="spacer")&&(a.type==="select"||a.type==="multiselect")&&a.required){const e=(a.options||[])[0];e&&(o[a.name]=typeof e=="object"?e.value:e)}}),o}function w(t,o){(o||[]).forEach(a=>{if(a.type!=="date"||!a.name)return;const e=t.querySelector(`[name="${a.name}"]`);e&&e.type!=="date"&&(e.type="date")})}function p(t,o,a){let e=t.querySelector(".cms-form-message");e||(e=document.createElement("p"),e.className="cms-form-message",t.appendChild(e)),e.textContent=o,e.style.cssText=a?"color:var(--danger,#f87171);margin-top:.75rem;":"color:var(--success,#4ade80);margin-top:.75rem;"}g.forEach(t=>{let o;try{o=JSON.parse(atob(t.dataset.formInline))}catch{return}const a=o.fields||[],e=o.settings||{},f=e.columns||1,d=e.layout||"stacked",l=a.some(c=>c.type==="page-break"),y=async c=>{try{const s=t.querySelector('[name="website"]'),u=t.querySelector('[name="_t"]'),r=Object.assign({},c);s!==null&&(r._hp=s.value),u!==null&&(r._t=u.value);const m=await H.post(`/api/forms/submit/${o.slug}`,r);if(m&&m.error){p(t,m.error,!0);const h=new Error(m.error);throw h.formSubmitFailed=!0,h}if(m?.redirect){window.location.href=m.redirect;return}for(;t.firstChild;)t.removeChild(t.firstChild);p(t,m?.message||e.successMessage||"Thank you for your submission.",!1)}catch(s){throw s.formSubmitFailed||p(t,s.message||"Submission failed. Please try again.",!0),s}};function i(c){const s=c.querySelector("form");if(!s)return;const u=document.createElement("div");u.className="fb-form-honeypot",u.setAttribute("aria-hidden","true");const r=document.createElement("input");r.name="website",r.type="text",r.tabIndex=-1,r.autocomplete="url",r.placeholder="https://",u.appendChild(r);const m=document.createElement("input");m.name="_t",m.type="hidden",m.value=Date.now(),u.appendChild(m),s.appendChild(u)}function n(){if(!window.FormLogicEngine||!a.some(s=>s.logic))return;const c=new window.FormLogicEngine.FormLogicRuntime(o,t);if(c.init(),t._formLogicRuntime=c,t.parentNode&&typeof MutationObserver<"u"){const s=new MutationObserver(function(u){for(const r of u)for(const m of r.removedNodes)if(m===t||m.nodeType===1&&m.contains&&m.contains(t)){c.destroy(),s.disconnect();return}});s.observe(t.parentNode,{childList:!0,subtree:!1})}}if(l&&F.wizard){const c=[];let s=[],u=o.title||"Step 1",r="";a.forEach(h=>{h.type==="page-break"?(c.push({title:u,description:r,fields:b(s,f)}),s=[],u=h.label||`Step ${c.length+1}`,r=h.description||""):h.type!=="spacer"&&s.push(h)}),c.push({title:u,description:r,fields:b(s,f)});const m=F.wizard(t,{schema:{steps:c},onSubmit:y});Promise.resolve(m).then(function(){w(t,a),e.honeypot!==!1&&i(t),n()})}else if(F.render){const c=F.render(t,b(a,f),v(a),{submitText:e.submitText||"Submit",layout:d,columns:f,onSubmit:y});Promise.resolve(c).then(function(){if(d==="grid"&&e.submitSpan==="full"){const s=t.querySelector(".form-buttons");s&&s.classList.add("col-span-full")}w(t,a),e.honeypot!==!1&&i(t),n()})}}),$(document).on("click",".dm-banner__dismiss",function(){$(this).closest(".dm-banner").remove()})})();
1
+ import{makeBadgeEl as _,applyColour as A,applyPill as D}from"/public/js/menu-decor.mjs";$(()=>{const C=window.__CMS_NAV__||{},g=window.__CMS_SITE__||{};if(g.autoTheme?.enabled){let o=function(e){const f=(e||"07:00").split(":");return+f[0]*60+(+f[1]||0)},a=function(){const e=new Date,f=e.getHours()*60+e.getMinutes();return f>=o(t.dayStart)&&f<o(t.nightStart)?t.dayTheme:t.nightTheme};const t=g.autoTheme;Domma.theme.set(a()),setInterval(()=>Domma.theme.set(a()),6e4)}const b=$("#site-navbar");if(b.length&&C.brand){let a=function(i,n=0){return Array.isArray(i)?i.filter(c=>!(c&&c.type==="separator"&&n===0)).map(c=>c&&c.type==="separator"?{divider:!0}:Array.isArray(c?.items)?{...c,items:a(c.items,n+1)}:c):i},e=function(i){return Array.isArray(i)?i.filter(n=>!(n&&n.type==="separator")):[]};const t={...C.brand},o=t.size&&t.size!=="md"?` navbar-brand-${t.size}`:"";if(t.logo||t.icon||t.tagline){let i="";t.logo?i+=`<img src="${t.logo}" class="navbar-brand-logo" alt="${t.text||""}">`:t.icon&&(i+=`<span data-icon="${t.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),t.text&&(i+=`<span class="navbar-brand-text${o}">${t.text}</span>`),t.tagline&&(i+=`<small class="navbar-brand-tagline">${t.tagline}</small>`),t.html=i}else o&&t.text&&(t.html=`<span class="navbar-brand-text${o}">${t.text}</span>`);Domma.elements.navbar("#site-navbar",{brand:t,items:a(C.items||[]),variant:C.variant||"dark",position:C.position||"sticky",collapsible:!0}),Domma.icons.scan("#site-navbar");const f=b.get(0);if(C.appearOnHover){const i=window.matchMedia("(min-width: 993px)");f.querySelectorAll(".navbar-dropdown").forEach(n=>{n.addEventListener("mouseenter",()=>{i.matches&&n.classList.add("open")}),n.addEventListener("mouseleave",()=>{i.matches&&n.classList.remove("open")})})}const d=f.querySelector(".navbar-toggle"),l=f.querySelector(".navbar-collapse");if(d&&l){let i=null;const n=()=>{l.classList.contains("show")&&d.click()},c=()=>{i&&(i.remove(),i=null),document.body.classList.remove("dm-drawer-open")},s=()=>{i||(i=document.createElement("div"),i.className="dm-navbar-backdrop",i.addEventListener("click",n),document.body.appendChild(i),document.body.classList.add("dm-drawer-open"))};d.addEventListener("click",()=>{requestAnimationFrame(()=>{l.classList.contains("show")?s():c()})}),l.addEventListener("click",u=>{u.target.closest("a")&&requestAnimationFrame(c)}),document.addEventListener("keydown",u=>{u.key==="Escape"&&l.classList.contains("show")&&n()})}const y=e(C.items||[]);f.querySelectorAll(".navbar-link[data-index], .navbar-dropdown-item[data-index]").forEach(i=>{const n=y[parseInt(i.dataset.index,10)];if(!n)return;const c=i.dataset.subindex!=null,s=c&&Array.isArray(n.items)?n.items[parseInt(i.dataset.subindex,10)]:n;if(!s)return;s.colour&&A(i,s.colour),s.pill&&!c&&D(i,s.pill);const u=_(s.badge);u&&i.appendChild(u)})}const v=$("#site-footer");if(v.length){let f=function(r){return String(r??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")},d=function(r){return f(r).replace(/'/g,"&#39;")},l=function(r){if(r&&r.type==="separator")return'<li class="footer-sep"><hr></li>';const m=d(r.url||"#"),h=f(r.text||""),k=Array.isArray(r.items)&&r.items.length?`<ul>${r.items.map(l).join("")}</ul>`:"";return`<li><a href="${m}">${h}</a>${k}</li>`},y=function(r,m){return(r||[]).forEach(h=>{h&&h.type==="separator"||(m.push(h),Array.isArray(h.items)&&h.items.length&&y(h.items,m))}),m};const t=g.social||{},o={twitter:{label:"X / Twitter",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.742l7.73-8.835L1.254 2.25H8.08l4.259 5.629L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>'},facebook:{label:"Facebook",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'},instagram:{label:"Instagram",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>'},linkedin:{label:"LinkedIn",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'},github:{label:"GitHub",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>'},youtube:{label:"YouTube",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.495 6.205a3.007 3.007 0 00-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 00.527 6.205a31.247 31.247 0 00-.522 5.805 31.247 31.247 0 00.522 5.783 3.007 3.007 0 002.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 002.088-2.088 31.247 31.247 0 00.5-5.783 31.247 31.247 0 00-.5-5.805zM9.609 15.601V8.408l6.264 3.602z"/></svg>'}};let a='<div class="footer-inner container">';const e=window.__CMS_FOOTER__||{primary:[],legal:[],copyright:""};if(e.copyright&&(a+=`<p>${f(e.copyright)}</p>`),e.primary.length&&(a+=`<nav class="footer-nav footer-nav--primary"><ul>${e.primary.map(l).join("")}</ul></nav>`),e.legal.length&&(a+=`<nav class="footer-nav footer-nav--legal"><ul>${e.legal.map(l).join("")}</ul></nav>`),g.footer){const r=Object.keys(o).filter(m=>t[m]);r.length&&(a+='<div class="footer-social">',r.forEach(m=>{const{label:h,svg:k}=o[m];a+=`<a href="${t[m]}" target="_blank" rel="noopener noreferrer" aria-label="${h}" class="footer-social-link">${k}</a>`}),a+="</div>")}a+="</div>",v.html(a);const i=[...y(e.primary,[]),...y(e.legal,[])];v.get(0).querySelectorAll(".footer-nav a").forEach((r,m)=>{const h=i[m];if(!h)return;h.colour&&A(r,h.colour);const k=_(h.badge);k&&r.appendChild(k)});const c=S.get("reduced_motion"),s=c!==null?!!c:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),u=v.get(0).querySelector(".footer-inner");if(u){const r=document.createElement("input");r.type="checkbox",r.className="form-switch-input",r.id="dm-motion-switch",r.checked=s,r.addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()});const m=document.createElement("span");m.className="form-switch-label",m.textContent="Reduce motion";const h=document.createElement("label");h.className="form-switch footer-motion-switch",h.title="Reduce motion",h.appendChild(r),h.appendChild(m),u.appendChild(h)}}$("#site-sidebar").length&&Domma.elements.sidebar("#site-sidebar",{autoGenerate:!0,selector:"h2, h3",collapsible:!1,push:!0,contentSelector:".site-content"}),Domma.icons.scan(),Domma.syntax?.scan&&Domma.syntax.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(){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 o=parseInt(this.dataset.animationDuration,10);Number.isFinite(o)&&(t.animationDuration=o)}this.dataset.animationEasing&&(t.animationEasing=this.dataset.animationEasing),Domma.elements.carousel(this,t)}),p.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)}),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(t=>{const o=t.dataset.revealMode||"stagger",a=t.dataset.revealAnimation||"slide-up",e=parseInt(t.dataset.revealDuration,10)||400,f=parseInt(t.dataset.revealStagger,10)||60,d=parseInt(t.dataset.revealDelay,10)||0,l=t.dataset.revealDirection==="rtl",y=Array.from(t.children),i={"slide-up":"translateY(30px)","slide-down":"translateY(-30px)","slide-left":"translateX(30px)","slide-right":"translateX(-30px)",zoom:"scale(0.85)",flip:"perspective(600px) rotateX(15deg)"};y.forEach((n,c)=>{n.style.opacity="0",n.style.transform=i[a]||"",n.style.transition=`opacity ${e}ms ease, transform ${e}ms ease`;const s=l?y.length-1-c:c;n.style.transitionDelay=o==="stagger"?`${d+s*f}ms`:`${d}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const n=new IntersectionObserver(c=>{c.forEach(s=>{s.isIntersecting&&(s.target.offsetWidth,s.target.style.opacity="1",s.target.style.transform="none",n.unobserve(s.target))})},{threshold:.1});y.forEach(c=>n.observe(c))}))}),p.find(".card[data-collapsible]").each(function(){const t=this.querySelector(".card-header");t&&t.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),p.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const t=this.dataset.soTarget,o=document.getElementById(t);if(!o)return;const a=E.slideover({title:o.dataset.soTitle||"",size:o.dataset.soSize||"md",position:o.dataset.soPosition||"right"});o.style.display="",a.setContent(o),a.open()})})}if(typeof $.setup=="function"){const t=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(o=>{try{const a=atob(o.dataset.config),e=JSON.parse(a);Object.assign(t,e)}catch{}}),Object.keys(t).length>0){const o={};for(const[a,e]of Object.entries(t)){const f=e?.events?.click,{confirm:d,toast:l,alert:y,prompt:i,...n}=f||{};d||l||y||i?($(a).on("click",async function(s){if(s.preventDefault(),d&&!await E.confirm(d))return;let u=null;if(!(i&&(u=await E.prompt(i,{inputPlaceholder:n.promptPlaceholder||"",inputValue:n.promptDefault||""}),u===null))){if(n.target){const r=$(n.target);n.toggleClass&&r.toggleClass(n.toggleClass),n.addClass&&r.addClass(n.addClass),n.removeClass&&r.removeClass(n.removeClass),u!==null&&(n.setText&&r.text(u),n.setVal&&r.val(u),n.setAttr&&r.attr(n.setAttr,u))}n.href&&(window.location.href=n.href),l&&E.toast(l,{type:n.toastType||"success"}),y&&E.alert(y)}}),Object.keys(n).length&&(o[a]={...e,events:{...e.events,click:n}})):o[a]=e}$.setup(o)}}p.length&&x(p.get(0))});function x(C){C.querySelectorAll(".dm-cta-trigger").forEach(g=>{g.__ctaWired||(g.__ctaWired=!0,g.addEventListener("click",async()=>{const b=g.dataset.action,v=g.dataset.entry,w=g.dataset.confirm;let p=S.get("auth_token");if(!p){E.toast("Please log in to perform this action.",{type:"warning"});return}if(w&&!await E.confirm(w))return;const t=Array.from(g.childNodes).map(a=>a.cloneNode(!0));g.disabled=!0,g.textContent="Running\u2026";const o=a=>fetch(`/api/actions/${b}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${a}`},body:JSON.stringify({entryId:v})});try{let a=await o(p);if(a.status===401){const f=S.get("auth_refresh_token");if(f){const d=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:f})});if(d.ok){const{token:l}=await d.json();S.set("auth_token",l),p=l,a=await o(p)}}}const e=await a.json().catch(()=>({}));if(!a.ok)throw new Error(e.error||e.message||`Error ${a.status}`);E.toast(e.message||"Action completed.",{type:"success"})}catch(a){E.toast(a.message||"Action failed.",{type:"error"})}finally{g.disabled=!1,g.textContent="",t.forEach(a=>g.appendChild(a)),Domma.icons.scan(g)}}))})}(function(){const g=document.querySelectorAll('.dm-collection-hydrate[data-collection-scope="mine"]');if(!g.length)return;const b=S.get("auth_token");function v(p,t){const o=document.createElement("div");o.className="dm-collection-display dm-collection-empty";const a=document.createElement("p");if(t){a.appendChild(document.createTextNode("Please "));const e=document.createElement("a");e.href="/login",e.textContent="sign in",a.appendChild(e),a.appendChild(document.createTextNode(" to view your entries."))}else a.textContent=p;return o.appendChild(a),o}function w(p,t){const o=document.createElement("template");o.innerHTML=t||"",p.replaceWith(o.content)}g.forEach(async p=>{const t=p.dataset.collectionAttrs;if(!t)return;if(!b){p.replaceChildren(v("",!0));return}const o=a=>fetch("/api/collections/render-scope",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${a}`},body:JSON.stringify({attrs:t})});try{let a=b,e=await o(a);if(e.status===401){const d=S.get("auth_refresh_token");if(d){const l=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:d})});if(l.ok){const{token:y}=await l.json();S.set("auth_token",y),a=y,e=await o(a)}}}if(!e.ok){const{error:d}=await e.json().catch(()=>({}));p.replaceChildren(v(d||"Unable to load entries.",!1));return}const{html:f}=await e.json();w(p,f)}catch{p.replaceChildren(v("Unable to load entries.",!1))}}),setTimeout(()=>{typeof Domma<"u"&&Domma.icons?.scan&&Domma.icons.scan(),typeof Domma<"u"&&Domma.syntax?.scan&&Domma.syntax.scan(),x(document.body)},100)})(),(function(){const g=document.querySelectorAll("[data-collection-table]");!g.length||typeof T>"u"||!T.create||g.forEach(b=>{let v;try{v=JSON.parse(atob(b.dataset.payload))}catch{return}const{columns:w,rows:p,search:t,sortable:o,exportable:a,pageSize:e,empty:f,ctaConfig:d}=v;if(!w?.length)return;if(d){const i=w.findIndex(n=>n.key==="_cta");i!==-1&&(w[i]={key:"_cta",title:"",render:(n,c)=>{const s=document.createElement("button");if(s.className=`btn btn-${d.style||"primary"} dm-cta-trigger`,s.dataset.action=d.action||"",s.dataset.entry=c._entryId||"",d.confirm&&(s.dataset.confirm=d.confirm),d.icon){const u=document.createElement("span");u.dataset.icon=d.icon,s.appendChild(u),s.appendChild(document.createTextNode(" "))}return s.appendChild(document.createTextNode(d.label||"Run")),s}})}const l="col-table-"+Math.random().toString(36).slice(2,7),y=document.createElement("div");y.id=l,b.replaceChildren(y),T.create("#"+l,{data:p,columns:w,search:t,sortable:o,exportable:a,pageSize:e,emptyMessage:f}),d&&x(y)})})(),(function(){const g=document.querySelectorAll("[data-form-inline]");if(!g.length||typeof F>"u")return;function b(t,o){const a={};return(t||[]).forEach(e=>{if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const f=e.type==="checkbox"?"boolean":e.type==="date"?"string":e.type,d={...e.formConfig||{}};d.span==="full"&&o&&(d.span=o);const l={type:f,label:e.label,required:e.required,options:e.options,formConfig:{...e.placeholder&&{placeholder:e.placeholder},...e.helper&&{hint:e.helper},...d}};e.type==="chooser"&&(e.variant&&(l.variant=e.variant),e.multiple&&(l.multiple=!0),e.density&&(l.density=e.density),e.columns&&(l.columns=e.columns),e.accent&&(l.accent=e.accent),e.accentStyle&&(l.accentStyle=e.accentStyle),e.glow&&(l.glow=!0),e.glowColour&&(l.glowColour=e.glowColour),e.shadow&&(l.shadow=e.shadow),e.shadowColour&&(l.shadowColour=e.shadowColour)),a[e.name]=l}),a}function v(t){const o={};return(t||[]).forEach(a=>{if(!(!a.name||a.type==="page-break"||a.type==="spacer")&&(a.type==="select"||a.type==="multiselect")&&a.required){const e=(a.options||[])[0];e&&(o[a.name]=typeof e=="object"?e.value:e)}}),o}function w(t,o){(o||[]).forEach(a=>{if(a.type!=="date"||!a.name)return;const e=t.querySelector(`[name="${a.name}"]`);e&&e.type!=="date"&&(e.type="date")})}function p(t,o,a){let e=t.querySelector(".cms-form-message");e||(e=document.createElement("p"),e.className="cms-form-message",t.appendChild(e)),e.textContent=o,e.style.cssText=a?"color:var(--danger,#f87171);margin-top:.75rem;":"color:var(--success,#4ade80);margin-top:.75rem;"}g.forEach(t=>{let o;try{o=JSON.parse(atob(t.dataset.formInline))}catch{return}const a=o.fields||[],e=o.settings||{},f=e.columns||1,d=e.layout||"stacked",l=a.some(c=>c.type==="page-break"),y=async c=>{try{const s=t.querySelector('[name="website"]'),u=t.querySelector('[name="_t"]'),r=Object.assign({},c);s!==null&&(r._hp=s.value),u!==null&&(r._t=u.value);const m=await H.post(`/api/forms/submit/${o.slug}`,r);if(m&&m.error){p(t,m.error,!0);const h=new Error(m.error);throw h.formSubmitFailed=!0,h}if(m?.redirect){window.location.href=m.redirect;return}for(;t.firstChild;)t.removeChild(t.firstChild);p(t,m?.message||e.successMessage||"Thank you for your submission.",!1)}catch(s){throw s.formSubmitFailed||p(t,s.message||"Submission failed. Please try again.",!0),s}};function i(c){const s=c.querySelector("form");if(!s)return;const u=document.createElement("div");u.className="fb-form-honeypot",u.setAttribute("aria-hidden","true");const r=document.createElement("input");r.name="website",r.type="text",r.tabIndex=-1,r.autocomplete="url",r.placeholder="https://",u.appendChild(r);const m=document.createElement("input");m.name="_t",m.type="hidden",m.value=Date.now(),u.appendChild(m),s.appendChild(u)}function n(){if(!window.FormLogicEngine||!a.some(s=>s.logic))return;const c=new window.FormLogicEngine.FormLogicRuntime(o,t);if(c.init(),t._formLogicRuntime=c,t.parentNode&&typeof MutationObserver<"u"){const s=new MutationObserver(function(u){for(const r of u)for(const m of r.removedNodes)if(m===t||m.nodeType===1&&m.contains&&m.contains(t)){c.destroy(),s.disconnect();return}});s.observe(t.parentNode,{childList:!0,subtree:!1})}}if(l&&F.wizard){const c=[];let s=[],u=o.title||"Step 1",r="";a.forEach(h=>{h.type==="page-break"?(c.push({title:u,description:r,fields:b(s,f)}),s=[],u=h.label||`Step ${c.length+1}`,r=h.description||""):h.type!=="spacer"&&s.push(h)}),c.push({title:u,description:r,fields:b(s,f)});const m=F.wizard(t,{schema:{steps:c},onSubmit:y});Promise.resolve(m).then(function(){w(t,a),e.honeypot!==!1&&i(t),n()})}else if(F.render){const c=F.render(t,b(a,f),v(a),{submitText:e.submitText||"Submit",layout:d,columns:f,onSubmit:y});Promise.resolve(c).then(function(){if(d==="grid"&&e.submitSpan==="full"){const s=t.querySelector(".form-buttons");s&&s.classList.add("col-span-full")}w(t,a),e.honeypot!==!1&&i(t),n()})}}),$(document).on("click",".dm-banner__dismiss",function(){$(this).closest(".dm-banner").remove()})})();
@@ -60,7 +60,7 @@ import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
60
60
  import {ensureFormForCollection} from '../../services/forms.js';
61
61
  import {hooks} from '../../services/hooks.js';
62
62
  import {scopeAllows, validateToken} from '../../services/apiTokens.js';
63
- import {resolveArtefactProject} from '../../services/projects.js';
63
+ import {isProjectEnabled, resolveArtefactProject} from '../../services/projects.js';
64
64
 
65
65
  const ALL_PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
66
66
 
@@ -146,6 +146,15 @@ export function applyReadFieldAllowlist(schema, payload) {
146
146
  * @returns {Promise<object|undefined>}
147
147
  */
148
148
  export async function checkPublicAccess(schema, operation, request, reply) {
149
+ // Hard kill-switch: if the artefact's project is disabled, the whole
150
+ // external surface is off — 404 (no oracle). This covers /api/v1
151
+ // collections, the synthesized-schema custom endpoints (/api/x/*, whose
152
+ // meta.project is the endpoint's project), and any token bound to the
153
+ // project — all funnel through here.
154
+ if (!(await isProjectEnabled(resolveArtefactProject(schema)))) {
155
+ return reply.status(404).send({ error: 'Not found' });
156
+ }
157
+
149
158
  const access = schema.api?.[operation];
150
159
  if (!access?.enabled) {
151
160
  return reply.status(403).send({ error: `Public ${operation} is disabled for this collection` });
@@ -80,6 +80,10 @@ export async function projectsRoutes(fastify, opts = {}) {
80
80
  if (request.params.slug === CORE_PROJECT_SLUG) {
81
81
  return reply.status(403).send({error: 'Cannot delete the core project'});
82
82
  }
83
+ const target = await getProject(request.params.slug);
84
+ if (target?.protected === true) {
85
+ return reply.status(403).send({error: 'This project is protected and cannot be deleted'});
86
+ }
83
87
  const grouped = await getArtefactsForProject(request.params.slug);
84
88
  const total = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
85
89
  if (total > 0) {
@@ -105,7 +109,11 @@ export async function projectsRoutes(fastify, opts = {}) {
105
109
  if (request.params.slug === CORE_PROJECT_SLUG) {
106
110
  return reply.status(403).send({error: 'Cannot untag the core project — artefacts would immediately resolve back to it'});
107
111
  }
108
- if (!(await getProject(request.params.slug))) {
112
+ const target = await getProject(request.params.slug);
113
+ if (target?.protected === true) {
114
+ return reply.status(403).send({error: 'This project is protected and cannot be untagged'});
115
+ }
116
+ if (!target) {
109
117
  return reply.status(404).send({error: 'Project not found'});
110
118
  }
111
119
  const counts = await untagAllForProject(request.params.slug);
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Domma Docs — public surface
3
+ *
4
+ * Serves the handbook at request time from the admin doc templates:
5
+ * GET /domma-docs → home landing
6
+ * GET /domma-docs/<path> → section landing or leaf page
7
+ *
8
+ * The publish switch is the `domma-docs` project's enabled flag: when the
9
+ * project is disabled the whole surface 404s (no existence oracle), identical
10
+ * to the catch-all's project kill-switch. Unknown paths 404 the same way.
11
+ *
12
+ * This dedicated /domma-docs(/*) prefix out-prioritises public.js's `/*`
13
+ * catch-all (Fastify ranks static segments above wildcards), exactly as
14
+ * routes/api/endpoints-public.js `/x/*` coexists with it — so no per-path
15
+ * special-casing is needed in public.js. Registered at app root (not behind a
16
+ * prefixed plugin) so the specificity ranking shares one router scope.
17
+ */
18
+ import {isProjectEnabled} from '../services/projects.js';
19
+ import {renderDocPage} from '../services/docs.js';
20
+ import {getBaseUrl, render404} from './public.js';
21
+
22
+ const DOCS_PROJECT_SLUG = 'domma-docs';
23
+
24
+ export async function docsPublicRoutes(fastify) {
25
+ const handle = async (request, reply) => {
26
+ // Hard kill-switch: docs disabled → 404 the entire surface.
27
+ if (!(await isProjectEnabled(DOCS_PROJECT_SLUG))) {
28
+ reply.status(404);
29
+ return reply.type('text/html').send(await render404(request.url.split('?')[0]));
30
+ }
31
+
32
+ const docPath = String(request.params['*'] || '');
33
+ const html = await renderDocPage(docPath, {user: null, baseUrl: getBaseUrl(request)});
34
+ if (html == null) {
35
+ reply.status(404);
36
+ return reply.type('text/html').send(await render404(`/domma-docs${docPath ? '/' + docPath : ''}`));
37
+ }
38
+ return reply.type('text/html').send(html);
39
+ };
40
+
41
+ fastify.get('/domma-docs', handle); // exact — params['*'] === '' (home landing)
42
+ fastify.get('/domma-docs/*', handle); // sections + leaves
43
+ }
@@ -5,6 +5,7 @@
5
5
  * The admin panel is excluded (handled by static serving).
6
6
  */
7
7
  import {getPage} from '../services/content.js';
8
+ import {getProjectForPage, isProjectEnabled} from '../services/projects.js';
8
9
  import {renderPage} from '../services/renderer.js';
9
10
  import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
10
11
  import {checkVisibility} from '../middleware/auth.js';
@@ -25,7 +26,7 @@ import * as cache from '../services/cache/index.js';
25
26
  * @param {import('fastify').FastifyRequest} request
26
27
  * @returns {string} e.g. 'https://example.com' (no trailing slash)
27
28
  */
28
- function getBaseUrl(request) {
29
+ export function getBaseUrl(request) {
29
30
  const override = (getConfig('site') || {}).baseUrl;
30
31
  if (override) return override.toString().trim().replace(/\/+$/, '');
31
32
  const host = request.host || request.headers.host || request.hostname;
@@ -108,6 +109,14 @@ export async function publicRoutes(fastify) {
108
109
  return reply.type('text/html').send(await render404(urlPath));
109
110
  }
110
111
 
112
+ // Hard kill-switch: a disabled project takes its pages off the public
113
+ // site entirely — 404 (no oracle), regardless of page visibility.
114
+ const pageProject = await getProjectForPage(page.urlPath || urlPath, page.project);
115
+ if (!(await isProjectEnabled(pageProject))) {
116
+ reply.status(404);
117
+ return reply.type('text/html').send(await render404(urlPath));
118
+ }
119
+
111
120
  // Enforce page visibility — role only resolved for gated pages,
112
121
  // so public pages share a single cache entry keyed `roleanon`.
113
122
  //
@@ -169,7 +178,7 @@ export async function publicRoutes(fastify) {
169
178
  * Render a 404 response — tries content/pages/404.md first, falls back to
170
179
  * a minimal inline page so the site theme is applied when possible.
171
180
  */
172
- async function render404(urlPath) {
181
+ export async function render404(urlPath) {
173
182
  try {
174
183
  const page404 = await getPage('/404');
175
184
  if (page404 && page404.status === 'published') {
package/server/server.js CHANGED
@@ -22,7 +22,7 @@ import {getLoadedPlugins, getPluginSettings, registerPlugins} from './services/p
22
22
  import {load as loadRoles, seed as seedRoles} from './services/roles.js';
23
23
  import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
24
24
  import {seedAll as seedPresetCollections} from './services/presetCollections.js';
25
- import {seedCoreProject} from './services/projects.js';
25
+ import {seedCoreProject, seedDocsProject} from './services/projects.js';
26
26
  import {seedDefaultBlocks} from './services/blocks.js';
27
27
  import {seedDefaultComponents} from './services/components.js';
28
28
  import {refreshComponentTagAllowlist} from './services/markdown.js';
@@ -186,6 +186,7 @@ await seedUserProfiles();
186
186
  await ensureAllProfiles();
187
187
  await seedPresetCollections();
188
188
  await seedCoreProject();
189
+ await seedDocsProject(); // built-in Domma Docs project — seeded disabled by default
189
190
  await seedDefaultBlocks();
190
191
  await seedDefaultComponents();
191
192
  await refreshComponentTagAllowlist();
@@ -223,6 +224,11 @@ try {
223
224
  await cache.initCache(config.cache);
224
225
  console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
225
226
 
227
+ // Docs are rendered at request time from the shipped admin templates; clear any
228
+ // previously-cached docs HTML so a version bump (new templates) never serves
229
+ // stale pages. No-op on a cold memory cache; matters for the Redis driver.
230
+ await cache.invalidateTags(['docs']);
231
+
226
232
  // Serve uploaded media files — nosniff prevents browsers rendering spoofed content types
227
233
  await app.register(staticPlugin, {
228
234
  root: mediaDir,
@@ -369,6 +375,11 @@ for (const [name, plugin] of Object.entries(getLoadedPlugins())) {
369
375
  // Public Site (catch-all — must be last)
370
376
  // ---------------------------------------------------------------------------
371
377
 
378
+ // Domma Docs (request-time handbook) — dedicated /domma-docs(/*) prefix that
379
+ // out-prioritises the catch-all; must be registered before it.
380
+ const { docsPublicRoutes } = await import('./routes/docs-public.js');
381
+ await app.register(docsPublicRoutes);
382
+
372
383
  const { publicRoutes } = await import('./routes/public.js');
373
384
  await app.register(publicRoutes);
374
385
 
@@ -395,3 +406,4 @@ try {
395
406
  process.exit(1);
396
407
  }
397
408
  // scaffold-restart 1779615684
409
+
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Domma Docs — request-time renderer
3
+ *
4
+ * Serves the Domma CMS handbook at /domma-docs/* directly from the admin doc
5
+ * HTML templates (admin/js/templates/docs/*.html) at request time. There is no
6
+ * baked content: a domma-cms version bump ships new templates and the docs
7
+ * update everywhere automatically — no porter, no per-site provisioning, no
8
+ * staleness. (Replaces the one-shot scripts/migrate-docs.js bake.)
9
+ *
10
+ * The publish switch is the `domma-docs` project's enabled flag, enforced by
11
+ * the route (server/routes/docs-public.js); this service only renders.
12
+ *
13
+ * Pipeline per leaf page: read template → strip admin chrome → rewrite links →
14
+ * extract inline <style> → dedent → escape code brackets → structure body into
15
+ * native Domma shortcodes ([card]/[tabs]/[accordion]) → wrap with hero, nav,
16
+ * breadcrumb and prev/next → parseMarkdown → renderPage. Section landing pages
17
+ * (/domma-docs and the Usage/Tutorials/API indexes) are synthesized from the
18
+ * MAP so the nav never links to a 404.
19
+ */
20
+ import {readFile} from 'node:fs/promises';
21
+ import {fileURLToPath} from 'node:url';
22
+ import {dirname, join, resolve} from 'node:path';
23
+
24
+ import {parseMarkdown} from './markdown.js';
25
+ import {renderPage} from './renderer.js';
26
+ import * as cache from './cache/index.js';
27
+
28
+ // import.meta.url-relative (NOT cwd-relative): the templates live beside the
29
+ // engine's admin dir in both the npm-package layout and each child site's
30
+ // physical copy. server/services/docs.js → ../../admin/js/templates/docs.
31
+ const TPL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..', 'admin/js/templates/docs');
32
+
33
+ // Functional nav chrome only (breadcrumb + prev/next + section nav). Everything
34
+ // else (hero, cards, tabs, accordion, buttons) is a native Domma component, so
35
+ // it follows the theme. Injected as an inline <style> per docs page at render
36
+ // time — no dependency on content/custom.css.
37
+ const DOCS_BODY_CSS = `.doc-breadcrumb { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; max-width: 80ch; margin: 0 auto 1.25rem; font-size: .85rem; color: var(--dm-text-muted); }
38
+ .doc-breadcrumb a { color: var(--dm-primary); text-decoration: none; }
39
+ .doc-breadcrumb a:hover { text-decoration: underline; }
40
+ .doc-breadcrumb [aria-current] { color: var(--dm-text); font-weight: 600; }
41
+ .doc-pagenav { display: flex; flex-wrap: wrap; gap: .75rem; justify-content: space-between; align-items: center; max-width: 80ch; margin: 2.5rem auto 3rem; }
42
+ .doc-pagenav .btn-secondary { margin-inline: auto; }
43
+ .doc-nav { display: flex; flex-wrap: wrap; gap: .4rem; justify-content: center; max-width: 80ch; margin: 0 auto 1.75rem; padding: .5rem; border: 1px solid var(--dm-card-border, var(--dm-border)); border-radius: var(--dm-radius-lg, 14px); background: var(--dm-card-bg, var(--dm-surface)); }`;
44
+
45
+ // template basename → [url tail (= file path), title]
46
+ const MAP = [
47
+ ['usage-pages', 'usage/pages', 'Pages'],
48
+ ['usage-media', 'usage/media', 'Media'],
49
+ ['usage-navigation', 'usage/navigation', 'Navigation'],
50
+ ['usage-dconfig', 'usage/dconfig', 'DConfig'],
51
+ ['usage-shortcodes', 'usage/shortcodes', 'Shortcodes'],
52
+ ['usage-site-settings', 'usage/site-settings', 'Site Settings'],
53
+ ['usage-plugins', 'usage/plugins', 'Plugins'],
54
+ ['usage-users-roles', 'usage/users-roles', 'Users & Roles'],
55
+ ['usage-views', 'usage/views', 'Views'],
56
+ ['usage-actions', 'usage/actions', 'Actions'],
57
+ ['usage-cta-shortcode', 'usage/cta-shortcode', 'CTA Shortcode'],
58
+ ['tutorial-crud', 'tutorials/crud', 'Building a CRUD App'],
59
+ ['tutorial-plugin', 'tutorials/plugin', 'Writing a Plugin'],
60
+ ['tutorial-forms', 'tutorials/forms', 'Form Follow-Up'],
61
+ ['components-reference', 'components', 'Components Reference'],
62
+ ['components-howto', 'components/howto', 'Components How-To'],
63
+ ['components-walkthrough','components/walkthrough','Components Walkthrough'],
64
+ ['components-rules', 'components/rules', 'Components Rules'],
65
+ ['api-authentication', 'api/authentication', 'Authentication'],
66
+ ['api-pages', 'api/pages', 'Pages API'],
67
+ ['api-settings', 'api/settings', 'Settings API'],
68
+ ['api-layouts', 'api/layouts', 'Layouts API'],
69
+ ['api-navigation', 'api/navigation', 'Navigation API'],
70
+ ['api-media', 'api/media', 'Media API'],
71
+ ['api-users', 'api/users', 'Users API'],
72
+ ['api-plugins', 'api/plugins', 'Plugins API'],
73
+ ['api-collections', 'api/collections', 'Collections API'],
74
+ ['api-views', 'api/views', 'Views API'],
75
+ ['api-actions', 'api/actions', 'Actions API']
76
+ ];
77
+
78
+ // Admin-SPA hash routes and stale /resources/… paths don't resolve on the
79
+ // public site — rewrite doc-to-doc links to /domma-docs/…, admin-feature links
80
+ // to the admin SPA, and unwrap the rest.
81
+ const LINK_MAP = {
82
+ '#/docs/components-walkthrough': '/domma-docs/components/walkthrough',
83
+ '#/docs/components-rules': '/domma-docs/components/rules',
84
+ '#/docs/components-howto': '/domma-docs/components/howto',
85
+ '#/docs/components': '/domma-docs/components',
86
+ '#/components': '/domma-docs/components',
87
+ '#/tutorials/plugin': '/domma-docs/tutorials/plugin',
88
+ '#/tutorials/forms': '/domma-docs/tutorials/forms',
89
+ '#/tutorials': '/domma-docs/tutorials',
90
+ '#/documentation': '/domma-docs',
91
+ '#/actions': '/admin#/actions',
92
+ '#/collections': '/admin#/collections',
93
+ '#/forms': '/admin#/forms',
94
+ '#/plugins': '/admin#/plugins',
95
+ '#/settings': '/admin#/settings',
96
+ '/resources/shortcodes': '/domma-docs/usage/shortcodes',
97
+ '/resources/components': '/domma-docs/components'
98
+ };
99
+
100
+ // The four top-level doc sections — rendered as a persistent nav on every page.
101
+ const SECTIONS = [
102
+ {label: 'Usage', href: '/domma-docs/usage'},
103
+ {label: 'Tutorials', href: '/domma-docs/tutorials'},
104
+ {label: 'Components', href: '/domma-docs/components'},
105
+ {label: 'API Reference', href: '/domma-docs/api'}
106
+ ];
107
+
108
+ // Section landing pages keyed by url tail → section label. The Components
109
+ // landing is the Components Reference leaf (MAP tail 'components'), so it is
110
+ // NOT synthesized here.
111
+ const LANDINGS = {
112
+ '': 'Home',
113
+ 'usage': 'Usage',
114
+ 'tutorials': 'Tutorials',
115
+ 'api': 'API Reference'
116
+ };
117
+
118
+ // ── pure transform helpers (relocated from scripts/migrate-docs.js) ──────────
119
+
120
+ function escText(s) {
121
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
122
+ }
123
+ function heroAttr(s) {
124
+ return String(s).replace(/"/g, '&quot;');
125
+ }
126
+
127
+ // Drop the admin-only header block (<div class="view-header"> … </div>).
128
+ function stripHeader(html) {
129
+ return html.replace(/<div class="view-header">[\s\S]*?<\/div>\s*/, '');
130
+ }
131
+
132
+ function rewriteLinks(html) {
133
+ let out = html;
134
+ for (const [from, to] of Object.entries(LINK_MAP)) {
135
+ out = out.split(`href="${from}"`).join(`href="${to}"`);
136
+ }
137
+ // Any remaining /resources/… link has no doc equivalent — unwrap to text.
138
+ out = out.replace(/<a\b[^>]*\shref="\/resources\/[^"]*"[^>]*>([\s\S]*?)<\/a>/gi, '$1');
139
+ return out;
140
+ }
141
+
142
+ // Pull every inline <style> block out of the body, returning the cleaned HTML
143
+ // plus the extracted (deduped) CSS — collected PER CALL (no shared module state,
144
+ // which would leak across requests in a long-lived server).
145
+ function stripStyleBlocks(html) {
146
+ const styles = new Set();
147
+ const out = html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>\s*/gi, (_m, css) => {
148
+ const trimmed = css.trim();
149
+ if (trimmed) styles.add(trimmed);
150
+ return '';
151
+ });
152
+ return {html: out, styles: [...styles]};
153
+ }
154
+
155
+ // Markdown treats any line indented 4+ spaces as a code block. Strip leading
156
+ // indentation OUTSIDE <pre> so structural HTML passes through as raw HTML;
157
+ // inside <pre> whitespace is significant and preserved.
158
+ function dedentOutsidePre(html) {
159
+ const parts = html.split(/(<pre\b[^>]*>[\s\S]*?<\/pre>)/gi);
160
+ return parts.map((part, i) =>
161
+ (i % 2 === 1) ? part : part.replace(/^[ \t]+/gm, '')
162
+ ).join('');
163
+ }
164
+
165
+ // Escape square brackets inside <pre> and <code> so shortcode examples render
166
+ // literally instead of being expanded by the render pipeline.
167
+ function escapeCodeBrackets(html) {
168
+ const esc = (region) => region.replace(/\[/g, '&#91;').replace(/\]/g, '&#93;');
169
+ html = html.replace(/<pre\b[^>]*>[\s\S]*?<\/pre>/gi, esc);
170
+ html = html.replace(/<code\b[^>]*>[\s\S]*?<\/code>/gi, esc);
171
+ return html;
172
+ }
173
+
174
+ // Strip the consistent .row > .col-12 > .docs-body shell to get the bare inner.
175
+ function unwrapDocsBody(html) {
176
+ return html.trim()
177
+ .replace(/^<div class="row">\s*<div class="col-12">\s*<div class="docs-body">/, '')
178
+ .replace(/<\/div>\s*<\/div>\s*<\/div>\s*$/, '')
179
+ .trim();
180
+ }
181
+
182
+ // Plain-text heading title, safe as a shortcode attribute value.
183
+ function headingText(inner) {
184
+ return inner.replace(/<[^>]+>/g, '')
185
+ .replace(/&amp;/g, '&').replace(/&mdash;/g, '—').replace(/&ndash;/g, '–')
186
+ .replace(/&quot;/g, "'").replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
187
+ .replace(/\s+/g, ' ').trim().replace(/"/g, "'");
188
+ }
189
+
190
+ // Split inner content at top-level <hN> headings into {preamble, sections}.
191
+ function splitSections(inner, level) {
192
+ const re = new RegExp(`<h${level}\\b[^>]*>([\\s\\S]*?)<\\/h${level}>`, 'gi');
193
+ const matches = [...inner.matchAll(re)];
194
+ if (!matches.length) return {preamble: inner, sections: []};
195
+ const preamble = inner.slice(0, matches[0].index).trim();
196
+ const sections = matches.map((m, i) => ({
197
+ title: headingText(m[1]),
198
+ body: inner.slice(m.index + m[0].length, i + 1 < matches.length ? matches[i + 1].index : inner.length).trim()
199
+ }));
200
+ return {preamble, sections};
201
+ }
202
+
203
+ // ≥7 sections → accordion; 2–6 → tabs; <2 → a single card.
204
+ function structureBody(body) {
205
+ const inner = unwrapDocsBody(body);
206
+ const level = /<h2\b/i.test(inner) ? 2 : 3;
207
+ const {preamble, sections} = splitSections(inner, level);
208
+ if (sections.length < 2) return `[card]\n${inner}\n[/card]`;
209
+ const lead = preamble ? `[card]\n${preamble}\n[/card]\n\n` : '';
210
+ if (sections.length <= 6) {
211
+ return lead + '[tabs]\n' +
212
+ sections.map(s => `[tab title="${s.title}"]\n${s.body}\n[/tab]`).join('\n') + '\n[/tabs]';
213
+ }
214
+ return lead + '[accordion multiple="true"]\n' +
215
+ sections.map(s => `[item title="${s.title}"]\n${s.body}\n[/item]`).join('\n') + '\n[/accordion]';
216
+ }
217
+
218
+ // Persistent section nav (Home + the four sections, current one highlighted).
219
+ function docNav(activeLabel) {
220
+ const links = [{label: 'Home', href: '/domma-docs'}, ...SECTIONS].map(s => {
221
+ const active = s.label === activeLabel;
222
+ return `<a class="btn btn-sm ${active ? 'btn-primary' : 'btn-ghost'}" href="${s.href}">${escText(s.label)}</a>`;
223
+ }).join('');
224
+ return `<nav class="doc-nav">${links}</nav>`;
225
+ }
226
+
227
+ // Section label + landing URL for the hero eyebrow, derived from the page path.
228
+ function sectionFor(path) {
229
+ if (path.startsWith('usage/')) return {label: 'Usage', href: '/domma-docs/usage'};
230
+ if (path.startsWith('tutorials/')) return {label: 'Tutorials', href: '/domma-docs/tutorials'};
231
+ if (path.startsWith('api/')) return {label: 'API Reference', href: '/domma-docs/api'};
232
+ if (path === 'components' || path.startsWith('components/')) return {label: 'Components', href: '/domma-docs/components'};
233
+ return {label: 'Documentation', href: '/domma-docs'};
234
+ }
235
+
236
+ // Assemble a leaf page: hero, persistent nav, breadcrumb, structured body, and
237
+ // a prev / back-to-section / next nav bar.
238
+ function buildPage(section, title, body, prev, next) {
239
+ const hero = `[hero title="${heroAttr(title)}" tagline="${heroAttr(section.label)}" variant="gradient" align="center" fullwidth="true"][/hero]`;
240
+ const nav = docNav(section.label);
241
+ const crumb = `<nav class="doc-breadcrumb"><a href="/domma-docs">Docs</a><span>/</span>` +
242
+ `<a href="${section.href}">${escText(section.label)}</a><span>/</span>` +
243
+ `<span aria-current="page">${escText(title)}</span></nav>`;
244
+ const prevA = prev ? `<a class="btn btn-ghost" href="/domma-docs/${prev[1]}">← ${escText(prev[2])}</a>` : '<span></span>';
245
+ const nextA = next ? `<a class="btn btn-ghost" href="/domma-docs/${next[1]}">${escText(next[2])} →</a>` : '<span></span>';
246
+ const pagenav = `<nav class="doc-pagenav">${prevA}` +
247
+ `<a class="btn btn-secondary" href="${section.href}">All ${escText(section.label)}</a>${nextA}</nav>`;
248
+ return `${hero}\n\n${nav}\n\n${crumb}\n\n${structureBody(body)}\n\n${pagenav}`;
249
+ }
250
+
251
+ // ── landing pages ────────────────────────────────────────────────────────────
252
+
253
+ // Build a section landing body: hero + nav + a grid of cards linking each leaf.
254
+ function buildSectionLanding(label) {
255
+ const section = SECTIONS.find(s => s.label === label);
256
+ const pages = MAP.filter(m => sectionFor(m[1]).label === label);
257
+ const hero = `[hero title="${heroAttr(label)}" tagline="Domma Docs" variant="gradient" align="center" fullwidth="true"][/hero]`;
258
+ const cards = pages.map(([, tail, title]) =>
259
+ `[card]\n### ${escText(title)}\n\n[button label="Read ${heroAttr(title)} →" href="/domma-docs/${tail}" variant="ghost" /]\n[/card]`
260
+ ).join('\n');
261
+ return {title: label, body: `${hero}\n\n${docNav(label)}\n\n[grid cols="3"]\n${cards}\n[/grid]`};
262
+ }
263
+
264
+ // Build the home landing: hero + nav + a card per top-level section.
265
+ function buildHomeLanding() {
266
+ const hero = `[hero title="Domma Docs" tagline="The Domma CMS handbook" variant="gradient" align="center" fullwidth="true"][/hero]`;
267
+ const cards = [
268
+ {label: 'Usage', href: '/domma-docs/usage', icon: 'book-open', blurb: 'Day-to-day guides for running the CMS — pages, media, navigation, shortcodes and more.'},
269
+ {label: 'Tutorials', href: '/domma-docs/tutorials', icon: 'academic-cap', blurb: 'Step-by-step walkthroughs that build something real from start to finish.'},
270
+ {label: 'Components', href: '/domma-docs/components', icon: 'puzzle', blurb: 'The full component reference — shortcodes, how-tos, walkthroughs and rules.'},
271
+ {label: 'API Reference', href: '/domma-docs/api', icon: 'code', blurb: 'Every REST endpoint for headless and external integrations.'}
272
+ ].map(s =>
273
+ `[card icon="${s.icon}" title="${heroAttr(s.label)}"]\n${escText(s.blurb)}\n\n[button label="Browse ${heroAttr(s.label)} →" href="${s.href}" variant="primary" /]\n[/card]`
274
+ ).join('\n');
275
+ return {title: 'Documentation', body: `${hero}\n\n${docNav('Home')}\n\n[grid cols="2"]\n${cards}\n[/grid]`};
276
+ }
277
+
278
+ // ── render ───────────────────────────────────────────────────────────────────
279
+
280
+ // Build the shortcode body for a doc path, or null if the path is unknown.
281
+ // Returns {title, body, styles[]} — `styles` is the inline CSS extracted from a
282
+ // leaf template (empty for synthesized landings).
283
+ async function buildDocBody(docPath) {
284
+ if (docPath in LANDINGS) {
285
+ const built = docPath === '' ? buildHomeLanding() : buildSectionLanding(LANDINGS[docPath]);
286
+ return {...built, styles: []};
287
+ }
288
+ const entry = MAP.find(m => m[1] === docPath);
289
+ if (!entry) return null;
290
+ const [tpl, path, title] = entry;
291
+ let src;
292
+ try {
293
+ src = await readFile(join(TPL_DIR, `${tpl}.html`), 'utf8');
294
+ } catch {
295
+ return null; // template missing — treat as a 404
296
+ }
297
+ const {html: stripped, styles} = stripStyleBlocks(stripHeader(src));
298
+ const body = escapeCodeBrackets(dedentOutsidePre(rewriteLinks(stripped))).trim();
299
+ const section = sectionFor(path);
300
+ // prev / next are the section-mates either side of this page in MAP order.
301
+ const mates = MAP.filter(m => sectionFor(m[1]).label === section.label);
302
+ const mi = mates.findIndex(m => m[1] === path);
303
+ const prev = mi > 0 ? mates[mi - 1] : null;
304
+ const next = mi >= 0 && mi < mates.length - 1 ? mates[mi + 1] : null;
305
+ return {title, body: buildPage(section, title, body, prev, next), styles};
306
+ }
307
+
308
+ /**
309
+ * Render a Domma Docs page to full HTML, or return null for an unknown path
310
+ * (the route turns that into a 404). Cached per path+origin under the static
311
+ * 'docs' tag — invalidated on boot so a version bump never serves stale HTML.
312
+ *
313
+ * @param {string} docPath url tail after /domma-docs (e.g. 'usage/pages', '' for home)
314
+ * @param {{user?: object|null, baseUrl?: string}} [opts]
315
+ * @returns {Promise<string|null>}
316
+ */
317
+ export async function renderDocPage(docPath, opts = {}) {
318
+ const path = String(docPath || '').replace(/^\/+|\/+$/g, '');
319
+ const built = await buildDocBody(path);
320
+ if (!built) return null;
321
+
322
+ const {user = null, baseUrl} = opts;
323
+ return cache.wrap(
324
+ `docs:${path}:o${baseUrl || ''}`,
325
+ async () => {
326
+ const parsed = await parseMarkdown(built.body, {user});
327
+ const css = [DOCS_BODY_CSS, ...built.styles].filter(Boolean).join('\n\n');
328
+ const html = `<style>${css}</style>\n${parsed.html}`;
329
+ const page = {
330
+ title: built.title,
331
+ layout: 'default',
332
+ status: 'published',
333
+ visibility: 'public',
334
+ project: 'domma-docs',
335
+ urlPath: `/domma-docs${path ? '/' + path : ''}`,
336
+ html,
337
+ usedComponents: parsed.usedComponents || [],
338
+ dconfig: parsed.data?.dconfig || null,
339
+ seo: {title: built.title},
340
+ sidebar: false,
341
+ tags: ['docs']
342
+ };
343
+ return renderPage(page, {baseUrl, user});
344
+ },
345
+ {tags: ['docs']}
346
+ );
347
+ }
348
+
349
+ /**
350
+ * Every valid doc path (home + 29 leaves + 3 section landings). The Components
351
+ * landing is the 'components' leaf, already in MAP. `renderDocPage` returning
352
+ * null is the authoritative miss; this is an enumeration aid (e.g. sitemap).
353
+ *
354
+ * @returns {string[]}
355
+ */
356
+ export function listDocPaths() {
357
+ return ['', ...MAP.map(m => m[1]), 'usage', 'tutorials', 'api'];
358
+ }
@@ -51,6 +51,7 @@ const PRESETS = [
51
51
  {name: 'icon', label: 'Icon', type: 'text', default: 'folder'},
52
52
  {name: 'rootUrl', label: 'Root URL', type: 'text'},
53
53
  {name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0},
54
+ {name: 'enabled', label: 'Enabled', type: 'boolean', default: true},
54
55
  {name: 'protected', label: 'Protected', type: 'boolean', default: false}
55
56
  ],
56
57
  api: {
@@ -36,6 +36,23 @@ const CORE_PROJECT_SEED = {
36
36
  icon: 'home',
37
37
  rootUrl: '/',
38
38
  sortOrder: -1,
39
+ enabled: true,
40
+ protected: true
41
+ };
42
+
43
+ /** Slug of the built-in, disabled-by-default documentation project. */
44
+ export const DOCS_PROJECT_SLUG = 'domma-docs';
45
+
46
+ /** Seed record for the Domma Docs project (created at boot if absent). */
47
+ const DOCS_PROJECT_SEED = {
48
+ slug: DOCS_PROJECT_SLUG,
49
+ name: 'Domma Docs',
50
+ description: 'The Domma CMS handbook — guides, tutorials, component & API ' +
51
+ 'reference. Switch on to publish it on your own site.',
52
+ icon: 'document',
53
+ rootUrl: '/domma-docs',
54
+ sortOrder: 0,
55
+ enabled: false,
39
56
  protected: true
40
57
  };
41
58
 
@@ -105,6 +122,7 @@ export async function createProject(input) {
105
122
  icon: input.icon || 'folder',
106
123
  ...(input.rootUrl != null && {rootUrl: input.rootUrl}),
107
124
  sortOrder: Number.isFinite(input.sortOrder) ? input.sortOrder : 0,
125
+ enabled: input.enabled !== false,
108
126
  ...(typeof input.createdBy === 'string' && input.createdBy.trim() && {createdBy: input.createdBy.trim()}),
109
127
  createdAt: now,
110
128
  updatedAt: now
@@ -127,8 +145,9 @@ export async function seedCoreProject() {
127
145
  const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
128
146
  const existing = entries.find(e => e.data?.slug === CORE_PROJECT_SLUG);
129
147
  if (existing) {
130
- if (existing.data.protected === true) return normalise(existing);
131
- const adopted = {...existing.data, protected: true, updatedAt: new Date().toISOString()};
148
+ // Core is always protected and always enabled — adopt both invariants.
149
+ if (existing.data.protected === true && existing.data.enabled !== false) return normalise(existing);
150
+ const adopted = {...existing.data, protected: true, enabled: true, updatedAt: new Date().toISOString()};
132
151
  await updateEntry(PROJECTS_COLLECTION_SLUG, existing.id, adopted);
133
152
  return adopted;
134
153
  }
@@ -138,6 +157,24 @@ export async function seedCoreProject() {
138
157
  return data;
139
158
  }
140
159
 
160
+ /**
161
+ * Seed the built-in Domma Docs project at boot. Create-if-absent by slug — an
162
+ * existing record (including the admin's enabled/disabled choice and any edits)
163
+ * is returned untouched. Disabled by default: a fresh site ships with the docs
164
+ * off until an admin enables the project.
165
+ *
166
+ * @returns {Promise<object>} The docs project record.
167
+ */
168
+ export async function seedDocsProject() {
169
+ const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
170
+ const existing = entries.find(e => e.data?.slug === DOCS_PROJECT_SLUG);
171
+ if (existing) return normalise(existing);
172
+ const now = new Date().toISOString();
173
+ const data = {...DOCS_PROJECT_SEED, createdAt: now, updatedAt: now};
174
+ await createEntry(PROJECTS_COLLECTION_SLUG, data);
175
+ return data;
176
+ }
177
+
141
178
  /**
142
179
  * Update a project. Slug is immutable. Preserves createdAt; bumps updatedAt.
143
180
  * The core project's `rootUrl` and `protected` flag are locked; its name,
@@ -159,6 +196,9 @@ export async function updateProject(slug, input) {
159
196
  if (input.protected != null && input.protected !== existing.protected) {
160
197
  throw new Error("Cannot change the core project's protected flag");
161
198
  }
199
+ if (input.enabled === false) {
200
+ throw new Error('Cannot disable the core project');
201
+ }
162
202
  }
163
203
  const merged = {
164
204
  ...existing,
@@ -167,6 +207,7 @@ export async function updateProject(slug, input) {
167
207
  ...(input.icon != null && {icon: input.icon}),
168
208
  ...(input.rootUrl != null && {rootUrl: input.rootUrl}),
169
209
  ...(Number.isFinite(input.sortOrder) && {sortOrder: input.sortOrder}),
210
+ ...(typeof input.enabled === 'boolean' && {enabled: input.enabled}),
170
211
  slug,
171
212
  createdAt: existing.createdAt,
172
213
  updatedAt: new Date().toISOString()
@@ -196,6 +237,9 @@ export async function deleteProject(slug) {
196
237
  const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
197
238
  const entry = entries.find(e => e.data?.slug === slug);
198
239
  if (!entry) return false;
240
+ if (entry.data?.protected === true) {
241
+ throw new Error(`Cannot delete the protected "${slug}" project`);
242
+ }
199
243
  await deleteEntry(PROJECTS_COLLECTION_SLUG, entry.id);
200
244
  return true;
201
245
  }
@@ -229,7 +273,28 @@ export function validateProject(project) {
229
273
  }
230
274
 
231
275
  function normalise(entry) {
232
- return {...(entry.data || {})};
276
+ const data = entry.data || {};
277
+ // `enabled` is default-on: legacy records (and any record missing the
278
+ // field) are treated as enabled. Only an explicit `false` disables.
279
+ return {...data, enabled: data.enabled !== false};
280
+ }
281
+
282
+ /**
283
+ * Whether the given project is enabled (i.e. not switched off). A disabled
284
+ * project is a hard kill-switch on its PUBLIC surfaces — public pages 404 and
285
+ * its external API (collections + custom endpoints + tokens) is refused.
286
+ *
287
+ * The core project is always enabled. An unknown slug resolves to core by
288
+ * fallback elsewhere, so it is reported enabled here (never a block).
289
+ *
290
+ * @param {string} slug
291
+ * @returns {Promise<boolean>}
292
+ */
293
+ export async function isProjectEnabled(slug) {
294
+ if (!slug || slug === CORE_PROJECT_SLUG) return true;
295
+ const project = await getProject(slug);
296
+ if (!project) return true;
297
+ return project.enabled !== false;
233
298
  }
234
299
 
235
300
  /**
@@ -455,6 +520,10 @@ export async function untagAllForProject(projectSlug) {
455
520
  if (projectSlug === CORE_PROJECT_SLUG) {
456
521
  throw new Error('Cannot untag the core project — artefacts would immediately resolve back to it');
457
522
  }
523
+ const proj = await getProject(projectSlug);
524
+ if (proj?.protected === true) {
525
+ throw new Error(`Cannot untag the protected "${projectSlug}" project — its pages would resolve back to core`);
526
+ }
458
527
  const counts = {
459
528
  pages: 0, collections: 0, forms: 0, actions: 0,
460
529
  menus: 0, blocks: 0, components: 0, views: 0, roles: 0, users: 0, apis: 0