domma-cms 1.6.47 → 1.6.48

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.
@@ -26,7 +26,7 @@
26
26
 
27
27
  <a href="#/tutorials/plugin" class="card" style="text-decoration:none;display:block;">
28
28
  <div class="card-body">
29
- <h3 style="margin-top:0;"><span data-icon="package"></span> Writing a Plugin</h3>
29
+ <h3 style="margin-top:0;"><span data-icon="package"></span> Writing a Tool</h3>
30
30
  <p class="text-muted" style="margin-bottom:0;">
31
31
  Extend the CMS with your own server routes, admin pages and page-injection snippets:
32
32
  the manifest, the Fastify entry point, switching it on and testing it.
@@ -1,14 +1,14 @@
1
- import{api as $}from"../api.js";import{addBannerAction as ue}from"../lib/plugin-chrome.js";import{helpPopovers as at}from"../lib/help-popover.js";import{openMenuAt as Fe,sidebarCount as nt}from"../lib/tool-kit.js";import{createSimpleEditor as Ie}from"../lib/simple-editor.js";import{checkForm as st,draftKey as j,draftOffer as rt,escapeHtml as Ne,extractPlaceholders as ct,formFromBlock as lt,formatHtml as it,payloadFromForm as ut,previewMessage as dt,previewShell as pt,rawPlaceholders as We,renderPreview as mt,sampleValue as _e,scopeName as vt,shortcodeFor as ft,snapshot as de}from"../lib/block-editor-arrange.js";import{fmtDateTime as He}from"../lib/dates.js";const ht=/^\/blocks\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,J=u=>String(u||"").split("?")[0],pe="block-editor.mode",bt=["write","split","preview"],Be="block-editor.preview",Ve=["full","1024","390"],yt="/__block-preview",me="dm-block-preview-root",Ke={title:"Block preview",layout:"blank",breadcrumbs:!1},kt={ADD_ATTR:["target","loading"],ALLOW_DATA_ATTR:!0,FORCE_BODY:!0},wt=()=>window.DOMPurify?.sanitize?u=>window.DOMPurify.sanitize(u,kt):null,gt=`
1
+ import{api as $}from"../api.js";import{addBannerAction as J}from"../lib/plugin-chrome.js";import{helpPopovers as at}from"../lib/help-popover.js";import{openMenuAt as Fe,sidebarCount as nt}from"../lib/tool-kit.js";import{createSimpleEditor as Ie}from"../lib/simple-editor.js";import{checkForm as st,draftKey as j,draftOffer as rt,escapeHtml as Ne,extractPlaceholders as ct,formFromBlock as lt,formatHtml as it,payloadFromForm as ut,previewMessage as dt,previewShell as pt,rawPlaceholders as We,renderPreview as mt,sampleValue as _e,scopeName as vt,shortcodeFor as ht,snapshot as de}from"../lib/block-editor-arrange.js";import{fmtDateTime as Be}from"../lib/dates.js";const ft=/^\/blocks\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,G=u=>String(u||"").split("?")[0],pe="block-editor.mode",bt=["write","split","preview"],He="block-editor.preview",Ve=["full","1024","390"],yt="/__block-preview",me="dm-block-preview-root",Ke={title:"Block preview",layout:"blank",breadcrumbs:!1},kt={ADD_ATTR:["target","loading"],ALLOW_DATA_ATTR:!0,FORCE_BODY:!0},wt=()=>window.DOMPurify?.sanitize?u=>window.DOMPurify.sanitize(u,kt):null,gt=`
2
2
  <div class="bex-sample" data-key="{{key}}" tabindex="0">
3
3
  <label class="bex-sample-label" for="bex-sample-{{key}}">{{label}}</label>
4
4
  <input id="bex-sample-{{key}}" type="text" class="form-input form-input--sm" data-bind-value="value" data-sample="{{key}}" placeholder="Sample {{key}}">
5
5
  <button type="button" class="bex-kebab" aria-label="Actions for {{label}}" title="Actions">\u22EE</button>
