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.
- package/admin/index.html +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/admin-brand.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +6 -6
- package/admin/js/templates/blocks.html +1 -1
- package/admin/js/templates/docs/tutorial-crud.html +1 -1
- package/admin/js/templates/docs/tutorial-plugin.html +114 -11
- package/admin/js/templates/docs/usage-blocks.html +243 -0
- package/admin/js/templates/docs/usage-plugins.html +1 -1
- package/admin/js/templates/docs/usage-site-settings.html +7 -0
- package/admin/js/templates/documentation.html +5 -1
- package/admin/js/templates/settings.html +2 -1
- package/admin/js/templates/tutorials.html +1 -1
- package/admin/js/views/block-editor.js +4 -4
- package/admin/js/views/blocks.js +4 -4
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/plugins.js +6 -6
- package/config/menus/admin-sidebar.json +1 -1
- package/package.json +1 -1
- package/server/routes/api/auth.js +3 -1
- package/server/server.js +1 -0
- package/server/services/adminBrand.js +32 -0
- package/server/services/docs.js +2 -1
- package/server/services/markdown.js +87 -72
- package/server/services/sidebar-migration.js +13 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as x,getUser as et}from"../api.js";import{addBannerAction as
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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
|
|
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
|
|
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
|
|
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))}}};
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/server/services/docs.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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 =
|
|
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
|
|
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.
|