domma-cms 1.6.47 → 1.6.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import{api as 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.49",
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",
@@ -24,6 +24,7 @@ import {config, getConfig} from '../../config.js';
24
24
  import {getRegisteredSidebarItems, hooks} from '../../services/hooks.js';
25
25
  import {getMenu} from '../../services/menus.js';
26
26
  import {resolveAdminHome} from '../../services/adminHome.js';
27
+ import {adminBrandFor} from '../../services/adminBrand.js';
27
28
  import {authenticate, checkVisibility} from '../../middleware/auth.js';
28
29
  import {getAdminScope, getPermissionsForRoles, getRoleLevel} from '../../services/roles.js';
29
30
  import {getEffectiveRegistry, GROUP_ORDER} from '../../services/permissionRegistry.js';
@@ -252,7 +253,8 @@ export async function authRoutes(fastify) {
252
253
  });
253
254
  // Dashboard Pro's "Open on my boards" - for a user the admin does not confine.
254
255
  if (!adminScope && isDashboardPro() && (await readPrefs(request.user)).home) adminHome = '/dashboards';
255
- return {permissions, adminScope, adminHome};
256
+ // adminBrand: the admin's name and mark for the top bar, sidebar header and tab title.
257
+ return {permissions, adminScope, adminHome, adminBrand: adminBrandFor(getConfig('site'))};
256
258
  });
257
259
 
258
260
  // GET /api/auth/permissions-registry - returns the full permissions registry,
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'}});
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The admin's own name and mark - what the top bar, the sidebar header and the
3
+ * browser tab show. Site Settings › Branding › Admin name / logo / icon, with
4
+ * the site title standing in for an empty name, so a site called ACME Ltd says
5
+ * ACME Ltd in its admin without anyone setting anything.
6
+ *
7
+ * It travels with GET /api/auth/permissions, so every signed-in user sees it -
8
+ * reading Site Settings needs the settings permission, which a confined role
9
+ * (a job-board candidate) does not have.
10
+ *
11
+ * @module server/services/adminBrand
12
+ */
13
+
14
+ /** The fallback when a site has neither an admin name nor a title. */
15
+ export const DEFAULT_ADMIN_TITLE = 'Domma CMS';
16
+
17
+ /**
18
+ * @param {object} [site] - config/site.json
19
+ * @returns {{title: string, logo: string, icon: string}}
20
+ */
21
+ export function adminBrandFor(site = {}) {
22
+ const own = site?.adminBrand && typeof site.adminBrand === 'object' ? site.adminBrand : {};
23
+ // Whitespace collapsed: a title reads back that way from the browser tab.
24
+ const str = (v) => (typeof v === 'string' ? v.replace(/[ \t\n\f\r]+/g, ' ').trim() : '');
25
+ const logo = str(own.logo);
26
+ return {
27
+ title: (str(own.title) || str(site?.title) || DEFAULT_ADMIN_TITLE).slice(0, 120),
28
+ // Same rule as the sidebar header: a site path or an http(s) address, nothing else.
29
+ logo: /^(?:\/(?!\/)|https?:\/\/)/i.test(logo) ? logo : '',
30
+ icon: /^[a-z0-9-]{1,40}$/.test(str(own.icon)) ? str(own.icon) : ''
31
+ };
32
+ }
@@ -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
 
@@ -111,7 +111,7 @@ function DOC_TUTORIALS_SUBMENU() {
111
111
  text: 'Tutorials', icon: 'document',
112
112
  items: [
113
113
  {text: 'Building a CRUD App', url: '#/tutorials/crud', icon: 'database'},
114
- {text: 'Writing a Plugin', url: '#/tutorials/plugin', icon: 'package'},
114
+ {text: 'Writing a Tool', url: '#/tutorials/plugin', icon: 'package'},
115
115
  {text: 'Form Follow-Up', url: '#/tutorials/forms', icon: 'layout'}
116
116
  ]
117
117
  };
@@ -385,6 +385,18 @@ export async function ensureFolderKeys(opts = {}) {
385
385
  changed = true;
386
386
  }
387
387
 
388
+ // Plugins are Tools, so the tutorial became "Writing a Tool" in 1.6.48. Renamed once
389
+ // (`meta.writingToolName`) and only where the item still says Writing a Plugin.
390
+ if (!menu.meta?.writingToolName && items.length) {
391
+ const rename = (list) => (list || []).forEach(n => {
392
+ if (n && n.url === '#/tutorials/plugin' && String(n.text || '').trim() === 'Writing a Plugin') n.text = 'Writing a Tool';
393
+ if (Array.isArray(n?.items)) rename(n.items);
394
+ });
395
+ rename(items);
396
+ menu.meta = {...(menu.meta || {}), writingToolName: true};
397
+ changed = true;
398
+ }
399
+
388
400
  // An empty menu is left empty: the renderer's minimal fallback (Dashboard,
389
401
  // Menus) is what an admin needs to repair it, and a lone Tools folder would
390
402
  // stop that fallback from applying.