6
- </div>`;let f=null,Ue=!1;function St(){Ue||!window.R?.use||(Ue=!0,R.use((u,p,s)=>{if(!(ht.test(p?.path||"")&&J(u?.path)!==J(p?.path))||!f?.dirty())return s();const c=f;return E.confirm(`You have unsaved changes to ${Ne(c.label())}. Leave anyway? They are kept as a draft for your next visit.`).then(B=>{B?s():history.replaceState(null,"",c.hash)})}),M.subscribe("router:afterChange",({to:u}={})=>{if(f&&J(u?.path)!==f.path){const p=f;f=null,p.dispose()}}))}function Et(u,p){const s=u.querySelector(".dm-plugin-banner-name"),g=s?.lastChild;g&&g.nodeType===3&&s.querySelector(".dm-plugin-banner-sep")&&(g.textContent=p)}function G(u){return navigator.clipboard?.writeText(u).then(()=>!0,()=>!1)??Promise.resolve(!1)}export const blockEditorView={templateUrl:"/admin/js/templates/block-editor.html",async onMount(u){if(St(),f){const e=f;f=null,e.dispose()}const p=u.get(0),s=p.querySelector(".bex")||p,g=[],c=e=>(g.push(e),e);let B=!1;const C=()=>!B&&s.isConnected,ve=location.hash.match(/^#\/blocks\/edit\/([^/?#]+)/),l=ve?decodeURIComponent(ve[1]):null,d=!l,Ye=location.hash,ze=J(location.hash.slice(1)),Je=d&&new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",fe=E.loader(p,{type:"dots"});let y=null,Q=[],X=[];try{[y,Q,X]=await Promise.all([l?$.blocks.get(l):Promise.resolve(null),$.blocks.list().catch(()=>[]),$.projects.list().catch(()=>[])])}catch(e){fe.destroy(),E.toast(e.message||"Block not found.",{type:"error"}),R.navigate("/blocks");return}if(fe.destroy(),!s.isConnected)return;const he=(Array.isArray(Q)?Q:[]).map(e=>e.name).sort((e,t)=>e.localeCompare(t));let be=y?.meta||{};const v=lt(y?{...y,name:l}:null,{project:Je}),a={};for(const[e,t]of Object.entries(v))a[e]=M.observable(t);const Z=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].peek()])),ye=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].value])),O=M.observable(de(v)),D=M.computed(()=>de(ye())!==O.value),q=M.observable(!1),V=M.observable(!1),K=M.computed(()=>st(ye(),{isNew:d,taken:he}));let ke="split";try{ke=bt.includes(S.get(pe))?S.get(pe):"split"}catch{}const k=M.observable(ke);c(M.effect(()=>{const e=k.value;try{S.set(pe,e)}catch{}}));const we=M.observable("Ln 1, Col 1");let L={};try{L=S.get(Be)||{}}catch{}const ee=M.observable(Ve.includes(L.width)?L.width:"full"),U=M.observable(String(L.repeat)==="3"?"3":"1"),m=M.observable(typeof L.theme=="string"?L.theme:""),x=M.observable("");c(M.effect(()=>{const e={width:ee.value,repeat:U.value,theme:m.value};try{S.set(Be,e)}catch{}}));const h={},P={},Y=M.observable(0),te=M.observable([]),ge=()=>We(a.content.peek());c(M.effect(()=>{const e=ct(a.content.value),t=We(a.content.value);let n=!1;for(const o of e){const w=t.has(o);if(!(o in h)||o in P&&P[o]!==w){const H=o in h;if(h[o]=_e(o,new Date,{raw:w}),P[o]=w,H){n=!0;const i=s.querySelector(`.bex-samples [data-sample="${CSS.escape(o)}"]`);i&&(i.value=h[o])}}}te.value=e.map(o=>({key:o,label:t.has(o)?`{{{${o}}}}`:`{{${o}}}`,value:h[o]})),n&&queueMicrotask(()=>Y.value++)}));const Ge={...a,dirty:D,saving:q,mode:k,cursor:we,sampleRows:te,isNew:d,notNew:!d,pvWidth:ee,pvRepeat:U,pvTheme:m,pvStatus:x,titleText:d?"New block":l,metaText:M.computed(()=>{if(d)return"Not saved yet";const e=[];return(y?.updatedAt||y?.meta?.updatedAt)&&e.push(`changed ${He(y.updatedAt||y.meta.updatedAt)}`),e.push(`${a.content.value.split(`
7
- `).length} lines`),a.css.value.trim()&&e.push(`${Math.ceil(a.css.value.length/1024)} KB CSS`),e.join(" \xB7 ")}),nameErr:M.computed(()=>(V.value||a.name.value)&&K.value.errors.name||""),cssErr:M.computed(()=>K.value.errors.css||""),scopeHint:M.computed(()=>`[data-block="${vt(a.name.value)}"]`),noSamples:M.computed(()=>te.value.length===0),noSamplesText:"No {{placeholders}} in the template yet.",modeCls:M.computed(()=>`bex-body is-${k.value}`),cannotSave:M.computed(()=>q.value||V.value&&Object.keys(K.value.errors).length>0)},oe=s.querySelector("#block-project");for(const e of Array.isArray(X)?X:[]){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,oe.appendChild(t)}if(v.project&&![...oe.options].some(e=>e.value===v.project)){const e=document.createElement("option");e.value=v.project,e.textContent=`${v.project} (no longer a project)`,oe.appendChild(e)}const F=s.querySelector("#block-quick-switch");for(const e of he){const t=document.createElement("option");t.value=e,t.textContent=e,e===l&&(t.selected=!0),F.appendChild(t)}const b=s.querySelector(".bex-samples");b.innerHTML=gt;let A=null,N=null,r=null;function Se(e){for(const[t,n]of Object.entries(e))a[t]&&(a[t].value=n);A.setValue(String(e.content??"")),N.setValue(String(e.css??""))}const ae=s.querySelector("#block-preview-frame");let T="none",W=0;const Qe="As your site shows it \xB7 not saved",Ee=()=>{if(!C()||k.peek()==="write"||T!=="ready")return;const e={content:a.content.peek(),css:a.css.peek(),name:a.name.peek()},t=wt();if(!t){x.value="The preview needs the HTML cleaner, which did not load - reload the admin.";return}const n=mt(e,h,{repeat:Number(U.peek()),sanitize:t});try{ae.contentWindow?.postMessage({source:"dm-block-editor",type:"paint",tick:W,html:n},"*")}catch{}},Me=e=>{const t=dt(e,ae.contentWindow,W);if(!(!t||!C())){if(t.type==="save"){z();return}if(T=t.host?"ready":"failed",T==="failed"){x.value="Your site's page came back without a place for the block - the preview cannot show it.";return}x.peek()==="Loading your site\u2026"&&(x.value=Qe),Ee()}};window.addEventListener("message",Me),c(()=>window.removeEventListener("message",Me));async function Ce(){const e=++W;T="loading",x.value="Loading your site\u2026";let t;try{const n=m.peek()?{...Ke,theme:m.peek()}:Ke;({html:t}=await $.pages.previewFull(yt,n,`<div id="${me}"></div>`))}catch(n){t=`<!doctype html><html><head><meta charset="utf-8"></head><body style="font:14px system-ui;margin:0"><div id="${me}" style="padding:1rem"></div></body></html>`,x.value=`Without your site's styles - ${n?.message||"the page did not render"}`}!C()||e!==W||(ae.srcdoc=pt(t,e,me))}let ne=0;c(M.effect(()=>{if(a.content.value,a.css.value,a.name.value,Y.value,U.value,k.value!=="write"){if(T==="none"){Ce();return}cancelAnimationFrame(ne),ne=requestAnimationFrame(Ee)}}));let xe=m.peek();c(M.effect(()=>{const e=m.value;e!==xe&&(xe=e,T!=="none"&&Ce())})),c(()=>{cancelAnimationFrame(ne),W++}),$.theme.catalog().then(({themes:e=[],custom:t=[]}={})=>{if(!C())return;const n=s.querySelector("#block-preview-theme"),o=i=>String(i).replace(/-/g," ").replace(/\b\w/g,le=>le.toUpperCase()),w=[...e.filter(i=>!String(i).startsWith("admin-")).map(i=>({id:i,label:o(i)})),...(Array.isArray(t)?t:[]).filter(i=>i?.id).map(i=>({id:i.id,label:i.label||o(i.id)}))],H=w.map(i=>i.id);for(const{id:i,label:le}of w){const ie=document.createElement("option");ie.value=i,ie.textContent=le,n.appendChild(ie)}m.peek()&&!H.includes(m.peek())?m.value="":n.value=m.peek()}).catch(()=>{s.querySelector("#block-preview-theme")?.remove(),C()&&m.peek()&&(m.value="")});const Ae=e=>{const t=e.target?.dataset?.sample;t&&(h[t]=e.target.value,delete P[t],Y.value++)};b.addEventListener("input",Ae),c(()=>b.removeEventListener("input",Ae));function se(e){r.focus(),r.setRangeText(e,r.selectionStart,r.selectionEnd,"end"),r.dispatchEvent(new Event("input",{bubbles:!0}))}async function Le(e){switch(e){case"undo":r.focus(),document.execCommand("undo");break;case"redo":r.focus(),document.execCommand("redo");break;case"cut":r.selectionStart!==r.selectionEnd&&(await G(r.value.slice(r.selectionStart,r.selectionEnd)),se(""));break;case"copy":r.selectionStart!==r.selectionEnd&&await G(r.value.slice(r.selectionStart,r.selectionEnd));break;case"paste":try{se(await navigator.clipboard.readText())}catch{E.toast("Use Ctrl+V to paste.",{type:"info"})}break;case"select-all":r.focus(),r.select();break;case"format":{const t=it(r.value);A.setValue(t),a.content.value=t;break}default:break}}async function z(){if(q.peek())return;V.value=!0;const{errors:e,name:t}=K.peek();if(Object.keys(e).length){E.toast(e.name||e.css,{type:"warning"});return}const n=d?t:l;q.value=!0;try{const o=ut(Z(),be);if(await $.blocks.put(n,o),!C())return;be=o.meta,O.value=de(Z());try{S.remove?S.remove(j(l)):S.set(j(l),null)}catch{}nt("#/blocks"),E.toast(d?`Created "${n}".`:"Block saved.",{type:"success"}),d&&(location.hash=`#/blocks/edit/${encodeURIComponent(n)}`)}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{q.value=!1}}async function Xe(){D.peek()&&await E.confirm("Discard the changes you have not saved?")&&(V.value=!1,Se(Ze(O.peek())))}function Ze(e){try{const[t,n,o,w,H]=JSON.parse(e);return{name:t,project:n,bundled:o,content:w,css:H}}catch{return v}}const et=M.applyBindings(Ge,s,{methods:{save:()=>z(),discard:()=>Xe(),setWrite:()=>{k.value="write"},setSplit:()=>{k.value="split"},setPreview:()=>{k.value="preview"},setWidth:e=>{Ve.includes(String(e))&&(ee.value=String(e))},act:e=>Le(e)}});c(()=>et.dispose()),c(at(s,{position:"bottom"})),A=Ie(s.querySelector(".bex-write"),{initialValue:v.content,language:"html",autoIndent:!0,onChange:e=>{a.content.value=e},onCursor:({line:e,col:t})=>{we.value=`Ln ${e}, Col ${t}`}}),N=Ie(s.querySelector(".bex-css"),{initialValue:v.css,language:"css",autoIndent:!0,onChange:e=>{a.css.value=e}}),A.textarea.setAttribute("aria-label","Template HTML"),A.textarea.placeholder=`<div class="card mb-3">
6
+ </div>`;let h=null,Ue=!1;function St(){Ue||!window.R?.use||(Ue=!0,R.use((u,p,s)=>{if(!(ft.test(p?.path||"")&&G(u?.path)!==G(p?.path))||!h?.dirty())return s();const c=h;return E.confirm(`You have unsaved changes to ${Ne(c.label())}. Leave anyway? They are kept as a draft for your next visit.`).then(H=>{H?s():history.replaceState(null,"",c.hash)})}),M.subscribe("router:afterChange",({to:u}={})=>{if(h&&G(u?.path)!==h.path){const p=h;h=null,p.dispose()}}))}function Et(u,p){const s=u.querySelector(".dm-plugin-banner-name"),g=s?.lastChild;g&&g.nodeType===3&&s.querySelector(".dm-plugin-banner-sep")&&(g.textContent=p)}function Q(u){return navigator.clipboard?.writeText(u).then(()=>!0,()=>!1)??Promise.resolve(!1)}export const blockEditorView={templateUrl:"/admin/js/templates/block-editor.html",async onMount(u){if(St(),h){const e=h;h=null,e.dispose()}const p=u.get(0),s=p.querySelector(".bex")||p,g=[],c=e=>(g.push(e),e);let H=!1;const C=()=>!H&&s.isConnected,ve=location.hash.match(/^#\/blocks\/edit\/([^/?#]+)/),l=ve?decodeURIComponent(ve[1]):null,d=!l,Ye=location.hash,ze=G(location.hash.slice(1)),Je=d&&new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",he=E.loader(p,{type:"dots"});let y=null,X=[],Z=[];try{[y,X,Z]=await Promise.all([l?$.blocks.get(l):Promise.resolve(null),$.blocks.list().catch(()=>[]),$.projects.list().catch(()=>[])])}catch(e){he.destroy(),E.toast(e.message||"Block not found.",{type:"error"}),R.navigate("/blocks");return}if(he.destroy(),!s.isConnected)return;const fe=(Array.isArray(X)?X:[]).map(e=>e.name).sort((e,t)=>e.localeCompare(t));let be=y?.meta||{};const v=lt(y?{...y,name:l}:null,{project:Je}),a={};for(const[e,t]of Object.entries(v))a[e]=M.observable(t);const ee=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].peek()])),ye=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].value])),O=M.observable(de(v)),D=M.computed(()=>de(ye())!==O.value),q=M.observable(!1),V=M.observable(!1),K=M.computed(()=>st(ye(),{isNew:d,taken:fe}));let ke="split";try{ke=bt.includes(S.get(pe))?S.get(pe):"split"}catch{}const k=M.observable(ke);c(M.effect(()=>{const e=k.value;try{S.set(pe,e)}catch{}}));const we=M.observable("Ln 1, Col 1");let L={};try{L=S.get(He)||{}}catch{}const te=M.observable(Ve.includes(L.width)?L.width:"full"),U=M.observable(String(L.repeat)==="3"?"3":"1"),m=M.observable(typeof L.theme=="string"?L.theme:""),x=M.observable("");c(M.effect(()=>{const e={width:te.value,repeat:U.value,theme:m.value};try{S.set(He,e)}catch{}}));const f={},P={},Y=M.observable(0),oe=M.observable([]),ge=()=>We(a.content.peek());c(M.effect(()=>{const e=ct(a.content.value),t=We(a.content.value);let n=!1;for(const o of e){const w=t.has(o);if(!(o in f)||o in P&&P[o]!==w){const B=o in f;if(f[o]=_e(o,new Date,{raw:w}),P[o]=w,B){n=!0;const i=s.querySelector(`.bex-samples [data-sample="${CSS.escape(o)}"]`);i&&(i.value=f[o])}}}oe.value=e.map(o=>({key:o,label:t.has(o)?`{{{${o}}}}`:`{{${o}}}`,value:f[o]})),n&&queueMicrotask(()=>Y.value++)}));const Ge={...a,dirty:D,saving:q,mode:k,cursor:we,sampleRows:oe,isNew:d,notNew:!d,pvWidth:te,pvRepeat:U,pvTheme:m,pvStatus:x,titleText:d?"New block":l,metaText:M.computed(()=>{if(d)return"Not saved yet";const e=[];return(y?.updatedAt||y?.meta?.updatedAt)&&e.push(`changed ${Be(y.updatedAt||y.meta.updatedAt)}`),e.push(`${a.content.value.split(`
7
+ `).length} lines`),a.css.value.trim()&&e.push(`${Math.ceil(a.css.value.length/1024)} KB CSS`),e.join(" \xB7 ")}),nameErr:M.computed(()=>(V.value||a.name.value)&&K.value.errors.name||""),cssErr:M.computed(()=>K.value.errors.css||""),scopeHint:M.computed(()=>`[data-block="${vt(a.name.value)}"]`),noSamples:M.computed(()=>oe.value.length===0),noSamplesText:"No {{placeholders}} in the template yet.",modeCls:M.computed(()=>`bex-body is-${k.value}`),cannotSave:M.computed(()=>q.value||V.value&&Object.keys(K.value.errors).length>0)},ae=s.querySelector("#block-project");for(const e of Array.isArray(Z)?Z:[]){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,ae.appendChild(t)}if(v.project&&![...ae.options].some(e=>e.value===v.project)){const e=document.createElement("option");e.value=v.project,e.textContent=`${v.project} (no longer a project)`,ae.appendChild(e)}const F=s.querySelector("#block-quick-switch");for(const e of fe){const t=document.createElement("option");t.value=e,t.textContent=e,e===l&&(t.selected=!0),F.appendChild(t)}const b=s.querySelector(".bex-samples");b.innerHTML=gt;let A=null,N=null,r=null;function Se(e){for(const[t,n]of Object.entries(e))a[t]&&(a[t].value=n);A.setValue(String(e.content??"")),N.setValue(String(e.css??""))}const ne=s.querySelector("#block-preview-frame");let T="none",W=0;const Qe="As your site shows it \xB7 not saved",Ee=()=>{if(!C()||k.peek()==="write"||T!=="ready")return;const e={content:a.content.peek(),css:a.css.peek(),name:a.name.peek()},t=wt();if(!t){x.value="The preview needs the HTML cleaner, which did not load - reload the admin.";return}const n=mt(e,f,{repeat:Number(U.peek()),sanitize:t});try{ne.contentWindow?.postMessage({source:"dm-block-editor",type:"paint",tick:W,html:n},"*")}catch{}},Me=e=>{const t=dt(e,ne.contentWindow,W);if(!(!t||!C())){if(t.type==="save"){z();return}if(T=t.host?"ready":"failed",T==="failed"){x.value="Your site's page came back without a place for the block - the preview cannot show it.";return}x.peek()==="Loading your site\u2026"&&(x.value=Qe),Ee()}};window.addEventListener("message",Me),c(()=>window.removeEventListener("message",Me));async function Ce(){const e=++W;T="loading",x.value="Loading your site\u2026";let t;try{const n=m.peek()?{...Ke,theme:m.peek()}:Ke;({html:t}=await $.pages.previewFull(yt,n,`<div id="${me}"></div>`))}catch(n){t=`<!doctype html><html><head><meta charset="utf-8"></head><body style="font:14px system-ui;margin:0"><div id="${me}" style="padding:1rem"></div></body></html>`,x.value=`Without your site's styles - ${n?.message||"the page did not render"}`}!C()||e!==W||(ne.srcdoc=pt(t,e,me))}let se=0;c(M.effect(()=>{if(a.content.value,a.css.value,a.name.value,Y.value,U.value,k.value!=="write"){if(T==="none"){Ce();return}cancelAnimationFrame(se),se=requestAnimationFrame(Ee)}}));let xe=m.peek();c(M.effect(()=>{const e=m.value;e!==xe&&(xe=e,T!=="none"&&Ce())})),c(()=>{cancelAnimationFrame(se),W++}),$.theme.catalog().then(({themes:e=[],custom:t=[]}={})=>{if(!C())return;const n=s.querySelector("#block-preview-theme"),o=i=>String(i).replace(/-/g," ").replace(/\b\w/g,ie=>ie.toUpperCase()),w=[...e.filter(i=>!String(i).startsWith("admin-")).map(i=>({id:i,label:o(i)})),...(Array.isArray(t)?t:[]).filter(i=>i?.id).map(i=>({id:i.id,label:i.label||o(i.id)}))],B=w.map(i=>i.id);for(const{id:i,label:ie}of w){const ue=document.createElement("option");ue.value=i,ue.textContent=ie,n.appendChild(ue)}m.peek()&&!B.includes(m.peek())?m.value="":n.value=m.peek()}).catch(()=>{s.querySelector("#block-preview-theme")?.remove(),C()&&m.peek()&&(m.value="")});const Ae=e=>{const t=e.target?.dataset?.sample;t&&(f[t]=e.target.value,delete P[t],Y.value++)};b.addEventListener("input",Ae),c(()=>b.removeEventListener("input",Ae));function re(e){r.focus(),r.setRangeText(e,r.selectionStart,r.selectionEnd,"end"),r.dispatchEvent(new Event("input",{bubbles:!0}))}async function Le(e){switch(e){case"undo":r.focus(),document.execCommand("undo");break;case"redo":r.focus(),document.execCommand("redo");break;case"cut":r.selectionStart!==r.selectionEnd&&(await Q(r.value.slice(r.selectionStart,r.selectionEnd)),re(""));break;case"copy":r.selectionStart!==r.selectionEnd&&await Q(r.value.slice(r.selectionStart,r.selectionEnd));break;case"paste":try{re(await navigator.clipboard.readText())}catch{E.toast("Use Ctrl+V to paste.",{type:"info"})}break;case"select-all":r.focus(),r.select();break;case"format":{const t=it(r.value);A.setValue(t),a.content.value=t;break}default:break}}async function z(){if(q.peek())return;V.value=!0;const{errors:e,name:t}=K.peek();if(Object.keys(e).length){E.toast(e.name||e.css,{type:"warning"});return}const n=d?t:l;q.value=!0;try{const o=ut(ee(),be);if(await $.blocks.put(n,o),!C())return;be=o.meta,O.value=de(ee());try{S.remove?S.remove(j(l)):S.set(j(l),null)}catch{}nt("#/blocks"),E.toast(d?`Created "${n}".`:"Block saved.",{type:"success"}),d&&(location.hash=`#/blocks/edit/${encodeURIComponent(n)}`)}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{q.value=!1}}async function Xe(){D.peek()&&await E.confirm("Discard the changes you have not saved?")&&(V.value=!1,Se(Ze(O.peek())))}function Ze(e){try{const[t,n,o,w,B]=JSON.parse(e);return{name:t,project:n,bundled:o,content:w,css:B}}catch{return v}}const et=M.applyBindings(Ge,s,{methods:{save:()=>z(),discard:()=>Xe(),setWrite:()=>{k.value="write"},setSplit:()=>{k.value="split"},setPreview:()=>{k.value="preview"},setWidth:e=>{Ve.includes(String(e))&&(te.value=String(e))},act:e=>Le(e)}});c(()=>et.dispose()),c(at(s,{position:"bottom"})),A=Ie(s.querySelector(".bex-write"),{initialValue:v.content,language:"html",autoIndent:!0,onChange:e=>{a.content.value=e},onCursor:({line:e,col:t})=>{we.value=`Ln ${e}, Col ${t}`}}),N=Ie(s.querySelector(".bex-css"),{initialValue:v.css,language:"css",autoIndent:!0,onChange:e=>{a.css.value=e}}),A.textarea.setAttribute("aria-label","Template HTML"),A.textarea.placeholder=`<div class="card mb-3">
8
8
  <div class="card-body">
9
9
  <h3>{{title}}</h3>
10
10
  <p>{{message}}</p>
11
11
  </div>
12
12
  </div>`,N.textarea.setAttribute("aria-label","Custom CSS"),N.textarea.placeholder=`/* Scoped to this block - e.g.
13
13
  color: var(--dm-primary);
14
- h3 { font-size: 1.25rem; } */`,c(()=>{A.destroy(),N.destroy()}),r=A.textarea;const Te=s.querySelector(".bex-toolbar"),Re=e=>{const t=e.target.closest("[data-action]");t&&Le(t.dataset.action)};Te.addEventListener("click",Re),c(()=>Te.removeEventListener("click",Re));const $e=()=>{const e=F.value;e&&e!==l?location.hash=`#/blocks/edit/${encodeURIComponent(e)}`:F.value=l||""};F.addEventListener("change",$e),c(()=>F.removeEventListener("change",$e));const re=e=>ge().has(e)?`{{{${e}}}}`:`{{${e}}}`,tt=e=>[{header:re(e)},{label:"Insert into the template",icon:"corner-down-right",action:()=>se(re(e))},{label:"Copy placeholder",icon:"copy",action:async()=>{const t=re(e);E.toast(await G(t)?"Copied.":t,{type:"info"})}},{label:"Reset sample value",icon:"rotate-ccw",action:()=>{const t=ge().has(e);h[e]=_e(e,new Date,{raw:t}),P[e]=t;const n=b.querySelector(`[data-sample="${CSS.escape(e)}"]`);n&&(n.value=h[e]),Y.value++}}],ot=E.contextMenu(b,{cursor:"glyph",match:".bex-sample",inherit:!1,density:"compact",items:e=>tt(e.dataset.key)});c(()=>ot?.destroy?.());const je=e=>{const t=e.target.closest(".bex-kebab");t&&Fe(t)},Oe=e=>{const t=e.target.closest(".bex-sample");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Fe(t))};b.addEventListener("click",je),b.addEventListener("keydown",Oe),c(()=>{b.removeEventListener("click",je),b.removeEventListener("keydown",Oe)}),Et(p,d?"New block":l),ue(u,{id:"block-editor-list",icon:"arrow-left",title:"All blocks",onClick:()=>{location.hash="#/blocks"}}),d?p.querySelector('[data-banner-action="block-editor-shortcode"]')?.remove():ue(u,{id:"block-editor-shortcode",icon:"copy",title:"Copy shortcode",onClick:async()=>{const e=ft(l);E.toast(await G(e)?"Shortcode copied - set the collection slug.":e,{type:"info",duration:5e3})}}),ue(u,{id:"block-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>z()});const De=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),z())};document.addEventListener("keydown",De,!0),c(()=>document.removeEventListener("keydown",De,!0));const qe=()=>{if(D.peek())try{S.set(j(l),{form:Z(),base:O.peek(),at:new Date().toISOString()})}catch{}},Pe=e=>{D.peek()&&(qe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Pe),c(()=>window.removeEventListener("beforeunload",Pe)),f={hash:Ye,path:ze,dirty:()=>D.peek(),label:()=>d?"the new block":`"${l}"`,dispose(){B=!0,qe(),g.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(s);let ce=null;try{ce=S.get(j(l))}catch{}const _=rt(ce,O.peek());if(ce)try{S.remove?S.remove(j(l)):S.set(j(l),null)}catch{}if(_){const e=_.at?` (${He(_.at)})`:"",t=d?"a new block":`"${Ne(l)}"`,n=_.stale?`You left unsaved changes to ${t}${e}, but the block has been saved since. Restore your version anyway?`:`You left unsaved changes to ${t}${e}. Restore them?`;if(await E.confirm(n)&&C()){const o={...v,..._.form};d||(o.name=l),Se(o)}}}};
14
+ h3 { font-size: 1.25rem; } */`,c(()=>{A.destroy(),N.destroy()}),r=A.textarea;const Te=s.querySelector(".bex-toolbar"),Re=e=>{const t=e.target.closest("[data-action]");t&&Le(t.dataset.action)};Te.addEventListener("click",Re),c(()=>Te.removeEventListener("click",Re));const $e=()=>{const e=F.value;e&&e!==l?location.hash=`#/blocks/edit/${encodeURIComponent(e)}`:F.value=l||""};F.addEventListener("change",$e),c(()=>F.removeEventListener("change",$e));const ce=e=>ge().has(e)?`{{{${e}}}}`:`{{${e}}}`,tt=e=>[{header:ce(e)},{label:"Insert into the template",icon:"corner-down-right",action:()=>re(ce(e))},{label:"Copy placeholder",icon:"copy",action:async()=>{const t=ce(e);E.toast(await Q(t)?"Copied.":t,{type:"info"})}},{label:"Reset sample value",icon:"rotate-ccw",action:()=>{const t=ge().has(e);f[e]=_e(e,new Date,{raw:t}),P[e]=t;const n=b.querySelector(`[data-sample="${CSS.escape(e)}"]`);n&&(n.value=f[e]),Y.value++}}],ot=E.contextMenu(b,{cursor:"glyph",match:".bex-sample",inherit:!1,density:"compact",items:e=>tt(e.dataset.key)});c(()=>ot?.destroy?.());const je=e=>{const t=e.target.closest(".bex-kebab");t&&Fe(t)},Oe=e=>{const t=e.target.closest(".bex-sample");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Fe(t))};b.addEventListener("click",je),b.addEventListener("keydown",Oe),c(()=>{b.removeEventListener("click",je),b.removeEventListener("keydown",Oe)}),Et(p,d?"New block":l),J(u,{id:"block-editor-list",icon:"arrow-left",title:"All blocks",onClick:()=>{location.hash="#/blocks"}}),d?p.querySelector('[data-banner-action="block-editor-shortcode"]')?.remove():J(u,{id:"block-editor-shortcode",icon:"copy",title:"Copy shortcode",onClick:async()=>{const e=ht(l);E.toast(await Q(e)?"Shortcode copied - set the collection slug.":e,{type:"info",duration:5e3})}}),J(u,{id:"block-editor-guide",icon:"book",title:"Blocks guide",onClick:()=>{location.hash="#/docs/usage/blocks"}}),J(u,{id:"block-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>z()});const De=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),z())};document.addEventListener("keydown",De,!0),c(()=>document.removeEventListener("keydown",De,!0));const qe=()=>{if(D.peek())try{S.set(j(l),{form:ee(),base:O.peek(),at:new Date().toISOString()})}catch{}},Pe=e=>{D.peek()&&(qe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Pe),c(()=>window.removeEventListener("beforeunload",Pe)),h={hash:Ye,path:ze,dirty:()=>D.peek(),label:()=>d?"the new block":`"${l}"`,dispose(){H=!0,qe(),g.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(s);let le=null;try{le=S.get(j(l))}catch{}const _=rt(le,O.peek());if(le)try{S.remove?S.remove(j(l)):S.set(j(l),null)}catch{}if(_){const e=_.at?` (${Be(_.at)})`:"",t=d?"a new block":`"${Ne(l)}"`,n=_.stale?`You left unsaved changes to ${t}${e}, but the block has been saved since. Restore your version anyway?`:`You left unsaved changes to ${t}${e}. Restore them?`;if(await E.confirm(n)&&C()){const o={...v,..._.form};d||(o.name=l),Se(o)}}}};
@@ -1,4 +1,4 @@
1
- import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as ee}from"../lib/plugin-chrome.js";import{helpPopovers as te}from"../lib/help-popover.js";import{onceClosed as ne,openMenuAt as ae,sidebarCount as Se,viewLifetime as Me}from"../lib/tool-kit.js";import{filterByProject as Le,getProjectFromHash as Ie,renderProjectFilterNotice as Te}from"../lib/project-context.js";import{FILTERS as je,arrangeBlocks as $e,blockRow as Ne,checkNewName as De,copyName as se,shortcodeFor as oe,usageText as le}from"../lib/blocks-arrange.js";import{fmtDate as Be}from"../lib/dates.js";const ce=/^#\/(blocks|projects\/[^/?#]+\/blocks)\/?(\?.*)?$/,Fe="#/blocks",N="blocks.draft",A="blocks.filter",Re=`
1
+ import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as A}from"../lib/plugin-chrome.js";import{helpPopovers as te}from"../lib/help-popover.js";import{onceClosed as ne,openMenuAt as ae,sidebarCount as Se,viewLifetime as Me}from"../lib/tool-kit.js";import{filterByProject as Le,getProjectFromHash as Ie,renderProjectFilterNotice as Te}from"../lib/project-context.js";import{FILTERS as je,arrangeBlocks as $e,blockRow as Ne,checkNewName as Be,copyName as oe,shortcodeFor as se,usageText as le}from"../lib/blocks-arrange.js";import{fmtDate as De}from"../lib/dates.js";const ce=/^#\/(blocks|projects\/[^/?#]+\/blocks)\/?(\?.*)?$/,Fe="#/blocks",N="blocks.draft",U="blocks.filter",Re=`
2
2
  <div class="blx-row" data-name="{{name}}" tabindex="0" data-bind-class="unused && 'is-unused'">
3
3
  <span class="blx-icon" aria-hidden="true"><span data-icon="box" data-icon-size="16"></span></span>
4
4
  <span class="blx-main"><strong>{{name}}</strong><span data-bind-hidden="!description">{{description}}</span><small>{{meta}}</small></span>
@@ -10,13 +10,13 @@ import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as ee}fr
10
10
  </span>
11
11
  <span class="blx-uses" title="Pages and views that use it">{{uses}}</span>
12
12
  <button type="button" class="blx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
13
- </div>`,g=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ie(c){const a=document.createElement("template");return a.innerHTML=c.trim(),a.content.firstElementChild}const i=Me(ce);async function qe(c){try{return await navigator.clipboard.writeText(c),!0}catch{const a=document.createElement("textarea");a.value=c,a.style.cssText="position:fixed;opacity:0;",document.body.appendChild(a),a.select();let h=!1;try{h=document.execCommand("copy")}catch{}return a.remove(),h}}export const blocksView={templateUrl:"/admin/js/templates/blocks.html",async onMount(c){i.reset();const a=c.get(0).querySelector(".blx")||c.get(0),h=()=>ce.test(location.hash)&&a.isConnected,w=Ie(),p=M.observable([]),U=M.observable(!0),D=M.observable("");let H="all";try{H=je.includes(S.get(A))?S.get(A):"all"}catch{}const y=M.observable(H),O=M.observable("");i.add(M.effect(()=>{const e=y.value;try{S.set(A,e)}catch{}}));const re=Be,B=M.observable([]);i.add(M.effect(()=>{B.value=$e(p.value,{filter:y.value,query:O.value}).map(e=>Ne(e,re))}));const de=M.computed(()=>{const e=p.value,t=e.filter(n=>(n.usage?.pages?.length||0)+(n.usage?.views?.length||0)>0).length;return{all:e.length,used:t,unused:e.length-t}}),ue=M.computed(()=>p.value.length?"No blocks match.":"No blocks yet - New block in the banner makes one, or Import brings one in."),F=e=>p.peek().find(t=>t.name===e),pe=w?`#/blocks/new?project=${encodeURIComponent(w)}`:"#/blocks/new",P=e=>`#/blocks/edit/${encodeURIComponent(e)}`;async function K(){try{let e=await Ce("/blocks?usage=1",{method:"GET"});if(!h())return;if(e=Array.isArray(e)?e:[],w){const t=e.length;e=Le(e,w),Te(a,{total:t,shown:e.length,slug:w,allHref:"#/blocks",noun:"blocks"})}p.value=e,D.value=""}catch(e){D.value=`Could not load the blocks: ${e.message||e}`}finally{U.value=!1}}const T=async()=>{Se(Fe),h()&&await K()};async function me(e){const n=(e.usage?.pages?.length||0)+(e.usage?.views?.length||0)?` It is ${g(le(e).toLowerCase())} - they will show nothing where it was.`:"";if(await E.confirm(`Delete the block "${g(e.name)}"?${n} This cannot be undone.`))try{await v.blocks.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),await T()}catch(r){E.toast(r.message||"Failed to delete block.",{type:"error"})}}async function be(e){try{await v.blocks.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}async function he(e){const t=oe(e),n=await qe(t);E.toast(n?`Copied ${t}`:`Could not copy - your browser refused. The shortcode is ${t}`,{type:n?"success":"error"})}const k=a.querySelector(".blx-import-file");async function W(){const e=k.files?.[0];if(!e)return;k.value="";let t;try{t=JSON.parse(await e.text())}catch{E.toast("Not a valid .dmblock.json file (could not parse JSON).",{type:"error"});return}if(!t||typeof t!="object"||!t.name||typeof t.html!="string"){E.toast("Bundle is missing a name or html field.",{type:"error"});return}try{const n=await v.blocks.importBundle(t);E.toast(`Imported "${n.name}".`,{type:"success"}),await T()}catch(n){if(n.code==="CONFLICT"){if(!await E.confirm(`A block named "${g(n.name)}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const r=await v.blocks.importBundle(t,{overwrite:!0});E.toast(`Overwrote "${r.name}".`,{type:"success"}),await T()}catch(r){E.toast(r.message||"Import failed.",{type:"error"})}return}E.toast(n.message||"Import failed.",{type:"error"})}}k.addEventListener("change",W),i.add(()=>k.removeEventListener("change",W));const x=new Set;let f=null,z=!1;function fe(e){const t=E.slideover({title:`Where ${g(e.name)} is used`,size:"sm",position:"right"}),n=ie(`
13
+ </div>`,g=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ie(s){const a=document.createElement("template");return a.innerHTML=s.trim(),a.content.firstElementChild}const i=Me(ce);async function qe(s){try{return await navigator.clipboard.writeText(s),!0}catch{const a=document.createElement("textarea");a.value=s,a.style.cssText="position:fixed;opacity:0;",document.body.appendChild(a),a.select();let h=!1;try{h=document.execCommand("copy")}catch{}return a.remove(),h}}export const blocksView={templateUrl:"/admin/js/templates/blocks.html",async onMount(s){i.reset();const a=s.get(0).querySelector(".blx")||s.get(0),h=()=>ce.test(location.hash)&&a.isConnected,w=Ie(),p=M.observable([]),H=M.observable(!0),B=M.observable("");let O="all";try{O=je.includes(S.get(U))?S.get(U):"all"}catch{}const y=M.observable(O),P=M.observable("");i.add(M.effect(()=>{const e=y.value;try{S.set(U,e)}catch{}}));const re=De,D=M.observable([]);i.add(M.effect(()=>{D.value=$e(p.value,{filter:y.value,query:P.value}).map(e=>Ne(e,re))}));const de=M.computed(()=>{const e=p.value,t=e.filter(n=>(n.usage?.pages?.length||0)+(n.usage?.views?.length||0)>0).length;return{all:e.length,used:t,unused:e.length-t}}),ue=M.computed(()=>p.value.length?"No blocks match.":"No blocks yet - New block in the banner makes one, or Import brings one in."),F=e=>p.peek().find(t=>t.name===e),pe=w?`#/blocks/new?project=${encodeURIComponent(w)}`:"#/blocks/new",K=e=>`#/blocks/edit/${encodeURIComponent(e)}`;async function W(){try{let e=await Ce("/blocks?usage=1",{method:"GET"});if(!h())return;if(e=Array.isArray(e)?e:[],w){const t=e.length;e=Le(e,w),Te(a,{total:t,shown:e.length,slug:w,allHref:"#/blocks",noun:"blocks"})}p.value=e,B.value=""}catch(e){B.value=`Could not load the blocks: ${e.message||e}`}finally{H.value=!1}}const T=async()=>{Se(Fe),h()&&await W()};async function me(e){const n=(e.usage?.pages?.length||0)+(e.usage?.views?.length||0)?` It is ${g(le(e).toLowerCase())} - they will show nothing where it was.`:"";if(await E.confirm(`Delete the block "${g(e.name)}"?${n} This cannot be undone.`))try{await v.blocks.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),await T()}catch(r){E.toast(r.message||"Failed to delete block.",{type:"error"})}}async function be(e){try{await v.blocks.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}async function he(e){const t=se(e),n=await qe(t);E.toast(n?`Copied ${t}`:`Could not copy - your browser refused. The shortcode is ${t}`,{type:n?"success":"error"})}const k=a.querySelector(".blx-import-file");async function z(){const e=k.files?.[0];if(!e)return;k.value="";let t;try{t=JSON.parse(await e.text())}catch{E.toast("Not a valid .dmblock.json file (could not parse JSON).",{type:"error"});return}if(!t||typeof t!="object"||!t.name||typeof t.html!="string"){E.toast("Bundle is missing a name or html field.",{type:"error"});return}try{const n=await v.blocks.importBundle(t);E.toast(`Imported "${n.name}".`,{type:"success"}),await T()}catch(n){if(n.code==="CONFLICT"){if(!await E.confirm(`A block named "${g(n.name)}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const r=await v.blocks.importBundle(t,{overwrite:!0});E.toast(`Overwrote "${r.name}".`,{type:"success"}),await T()}catch(r){E.toast(r.message||"Import failed.",{type:"error"})}return}E.toast(n.message||"Import failed.",{type:"error"})}}k.addEventListener("change",z),i.add(()=>k.removeEventListener("change",z));const x=new Set;let f=null,_=!1;function fe(e){const t=E.slideover({title:`Where ${g(e.name)} is used`,size:"sm",position:"right"}),n=ie(`
14
14
  <div class="blx-form">
15
15
  <p class="blx-note blx-where-sum"></p>
16
16
  <ul class="blx-where"></ul>
17
17
  <div><div class="form-label">Shortcode</div><div class="blx-code"></div>
18
18
  <small class="blx-hint">Right-click the block in the list and Copy shortcode to paste it into a page.</small></div>
19
- </div>`);n.querySelector(".blx-where-sum").textContent=le(e)+".",n.querySelector(".blx-code").textContent=oe(e);const r=n.querySelector(".blx-where"),b=(o,j,$)=>{const L=document.createElement("li"),s=document.createElement("a");s.href=o,s.textContent=j,s.addEventListener("click",()=>t.close());const d=document.createElement("small");d.textContent=$,L.append(s,d),r.appendChild(L)};for(const o of e.usage?.pages||[])b(`#/pages/edit${o}`,o,"page");for(const o of e.usage?.views||[])b(`#/views/edit/${encodeURIComponent(o)}`,o,"view");t.element.appendChild(n),x.add(t),ne(t,()=>x.delete(t)),t.open()}function _(e,t=null){const n=()=>p.peek().map(l=>l.name),r=t?.name??se(e.name,n()),b=M.observable(r),o=M.observable(!1),j=M.computed(()=>De(b.value,p.value.map(l=>l.name))),$={name:b,saving:o,err:j,cannot:M.computed(()=>o.value||!!j.value)},L=()=>b.peek()!==se(e.name,n())||!!t,s=E.slideover({title:`Duplicate ${g(e.name)}`,size:"sm",position:"right"}),d=ie(`
19
+ </div>`);n.querySelector(".blx-where-sum").textContent=le(e)+".",n.querySelector(".blx-code").textContent=se(e);const r=n.querySelector(".blx-where"),b=(l,j,$)=>{const L=document.createElement("li"),o=document.createElement("a");o.href=l,o.textContent=j,o.addEventListener("click",()=>t.close());const d=document.createElement("small");d.textContent=$,L.append(o,d),r.appendChild(L)};for(const l of e.usage?.pages||[])b(`#/pages/edit${l}`,l,"page");for(const l of e.usage?.views||[])b(`#/views/edit/${encodeURIComponent(l)}`,l,"view");t.element.appendChild(n),x.add(t),ne(t,()=>x.delete(t)),t.open()}function Y(e,t=null){const n=()=>p.peek().map(c=>c.name),r=t?.name??oe(e.name,n()),b=M.observable(r),l=M.observable(!1),j=M.computed(()=>Be(b.value,p.value.map(c=>c.name))),$={name:b,saving:l,err:j,cannot:M.computed(()=>l.value||!!j.value)},L=()=>b.peek()!==oe(e.name,n())||!!t,o=E.slideover({title:`Duplicate ${g(e.name)}`,size:"sm",position:"right"}),d=ie(`
20
20
  <div class="blx-form">
21
21
  <div>
22
22
  <label class="form-label" for="blx-dup-name">Name of the copy
@@ -31,4 +31,4 @@ import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as ee}fr
31
31
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
32
32
  <span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
33
33
  </div>
34
- </div>`);let R=!1;async function Q(){if($.cannot.peek())return;o.value=!0;const l=b.peek().trim();try{const u=await v.blocks.get(e.name);await v.blocks.put(l,{content:u.content,css:u.css||"",...u.meta?.project&&{meta:{project:u.meta.project}}}),E.toast(`Made "${l}".`,{type:"success"}),R=!0,s.close(),await T()}catch(u){E.toast(`Not duplicated: ${u.message||u}`,{type:"error"})}finally{o.value=!1}}s.element.appendChild(d);const we=te(d),ke=M.applyBindings($,d,{methods:{save:Q,cancel:()=>s.close()}}),xe=l=>{l.key==="Enter"&&l.target.matches("input")&&(l.preventDefault(),Q())};d.addEventListener("keydown",xe);const X={dirty:L,values:()=>({name:b.peek()}),source:e.name};f=X,x.add(s),ne(s,()=>{ke.dispose(),we(),x.delete(s),f===X&&(f=null)});const Ee=s.close.bind(s);let q=!1;s.close=async l=>{if(!R&&!z&&l!==!0&&L()){if(q)return;q=!0;const u=await E.confirm("Discard the name you typed?");if(q=!1,!u)return}return R=!0,Ee()},I.scan(d),s.open();const Z=d.querySelector("#blx-dup-name");Z?.focus(),Z?.select()}function ye(e){const t=F(e);if(!t)return[];const n=(t.usage?.pages?.length||0)+(t.usage?.views?.length||0);return[{header:t.name},{label:"Edit",icon:"edit",action:()=>{location.hash=P(t.name)}},{label:"Copy shortcode",icon:"copy",action:()=>he(t)},{label:n?`Where it is used (${n})\u2026`:"Where it is used\u2026",icon:"link",action:()=>fe(t)},{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>_(t)},{label:"Export (.dmblock.json)",icon:"download",action:()=>be(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>me(t)}]}const m=a.querySelector(".blx-list");m.innerHTML=Re;const ve=M.applyBindings({blocks:p,loading:U,loadError:D,filter:y,query:O,rows:B,counts:de,emptyText:ue},a,{methods:{showAll:()=>{y.value="all"},showUsed:()=>{y.value="used"},showUnused:()=>{y.value="unused"}}});i.add(()=>ve.dispose()),i.add(te(a,{position:"bottom"})),i.add(M.effect(()=>{B.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})}));const ge=E.contextMenu(m,{cursor:"glyph",match:".blx-row",inherit:!1,density:"compact",items:e=>ye(e.dataset.name)});i.add(()=>ge?.destroy?.());const Y=e=>{F(e)&&(location.hash=P(e))},J=e=>{const t=e.target.closest(".blx-row");if(!t)return;const n=e.target.closest(".blx-kebab");if(n){ae(n);return}Y(t.dataset.name)},V=e=>{const t=e.target.closest(".blx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ae(t)):e.key==="Enter"&&e.target===t&&Y(t.dataset.name))};m.addEventListener("click",J),m.addEventListener("keydown",V),i.add(()=>{m.removeEventListener("click",J),m.removeEventListener("keydown",V)}),ee(c,{id:"blocks-import",icon:"upload",title:"Import a .dmblock.json bundle",onClick:()=>k.click()}),ee(c,{id:"blocks-new",icon:"plus",title:"New block",onClick:()=>{location.hash=pe}});const G=e=>{f?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",G),i.add(()=>window.removeEventListener("beforeunload",G)),i.add(()=>{if(f?.dirty())try{S.set(N,{source:f.source,values:f.values(),at:new Date().toISOString()})}catch{}z=!0,[...x].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(a),await K(),!h())return;let C=null;try{C=S.get(N)}catch{}if(C?.values&&C.source){try{S.remove?S.remove(N):S.set(N,null)}catch{}const e=F(C.source);e&&await E.confirm(`You were duplicating "${g(e.name)}" and left it unfinished. Pick up where you left off?`)&&h()&&_(e,C.values)}}};
34
+ </div>`);let R=!1;async function X(){if($.cannot.peek())return;l.value=!0;const c=b.peek().trim();try{const u=await v.blocks.get(e.name);await v.blocks.put(c,{content:u.content,css:u.css||"",...u.meta?.project&&{meta:{project:u.meta.project}}}),E.toast(`Made "${c}".`,{type:"success"}),R=!0,o.close(),await T()}catch(u){E.toast(`Not duplicated: ${u.message||u}`,{type:"error"})}finally{l.value=!1}}o.element.appendChild(d);const we=te(d),ke=M.applyBindings($,d,{methods:{save:X,cancel:()=>o.close()}}),xe=c=>{c.key==="Enter"&&c.target.matches("input")&&(c.preventDefault(),X())};d.addEventListener("keydown",xe);const Z={dirty:L,values:()=>({name:b.peek()}),source:e.name};f=Z,x.add(o),ne(o,()=>{ke.dispose(),we(),x.delete(o),f===Z&&(f=null)});const Ee=o.close.bind(o);let q=!1;o.close=async c=>{if(!R&&!_&&c!==!0&&L()){if(q)return;q=!0;const u=await E.confirm("Discard the name you typed?");if(q=!1,!u)return}return R=!0,Ee()},I.scan(d),o.open();const ee=d.querySelector("#blx-dup-name");ee?.focus(),ee?.select()}function ye(e){const t=F(e);if(!t)return[];const n=(t.usage?.pages?.length||0)+(t.usage?.views?.length||0);return[{header:t.name},{label:"Edit",icon:"edit",action:()=>{location.hash=K(t.name)}},{label:"Copy shortcode",icon:"copy",action:()=>he(t)},{label:n?`Where it is used (${n})\u2026`:"Where it is used\u2026",icon:"link",action:()=>fe(t)},{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>Y(t)},{label:"Export (.dmblock.json)",icon:"download",action:()=>be(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>me(t)}]}const m=a.querySelector(".blx-list");m.innerHTML=Re;const ve=M.applyBindings({blocks:p,loading:H,loadError:B,filter:y,query:P,rows:D,counts:de,emptyText:ue},a,{methods:{showAll:()=>{y.value="all"},showUsed:()=>{y.value="used"},showUnused:()=>{y.value="unused"}}});i.add(()=>ve.dispose()),i.add(te(a,{position:"bottom"})),i.add(M.effect(()=>{D.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})}));const ge=E.contextMenu(m,{cursor:"glyph",match:".blx-row",inherit:!1,density:"compact",items:e=>ye(e.dataset.name)});i.add(()=>ge?.destroy?.());const J=e=>{F(e)&&(location.hash=K(e))},V=e=>{const t=e.target.closest(".blx-row");if(!t)return;const n=e.target.closest(".blx-kebab");if(n){ae(n);return}J(t.dataset.name)},G=e=>{const t=e.target.closest(".blx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ae(t)):e.key==="Enter"&&e.target===t&&J(t.dataset.name))};m.addEventListener("click",V),m.addEventListener("keydown",G),i.add(()=>{m.removeEventListener("click",V),m.removeEventListener("keydown",G)}),A(s,{id:"blocks-guide",icon:"book",title:"Blocks guide",onClick:()=>{location.hash="#/docs/usage/blocks"}}),A(s,{id:"blocks-import",icon:"upload",title:"Import a .dmblock.json bundle",onClick:()=>k.click()}),A(s,{id:"blocks-new",icon:"plus",title:"New block",onClick:()=>{location.hash=pe}});const Q=e=>{f?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Q),i.add(()=>window.removeEventListener("beforeunload",Q)),i.add(()=>{if(f?.dirty())try{S.set(N,{source:f.source,values:f.values(),at:new Date().toISOString()})}catch{}_=!0,[...x].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(a),await W(),!h())return;let C=null;try{C=S.get(N)}catch{}if(C?.values&&C.source){try{S.remove?S.remove(N):S.set(N,null)}catch{}const e=F(C.source);e&&await E.confirm(`You were duplicating "${g(e.name)}" and left it unfinished. Pick up where you left off?`)&&h()&&Y(e,C.values)}}};
@@ -1 +1 @@
1
- import{mountScaffolder as i}from"../lib/crud-tutorial.js";function t(o,n){return{templateUrl:`/admin/js/templates/docs/${o}.html`,async onMount(e){typeof n=="function"&&n(e),Domma.icons.scan(),Domma.syntax.scan()}}}export const docViews={tutorialCrud:t("tutorial-crud",o=>{const n=o.find("#tutorial-scaffolder-mount").get(0);n&&i(n,null,null)}),tutorialPlugin:t("tutorial-plugin"),tutorialForms:t("tutorial-forms"),componentsReference:t("components-reference"),componentsHowto:t("components-howto"),componentsWalkthrough:t("components-walkthrough"),componentsRules:t("components-rules")};const a=["pages","media","navigation","dconfig","shortcodes","site-settings","plugins","users-roles","views","actions","cta-shortcode","editions","collections","tools","dashboard"],c=["authentication","pages","settings","layouts","navigation","media","users","plugins","collections","views","actions","forms","scaffold","external","content","builder"],s=o=>o.split("-").map(n=>n.charAt(0).toUpperCase()+n.slice(1)).join("");for(const o of a)docViews["usage"+s(o)]=t(`usage-${o}`);for(const o of c)docViews["apiRef"+s(o)]=t(`api-${o}`);
1
+ import{mountScaffolder as i}from"../lib/crud-tutorial.js";function t(o,s){return{templateUrl:`/admin/js/templates/docs/${o}.html`,async onMount(e){typeof s=="function"&&s(e),Domma.icons.scan(),Domma.syntax.scan()}}}export const docViews={tutorialCrud:t("tutorial-crud",o=>{const s=o.find("#tutorial-scaffolder-mount").get(0);s&&i(s,null,null)}),tutorialPlugin:t("tutorial-plugin"),tutorialForms:t("tutorial-forms"),componentsReference:t("components-reference"),componentsHowto:t("components-howto"),componentsWalkthrough:t("components-walkthrough"),componentsRules:t("components-rules")};const a=["pages","media","navigation","dconfig","shortcodes","site-settings","plugins","users-roles","views","actions","cta-shortcode","editions","collections","blocks","tools","dashboard"],c=["authentication","pages","settings","layouts","navigation","media","users","plugins","collections","views","actions","forms","scaffold","external","content","builder"],n=o=>o.split("-").map(s=>s.charAt(0).toUpperCase()+s.slice(1)).join("");for(const o of a)docViews["usage"+n(o)]=t(`usage-${o}`);for(const o of c)docViews["apiRef"+n(o)]=t(`api-${o}`);
@@ -1,4 +1,4 @@
1
- import{api as x,getUser as et}from"../api.js";import{addBannerAction as Ae}from"../lib/plugin-chrome.js";import{SHAPES as tt,whileLoading as at}from"../lib/loading.js";import{helpPopovers as Le}from"../lib/help-popover.js";import{onceClosed as nt,openMenuAt as Ue,sidebarCount as st,viewLifetime as lt}from"../lib/tool-kit.js";import{pluginAccent as Re}from"../lib/plugin-accent.js";import{reportRestart as it}from"../lib/server-restart.js";import{FILTERS as ot,arrangeCatalogue as rt,arrangePlugins as dt,catalogueAction as te,catalogueRow as ct,checkScaffold as ut,dependencyRows as pt,formatPrice as je,fmtReleased as mt,needsAttention as bt,planLines as ht,pluginRow as ft,pluginStatus as Be,readSettings as vt,settingsFields as De,slugifyPlugin as gt,updateFor as Fe}from"../lib/plugins-arrange.js";const qe=/^#\/plugins\/?(\?.*)?$/,yt="#/plugins",ue="plugins.filter",ae="plugins.draft",wt=`
1
+ import{api as x,getUser as et}from"../api.js";import{addBannerAction as ue}from"../lib/plugin-chrome.js";import{SHAPES as tt,whileLoading as at}from"../lib/loading.js";import{helpPopovers as Le}from"../lib/help-popover.js";import{onceClosed as nt,openMenuAt as Ue,sidebarCount as st,viewLifetime as lt}from"../lib/tool-kit.js";import{pluginAccent as Re}from"../lib/plugin-accent.js";import{reportRestart as it}from"../lib/server-restart.js";import{FILTERS as ot,arrangeCatalogue as rt,arrangePlugins as dt,catalogueAction as te,catalogueRow as ct,checkScaffold as ut,dependencyRows as pt,formatPrice as je,fmtReleased as bt,needsAttention as mt,planLines as ht,pluginRow as ft,pluginStatus as Be,readSettings as vt,settingsFields as De,slugifyPlugin as gt,updateFor as Fe}from"../lib/plugins-arrange.js";const qe=/^#\/plugins\/?(\?.*)?$/,yt="#/plugins",pe="plugins.filter",ae="plugins.draft",wt=`
2
2
  <div class="plx-row" data-name="{{name}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
3
3
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
4
4
  <span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
@@ -34,7 +34,7 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as Ae}from"
34
34
  <strong data-bind-text="displayName"></strong>
35
35
  <span class="plx-chip" data-bind-class="stateCls" data-bind-text="stateText"></span>
36
36
  <small class="plx-hint" data-bind-hidden="!reason" data-bind-text="reason"></small>
37
- </li>`,d=A=>String(A??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function pe(A){const b=document.createElement("template");return b.innerHTML=A.trim(),b.content.firstElementChild}const v=lt(qe);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(A){v.reset();const b=A.get(0).querySelector(".plx")||A.get(0),k=()=>qe.test(location.hash)&&b.isConnected,L=M.observable([]),R=M.observable([]),me=M.computed(()=>R.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),h=M.observable({available:null,site:null,items:[]}),be=M.observable(!0),ne=M.observable(""),m=M.observable("");let he="all";try{he=ot.includes(S.get(ue))?S.get(ue):"all"}catch{}const T=M.observable(he),V=M.observable(""),j=M.observable("all"),fe=M.observable("");v.add(M.effect(()=>{const e=T.value;try{S.set(ue,e)}catch{}}));const Y=M.computed(()=>new Map((h.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(h.peek().items||[]).find(t=>t.slug===e),se=M.observable([]);v.add(M.effect(()=>{const e=Y.value;se.value=dt(L.value,{filter:T.value,query:V.value,latest:e}).map(t=>({...ft(t,e),accent:Re(t.name)}))}));const We=M.computed(()=>{const e=L.value,t=Y.value,a=e.filter(n=>Be(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>bt(n,t)).length}}),J=M.computed(()=>(h.value.items||[]).filter(e=>te(e)==="update").length),Pe=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),le=M.observable([]);v.add(M.effect(()=>{le.value=rt(h.value.items,{filter:j.value,query:fe.value}).map(e=>{const t=ct(e),a=t.action==="update"||t.action==="install"||t.action==="buy";return{...t,accent:Re(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const ze=M.computed(()=>{const e=h.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>te(t)==="install").length,updates:J.value}}),Q=e=>L.peek().find(t=>t.name===e);async function ie(){try{const[e,t]=await at(b.querySelector(".dm-loading-slot"),Promise.all([x.plugins.list(),x.tools.list().catch(()=>[])]),tt.iconRows);if(!k())return;L.value=Array.isArray(e)?e:[],R.value=Array.isArray(t)?t:[],ne.value=""}catch(e){ne.value=`Could not load the plugins: ${e.message||e}`}finally{be.value=!1}}async function ve(){try{const e=await x.marketplace.catalogue();if(!k())return;h.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{h.value={available:!1,site:null,items:[],failed:!0}}}const P=async()=>{await Promise.all([ie(),ve()]),st(yt)};async function B(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${d(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||m.peek())return;const l=i=>a==="tool"?x.tools.update(e.name,{enabled:t,cascade:i}):x.plugins.update(e.name,{enabled:t,cascade:i});m.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let i;try{i=await l(!1)}catch(c){if(c.status!==409||!c.body?.needsConfirm)throw c;const u=ht(c.body.plan);if(m.value="",!await E.confirm(`<p>Switching ${d(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(o=>`<li>${d(o)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;m.value=`Switching ${e.displayName||e.name} ${n}\u2026`,i=await l(!0)}await it(i,{onStatus:c=>{m.value=c}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(i){E.toast(`Not switched ${n}: ${i.message||i}`,{type:"error"})}finally{m.value="",k()&&await P()}}async function D(e,{update:t=!1}={}){const a=d(e.displayName||e.slug),n=t?`<p>Update ${a} from ${d(e.installedVersion)} to ${d(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${d(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await x.marketplace.install(e.slug,e.version,{update:t}),i=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${i} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}k()&&await P()}}function ge(e){e?.buyUrl&&(window.open(e.buyUrl,"_blank","noopener"),E.toast(`Manage my site opened in a new tab. We email you a sign-in link; ${e.displayName||e.slug} is ticked ready once you are in. Install it here when it is paid for.`,{type:"info",duration:9e3}))}async function ye(e,t){const n=W(e)?.tier==="paid"?` <p class="text-muted">It is a paid Tool: your site's manager is told, and if you pay for it on its own it comes off your monthly bill (the rest of this month is credited).</p>`:"";if(await E.confirm(`<p>Uninstall ${d(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>${n}`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{const i=(await x.marketplace.uninstall(e))?.release?.removed?" It is off your bill.":"";E.toast(`${t||e} uninstalled.${i} Restart the server to apply.`,{type:"success",duration:6e3})}catch(l){E.toast(`Uninstall failed: ${l.message||l}`,{type:"error"})}k()&&await P()}}async function we(e){try{await x.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const xe=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},z=b.querySelector(".plx-file");async function _e(e){if(m.peek())return;m.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await x.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(m.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${d(n.name)} ${d(n.installedVersion||"the installed version")} is already installed. Update it to ${d(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${d(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${d(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}m.value=`Installing ${e.name}\u2026`}}finally{m.value="",k()&&await P()}}const ke=async()=>{const e=z.files?.[0];z.value="",e&&await _e(e)};z.addEventListener("change",ke),v.add(()=>z.removeEventListener("change",ke));const G=new Set;let U=null,$e=!1;function Ee({title:e,size:t="md",panel:a,model:n,methods:l,dirty:i=()=>!1,form:c=null,cleanup:u=()=>{}}){const o=E.slideover({title:d(e),size:t,position:"right"});o.element.appendChild(a);const $=Le(a),y=M.applyBindings(n,a,{methods:l});c&&(U=c),G.add(o),nt(o,()=>{u(),y.dispose(),$(),G.delete(o),U===c&&(U=null)});const w=o.close.bind(o);let f=!1,N=!1;return o.close=async F=>{if(!f&&F!==!0&&i()){if(N)return;N=!0;const q=await E.confirm("Discard the changes you have not saved?");if(N=!1,!q)return}return f=!0,w()},I.scan(a),o.open(),o}function oe(e,t=null){const a=Q(e);if(!a)return;const n=Be(a),l=De(a),i=W(a.name),c=Fe(a,Y.peek()),u=!!(c&&i?.entitled&&!i?.needsCms),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},y=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>vt(l,Object.fromEntries(l.map(s=>[s.key,y[s.key].value])))),f=M.observable(!1),N=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",mt(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),q=(s,p)=>{const O=`plx-set-${p}`,Z=s.help?` <span data-help="${d(s.help)}"></span>`:"",H=`<label class="form-label" for="${O}">${d(s.label)}${Z}</label>`,K=`data-model="v${p}.value"`,ee=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${H}<input id="${O}" type="checkbox" class="form-switch" ${K}></div>`;if(s.type==="select")return`<div>${H}<select id="${O}" class="form-input" ${K}>${s.options.map(Oe=>`<option value="${d(Oe.value)}">${d(Oe.label)}</option>`).join("")}</select>${ee}</div>`;if(s.type==="json")return`<div>${H}<textarea id="${O}" class="form-input plx-json" rows="5" spellcheck="false" ${K}></textarea>${ee}</div>`;if(s.type==="textarea")return`<div>${H}<textarea id="${O}" class="form-input" rows="3" ${K}></textarea>${ee}</div>`;const Xe=s.type==="number"?"number":s.secret?"password":"text",Ze=`${s.min!=null?` min="${d(s.min)}"`:""}${s.max!=null?` max="${d(s.max)}"`:""}`;return`<div>${H}<input id="${O}" type="${Xe}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Ze} ${K}>${ee}</div>`},r=pt(a,{plugins:L.peek(),tools:R.peek(),catalogue:h.peek().items||[]}),C={saving:f,needs:M.observable(r.needs),worksWith:M.observable(r.worksWith),hasNeeds:r.needs.length>0,hasWorksWith:r.worksWith.length>0,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,y[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},_=pe(`
37
+ </li>`,d=T=>String(T??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function be(T){const m=document.createElement("template");return m.innerHTML=T.trim(),m.content.firstElementChild}const v=lt(qe);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(T){v.reset();const m=T.get(0).querySelector(".plx")||T.get(0),k=()=>qe.test(location.hash)&&m.isConnected,L=M.observable([]),R=M.observable([]),me=M.computed(()=>R.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),h=M.observable({available:null,site:null,items:[]}),he=M.observable(!0),ne=M.observable(""),b=M.observable("");let fe="all";try{fe=ot.includes(S.get(pe))?S.get(pe):"all"}catch{}const N=M.observable(fe),V=M.observable(""),j=M.observable("all"),ve=M.observable("");v.add(M.effect(()=>{const e=N.value;try{S.set(pe,e)}catch{}}));const Y=M.computed(()=>new Map((h.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(h.peek().items||[]).find(t=>t.slug===e),se=M.observable([]);v.add(M.effect(()=>{const e=Y.value;se.value=dt(L.value,{filter:N.value,query:V.value,latest:e}).map(t=>({...ft(t,e),accent:Re(t.name)}))}));const We=M.computed(()=>{const e=L.value,t=Y.value,a=e.filter(n=>Be(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>mt(n,t)).length}}),J=M.computed(()=>(h.value.items||[]).filter(e=>te(e)==="update").length),Pe=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),le=M.observable([]);v.add(M.effect(()=>{le.value=rt(h.value.items,{filter:j.value,query:ve.value}).map(e=>{const t=ct(e),a=t.action==="update"||t.action==="install"||t.action==="buy";return{...t,accent:Re(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const ze=M.computed(()=>{const e=h.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>te(t)==="install").length,updates:J.value}}),Q=e=>L.peek().find(t=>t.name===e);async function ie(){try{const[e,t]=await at(m.querySelector(".dm-loading-slot"),Promise.all([x.plugins.list(),x.tools.list().catch(()=>[])]),tt.iconRows);if(!k())return;L.value=Array.isArray(e)?e:[],R.value=Array.isArray(t)?t:[],ne.value=""}catch(e){ne.value=`Could not load the plugins: ${e.message||e}`}finally{he.value=!1}}async function ge(){try{const e=await x.marketplace.catalogue();if(!k())return;h.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{h.value={available:!1,site:null,items:[],failed:!0}}}const P=async()=>{await Promise.all([ie(),ge()]),st(yt)};async function B(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${d(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||b.peek())return;const l=i=>a==="tool"?x.tools.update(e.name,{enabled:t,cascade:i}):x.plugins.update(e.name,{enabled:t,cascade:i});b.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let i;try{i=await l(!1)}catch(c){if(c.status!==409||!c.body?.needsConfirm)throw c;const u=ht(c.body.plan);if(b.value="",!await E.confirm(`<p>Switching ${d(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(o=>`<li>${d(o)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;b.value=`Switching ${e.displayName||e.name} ${n}\u2026`,i=await l(!0)}await it(i,{onStatus:c=>{b.value=c}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(i){E.toast(`Not switched ${n}: ${i.message||i}`,{type:"error"})}finally{b.value="",k()&&await P()}}async function D(e,{update:t=!1}={}){const a=d(e.displayName||e.slug),n=t?`<p>Update ${a} from ${d(e.installedVersion)} to ${d(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${d(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await x.marketplace.install(e.slug,e.version,{update:t}),i=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${i} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}k()&&await P()}}function ye(e){e?.buyUrl&&(window.open(e.buyUrl,"_blank","noopener"),E.toast(`Manage my site opened in a new tab. We email you a sign-in link; ${e.displayName||e.slug} is ticked ready once you are in. Install it here when it is paid for.`,{type:"info",duration:9e3}))}async function we(e,t){const n=W(e)?.tier==="paid"?` <p class="text-muted">It is a paid Tool: your site's manager is told, and if you pay for it on its own it comes off your monthly bill (the rest of this month is credited).</p>`:"";if(await E.confirm(`<p>Uninstall ${d(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>${n}`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{const i=(await x.marketplace.uninstall(e))?.release?.removed?" It is off your bill.":"";E.toast(`${t||e} uninstalled.${i} Restart the server to apply.`,{type:"success",duration:6e3})}catch(l){E.toast(`Uninstall failed: ${l.message||l}`,{type:"error"})}k()&&await P()}}async function xe(e){try{await x.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const ke=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},z=m.querySelector(".plx-file");async function _e(e){if(b.peek())return;b.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await x.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(b.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${d(n.name)} ${d(n.installedVersion||"the installed version")} is already installed. Update it to ${d(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${d(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${d(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}b.value=`Installing ${e.name}\u2026`}}finally{b.value="",k()&&await P()}}const $e=async()=>{const e=z.files?.[0];z.value="",e&&await _e(e)};z.addEventListener("change",$e),v.add(()=>z.removeEventListener("change",$e));const G=new Set;let U=null,Ee=!1;function Se({title:e,size:t="md",panel:a,model:n,methods:l,dirty:i=()=>!1,form:c=null,cleanup:u=()=>{}}){const o=E.slideover({title:d(e),size:t,position:"right"});o.element.appendChild(a);const $=Le(a),y=M.applyBindings(n,a,{methods:l});c&&(U=c),G.add(o),nt(o,()=>{u(),y.dispose(),$(),G.delete(o),U===c&&(U=null)});const w=o.close.bind(o);let f=!1,O=!1;return o.close=async F=>{if(!f&&F!==!0&&i()){if(O)return;O=!0;const q=await E.confirm("Discard the changes you have not saved?");if(O=!1,!q)return}return f=!0,w()},I.scan(a),o.open(),o}function oe(e,t=null){const a=Q(e);if(!a)return;const n=Be(a),l=De(a),i=W(a.name),c=Fe(a,Y.peek()),u=!!(c&&i?.entitled&&!i?.needsCms),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},y=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>vt(l,Object.fromEntries(l.map(s=>[s.key,y[s.key].value])))),f=M.observable(!1),O=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",bt(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),q=(s,p)=>{const A=`plx-set-${p}`,Z=s.help?` <span data-help="${d(s.help)}"></span>`:"",H=`<label class="form-label" for="${A}">${d(s.label)}${Z}</label>`,K=`data-model="v${p}.value"`,ee=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${H}<input id="${A}" type="checkbox" class="form-switch" ${K}></div>`;if(s.type==="select")return`<div>${H}<select id="${A}" class="form-input" ${K}>${s.options.map(Ae=>`<option value="${d(Ae.value)}">${d(Ae.label)}</option>`).join("")}</select>${ee}</div>`;if(s.type==="json")return`<div>${H}<textarea id="${A}" class="form-input plx-json" rows="5" spellcheck="false" ${K}></textarea>${ee}</div>`;if(s.type==="textarea")return`<div>${H}<textarea id="${A}" class="form-input" rows="3" ${K}></textarea>${ee}</div>`;const Xe=s.type==="number"?"number":s.secret?"password":"text",Ze=`${s.min!=null?` min="${d(s.min)}"`:""}${s.max!=null?` max="${d(s.max)}"`:""}`;return`<div>${H}<input id="${A}" type="${Xe}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Ze} ${K}>${ee}</div>`},r=pt(a,{plugins:L.peek(),tools:R.peek(),catalogue:h.peek().items||[]}),C={saving:f,needs:M.observable(r.needs),worksWith:M.observable(r.worksWith),hasNeeds:r.needs.length>0,hasWorksWith:r.worksWith.length>0,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,y[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},_=be(`
38
38
  <div class="plx-form">
39
39
  <p class="plx-desc"></p>
40
40
  <dl class="plx-facts"></dl>
@@ -65,7 +65,7 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as Ae}from"
65
65
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-if="hasFields" data-bind-disabled="cannot.value">
66
66
  <span data-icon="check" data-icon-size="13"></span> Save settings</button>
67
67
  </div>
68
- </div>`);_.querySelector(".plx-desc").textContent=a.description||"",_.querySelectorAll(".plx-deps").forEach(s=>{s.innerHTML=$t});const Ge=_.querySelector(".plx-facts");for(const[s,p]of F){const O=document.createElement("dt");O.textContent=s;const Z=document.createElement("dd");Z.textContent=p,Ge.append(O,Z)}async function Ie(){if(C.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await x.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),X.close(!0),k()&&await ie()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const ce=s=>async()=>{await X.close(),G.has(X)||s()},X=Ee({title:a.displayName||a.name,size:"md",panel:_,model:C,dirty:N,form:l.length?{kind:"settings",name:a.name,dirty:N,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=y[p.key].peek());return s}}:null,methods:{save:Ie,cancel:()=>X.close(),toggle:ce(()=>B(a,!a.enabled)),update:ce(()=>D(i,{update:!0})),source:ce(()=>xe(a)),exportIt:()=>we(a)}});_.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Ie())})}function Se(e=null){const t=et(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.peek()])),i=()=>JSON.stringify(l())!==n||!!e;let c=!!e?.slug;const u=M.effect(()=>{const r=a.displayName.value;c||(a.slug.value=gt(r))}),o=M.observable(!1),$=M.computed(()=>ut(Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.value])),L.value.map(r=>r.name))),y=r=>M.computed(()=>$.value.errors[r]||""),w={...a,saving:o,nameErr:y("displayName"),slugErr:y("slug"),descErr:y("description"),authorErr:y("author"),iconErr:y("icon"),cannot:M.computed(()=>o.value||Object.keys($.value.errors).length>0)},f=pe(`
68
+ </div>`);_.querySelector(".plx-desc").textContent=a.description||"",_.querySelectorAll(".plx-deps").forEach(s=>{s.innerHTML=$t});const Ge=_.querySelector(".plx-facts");for(const[s,p]of F){const A=document.createElement("dt");A.textContent=s;const Z=document.createElement("dd");Z.textContent=p,Ge.append(A,Z)}async function Oe(){if(C.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await x.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),X.close(!0),k()&&await ie()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const ce=s=>async()=>{await X.close(),G.has(X)||s()},X=Se({title:a.displayName||a.name,size:"md",panel:_,model:C,dirty:O,form:l.length?{kind:"settings",name:a.name,dirty:O,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=y[p.key].peek());return s}}:null,methods:{save:Oe,cancel:()=>X.close(),toggle:ce(()=>B(a,!a.enabled)),update:ce(()=>D(i,{update:!0})),source:ce(()=>ke(a)),exportIt:()=>xe(a)}});_.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Oe())})}function Me(e=null){const t=et(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.peek()])),i=()=>JSON.stringify(l())!==n||!!e;let c=!!e?.slug;const u=M.effect(()=>{const r=a.displayName.value;c||(a.slug.value=gt(r))}),o=M.observable(!1),$=M.computed(()=>ut(Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.value])),L.value.map(r=>r.name))),y=r=>M.computed(()=>$.value.errors[r]||""),w={...a,saving:o,nameErr:y("displayName"),slugErr:y("slug"),descErr:y("description"),authorErr:y("author"),iconErr:y("icon"),cannot:M.computed(()=>o.value||Object.keys($.value.errors).length>0)},f=be(`
69
69
  <div class="plx-form">
70
70
  <div>
71
71
  <label class="form-label" for="plx-np-name">Display name <span class="text-danger">*</span></label>
@@ -94,14 +94,14 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as Ae}from"
94
94
  <input id="plx-np-icon" type="text" class="form-input font-mono" maxlength="40" data-model="icon.value">
95
95
  <small class="plx-err" data-bind-hidden="!iconErr.value" data-bind-text="iconErr.value"></small>
96
96
  </div>
97
- <p class="plx-hint">Made from the built-in template - server routes, an admin view and a CLAUDE.md - and switched on. Restart the server to activate it. Ctrl+Enter creates.</p>
97
+ <p class="plx-hint">Made from the built-in template - server routes, an admin view and a CLAUDE.md - and switched on. Restart the server to activate it. The book button in the banner opens the Writing a Tool guide. Ctrl+Enter creates.</p>
98
98
  <div class="plx-actions">
99
99
  <span class="plx-spacer"></span>
100
100
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
101
101
  <button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
102
102
  <span data-icon="plus" data-icon-size="13"></span> Create plugin</button>
103
103
  </div>
104
- </div>`);async function N(){if(w.cannot.peek())return;o.value=!0;const{payload:r}=$.peek();try{await x.plugins.scaffold(r),E.toast(`"${r.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),k()&&await P()}catch(C){E.toast(C.message||"Could not create the plugin.",{type:"error"})}finally{o.value=!1}}const F=Ee({title:"New plugin",size:"sm",panel:f,model:w,dirty:i,form:{kind:"new",name:null,dirty:i,values:l},cleanup:()=>u(),methods:{create:N,cancel:()=>F.close()}}),q=f.querySelector("#plx-np-slug");q.addEventListener("input",()=>{c=!!q.value}),f.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="Enter"&&(r.preventDefault(),N())}),f.querySelector("#plx-np-name")?.focus()}function He(e){const t=Q(e);if(!t)return[];const a=W(t.name),n=Fe(t,Y.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:De(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>oe(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>B(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>B(t,!0)}],...n?[{label:a?.needsCms?`Update to v${n} - needs a newer Domma CMS`:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled||!!a?.needsCms,action:()=>D(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>xe(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>we(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>ye(t.name,t.displayName)}]]}function Ke(e){const t=R.peek().find(n=>n.name===e);if(!t)return[];const a=!!m.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>B(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>B(t,!0,"tool")}]}const Ve=e=>{const t=R.peek().find(a=>a.name===e);t&&B(t,!t.enabled,"tool")};function Ye(e){const t=W(e);if(!t)return[];const a=te(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{V.value=t.slug,T.value="all",Te.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>ye(t.slug,t.displayName)}]:[],...a==="buy"?[{label:`${t.licenceStatus==="expired"?"Renew":"Buy"}${je(t)!=="Paid"?` - ${je(t)}`:""}\u2026`,icon:"shopping-cart",action:()=>ge(t)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[],...a==="needs-cms"?[{label:t.needsCms,icon:"alert-triangle",disabled:!0,action:()=>{}}]:[]]}function Je(e){const t=W(e);if(!t)return;const a=te(t);a==="install"?D(t):a==="update"?D(t,{update:!0}):a==="buy"?ge(t):a==="needs-cms"?E.toast(t.needsCms,{type:"warning",duration:6e3}):t.installed&&(V.value=t.slug,T.value="all",Te.activate?.(0))}function re(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});v.add(()=>l?.destroy?.());const i=u=>{const o=u.target.closest(".plx-row");if(!o)return;const $=u.target.closest(".plx-kebab");if($){Ue($);return}if(u.target.closest(".plx-act")){u.target.closest("button").disabled||n(o.dataset[t]);return}n(o.dataset[t])},c=u=>{const o=u.target.closest(".plx-row");o&&(u.key==="F10"&&u.shiftKey?(u.preventDefault(),Ue(o)):u.key==="Enter"&&u.target===o&&n(o.dataset[t]))};e.addEventListener("click",i),e.addEventListener("keydown",c),v.add(()=>{e.removeEventListener("click",i),e.removeEventListener("keydown",c)})}const de=b.querySelector(".plx-browse");de.appendChild(pe(`
104
+ </div>`);async function O(){if(w.cannot.peek())return;o.value=!0;const{payload:r}=$.peek();try{await x.plugins.scaffold(r),E.toast(`"${r.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),k()&&await P()}catch(C){E.toast(C.message||"Could not create the plugin.",{type:"error"})}finally{o.value=!1}}const F=Se({title:"New plugin",size:"sm",panel:f,model:w,dirty:i,form:{kind:"new",name:null,dirty:i,values:l},cleanup:()=>u(),methods:{create:O,cancel:()=>F.close()}}),q=f.querySelector("#plx-np-slug");q.addEventListener("input",()=>{c=!!q.value}),f.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="Enter"&&(r.preventDefault(),O())}),f.querySelector("#plx-np-name")?.focus()}function He(e){const t=Q(e);if(!t)return[];const a=W(t.name),n=Fe(t,Y.peek()),l=!!b.peek();return[{header:t.displayName||t.name},{label:De(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>oe(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>B(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>B(t,!0)}],...n?[{label:a?.needsCms?`Update to v${n} - needs a newer Domma CMS`:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled||!!a?.needsCms,action:()=>D(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>ke(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>xe(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>we(t.name,t.displayName)}]]}function Ke(e){const t=R.peek().find(n=>n.name===e);if(!t)return[];const a=!!b.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>B(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>B(t,!0,"tool")}]}const Ve=e=>{const t=R.peek().find(a=>a.name===e);t&&B(t,!t.enabled,"tool")};function Ye(e){const t=W(e);if(!t)return[];const a=te(t),n=!!b.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{V.value=t.slug,N.value="all",Ne.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>we(t.slug,t.displayName)}]:[],...a==="buy"?[{label:`${t.licenceStatus==="expired"?"Renew":"Buy"}${je(t)!=="Paid"?` - ${je(t)}`:""}\u2026`,icon:"shopping-cart",action:()=>ye(t)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[],...a==="needs-cms"?[{label:t.needsCms,icon:"alert-triangle",disabled:!0,action:()=>{}}]:[]]}function Je(e){const t=W(e);if(!t)return;const a=te(t);a==="install"?D(t):a==="update"?D(t,{update:!0}):a==="buy"?ye(t):a==="needs-cms"?E.toast(t.needsCms,{type:"warning",duration:6e3}):t.installed&&(V.value=t.slug,N.value="all",Ne.activate?.(0))}function re(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});v.add(()=>l?.destroy?.());const i=u=>{const o=u.target.closest(".plx-row");if(!o)return;const $=u.target.closest(".plx-kebab");if($){Ue($);return}if(u.target.closest(".plx-act")){u.target.closest("button").disabled||n(o.dataset[t]);return}n(o.dataset[t])},c=u=>{const o=u.target.closest(".plx-row");o&&(u.key==="F10"&&u.shiftKey?(u.preventDefault(),Ue(o)):u.key==="Enter"&&u.target===o&&n(o.dataset[t]))};e.addEventListener("click",i),e.addEventListener("keydown",c),v.add(()=>{e.removeEventListener("click",i),e.removeEventListener("keydown",c)})}const de=m.querySelector(".plx-browse");de.appendChild(be(`
105
105
  <div class="plx-card">
106
106
  <p class="plx-note" data-bind-hidden="browseState.value !== 'loading'">Loading the catalogue\u2026</p>
107
107
  <p class="plx-note" data-bind-hidden="browseState.value !== 'error'">The catalogue could not be loaded - it needs an administrator, and a manager that is reachable.</p>
@@ -124,4 +124,4 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as Ae}from"
124
124
  <p class="plx-note" data-bind-hidden="browseRows.value.length">Nothing in the catalogue matches.</p>
125
125
  <div class="plx-list plx-catalogue" data-each="browseRows.value key=slug"></div>
126
126
  </div>
127
- </div>`)),de.querySelector(".plx-catalogue").innerHTML=kt;const Me=b.querySelector(".plx-installed");Me.innerHTML=wt;const Ce=b.querySelector(".plx-tools");Ce.innerHTML=xt;const Te=E.tabs(b.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Qe=M.applyBindings({plugins:L,tools:R,toolRows:me,catalogue:h,loading:be,loadError:ne,restarting:m,filter:T,query:V,rows:se,counts:We,emptyText:Pe,updateCount:J,updateCountText:M.computed(()=>`${J.value} update${J.value===1?"":"s"}`),browseFilter:j,browseQuery:fe,browseRows:le,browseCounts:ze,siteText:M.computed(()=>h.value.site?`Licensed to ${h.value.site}.`:""),browseState:M.computed(()=>h.value.available===null?"loading":h.value.failed?"error":h.value.available?"list":"file")},b,{methods:{showAll:()=>{T.value="all"},showOn:()=>{T.value="on"},showOff:()=>{T.value="off"},showAttention:()=>{T.value="attention"},bAll:()=>{j.value="all"},bAvailable:()=>{j.value="available"},bInstalled:()=>{j.value="installed"},bUpdates:()=>{j.value="updates"}}});v.add(()=>Qe.dispose()),v.add(Le(b,{position:"bottom"})),v.add(M.effect(()=>{se.value,me.value,le.value,requestAnimationFrame(()=>{b.isConnected&&I.scan(b)})})),re(Me,"name",He,e=>oe(e)),re(Ce,"tool",Ke,Ve),re(de.querySelector(".plx-catalogue"),"slug",Ye,Je),Ae(A,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>z.click()}),Ae(A,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>Se()});const Ne=e=>{(m.peek()||U?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ne),v.add(()=>window.removeEventListener("beforeunload",Ne)),v.add(()=>{if(U?.dirty())try{S.set(ae,{kind:U.kind,name:U.name,values:U.values(),at:new Date().toISOString()})}catch{}$e=!0,[...G].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(b),await Promise.all([ie(),ve()]),!k())return;let g=null;try{g=S.get(ae)}catch{}if(g?.values&&!$e){try{S.remove?S.remove(ae):S.set(ae,null)}catch{}const e=g.kind==="new"?"the new plugin you started":`your settings for ${d(Q(g.name)?.displayName||g.name)}`;(g.kind==="new"||Q(g.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&k()&&(g.kind==="new"?Se(g.values):oe(g.name,g.values))}}};
127
+ </div>`)),de.querySelector(".plx-catalogue").innerHTML=kt;const Ce=m.querySelector(".plx-installed");Ce.innerHTML=wt;const Te=m.querySelector(".plx-tools");Te.innerHTML=xt;const Ne=E.tabs(m.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Qe=M.applyBindings({plugins:L,tools:R,toolRows:me,catalogue:h,loading:he,loadError:ne,restarting:b,filter:N,query:V,rows:se,counts:We,emptyText:Pe,updateCount:J,updateCountText:M.computed(()=>`${J.value} update${J.value===1?"":"s"}`),browseFilter:j,browseQuery:ve,browseRows:le,browseCounts:ze,siteText:M.computed(()=>h.value.site?`Licensed to ${h.value.site}.`:""),browseState:M.computed(()=>h.value.available===null?"loading":h.value.failed?"error":h.value.available?"list":"file")},m,{methods:{showAll:()=>{N.value="all"},showOn:()=>{N.value="on"},showOff:()=>{N.value="off"},showAttention:()=>{N.value="attention"},bAll:()=>{j.value="all"},bAvailable:()=>{j.value="available"},bInstalled:()=>{j.value="installed"},bUpdates:()=>{j.value="updates"}}});v.add(()=>Qe.dispose()),v.add(Le(m,{position:"bottom"})),v.add(M.effect(()=>{se.value,me.value,le.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})})),re(Ce,"name",He,e=>oe(e)),re(Te,"tool",Ke,Ve),re(de.querySelector(".plx-catalogue"),"slug",Ye,Je),ue(T,{id:"plugins-guide",icon:"book",title:"Writing a Tool (guide)",onClick:()=>{location.hash="#/tutorials/plugin"}}),ue(T,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>z.click()}),ue(T,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>Me()});const Ie=e=>{(b.peek()||U?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ie),v.add(()=>window.removeEventListener("beforeunload",Ie)),v.add(()=>{if(U?.dirty())try{S.set(ae,{kind:U.kind,name:U.name,values:U.values(),at:new Date().toISOString()})}catch{}Ee=!0,[...G].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(m),await Promise.all([ie(),ge()]),!k())return;let g=null;try{g=S.get(ae)}catch{}if(g?.values&&!Ee){try{S.remove?S.remove(ae):S.set(ae,null)}catch{}const e=g.kind==="new"?"the new plugin you started":`your settings for ${d(Q(g.name)?.displayName||g.name)}`;(g.kind==="new"||Q(g.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&k()&&(g.kind==="new"?Me(g.values):oe(g.name,g.values))}}};
@@ -265,7 +265,7 @@
265
265
  "icon": "database"
266
266
  },
267
267
  {
268
- "text": "Writing a Plugin",
268
+ "text": "Writing a Tool",
269
269
  "url": "#/tutorials/plugin",
270
270
  "icon": "package"
271
271
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.47",
3
+ "version": "1.6.48",
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/server/server.js CHANGED
@@ -684,6 +684,7 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Content
684
684
  // seeded once, so new pages reach existing sites only by registering here. The two
685
685
  // sub-folders have no key in older menus; findFolder() falls back to their name.
686
686
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Collections & Forms', url: '#/docs/usage/collections', icon: 'database'}});
687
+ registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Blocks', url: '#/docs/usage/blocks', icon: 'box'}});
687
688
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Built-in Tools', url: '#/docs/usage/tools', icon: 'tool'}});
688
689
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Dashboard', url: '#/docs/usage/dashboard', icon: 'home'}});
689
690
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Editions & Licences', url: '#/docs/usage/editions', icon: 'key'}});
@@ -57,10 +57,11 @@ const MAP = [
57
57
  ['usage-cta-shortcode', 'usage/cta-shortcode', 'CTA Shortcode'],
58
58
  ['usage-editions', 'usage/editions', 'Editions & Licences'],
59
59
  ['usage-collections', 'usage/collections', 'Collections & Forms'],
60
+ ['usage-blocks', 'usage/blocks', 'Blocks'],
60
61
  ['usage-tools', 'usage/tools', 'Built-in Tools'],
61
62
  ['usage-dashboard', 'usage/dashboard', 'Dashboard'],
62
63
  ['tutorial-crud', 'tutorials/crud', 'Building a CRUD App'],
63
- ['tutorial-plugin', 'tutorials/plugin', 'Writing a Plugin'],
64
+ ['tutorial-plugin', 'tutorials/plugin', 'Writing a Tool'],
64
65
  ['tutorial-forms', 'tutorials/forms', 'Form Follow-Up'],
65
66
  ['components-reference', 'components', 'Components Reference'],
66
67
  ['components-howto', 'components/howto', 'Components How-To'],
@@ -1178,9 +1178,11 @@ async function processCollectionBlocks(markdown, tagSet) {
1178
1178
  */
1179
1179
  export async function renderScopedCollection(attrs, userId) {
1180
1180
  const opts = {extraFilter: {createdBy: userId}, scope: 'mine'};
1181
- return isInteractiveCollection(attrs)
1182
- ? renderCollectionBrowserShell(attrs, opts)
1183
- : renderCollectionFragment(attrs, opts);
1181
+ // Drawn into the page with innerHTML by site.js, after the page itself was
1182
+ // sanitised - so it is cleaned here, the same way.
1183
+ return sanitizePageHtml(isInteractiveCollection(attrs)
1184
+ ? await renderCollectionBrowserShell(attrs, opts)
1185
+ : await renderCollectionFragment(attrs, opts));
1184
1186
  }
1185
1187
 
1186
1188
  /**
@@ -4528,6 +4530,87 @@ function processCtaBlocks(markdown) {
4528
4530
  return restore(processed);
4529
4531
  }
4530
4532
 
4533
+ /**
4534
+ * Clean rendered page HTML - the one sanitiser every page fragment goes through,
4535
+ * whether it is part of a full page (parseMarkdown) or drawn later in the
4536
+ * browser (the scope="mine" fragment from renderScopedCollection), so a block
4537
+ * or a {{{raw}}} field cannot carry a handler onto the page by the second road.
4538
+ *
4539
+ * @param {string} html
4540
+ * @returns {string}
4541
+ */
4542
+ export function sanitizePageHtml(html) {
4543
+ const extensions = getSanitizeExtensions();
4544
+ return sanitizeHtml(html, {
4545
+ // `style` is intentionally allowed below so per-block custom CSS can
4546
+ // reach the page. Admins write block templates; same trust model as
4547
+ // content/custom.css. This flag silences sanitize-html's warning.
4548
+ allowVulnerableTags: true,
4549
+ // [embed] emits <iframe>/<video>; hostnames are gated by
4550
+ // allowedIframeHostnames below (defence in depth over the render-time
4551
+ // host allow-list).
4552
+ allowedIframeHostnames: [
4553
+ 'www.youtube-nocookie.com', 'www.youtube.com', 'youtube.com',
4554
+ 'player.vimeo.com',
4555
+ ...getEmbedAllowedHosts(),
4556
+ ],
4557
+ // sanitize-html drops a RELATIVE iframe src whenever allowedIframeHostnames
4558
+ // is set, because it cannot match a hostname it has not been given. That
4559
+ // silently emptied every [demo] iframe. A relative src is same-origin by
4560
+ // definition, so the hostname gate has nothing to say about it: the gate
4561
+ // exists to stop embedding somebody else's site, not your own page.
4562
+ allowIframeRelativeUrls: true,
4563
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat([
4564
+ 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
4565
+ 'form', 'input', 'textarea', 'select', 'option', 'optgroup',
4566
+ 'button', 'label', 'fieldset', 'legend',
4567
+ // [embed] shortcode output
4568
+ 'iframe', 'video', 'source', 'figure', 'figcaption',
4569
+ // <style> tags are emitted by block rendering (buildBlockStyleTag) so
4570
+ // per-block custom CSS can reach the page. Admin-trust: block authors
4571
+ // are admins, same security model as site-wide content/custom.css.
4572
+ 'style',
4573
+ // dm-<name> custom-element tags - one per registered component. Refreshed
4574
+ // by refreshComponentTagAllowlist() on startup and after create/delete.
4575
+ // Component authors are super-admins, same trust model as plugin code.
4576
+ ..._dmTagAllowlist,
4577
+ ...extensions.tags
4578
+ ]),
4579
+ allowedAttributes: {
4580
+ ...sanitizeHtml.defaults.allowedAttributes,
4581
+ // aria-hidden marks decorative markup (menu spacers, the floating panel's
4582
+ // drag grip) so it isn't announced as an empty item. aria-current marks
4583
+ // the [menu] item for the page you are on. Both presentational only, no
4584
+ // script surface - unlike the on* handlers stripped globally.
4585
+ '*': ['class', 'id', 'style', 'data-*', 'aria-hidden', 'aria-current'],
4586
+ img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
4587
+ form: ['action', 'method'],
4588
+ input: ['type', 'name', 'placeholder', 'value', 'required', 'disabled',
4589
+ 'readonly', 'min', 'max', 'step', 'pattern', 'maxlength',
4590
+ 'minlength', 'checked', 'autocomplete'],
4591
+ textarea: ['name', 'placeholder', 'rows', 'cols', 'required',
4592
+ 'disabled', 'readonly', 'maxlength'],
4593
+ select: ['name', 'required', 'disabled', 'multiple'],
4594
+ option: ['value', 'selected', 'disabled'],
4595
+ optgroup: ['label', 'disabled'],
4596
+ button: ['type', 'disabled', 'aria-label', 'data-action', 'data-entry', 'data-confirm'],
4597
+ label: ['for'],
4598
+ fieldset: ['disabled'],
4599
+ iframe: ['src', 'title', 'width', 'height', 'allow', 'allowfullscreen',
4600
+ 'loading', 'referrerpolicy', 'frameborder', 'style'],
4601
+ video: ['controls', 'autoplay', 'muted', 'loop', 'playsinline',
4602
+ 'preload', 'poster', 'width', 'height', 'style'],
4603
+ source: ['src', 'type'],
4604
+ // Allow any attribute on every registered dm-* tag. Props are
4605
+ // author-defined and enumerating them at sanitise time has no security
4606
+ // benefit - custom-element attributes cannot execute JS on their own,
4607
+ // and `on*` handlers + `javascript:` URLs are still stripped globally.
4608
+ ..._dmTagAllowlist.reduce((acc, tag) => { acc[tag] = ['*']; return acc; }, {}),
4609
+ ...extensions.attributes
4610
+ }
4611
+ });
4612
+ }
4613
+
4531
4614
  /**
4532
4615
  * Parse a Markdown file string into frontmatter data and rendered HTML.
4533
4616
  *
@@ -4548,7 +4631,6 @@ function processCtaBlocks(markdown) {
4548
4631
  */
4549
4632
  export async function parseMarkdown(raw, opts = {}) {
4550
4633
  const {data, content} = matter(raw);
4551
- const extensions = getSanitizeExtensions();
4552
4634
 
4553
4635
  // Pipeline (earlier stages are already HTML when later ones run; keep in step
4554
4636
  // with "Shortcode Nesting Rules" in CLAUDE.md and docs/markdown-shortcodes.md):
@@ -4607,74 +4689,7 @@ export async function parseMarkdown(raw, opts = {}) {
4607
4689
  const withSlideover = processSlideoverBlocks(withBanner);
4608
4690
  const rendered = marked.parse(withSlideover);
4609
4691
 
4610
- const sanitized = sanitizeHtml(rendered, {
4611
- // `style` is intentionally allowed below so per-block custom CSS can
4612
- // reach the page. Admins write block templates; same trust model as
4613
- // content/custom.css. This flag silences sanitize-html's warning.
4614
- allowVulnerableTags: true,
4615
- // [embed] emits <iframe>/<video>; hostnames are gated by
4616
- // allowedIframeHostnames below (defence in depth over the render-time
4617
- // host allow-list).
4618
- allowedIframeHostnames: [
4619
- 'www.youtube-nocookie.com', 'www.youtube.com', 'youtube.com',
4620
- 'player.vimeo.com',
4621
- ...getEmbedAllowedHosts(),
4622
- ],
4623
- // sanitize-html drops a RELATIVE iframe src whenever allowedIframeHostnames
4624
- // is set, because it cannot match a hostname it has not been given. That
4625
- // silently emptied every [demo] iframe. A relative src is same-origin by
4626
- // definition, so the hostname gate has nothing to say about it: the gate
4627
- // exists to stop embedding somebody else's site, not your own page.
4628
- allowIframeRelativeUrls: true,
4629
- allowedTags: sanitizeHtml.defaults.allowedTags.concat([
4630
- 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
4631
- 'form', 'input', 'textarea', 'select', 'option', 'optgroup',
4632
- 'button', 'label', 'fieldset', 'legend',
4633
- // [embed] shortcode output
4634
- 'iframe', 'video', 'source', 'figure', 'figcaption',
4635
- // <style> tags are emitted by block rendering (buildBlockStyleTag) so
4636
- // per-block custom CSS can reach the page. Admin-trust: block authors
4637
- // are admins, same security model as site-wide content/custom.css.
4638
- 'style',
4639
- // dm-<name> custom-element tags - one per registered component. Refreshed
4640
- // by refreshComponentTagAllowlist() on startup and after create/delete.
4641
- // Component authors are super-admins, same trust model as plugin code.
4642
- ..._dmTagAllowlist,
4643
- ...extensions.tags
4644
- ]),
4645
- allowedAttributes: {
4646
- ...sanitizeHtml.defaults.allowedAttributes,
4647
- // aria-hidden marks decorative markup (menu spacers, the floating panel's
4648
- // drag grip) so it isn't announced as an empty item. aria-current marks
4649
- // the [menu] item for the page you are on. Both presentational only, no
4650
- // script surface - unlike the on* handlers stripped globally.
4651
- '*': ['class', 'id', 'style', 'data-*', 'aria-hidden', 'aria-current'],
4652
- img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
4653
- form: ['action', 'method'],
4654
- input: ['type', 'name', 'placeholder', 'value', 'required', 'disabled',
4655
- 'readonly', 'min', 'max', 'step', 'pattern', 'maxlength',
4656
- 'minlength', 'checked', 'autocomplete'],
4657
- textarea: ['name', 'placeholder', 'rows', 'cols', 'required',
4658
- 'disabled', 'readonly', 'maxlength'],
4659
- select: ['name', 'required', 'disabled', 'multiple'],
4660
- option: ['value', 'selected', 'disabled'],
4661
- optgroup: ['label', 'disabled'],
4662
- button: ['type', 'disabled', 'aria-label', 'data-action', 'data-entry', 'data-confirm'],
4663
- label: ['for'],
4664
- fieldset: ['disabled'],
4665
- iframe: ['src', 'title', 'width', 'height', 'allow', 'allowfullscreen',
4666
- 'loading', 'referrerpolicy', 'frameborder', 'style'],
4667
- video: ['controls', 'autoplay', 'muted', 'loop', 'playsinline',
4668
- 'preload', 'poster', 'width', 'height', 'style'],
4669
- source: ['src', 'type'],
4670
- // Allow any attribute on every registered dm-* tag. Props are
4671
- // author-defined and enumerating them at sanitise time has no security
4672
- // benefit - custom-element attributes cannot execute JS on their own,
4673
- // and `on*` handlers + `javascript:` URLs are still stripped globally.
4674
- ..._dmTagAllowlist.reduce((acc, tag) => { acc[tag] = ['*']; return acc; }, {}),
4675
- ...extensions.attributes
4676
- }
4677
- });
4692
+ const sanitized = sanitizePageHtml(rendered);
4678
4693
 
4679
4694
  const html = applyTransforms('markdown:afterParse', sanitized);
4680
4695