domma-cms 1.6.49 → 1.6.50

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/js/api.js CHANGED
@@ -1 +1 @@
1
- import{loginHashFor as $}from"./lib/login-arrange.js";const c="/api";function a(){return S.get("auth_token")}function u(){return S.get("auth_refresh_token")}function y(e){S.set("auth_token",e)}function f(){S.remove("auth_token"),S.remove("auth_refresh_token"),S.remove("auth_user")}async function T(){const e=u();if(!e)throw new Error("No refresh token");const o=await fetch(`${c}/auth/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(!o.ok)throw f(),sendToLogin({expired:!0}),new Error("Token refresh failed");const{token:n}=await o.json();return y(n),n}async function t(e,o={}){let n=a();const r=i=>({...o.body!==void 0?{"Content-Type":"application/json"}:{},...o.headers,...i?{Authorization:`Bearer ${i}`}:{}});let s=await fetch(`${c}${e}`,{...o,headers:r(n)});if(s.status===401&&u())try{n=await T(),s=await fetch(`${c}${e}`,{...o,headers:r(n)})}catch{return}if(!s.ok){const i=await s.json().catch(()=>({error:"Request failed"})),d=new Error(i.error||i.message||`HTTP ${s.status}`);throw Array.isArray(i.validation)&&(d.validation=i.validation),d.status=s.status,d.body=i,d}return s.status===204?null:s.json()}async function O(e,o){const n=a(),r=n?{Authorization:`Bearer ${n}`}:{},s=await fetch(`${c}${e}`,{method:"POST",headers:r,body:o});if(!s.ok){const i=await s.json().catch(()=>({error:"Upload failed"}));throw new Error(i.error||i.message||`HTTP ${s.status}`)}return s.json()}export const api={auth:{setupStatus:()=>t("/auth/setup-status",{method:"GET"}),setup:e=>t("/auth/setup",{method:"POST",body:JSON.stringify(e)}),login:e=>t("/auth/login",{method:"POST",body:JSON.stringify(e)}),verifyLogin:e=>t("/auth/login/verify",{method:"POST",body:JSON.stringify(e)}),me:()=>t("/auth/me",{method:"GET"}),updateMe:e=>t("/auth/me",{method:"PUT",body:JSON.stringify(e)}),refresh:e=>t("/auth/refresh",{method:"POST",body:JSON.stringify({refreshToken:e})}),forgotPassword:e=>t("/auth/forgot-password",{method:"POST",body:JSON.stringify({email:e})}),checkReset:(e,o)=>t("/auth/reset-password/check",{method:"POST",body:JSON.stringify({token:e,...o&&{password:o}})}),resetPassword:(e,o)=>t("/auth/reset-password",{method:"POST",body:JSON.stringify({token:e,password:o})})},pages:{list:()=>t("/pages",{method:"GET"}),get:e=>t(`/pages${e}`,{method:"GET"}),create:e=>t("/pages",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/pages${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/pages${e}`,{method:"DELETE"}),preview:e=>t("/pages/preview",{method:"POST",body:JSON.stringify({markdown:e})}),previewFull:(e,o,n)=>t("/pages/preview/full",{method:"POST",body:JSON.stringify({urlPath:e,frontmatter:o,body:n})}),previewLinks:{list:e=>t(`/pages/preview-links${e?`?urlPath=${encodeURIComponent(e)}`:""}`,{method:"GET"}).then(o=>o.links||[]),create:(e,o,n)=>t("/pages/preview-links",{method:"POST",body:JSON.stringify({urlPath:e,expiresIn:o,label:n})}),revoke:e=>t(`/pages/preview-links/${e}`,{method:"DELETE"})},tags:()=>t("/pages/tags",{method:"GET"}).then(e=>e.tags||[])},settings:{get:()=>t("/settings",{method:"GET"}),save:e=>t("/settings",{method:"PUT",body:JSON.stringify(e)}),getCustomCss:()=>t("/settings/custom-css",{method:"GET"}),saveCustomCss:e=>t("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:e})})},theme:{get:()=>t("/theme",{method:"GET"}),save:e=>t("/theme",{method:"PUT",body:JSON.stringify(e)}),catalog:()=>t("/theme/catalog",{method:"GET"}),admin:e=>t(`/theme/admin${e?`?id=${encodeURIComponent(e)}`:""}`,{method:"GET"}),themeTokens:e=>t(`/theme/themes/${encodeURIComponent(e)}`,{method:"GET"}),compile:e=>t("/theme/compile",{method:"POST",body:JSON.stringify(e)})},navigation:{get:()=>t("/navigation",{method:"GET"}),save:e=>t("/navigation",{method:"PUT",body:JSON.stringify(e)})},menus:{list:()=>t("/menus",{method:"GET"}),get:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/menus",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/menus/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"DELETE"}),duplicate:e=>t(`/menus/${encodeURIComponent(e)}/duplicate`,{method:"POST"})},contextMenus:{list:()=>t("/context-menus",{method:"GET"}),get:e=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/context-menus",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"DELETE"}),duplicate:e=>t(`/context-menus/${encodeURIComponent(e)}/duplicate`,{method:"POST"}),actionTypes:()=>t("/context-menus/action-types",{method:"GET"})},menuLocations:{get:()=>t("/menu-locations",{method:"GET"}),save:e=>t("/menu-locations",{method:"PUT",body:JSON.stringify(e)}),registry:()=>t("/menu-locations/registry",{method:"GET"})},projects:{list:()=>t("/projects",{method:"GET"}),listWithCounts:()=>t("/projects?counts=1",{method:"GET"}),get:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/projects",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/projects/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"DELETE"}),artefacts:e=>t(`/projects/${encodeURIComponent(e)}/artefacts`,{method:"GET"}),untagAll:e=>t(`/projects/${encodeURIComponent(e)}/untag-all`,{method:"POST"}),removeContents:(e,o)=>t(`/projects/${encodeURIComponent(e)}/remove-contents`,{method:"POST",body:JSON.stringify({confirm:o})})},apiTokens:{list:()=>t("/api-tokens",{method:"GET"}),create:e=>t("/api-tokens",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),revoke:e=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"DELETE"})},apiEndpoints:{list:()=>t("/api-endpoints",{method:"GET"}),get:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/api-endpoints",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"DELETE"})},layouts:{get:()=>t("/layouts",{method:"GET"}),save:e=>t("/layouts",{method:"PUT",body:JSON.stringify(e)}),create:e=>t("/layouts",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/layouts/${e}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/layouts/${e}`,{method:"DELETE"}),getOptions:()=>t("/layouts/options",{method:"GET"}),saveOptions:e=>t("/layouts/options",{method:"PUT",body:JSON.stringify(e)})},media:{list:()=>t("/media",{method:"GET"}),upload:e=>O("/media",e),delete:e=>t(`/media/${encodeURIComponent(e)}`,{method:"DELETE"}),rename:(e,o)=>t(`/media/${encodeURIComponent(e)}`,{method:"PATCH",body:JSON.stringify({newName:o})}),info:e=>t(`/media/${encodeURIComponent(e)}/info`,{method:"GET"}),transform:(e,o)=>t(`/media/${encodeURIComponent(e)}/transform`,{method:"POST",body:JSON.stringify(o)})},users:{list:()=>t("/users",{method:"GET"}),get:e=>t(`/users/${e}`,{method:"GET"}),create:e=>t("/users",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/users/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/users/${e}`,{method:"DELETE"}),passwordReset:(e,o)=>t(`/users/${encodeURIComponent(e)}/password-reset`,{method:"POST",body:JSON.stringify({via:o})}),cancelPasswordReset:e=>t(`/users/${encodeURIComponent(e)}/password-reset`,{method:"DELETE"})},tools:{list:()=>t("/tools",{method:"GET"}),update:(e,o)=>t(`/tools/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)})},plugins:{list:()=>t("/plugins",{method:"GET"}),scaffold:e=>t("/plugins/scaffold",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/plugins/${e}`,{method:"PUT",body:JSON.stringify(o)}),files:e=>t(`/plugins/${e}/files`,{method:"GET"}),readFile:(e,o)=>t(`/plugins/${e}/file?path=${encodeURIComponent(o)}`,{method:"GET"}),writeFile:(e,o,n)=>t(`/plugins/${e}/file`,{method:"PUT",body:JSON.stringify({path:o,content:n})}),deleteFile:(e,o)=>t(`/plugins/${e}/file?path=${encodeURIComponent(o)}`,{method:"DELETE"}),restart:()=>t("/plugins/restart",{method:"POST"}),adminConfig:()=>t("/plugins/admin-config",{method:"GET"}),async exportBundle(e,{includeData:o=!1}={}){const n=a(),r=o?"?includeData=1":"",s=await fetch(`${c}/plugins/${encodeURIComponent(e)}/export${r}`,{headers:n?{Authorization:`Bearer ${n}`}:{}});if(!s.ok){const E=await s.json().catch(()=>({}));throw new Error(E.error||`Export failed (${s.status})`)}const i=s.headers.get("Content-Disposition")||"",d=/filename="([^"]+)"/.exec(i)?.[1],m=await s.blob(),h=URL.createObjectURL(m),l=document.createElement("a");l.href=h,l.download=d||`${e}.dcmsplugin`,document.body.appendChild(l),l.click(),l.remove(),URL.revokeObjectURL(h)},async installFile(e,{confirmUnsigned:o=!1,confirmUpgrade:n=!1,confirmDowngrade:r=!1}={}){const s=new FormData;o&&s.append("confirmUnsigned","true"),n&&s.append("confirmUpgrade","true"),r&&s.append("confirmDowngrade","true"),s.append("file",e);const i=a(),d=await fetch(`${c}/plugins/install-upload`,{method:"POST",headers:i?{Authorization:`Bearer ${i}`}:{},body:s}),m=await d.json().catch(()=>({}));if(d.status===409){const h=new Error(m.error||"Plugin already installed");throw h.code="CONFLICT",h.name=m.name,h.installedVersion=m.installedVersion,h.incomingVersion=m.incomingVersion,h}if(!d.ok)throw new Error(m.error||`Install failed (${d.status})`);return m}},marketplace:{catalogue:()=>t("/plugins/marketplace",{method:"GET"}),install:(e,o,{update:n=!1}={})=>t("/plugins/marketplace/install",{method:"POST",body:JSON.stringify({slug:e,version:o,update:n})}),uninstall:e=>t(`/plugins/marketplace/${encodeURIComponent(e)}`,{method:"DELETE"})},collections:{list:()=>t("/collections",{method:"GET"}),proStatus:()=>t("/collections/pro-status",{method:"GET"}),get:e=>t(`/collections/${e}`,{method:"GET"}),create:e=>t("/collections",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/collections/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/collections/${e}`,{method:"DELETE"}),listEntries:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/collections/${e}/entries${n?"?"+n:""}`,{method:"GET"})},getEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"GET"}),createEntry:(e,o)=>t(`/collections/${e}/entries`,{method:"POST",body:JSON.stringify({data:o})}),updateEntry:(e,o,n)=>t(`/collections/${e}/entries/${o}`,{method:"PUT",body:JSON.stringify({data:n})}),deleteEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"DELETE"}),clearEntries:e=>t(`/collections/${e}/entries`,{method:"DELETE"}),import:(e,o)=>t(`/collections/${e}/import`,{method:"POST",body:JSON.stringify({entries:o})}),publicList:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/collections/${e}/public${n?"?"+n:""}`,{method:"GET"})},getConnections:()=>t("/collections/connections",{method:"GET"}),saveConnections:e=>t("/collections/connections",{method:"PUT",body:JSON.stringify(e)}),migrateStorage:(e,o)=>t(`/collections/${e}/migrate-storage`,{method:"POST",body:JSON.stringify({storage:o})})},forms:{list:()=>t("/forms",{method:"GET"}),create:e=>t("/forms",{method:"POST",body:JSON.stringify(e)}),get:e=>t(`/forms/${e}`,{method:"GET"}),update:(e,o)=>t(`/forms/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/forms/${e}`,{method:"DELETE"}),listSubmissions:e=>t(`/forms/${e}/submissions`,{method:"GET"}),clearSubmissions:e=>t(`/forms/${e}/submissions`,{method:"DELETE"}),deleteSubmission:(e,o)=>t(`/forms/${e}/submissions/${o}`,{method:"DELETE"}),testEmail:e=>t("/forms/test-email",{method:"POST",body:JSON.stringify({to:e})})},views:{list:()=>t("/views",{method:"GET"}),get:e=>t(`/views/${e}`,{method:"GET"}),create:e=>t("/views",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/views/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/views/${e}`,{method:"DELETE"}),execute:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/views/${e}/execute${n?"?"+n:""}`,{method:"GET"})},forCollection:e=>t(`/views/collection/${e}`,{method:"GET"})},actions:{list:()=>t("/actions",{method:"GET"}),get:e=>t(`/actions/${e}`,{method:"GET"}),create:e=>t("/actions",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/actions/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/actions/${e}`,{method:"DELETE"}),execute:(e,o)=>t(`/actions/${e}/execute`,{method:"POST",body:JSON.stringify({entryId:o})}),forCollection:e=>t(`/actions/collection/${e}`,{method:"GET"}),checkAccess:(e,o)=>t(`/actions/${e}/check-access`,{method:"POST",body:JSON.stringify({entryIds:o})})},versions:{list:e=>t(`/versions/list${e}`),get:(e,o)=>t(`/versions/get/${encodeURIComponent(o)}${e}`),create:(e,o)=>t(`/versions/create${e}`,{method:"POST",body:JSON.stringify({label:o})}),restore:(e,o)=>t(`/versions/restore/${encodeURIComponent(o)}${e}`,{method:"POST"}),delete:(e,o)=>t(`/versions/delete/${encodeURIComponent(o)}${e}`,{method:"DELETE"}),bulkDelete:(e,o)=>t(`/versions/bulk-delete${e}`,{method:"POST",body:JSON.stringify({filenames:o})}),prune:(e,o)=>t(`/versions/prune${e}`,{method:"POST",body:JSON.stringify({keep:o})})},blocks:{list:()=>t("/blocks",{method:"GET"}),get:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"GET"}),put:(e,o)=>t(`/blocks/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=a(),n=await fetch(`${c}/blocks/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!n.ok){const d=await n.json().catch(()=>({}));throw new Error(d.error||`Export failed (${n.status})`)}const r=await n.blob(),s=URL.createObjectURL(r),i=document.createElement("a");i.href=s,i.download=`${e}.dmblock.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(s)},async importBundle(e,{overwrite:o=!1}={}){const n=await fetch(`${c}/blocks/import`,{method:"POST",headers:{"Content-Type":"application/json",...a()?{Authorization:`Bearer ${a()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(n.status===409){const r=await n.json().catch(()=>({})),s=new Error(r.error||"Block already exists");throw s.code="CONFLICT",s.name=r.name,s}if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`Import failed (${n.status})`)}return n.json()}},components:{list:()=>t("/components",{method:"GET"}),get:e=>t(`/components/${encodeURIComponent(e)}`,{method:"GET"}),compile:(e,o)=>t("/components/compile",{method:"POST",body:JSON.stringify({name:e,source:o})}),put:(e,o)=>t(`/components/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/components/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=a(),n=await fetch(`${c}/components/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!n.ok){const d=await n.json().catch(()=>({}));throw new Error(d.error||`Export failed (${n.status})`)}const r=await n.blob(),s=URL.createObjectURL(r),i=document.createElement("a");i.href=s,i.download=`${e}.dmcomponent.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(s)},async importBundle(e,{overwrite:o=!1}={}){const n=await fetch(`${c}/components/import`,{method:"POST",headers:{"Content-Type":"application/json",...a()?{Authorization:`Bearer ${a()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(n.status===409){const r=await n.json().catch(()=>({})),s=new Error(r.error||"Component already exists");throw s.code="CONFLICT",s.name=r.name,s}if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`Import failed (${n.status})`)}return n.json()}},get:e=>t(e,{method:"GET"}),post:(e,o)=>t(e,{method:"POST",body:JSON.stringify(o)}),put:(e,o)=>t(e,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(e,{method:"DELETE"}),settingsExt:{testEmail:e=>t("/settings/test-email",{method:"POST",body:JSON.stringify({to:e})})},system:{notifications:{list:()=>t("/system/notifications",{method:"GET"}),unreadCount:()=>t("/system/notifications/unread-count",{method:"GET"}),markRead:e=>t(`/system/notifications/${e}/read`,{method:"POST"}),dismiss:e=>t(`/system/notifications/${e}/dismiss`,{method:"POST"}),remove:e=>t(`/system/notifications/${e}`,{method:"DELETE"})}},dashboard:{summary:()=>t("/dashboard/summary",{method:"GET"}),summaryLite:()=>t("/dashboard/summary?lite=1",{method:"GET"})}};export function isAuthenticated(){return!!a()}export function getUser(){return S.get("auth_user")}export function setAuthData({token:e,refreshToken:o,user:n}){e&&y(e),o&&S.set("auth_refresh_token",o),n&&S.set("auth_user",n)}let p=!1,g=!1;export function sendToLogin({expired:e=!1}={}){/^#\/(login|reset-password)(\?|\/|$)/.test(location.hash)||(location.hash=$(location.hash,{expired:e}))}export function logout(){!g&&window.M?.subscribe&&(g=!0,M.subscribe("router:afterChange",({to:e}={})=>{/^\/login(\?|$)/.test(e?.path||"")||(p=!1)})),p=!0,R.navigate("/login")}export function takePendingLogout(){return p?(p=!1,b(),!0):!1}function b(){const e=u();e&&fetch(`${c}/auth/logout`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})}).catch(()=>{}),f()}export{t as apiRequest,T as refreshAccessToken,a as getToken,f as clearAuth};
1
+ import{loginHashFor as $}from"./lib/login-arrange.js";const m="/api";function a(){return S.get("auth_token")}function u(){return S.get("auth_refresh_token")}function f(e){S.set("auth_token",e)}function y(){S.remove("auth_token"),S.remove("auth_refresh_token"),S.remove("auth_user")}async function g(){const e=u();if(!e)throw new Error("No refresh token");const o=await fetch(`${m}/auth/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(!o.ok)throw y(),sendToLogin({expired:!0}),new Error("Token refresh failed");const{token:n}=await o.json();return f(n),n}async function t(e,o={}){let n=a();const r=i=>({...o.body!==void 0?{"Content-Type":"application/json"}:{},...o.headers,...i?{Authorization:`Bearer ${i}`}:{}});let s=await fetch(`${m}${e}`,{...o,headers:r(n)});if(s.status===401&&u())try{n=await g(),s=await fetch(`${m}${e}`,{...o,headers:r(n)})}catch{return}if(!s.ok){const i=await s.json().catch(()=>({error:"Request failed"})),d=new Error(i.error||i.message||`HTTP ${s.status}`);throw Array.isArray(i.validation)&&(d.validation=i.validation),d.status=s.status,d.body=i,d}return s.status===204?null:s.json()}async function O(e,o){const n=a(),r=n?{Authorization:`Bearer ${n}`}:{},s=await fetch(`${m}${e}`,{method:"POST",headers:r,body:o});if(!s.ok){const i=await s.json().catch(()=>({error:"Upload failed"}));throw new Error(i.error||i.message||`HTTP ${s.status}`)}return s.json()}export const api={auth:{setupStatus:()=>t("/auth/setup-status",{method:"GET"}),setup:e=>t("/auth/setup",{method:"POST",body:JSON.stringify(e)}),login:e=>t("/auth/login",{method:"POST",body:JSON.stringify(e)}),verifyLogin:e=>t("/auth/login/verify",{method:"POST",body:JSON.stringify(e)}),me:()=>t("/auth/me",{method:"GET"}),updateMe:e=>t("/auth/me",{method:"PUT",body:JSON.stringify(e)}),refresh:e=>t("/auth/refresh",{method:"POST",body:JSON.stringify({refreshToken:e})}),forgotPassword:e=>t("/auth/forgot-password",{method:"POST",body:JSON.stringify({email:e})}),checkReset:(e,o)=>t("/auth/reset-password/check",{method:"POST",body:JSON.stringify({token:e,...o&&{password:o}})}),resetPassword:(e,o)=>t("/auth/reset-password",{method:"POST",body:JSON.stringify({token:e,password:o})})},pages:{list:()=>t("/pages",{method:"GET"}),get:e=>t(`/pages${e}`,{method:"GET"}),create:e=>t("/pages",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/pages${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/pages${e}`,{method:"DELETE"}),preview:e=>t("/pages/preview",{method:"POST",body:JSON.stringify({markdown:e})}),previewFull:(e,o,n)=>t("/pages/preview/full",{method:"POST",body:JSON.stringify({urlPath:e,frontmatter:o,body:n})}),previewLinks:{list:e=>t(`/pages/preview-links${e?`?urlPath=${encodeURIComponent(e)}`:""}`,{method:"GET"}).then(o=>o.links||[]),create:(e,o,n)=>t("/pages/preview-links",{method:"POST",body:JSON.stringify({urlPath:e,expiresIn:o,label:n})}),revoke:e=>t(`/pages/preview-links/${e}`,{method:"DELETE"})},tags:()=>t("/pages/tags",{method:"GET"}).then(e=>e.tags||[])},listTags:{list:e=>t(`/list-tags/${e}`,{method:"GET"}).then(o=>o.tags||[]),add:(e,o)=>t(`/list-tags/${e}`,{method:"POST",body:JSON.stringify({name:o})}),changeItem:(e,o,n)=>t(`/list-tags/${e}/item`,{method:"PUT",body:JSON.stringify({id:o,...n})}).then(r=>r.tags||[]),rename:(e,o,n)=>t(`/list-tags/${e}/rename`,{method:"POST",body:JSON.stringify({from:o,to:n})}),remove:(e,o)=>t(`/list-tags/${e}/delete`,{method:"POST",body:JSON.stringify({name:o})})},settings:{get:()=>t("/settings",{method:"GET"}),save:e=>t("/settings",{method:"PUT",body:JSON.stringify(e)}),getCustomCss:()=>t("/settings/custom-css",{method:"GET"}),saveCustomCss:e=>t("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:e})})},theme:{get:()=>t("/theme",{method:"GET"}),save:e=>t("/theme",{method:"PUT",body:JSON.stringify(e)}),catalog:()=>t("/theme/catalog",{method:"GET"}),admin:e=>t(`/theme/admin${e?`?id=${encodeURIComponent(e)}`:""}`,{method:"GET"}),themeTokens:e=>t(`/theme/themes/${encodeURIComponent(e)}`,{method:"GET"}),compile:e=>t("/theme/compile",{method:"POST",body:JSON.stringify(e)})},navigation:{get:()=>t("/navigation",{method:"GET"}),save:e=>t("/navigation",{method:"PUT",body:JSON.stringify(e)})},menus:{list:()=>t("/menus",{method:"GET"}),get:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/menus",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/menus/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"DELETE"}),duplicate:e=>t(`/menus/${encodeURIComponent(e)}/duplicate`,{method:"POST"})},contextMenus:{list:()=>t("/context-menus",{method:"GET"}),get:e=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/context-menus",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/context-menus/${encodeURIComponent(e)}`,{method:"DELETE"}),duplicate:e=>t(`/context-menus/${encodeURIComponent(e)}/duplicate`,{method:"POST"}),actionTypes:()=>t("/context-menus/action-types",{method:"GET"})},menuLocations:{get:()=>t("/menu-locations",{method:"GET"}),save:e=>t("/menu-locations",{method:"PUT",body:JSON.stringify(e)}),registry:()=>t("/menu-locations/registry",{method:"GET"})},projects:{list:()=>t("/projects",{method:"GET"}),listWithCounts:()=>t("/projects?counts=1",{method:"GET"}),get:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/projects",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/projects/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"DELETE"}),artefacts:e=>t(`/projects/${encodeURIComponent(e)}/artefacts`,{method:"GET"}),untagAll:e=>t(`/projects/${encodeURIComponent(e)}/untag-all`,{method:"POST"}),removeContents:(e,o)=>t(`/projects/${encodeURIComponent(e)}/remove-contents`,{method:"POST",body:JSON.stringify({confirm:o})})},apiTokens:{list:()=>t("/api-tokens",{method:"GET"}),create:e=>t("/api-tokens",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),revoke:e=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"DELETE"})},apiEndpoints:{list:()=>t("/api-endpoints",{method:"GET"}),get:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/api-endpoints",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"DELETE"})},layouts:{get:()=>t("/layouts",{method:"GET"}),save:e=>t("/layouts",{method:"PUT",body:JSON.stringify(e)}),create:e=>t("/layouts",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/layouts/${e}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/layouts/${e}`,{method:"DELETE"}),getOptions:()=>t("/layouts/options",{method:"GET"}),saveOptions:e=>t("/layouts/options",{method:"PUT",body:JSON.stringify(e)})},media:{list:()=>t("/media",{method:"GET"}),upload:e=>O("/media",e),delete:e=>t(`/media/${encodeURIComponent(e)}`,{method:"DELETE"}),rename:(e,o)=>t(`/media/${encodeURIComponent(e)}`,{method:"PATCH",body:JSON.stringify({newName:o})}),info:e=>t(`/media/${encodeURIComponent(e)}/info`,{method:"GET"}),transform:(e,o)=>t(`/media/${encodeURIComponent(e)}/transform`,{method:"POST",body:JSON.stringify(o)})},users:{list:()=>t("/users",{method:"GET"}),get:e=>t(`/users/${e}`,{method:"GET"}),create:e=>t("/users",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/users/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/users/${e}`,{method:"DELETE"}),passwordReset:(e,o)=>t(`/users/${encodeURIComponent(e)}/password-reset`,{method:"POST",body:JSON.stringify({via:o})}),cancelPasswordReset:e=>t(`/users/${encodeURIComponent(e)}/password-reset`,{method:"DELETE"})},tools:{list:()=>t("/tools",{method:"GET"}),update:(e,o)=>t(`/tools/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)})},plugins:{list:()=>t("/plugins",{method:"GET"}),scaffold:e=>t("/plugins/scaffold",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/plugins/${e}`,{method:"PUT",body:JSON.stringify(o)}),files:e=>t(`/plugins/${e}/files`,{method:"GET"}),readFile:(e,o)=>t(`/plugins/${e}/file?path=${encodeURIComponent(o)}`,{method:"GET"}),writeFile:(e,o,n)=>t(`/plugins/${e}/file`,{method:"PUT",body:JSON.stringify({path:o,content:n})}),deleteFile:(e,o)=>t(`/plugins/${e}/file?path=${encodeURIComponent(o)}`,{method:"DELETE"}),restart:()=>t("/plugins/restart",{method:"POST"}),adminConfig:()=>t("/plugins/admin-config",{method:"GET"}),async exportBundle(e,{includeData:o=!1}={}){const n=a(),r=o?"?includeData=1":"",s=await fetch(`${m}/plugins/${encodeURIComponent(e)}/export${r}`,{headers:n?{Authorization:`Bearer ${n}`}:{}});if(!s.ok){const E=await s.json().catch(()=>({}));throw new Error(E.error||`Export failed (${s.status})`)}const i=s.headers.get("Content-Disposition")||"",d=/filename="([^"]+)"/.exec(i)?.[1],c=await s.blob(),h=URL.createObjectURL(c),l=document.createElement("a");l.href=h,l.download=d||`${e}.dcmsplugin`,document.body.appendChild(l),l.click(),l.remove(),URL.revokeObjectURL(h)},async installFile(e,{confirmUnsigned:o=!1,confirmUpgrade:n=!1,confirmDowngrade:r=!1}={}){const s=new FormData;o&&s.append("confirmUnsigned","true"),n&&s.append("confirmUpgrade","true"),r&&s.append("confirmDowngrade","true"),s.append("file",e);const i=a(),d=await fetch(`${m}/plugins/install-upload`,{method:"POST",headers:i?{Authorization:`Bearer ${i}`}:{},body:s}),c=await d.json().catch(()=>({}));if(d.status===409){const h=new Error(c.error||"Plugin already installed");throw h.code="CONFLICT",h.name=c.name,h.installedVersion=c.installedVersion,h.incomingVersion=c.incomingVersion,h}if(!d.ok)throw new Error(c.error||`Install failed (${d.status})`);return c}},marketplace:{catalogue:()=>t("/plugins/marketplace",{method:"GET"}),install:(e,o,{update:n=!1}={})=>t("/plugins/marketplace/install",{method:"POST",body:JSON.stringify({slug:e,version:o,update:n})}),uninstall:e=>t(`/plugins/marketplace/${encodeURIComponent(e)}`,{method:"DELETE"})},collections:{list:()=>t("/collections",{method:"GET"}),proStatus:()=>t("/collections/pro-status",{method:"GET"}),get:e=>t(`/collections/${e}`,{method:"GET"}),create:e=>t("/collections",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/collections/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/collections/${e}`,{method:"DELETE"}),listEntries:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/collections/${e}/entries${n?"?"+n:""}`,{method:"GET"})},getEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"GET"}),createEntry:(e,o)=>t(`/collections/${e}/entries`,{method:"POST",body:JSON.stringify({data:o})}),updateEntry:(e,o,n)=>t(`/collections/${e}/entries/${o}`,{method:"PUT",body:JSON.stringify({data:n})}),deleteEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"DELETE"}),clearEntries:e=>t(`/collections/${e}/entries`,{method:"DELETE"}),import:(e,o)=>t(`/collections/${e}/import`,{method:"POST",body:JSON.stringify({entries:o})}),publicList:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/collections/${e}/public${n?"?"+n:""}`,{method:"GET"})},getConnections:()=>t("/collections/connections",{method:"GET"}),saveConnections:e=>t("/collections/connections",{method:"PUT",body:JSON.stringify(e)}),migrateStorage:(e,o)=>t(`/collections/${e}/migrate-storage`,{method:"POST",body:JSON.stringify({storage:o})})},forms:{list:()=>t("/forms",{method:"GET"}),create:e=>t("/forms",{method:"POST",body:JSON.stringify(e)}),get:e=>t(`/forms/${e}`,{method:"GET"}),update:(e,o)=>t(`/forms/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/forms/${e}`,{method:"DELETE"}),listSubmissions:e=>t(`/forms/${e}/submissions`,{method:"GET"}),clearSubmissions:e=>t(`/forms/${e}/submissions`,{method:"DELETE"}),deleteSubmission:(e,o)=>t(`/forms/${e}/submissions/${o}`,{method:"DELETE"}),testEmail:e=>t("/forms/test-email",{method:"POST",body:JSON.stringify({to:e})})},views:{list:()=>t("/views",{method:"GET"}),get:e=>t(`/views/${e}`,{method:"GET"}),create:e=>t("/views",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/views/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/views/${e}`,{method:"DELETE"}),execute:(e,o={})=>{const n=new URLSearchParams(o).toString();return t(`/views/${e}/execute${n?"?"+n:""}`,{method:"GET"})},forCollection:e=>t(`/views/collection/${e}`,{method:"GET"})},actions:{list:()=>t("/actions",{method:"GET"}),get:e=>t(`/actions/${e}`,{method:"GET"}),create:e=>t("/actions",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/actions/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/actions/${e}`,{method:"DELETE"}),execute:(e,o)=>t(`/actions/${e}/execute`,{method:"POST",body:JSON.stringify({entryId:o})}),forCollection:e=>t(`/actions/collection/${e}`,{method:"GET"}),checkAccess:(e,o)=>t(`/actions/${e}/check-access`,{method:"POST",body:JSON.stringify({entryIds:o})})},versions:{list:e=>t(`/versions/list${e}`),get:(e,o)=>t(`/versions/get/${encodeURIComponent(o)}${e}`),create:(e,o)=>t(`/versions/create${e}`,{method:"POST",body:JSON.stringify({label:o})}),restore:(e,o)=>t(`/versions/restore/${encodeURIComponent(o)}${e}`,{method:"POST"}),delete:(e,o)=>t(`/versions/delete/${encodeURIComponent(o)}${e}`,{method:"DELETE"}),bulkDelete:(e,o)=>t(`/versions/bulk-delete${e}`,{method:"POST",body:JSON.stringify({filenames:o})}),prune:(e,o)=>t(`/versions/prune${e}`,{method:"POST",body:JSON.stringify({keep:o})})},blocks:{list:()=>t("/blocks",{method:"GET"}),get:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"GET"}),put:(e,o)=>t(`/blocks/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=a(),n=await fetch(`${m}/blocks/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!n.ok){const d=await n.json().catch(()=>({}));throw new Error(d.error||`Export failed (${n.status})`)}const r=await n.blob(),s=URL.createObjectURL(r),i=document.createElement("a");i.href=s,i.download=`${e}.dmblock.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(s)},async importBundle(e,{overwrite:o=!1}={}){const n=await fetch(`${m}/blocks/import`,{method:"POST",headers:{"Content-Type":"application/json",...a()?{Authorization:`Bearer ${a()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(n.status===409){const r=await n.json().catch(()=>({})),s=new Error(r.error||"Block already exists");throw s.code="CONFLICT",s.name=r.name,s}if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`Import failed (${n.status})`)}return n.json()}},components:{list:()=>t("/components",{method:"GET"}),get:e=>t(`/components/${encodeURIComponent(e)}`,{method:"GET"}),compile:(e,o)=>t("/components/compile",{method:"POST",body:JSON.stringify({name:e,source:o})}),put:(e,o)=>t(`/components/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/components/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=a(),n=await fetch(`${m}/components/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!n.ok){const d=await n.json().catch(()=>({}));throw new Error(d.error||`Export failed (${n.status})`)}const r=await n.blob(),s=URL.createObjectURL(r),i=document.createElement("a");i.href=s,i.download=`${e}.dmcomponent.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(s)},async importBundle(e,{overwrite:o=!1}={}){const n=await fetch(`${m}/components/import`,{method:"POST",headers:{"Content-Type":"application/json",...a()?{Authorization:`Bearer ${a()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(n.status===409){const r=await n.json().catch(()=>({})),s=new Error(r.error||"Component already exists");throw s.code="CONFLICT",s.name=r.name,s}if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`Import failed (${n.status})`)}return n.json()}},get:e=>t(e,{method:"GET"}),post:(e,o)=>t(e,{method:"POST",body:JSON.stringify(o)}),put:(e,o)=>t(e,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(e,{method:"DELETE"}),settingsExt:{testEmail:e=>t("/settings/test-email",{method:"POST",body:JSON.stringify({to:e})})},system:{notifications:{list:()=>t("/system/notifications",{method:"GET"}),unreadCount:()=>t("/system/notifications/unread-count",{method:"GET"}),markRead:e=>t(`/system/notifications/${e}/read`,{method:"POST"}),dismiss:e=>t(`/system/notifications/${e}/dismiss`,{method:"POST"}),remove:e=>t(`/system/notifications/${e}`,{method:"DELETE"})}},dashboard:{summary:()=>t("/dashboard/summary",{method:"GET"}),summaryLite:()=>t("/dashboard/summary?lite=1",{method:"GET"})}};export function isAuthenticated(){return!!a()}export function getUser(){return S.get("auth_user")}export function setAuthData({token:e,refreshToken:o,user:n}){e&&f(e),o&&S.set("auth_refresh_token",o),n&&S.set("auth_user",n)}let p=!1,T=!1;export function sendToLogin({expired:e=!1}={}){/^#\/(login|reset-password)(\?|\/|$)/.test(location.hash)||(location.hash=$(location.hash,{expired:e}))}export function logout(){!T&&window.M?.subscribe&&(T=!0,M.subscribe("router:afterChange",({to:e}={})=>{/^\/login(\?|$)/.test(e?.path||"")||(p=!1)})),p=!0,R.navigate("/login")}export function takePendingLogout(){return p?(p=!1,b(),!0):!1}function b(){const e=u();e&&fetch(`${m}/auth/logout`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})}).catch(()=>{}),y()}export{t as apiRequest,g as refreshAccessToken,a as getToken,y as clearAuth};
@@ -1,4 +1,4 @@
1
- import{fmtDate as M}from"./dates.js";export const NAME_RE=/^[a-z0-9][a-z0-9-]*$/,MAX_CSS=50*1024,FORM_FIELDS=["name","project","bundled","content","css"];export function formFromBlock(e,{project:r=""}={}){return e?{name:String(e.name||""),project:String(e.meta?.project||""),bundled:e.bundled===!0,content:String(e.content??""),css:String(e.css??"")}:{name:"",project:String(r||""),bundled:!1,content:"",css:""}}export function snapshot(e){const r=e||{};return JSON.stringify(FORM_FIELDS.map(n=>n==="bundled"?r[n]===!0:String(r[n]??"")))}export function checkForm(e,{isNew:r,taken:n=[]}={}){const t=String(e?.name||"").trim(),o={};r&&(t?NAME_RE.test(t)?n.includes(t)&&(o.name=`"${t}" is already a block - open it from the list, or choose another name.`):o.name="Lowercase letters, digits and hyphens, starting with a letter or digit.":o.name="A block needs a name.");const i=String(e?.css??"");return i.length>MAX_CSS&&(o.css=`The CSS is ${Math.ceil(i.length/1024)} KB - a block's CSS is limited to 50 KB.`),{errors:o,name:t}}export function payloadFromForm(e,r={}){return{content:String(e?.content??""),bundled:e?.bundled===!0,css:String(e?.css??""),meta:{...r||{},project:String(e?.project||"")||null}}}export function extractPlaceholders(e){const r=new Set;for(const[,n]of y(e).matchAll(/\{\{([\w_]+)(?::[^{}]+)?\}\}/g))r.add(n);return[...r]}export function rawPlaceholders(e){return new Set([...y(e).matchAll(/\{\{\{(\w+)\}\}\}/g)].map(r=>r[1]))}const y=e=>String(e??"").replace(/<!--[\s\S]*?-->/g,""),d=e=>String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");export{d as escapeHtml};export function formatDate(e,r){if(e==null||e==="")return null;const n=e instanceof Date?e:new Date(e);if(Number.isNaN(n.getTime()))return null;const t=(a,l=2)=>String(a).padStart(l,"0"),o=["January","February","March","April","May","June","July","August","September","October","November","December"],i=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],s=()=>(n.getHours()+11)%12+1,c={YYYY:()=>n.getFullYear(),YY:()=>t(n.getFullYear()%100),MMMM:()=>o[n.getMonth()],MMM:()=>o[n.getMonth()].slice(0,3),MM:()=>t(n.getMonth()+1),M:()=>n.getMonth()+1,DD:()=>t(n.getDate()),D:()=>n.getDate(),dddd:()=>i[n.getDay()],ddd:()=>i[n.getDay()].slice(0,3),HH:()=>t(n.getHours()),H:()=>n.getHours(),hh:()=>t(s()),h:()=>s(),mm:()=>t(n.getMinutes()),m:()=>n.getMinutes(),ss:()=>t(n.getSeconds()),s:()=>n.getSeconds(),A:()=>n.getHours()<12?"AM":"PM",a:()=>n.getHours()<12?"am":"pm"};return String(r).replace(/YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g,a=>String(c[a]()))}const S={icon:"box","facebook-icon":"facebook","instagram-icon":"instagram"};export const SAMPLE_IMAGE="data:image/svg+xml,"+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="#94a3b8"/><path d="M0 500 L260 230 L420 390 L540 290 L800 500 Z" fill="#64748b"/><circle cx="610" cy="140" r="52" fill="#cbd5e1"/></svg>');const v=/(image|img|photo|picture|avatar|logo|thumb|thumbnail|cover|banner)(url|src)?$/;export function sampleValue(e,r=new Date,{raw:n=!1}={}){const t=String(e).toLowerCase();return S[t]?S[t]:/(class|style)$/.test(t)?"":t==="_id"?"a1b2c3d4-e5f6-7890-abcd-ef1234567890":t==="_createdat"||t==="_updatedat"?r.toISOString():t.replace(/update(?!d)/g,"").includes("date")||/(published|created|updated)at$/.test(t)?M(r):v.test(t)?n?t.includes("avatar")?`<img src="${SAMPLE_IMAGE}" alt="" width="40" height="40">`:`<img src="${SAMPLE_IMAGE}" alt="">`:SAMPLE_IMAGE:/(url|href|link)$/.test(t)?"#":t.includes("email")?"user@example.com":t.includes("phone")?"+44 7700 900000":t.includes("name")?"Jane Smith":t.includes("title")?"Sample Title":["message","content","description","summary","excerpt"].some(o=>t.includes(o))?"This is a sample value for preview purposes.":t.includes("rating")?"excellent":t.includes("status")?"active":t.includes("priority")?"high":t.includes("tag")?"tag1, tag2":t.includes("subject")?"Sample Subject":t.includes("categor")?n?'<a href="#">General</a>':"general":`Sample ${e}`}export const scopeName=e=>String(e||"").trim()||"preview",PREVIEW_REPEATS=[1,3];export function renderPreview(e,r={},{repeat:n=1,sanitize:t=null}={}){if(typeof t!="function")return"";const o=u=>r[u]??`[${u}]`,i=String(e?.content??"").replace(/<!--[\s\S]*?-->/g,"").replace(/\{\{\{(\w+)\}\}\}/g,(u,g)=>String(o(g))).replace(/\{\{([\w_]+)(?::([^{}]+))?\}\}/g,(u,g,p)=>{const f=o(g);if(p){const h=formatDate(f,p);if(h!==null)return d(h)}return d(f)}),s=d(scopeName(e?.name)),c=String(e?.css??""),a=c.trim()?`<style>[data-block="${s}"] { ${c.replace(/<\/style/gi,"<\\/style")} }</style>`:"",l=PREVIEW_REPEATS.includes(Number(n))?Number(n):1,m=String(t(i)??"");if(l===1)return`${a}<div class="dm-static-block" data-block="${s}">${m}</div>`;const b=`<div data-block="${s}">${m}</div>`;return`${a}<div class="dm-collection-display dm-collection-blocks grid grid-cols-1 grid-cols-md-2 grid-cols-lg-3 gap-4">`+Array.from({length:l},()=>b).join("")+"</div>"}export function previewBridge(e,r){const n=JSON.stringify(Number(e)||0),t=JSON.stringify(String(r)).replace(/</g,"\\u003c");return`<script>(function () {
1
+ import{fmtDate as M}from"./dates.js";export const NAME_RE=/^[a-z0-9][a-z0-9-]*$/,MAX_CSS=50*1024,FORM_FIELDS=["name","project","bundled","content","css"];export function formFromBlock(e,{project:r=""}={}){return e?{name:String(e.name||""),project:String(e.meta?.project||""),bundled:e.bundled===!0,content:String(e.content??""),css:String(e.css??"")}:{name:"",project:String(r||""),bundled:!1,content:"",css:""}}export function snapshot(e){const r=e||{};return JSON.stringify(FORM_FIELDS.map(n=>n==="bundled"?r[n]===!0:String(r[n]??"")))}export function checkForm(e,{isNew:r,taken:n=[]}={}){const t=String(e?.name||"").trim(),o={};r&&(t?NAME_RE.test(t)?n.includes(t)&&(o.name=`"${t}" is already a block - open it from the list, or choose another name.`):o.name="Lowercase letters, digits and hyphens, starting with a letter or digit.":o.name="A block needs a name.");const i=String(e?.css??"");return i.length>MAX_CSS&&(o.css=`The CSS is ${Math.ceil(i.length/1024)} KB - a block's CSS is limited to 50 KB.`),{errors:o,name:t}}export function payloadFromForm(e,r={}){const{adminTags:n,...t}=r||{};return{content:String(e?.content??""),bundled:e?.bundled===!0,css:String(e?.css??""),meta:{...t,project:String(e?.project||"")||null}}}export function extractPlaceholders(e){const r=new Set;for(const[,n]of y(e).matchAll(/\{\{([\w_]+)(?::[^{}]+)?\}\}/g))r.add(n);return[...r]}export function rawPlaceholders(e){return new Set([...y(e).matchAll(/\{\{\{(\w+)\}\}\}/g)].map(r=>r[1]))}const y=e=>String(e??"").replace(/<!--[\s\S]*?-->/g,""),d=e=>String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");export{d as escapeHtml};export function formatDate(e,r){if(e==null||e==="")return null;const n=e instanceof Date?e:new Date(e);if(Number.isNaN(n.getTime()))return null;const t=(a,l=2)=>String(a).padStart(l,"0"),o=["January","February","March","April","May","June","July","August","September","October","November","December"],i=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],s=()=>(n.getHours()+11)%12+1,c={YYYY:()=>n.getFullYear(),YY:()=>t(n.getFullYear()%100),MMMM:()=>o[n.getMonth()],MMM:()=>o[n.getMonth()].slice(0,3),MM:()=>t(n.getMonth()+1),M:()=>n.getMonth()+1,DD:()=>t(n.getDate()),D:()=>n.getDate(),dddd:()=>i[n.getDay()],ddd:()=>i[n.getDay()].slice(0,3),HH:()=>t(n.getHours()),H:()=>n.getHours(),hh:()=>t(s()),h:()=>s(),mm:()=>t(n.getMinutes()),m:()=>n.getMinutes(),ss:()=>t(n.getSeconds()),s:()=>n.getSeconds(),A:()=>n.getHours()<12?"AM":"PM",a:()=>n.getHours()<12?"am":"pm"};return String(r).replace(/YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g,a=>String(c[a]()))}const S={icon:"box","facebook-icon":"facebook","instagram-icon":"instagram"};export const SAMPLE_IMAGE="data:image/svg+xml,"+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="#94a3b8"/><path d="M0 500 L260 230 L420 390 L540 290 L800 500 Z" fill="#64748b"/><circle cx="610" cy="140" r="52" fill="#cbd5e1"/></svg>');const v=/(image|img|photo|picture|avatar|logo|thumb|thumbnail|cover|banner)(url|src)?$/;export function sampleValue(e,r=new Date,{raw:n=!1}={}){const t=String(e).toLowerCase();return S[t]?S[t]:/(class|style)$/.test(t)?"":t==="_id"?"a1b2c3d4-e5f6-7890-abcd-ef1234567890":t==="_createdat"||t==="_updatedat"?r.toISOString():t.replace(/update(?!d)/g,"").includes("date")||/(published|created|updated)at$/.test(t)?M(r):v.test(t)?n?t.includes("avatar")?`<img src="${SAMPLE_IMAGE}" alt="" width="40" height="40">`:`<img src="${SAMPLE_IMAGE}" alt="">`:SAMPLE_IMAGE:/(url|href|link)$/.test(t)?"#":t.includes("email")?"user@example.com":t.includes("phone")?"+44 7700 900000":t.includes("name")?"Jane Smith":t.includes("title")?"Sample Title":["message","content","description","summary","excerpt"].some(o=>t.includes(o))?"This is a sample value for preview purposes.":t.includes("rating")?"excellent":t.includes("status")?"active":t.includes("priority")?"high":t.includes("tag")?"tag1, tag2":t.includes("subject")?"Sample Subject":t.includes("categor")?n?'<a href="#">General</a>':"general":`Sample ${e}`}export const scopeName=e=>String(e||"").trim()||"preview",PREVIEW_REPEATS=[1,3];export function renderPreview(e,r={},{repeat:n=1,sanitize:t=null}={}){if(typeof t!="function")return"";const o=u=>r[u]??`[${u}]`,i=String(e?.content??"").replace(/<!--[\s\S]*?-->/g,"").replace(/\{\{\{(\w+)\}\}\}/g,(u,g)=>String(o(g))).replace(/\{\{([\w_]+)(?::([^{}]+))?\}\}/g,(u,g,p)=>{const f=o(g);if(p){const h=formatDate(f,p);if(h!==null)return d(h)}return d(f)}),s=d(scopeName(e?.name)),c=String(e?.css??""),a=c.trim()?`<style>[data-block="${s}"] { ${c.replace(/<\/style/gi,"<\\/style")} }</style>`:"",l=PREVIEW_REPEATS.includes(Number(n))?Number(n):1,m=String(t(i)??"");if(l===1)return`${a}<div class="dm-static-block" data-block="${s}">${m}</div>`;const b=`<div data-block="${s}">${m}</div>`;return`${a}<div class="dm-collection-display dm-collection-blocks grid grid-cols-1 grid-cols-md-2 grid-cols-lg-3 gap-4">`+Array.from({length:l},()=>b).join("")+"</div>"}export function previewBridge(e,r){const n=JSON.stringify(Number(e)||0),t=JSON.stringify(String(r)).replace(/</g,"\\u003c");return`<script>(function () {
2
2
  var P = window.parent, TICK = ${n}, HOST = ${t};
3
3
  function host() { return document.getElementById(HOST); }
4
4
  function up(msg) { msg.source = 'dm-block-preview'; msg.tick = TICK; P.postMessage(msg, '*'); }
@@ -1 +1 @@
1
- import{fmtDate as u}from"./dates.js";export const NAME_RE=/^[a-z0-9][a-z0-9-]*$/,FILTERS=["all","used","unused"];const i=(e,t,n=`${t}s`)=>`${e} ${e===1?t:n}`;export function formatSize(e){const t=Number(e)||0;return t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(t<10*1024?1:0)} KB`:`${(t/1024/1024).toFixed(1)} MB`}export function usageCount(e){return e?.usage?(e.usage.pages?.length||0)+(e.usage.views?.length||0):null}export function usageText(e){const t=usageCount(e);if(t===null)return"";if(!t)return"Not used on any page or view";const n=e.usage.pages?.length||0,r=e.usage.views?.length||0;return`Used on ${[n&&i(n,"page"),r&&i(r,"view")].filter(Boolean).join(" and ")}`}export function shortcodeFor(e){return e?.example||`[block template="${e?.name??""}" /]`}export function arrangeBlocks(e,{filter:t="all",query:n=""}={}){const r=String(n).trim().toLowerCase();return[...e||[]].filter(o=>t==="used"?usageCount(o)>0:t==="unused"?usageCount(o)===0:!0).filter(o=>!r||[o.name,o.description,o.meta?.project].some(s=>String(s||"").toLowerCase().includes(r))).sort((o,s)=>String(o.name).localeCompare(String(s.name)))}export function blockRow(e,t=u){const n=usageCount(e);return{name:e.name,description:e.description||"",meta:[`${e.name}.html`,formatSize(e.size),e.updatedAt?`changed ${t(e.updatedAt)}`:"",usageText(e)].filter(Boolean).join(" \xB7 "),hasCss:!!e.hasCss,bundled:!!e.bundled,project:e.meta?.project||"",unused:n===0,uses:n===null?"":String(n)}}export function checkNewName(e,t=[]){const n=String(e??"").trim();return n?NAME_RE.test(n)?t.includes(n)?`"${n}" already exists.`:"":"Lowercase letters, digits and hyphens; starting with a letter or digit.":"A block needs a name."}export function copyName(e,t=[]){const n=`${e}-copy`;if(!t.includes(n))return n;for(let r=2;r<1e3;r++)if(!t.includes(`${n}-${r}`))return`${n}-${r}`;return n}
1
+ import{fmtDate as u}from"./dates.js";import{matchesTag as l,tagsOf as c,tagsText as d}from"./list-tags-arrange.js";export const NAME_RE=/^[a-z0-9][a-z0-9-]*$/,FILTERS=["all","used","unused"];const i=(e,t,n=`${t}s`)=>`${e} ${e===1?t:n}`;export function formatSize(e){const t=Number(e)||0;return t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(t<10*1024?1:0)} KB`:`${(t/1024/1024).toFixed(1)} MB`}export function usageCount(e){return e?.usage?(e.usage.pages?.length||0)+(e.usage.views?.length||0):null}export function usageText(e){const t=usageCount(e);if(t===null)return"";if(!t)return"Not used on any page or view";const n=e.usage.pages?.length||0,o=e.usage.views?.length||0;return`Used on ${[n&&i(n,"page"),o&&i(o,"view")].filter(Boolean).join(" and ")}`}export function shortcodeFor(e){return e?.example||`[block template="${e?.name??""}" /]`}export function arrangeBlocks(e,{filter:t="all",query:n="",tag:o=""}={}){const a=String(n).trim().toLowerCase();return[...e||[]].filter(r=>t==="used"?usageCount(r)>0:t==="unused"?usageCount(r)===0:!0).filter(r=>l(r,o)).filter(r=>!a||[r.name,r.description,r.meta?.project,...c(r)].some(s=>String(s||"").toLowerCase().includes(a))).sort((r,s)=>String(r.name).localeCompare(String(s.name)))}export function blockRow(e,t=u){const n=usageCount(e);return{name:e.name,description:e.description||"",meta:[`${e.name}.html`,formatSize(e.size),e.updatedAt?`changed ${t(e.updatedAt)}`:"",usageText(e)].filter(Boolean).join(" \xB7 "),hasCss:!!e.hasCss,bundled:!!e.bundled,project:e.meta?.project||"",listTags:d(e),unused:n===0,uses:n===null?"":String(n)}}export function checkNewName(e,t=[]){const n=String(e??"").trim();return n?NAME_RE.test(n)?t.includes(n)?`"${n}" already exists.`:"":"Lowercase letters, digits and hyphens; starting with a letter or digit.":"A block needs a name."}export function copyName(e,t=[]){const n=`${e}-copy`;if(!t.includes(n))return n;for(let o=2;o<1e3;o++)if(!t.includes(`${n}-${o}`))return`${n}-${o}`;return n}
@@ -0,0 +1 @@
1
+ export const UNTAGGED=" untagged",MAX_TAG=40,same=(t,e)=>String(t).toLowerCase()===String(e).toLowerCase();export function normaliseTag(t){return typeof t!="string"?"":t.replace(/[\u0000-\u001f\u007f]/g,"").replace(/\s+/g," ").trim().slice(0,40)}export function normaliseActive(t){return t===UNTAGGED?UNTAGGED:normaliseTag(t)}export function tagsOf(t){const e=Array.isArray(t?.adminTags)?t.adminTags:t?.meta?.adminTags;return Array.isArray(e)?e.filter(o=>typeof o=="string"&&o):[]}export function matchesTag(t,e){if(!e)return!0;const o=tagsOf(t);return e===UNTAGGED?o.length===0:o.some(r=>same(r,e))}export const tagsText=t=>tagsOf(t).join(", ");export function tagCounts(t,e){const o=[],r=n=>{n&&!o.some(i=>same(i,n))&&o.push(n)};return(t||[]).forEach(r),(e||[]).forEach(n=>tagsOf(n).forEach(r)),o.sort((n,i)=>n.localeCompare(i,void 0,{sensitivity:"base"})).map(n=>({name:n,count:(e||[]).filter(i=>tagsOf(i).some(s=>same(s,n))).length}))}export function activeNote(t,e,o){if(!t)return"";const r=`${e} ${e===1?o.replace(/s$/,""):o}`;return t===UNTAGGED?`Showing ${r} with no tag.`:`Showing ${r} tagged "${t}".`}export function tagMenuItems({all:t,current:e,change:o,newTag:r}){const n=(t||[]).filter(a=>!e.some(l=>same(l,a))),s=[{label:"Add tag",icon:"tag",submenu:[...n.map(a=>({label:a,icon:"tag",action:()=>o({add:[a]})})),...n.length?[{type:"divider"}]:[],{label:"New tag\u2026",icon:"plus",action:r}]}];return e.length?s.push({label:"Remove tag",icon:"x",submenu:[...e.map(a=>({label:a,icon:"tag",action:()=>o({remove:[a]})})),...e.length>1?[{type:"divider"},{label:"Remove all tags",icon:"x",action:()=>o({tags:[]})}]:[]]}):s.push({label:"Remove tag",icon:"x",disabled:!0}),s}
@@ -0,0 +1,37 @@
1
+ import{api as x}from"../api.js";import{helpPopovers as H}from"./help-popover.js";import{onceClosed as P}from"./tool-kit.js";import{MAX_TAG as S,UNTAGGED as U,normaliseActive as K,normaliseTag as C,same as N}from"./list-tags-arrange.js";export*from"./list-tags-arrange.js";const u=a=>String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");export async function askTagName({title:a="New tag",value:l="",confirmText:f="Add"}={}){const p=await E.prompt(`Up to ${S} characters. Tags are only seen here in the admin.`,{title:a,inputPlaceholder:"e.g. Spring campaign",inputValue:u(l),confirmText:f});return p==null?"":C(String(p))}let w=!1;function V(){if(w||document.getElementById("ltg-styles")){w=!0;return}const a=document.createElement("style");a.id="ltg-styles",a.textContent=`
2
+ .ltg-panel { display: flex; flex-direction: column; gap: 1rem; padding: .25rem 0 .5rem; }
3
+ .ltg-panel .form-label { display: flex; align-items: center; gap: .3rem; font-weight: 500; margin-bottom: .3rem; }
4
+ .ltg-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); }
5
+ .ltg-list li { display: flex; align-items: center; gap: .5rem; padding: .4rem .6rem; border-bottom: 1px solid var(--dm-border); }
6
+ .ltg-list li:last-child { border-bottom: 0; }
7
+ .ltg-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
8
+ .ltg-count { color: var(--dm-text-muted); font-size: var(--dm-text-xs); font-variant-numeric: tabular-nums; }
9
+ .ltg-list button { border: 0; background: none; cursor: pointer; color: var(--dm-text-muted); padding: .15rem .3rem; border-radius: var(--dm-radius-sm); display: inline-flex; }
10
+ .ltg-list button:hover { color: var(--dm-primary); }
11
+ .ltg-list button.is-danger:hover { color: var(--dm-danger); }
12
+ .ltg-empty { margin: 0; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
13
+ .ltg-add { display: flex; gap: .4rem; }
14
+ .ltg-add .form-input { flex: 1; }
15
+ .ltg-meta { font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
16
+ .ltg-actions { display: flex; align-items: center; gap: .5rem; padding-top: .75rem; border-top: 1px solid var(--dm-border); }
17
+ .ltg-actions .spacer { flex: 1; }`,document.head.appendChild(a),w=!0}export function openTagSettings({kind:a,noun:l,title:f,tags:p,active:i,reload:A}){V();const d=E.slideover({title:f,size:"sm",position:"right"}),$=document.createElement("template");$.innerHTML=`
18
+ <div class="ltg-panel">
19
+ <div>
20
+ <label class="form-label" for="ltg-show">Show
21
+ <span data-help="Narrow the list to the ${l} wearing one tag. Remembered in this browser; everyone else keeps their own choice."></span></label>
22
+ <select id="ltg-show" class="form-input"></select>
23
+ </div>
24
+ <div>
25
+ <label class="form-label">Tags
26
+ <span data-help="Tags link ${l} together - an item can wear several. They are seen only here in the admin, never on the site. Right-click any of the ${l} to add or remove one. Deleting a tag takes it off its ${l}; nothing else changes."></span></label>
27
+ <ul class="ltg-list"></ul>
28
+ <p class="ltg-empty">No tags yet.</p>
29
+ </div>
30
+ <div class="ltg-add">
31
+ <input type="text" class="form-input" maxlength="${S}" placeholder="New tag" aria-label="New tag">
32
+ <button type="button" class="btn btn-sm btn-primary">Add</button>
33
+ </div>
34
+ <small class="ltg-meta">Tags are shared by everyone who edits ${l}.</small>
35
+ <div class="ltg-actions"><span class="spacer"></span>
36
+ <button type="button" class="btn btn-sm btn-primary ltg-done">Done</button></div>
37
+ </div>`;const o=$.content.firstElementChild,g=o.querySelector("#ltg-show"),h=o.querySelector(".ltg-list"),q=o.querySelector(".ltg-empty"),m=o.querySelector(".ltg-add input");let y=!1;const v=(e,n)=>{const t=document.createElement("option");return t.value=e,t.textContent=n,t},T=(e,n,t,c)=>{const r=document.createElement("button");r.type="button",r.title=n,r.setAttribute("aria-label",n),t&&(r.className=t);const s=document.createElement("span");return s.setAttribute("data-icon",e),s.setAttribute("data-icon-size","14"),r.appendChild(s),r.addEventListener("click",c),r};async function b(e,n){if(!y){y=!0;try{const t=await e();return await A(),n&&E.toast(n(t),{type:"success"}),!0}catch(t){return E.toast(t.message||"That did not work.",{type:"error"}),!1}finally{y=!1}}}async function D(e){const n=await askTagName({title:`Rename "${u(e)}"`,value:e,confirmText:"Rename"});if(!n||n===e)return;await b(()=>x.listTags.rename(a,e,n),c=>`Renamed - ${c.changed} ${l} updated.`)&&N(i.peek(),e)&&(i.value=n)}async function R(e){const n=e.count?`Delete the tag "${u(e.name)}"? It comes off ${e.count} ${e.count===1?l.replace(/s$/,""):l}; nothing else about them changes.`:`Delete the tag "${u(e.name)}"?`;if(!await E.confirm(n))return;await b(()=>x.listTags.remove(a,e.name),()=>"Tag deleted.")&&N(i.peek(),e.name)&&(i.value="")}async function k(){const e=C(m.value);if(!e){m.focus();return}await b(()=>x.listTags.add(a,e)),m.value="",m.focus()}const L=M.effect(()=>{const e=p.value,n=i.value;g.replaceChildren(v("",`All ${l}`),v(U,"No tag"),...e.map(t=>v(t.name,`${t.name} (${t.count})`))),g.value=n,h.replaceChildren(...e.map(t=>{const c=document.createElement("li"),r=document.createElement("span");r.className="ltg-name",r.textContent=t.name;const s=document.createElement("span");return s.className="ltg-count",s.textContent=String(t.count),c.append(r,s,T("edit",`Rename ${t.name}`,"",()=>D(t.name)),T("trash",`Delete ${t.name}`,"is-danger",()=>R(t))),c})),h.hidden=!e.length,q.hidden=e.length>0,I.scan(h)}),z=()=>{i.value=K(g.value)},G=e=>{e.key==="Enter"&&e.target===m&&(e.preventDefault(),k())};g.addEventListener("change",z),o.addEventListener("keydown",G),o.querySelector(".ltg-add button").addEventListener("click",k),o.querySelector(".ltg-done").addEventListener("click",()=>d.close()),d.element.appendChild(o);const B=H(o);return P(d,()=>{L(),B()}),I.scan(o),d.open(),d}
@@ -1 +1 @@
1
- import{fmtDate as a}from"./dates.js";export const STATUSES=["all","published","draft"],SORTS=["order","updated","title","url"],MODES=["list","tree"],DEFAULT_SETTINGS={status:"all",sort:"order",mode:"list"};const l=(t,e,r=`${e}s`)=>`${t} ${t===1?e:r}`;export function normaliseSettings(t){const e=t&&typeof t=="object"?t:{};return{status:STATUSES.includes(e.status)?e.status:DEFAULT_SETTINGS.status,sort:SORTS.includes(e.sort)?e.sort:DEFAULT_SETTINGS.sort,mode:MODES.includes(e.mode)?e.mode:DEFAULT_SETTINGS.mode}}export function ago(t,e=Date.now()){const r=new Date(t).getTime();if(!Number.isFinite(r))return"";const o=Math.max(0,Math.round((e-r)/1e3));return o<60?"just now":o<3600?`${Math.round(o/60)} min ago`:o<86400?`${l(Math.round(o/3600),"hour")} ago`:o<86400*14?`${l(Math.round(o/86400),"day")} ago`:a(r)}export function visibilityLabel(t){const e=String(t??"public").trim();return!e||e==="public"?"":e==="private"?"Private":e.startsWith("=")?`Only ${e.slice(1)}`:`${e}+`}const d=t=>`${t.title||""} ${t.urlPath||""} ${(t.tags||[]).join(" ")} ${t.layout||""}`.toLowerCase();export function arrangePages(t,{status:e="all",query:r="",sort:o="order"}={}){const s=String(r).trim().toLowerCase(),i=(t||[]).filter(n=>e==="all"||(e==="draft"?n.status!=="published":n.status==="published")).filter(n=>!s||d(n).includes(s));return o==="updated"?[...i].sort((n,u)=>String(u.updatedAt||"").localeCompare(String(n.updatedAt||""))):o==="title"?[...i].sort((n,u)=>String(n.title||"").localeCompare(String(u.title||""))):o==="url"?[...i].sort((n,u)=>String(n.urlPath).localeCompare(String(u.urlPath))):i}export function statusCounts(t){const e=(t||[]).length,r=(t||[]).filter(o=>o.status==="published").length;return{all:e,published:r,draft:e-r}}export function pageRow(t,e=Date.now()){const r=t.status!=="published",o=Number.isFinite(t.versionCount)?t.versionCount:0,s=t.resolvedProject&&t.resolvedProject!=="core"?t.resolvedProject:"";return{urlPath:t.urlPath,title:t.title||t.urlPath,statusText:t.status==="review"?"Review":r?"Draft":"Published",statusCls:r?"is-warn":"is-ok",visibility:visibilityLabel(t.visibility),project:s,plugin:t.plugin?String(t.plugin):"",bundled:t.bundled===!0,tagsText:(t.tags||[]).filter(Boolean).map(i=>`#${i}`).join(" "),meta:[t.urlPath,t.layout||"default",l(o,"revision"),t.updatedAt?`updated ${ago(t.updatedAt,e)}`:""].filter(Boolean).join(" \xB7 "),revisions:o,rowCls:r?"is-draft":""}}export function treeRows(t){const e=new Set((t||[]).map(r=>r.urlPath));return(t||[]).map(r=>{const o=String(r.urlPath).split("/").filter(Boolean);let s=null;for(let i=o.length-1;i>0&&!s;i--){const n="/"+o.slice(0,i).join("/");e.has(n)&&(s=n)}return{id:r.urlPath,parent_id:s,name:r.title||r.urlPath,icon:r.status==="published"?"check-circle":"file-text"}})}export function checkPrune(t,e){const r=String(t??"").trim();if(!/^\d+$/.test(r))return{error:"A whole number, 0 or more.",keep:0,deletes:0};const o=Number.parseInt(r,10);return{error:"",keep:o,deletes:Math.max(0,(Number(e)||0)-o)}}
1
+ import{fmtDate as d}from"./dates.js";import{matchesTag as c,normaliseActive as f,tagsOf as h,tagsText as g}from"./list-tags-arrange.js";export const STATUSES=["all","published","draft"],SORTS=["order","updated","title","url"],MODES=["list","tree"],DEFAULT_SETTINGS={status:"all",sort:"order",mode:"list",tag:""};const u=(t,e,r=`${e}s`)=>`${t} ${t===1?e:r}`;export function normaliseSettings(t){const e=t&&typeof t=="object"?t:{};return{status:STATUSES.includes(e.status)?e.status:DEFAULT_SETTINGS.status,sort:SORTS.includes(e.sort)?e.sort:DEFAULT_SETTINGS.sort,mode:MODES.includes(e.mode)?e.mode:DEFAULT_SETTINGS.mode,tag:f(e.tag)}}export function ago(t,e=Date.now()){const r=new Date(t).getTime();if(!Number.isFinite(r))return"";const o=Math.max(0,Math.round((e-r)/1e3));return o<60?"just now":o<3600?`${Math.round(o/60)} min ago`:o<86400?`${u(Math.round(o/3600),"hour")} ago`:o<86400*14?`${u(Math.round(o/86400),"day")} ago`:d(r)}export function visibilityLabel(t){const e=String(t??"public").trim();return!e||e==="public"?"":e==="private"?"Private":e.startsWith("=")?`Only ${e.slice(1)}`:`${e}+`}const m=t=>`${t.title||""} ${t.urlPath||""} ${(t.tags||[]).join(" ")} ${h(t).join(" ")} ${t.layout||""}`.toLowerCase();export function arrangePages(t,{status:e="all",query:r="",sort:o="order",tag:s=""}={}){const n=String(r).trim().toLowerCase(),a=(t||[]).filter(i=>e==="all"||(e==="draft"?i.status!=="published":i.status==="published")).filter(i=>c(i,s)).filter(i=>!n||m(i).includes(n));return o==="updated"?[...a].sort((i,l)=>String(l.updatedAt||"").localeCompare(String(i.updatedAt||""))):o==="title"?[...a].sort((i,l)=>String(i.title||"").localeCompare(String(l.title||""))):o==="url"?[...a].sort((i,l)=>String(i.urlPath).localeCompare(String(l.urlPath))):a}export function statusCounts(t){const e=(t||[]).length,r=(t||[]).filter(o=>o.status==="published").length;return{all:e,published:r,draft:e-r}}export function pageRow(t,e=Date.now()){const r=t.status!=="published",o=Number.isFinite(t.versionCount)?t.versionCount:0,s=t.resolvedProject&&t.resolvedProject!=="core"?t.resolvedProject:"";return{urlPath:t.urlPath,title:t.title||t.urlPath,statusText:t.status==="review"?"Review":r?"Draft":"Published",statusCls:r?"is-warn":"is-ok",visibility:visibilityLabel(t.visibility),project:s,plugin:t.plugin?String(t.plugin):"",bundled:t.bundled===!0,tagsText:(t.tags||[]).filter(Boolean).map(n=>`#${n}`).join(" "),listTags:g(t),meta:[t.urlPath,t.layout||"default",u(o,"revision"),t.updatedAt?`updated ${ago(t.updatedAt,e)}`:""].filter(Boolean).join(" \xB7 "),revisions:o,rowCls:r?"is-draft":""}}export function treeRows(t){const e=new Set((t||[]).map(r=>r.urlPath));return(t||[]).map(r=>{const o=String(r.urlPath).split("/").filter(Boolean);let s=null;for(let n=o.length-1;n>0&&!s;n--){const a="/"+o.slice(0,n).join("/");e.has(a)&&(s=a)}return{id:r.urlPath,parent_id:s,name:r.title||r.urlPath,icon:r.status==="published"?"check-circle":"file-text"}})}export function checkPrune(t,e){const r=String(t??"").trim();if(!/^\d+$/.test(r))return{error:"A whole number, 0 or more.",keep:0,deletes:0};const o=Number.parseInt(r,10);return{error:"",keep:o,deletes:Math.max(0,(Number(e)||0)-o)}}
@@ -9,6 +9,10 @@
9
9
  <!-- Hidden file picker driven by the Import banner action -->
10
10
  <input class="blx-import-file" type="file" accept=".dmblock.json,application/json" hidden>
11
11
 
12
+ <p class="blx-notice" data-bind-hidden="!tagNote.value">
13
+ <span data-bind-text="tagNote.value"></span> <button type="button" class="blx-link" data-on-click="showAllTags">Show all blocks</button>
14
+ </p>
15
+
12
16
  <div class="blx-card">
13
17
  <div class="blx-bar">
14
18
  <div class="blx-seg" role="group" aria-label="Show">
@@ -17,7 +21,7 @@
17
21
  <button type="button" data-on-click="showUnused" data-bind-class="filter.value === 'unused' && 'is-on'">Unused <b data-bind-text="counts.value.unused"></b></button>
18
22
  </div>
19
23
  <input type="search" class="form-input blx-search" placeholder="Search blocks…" aria-label="Search blocks" data-model="query.value">
20
- <span data-help-title="The list" data-help="Click a block to edit it. Right-click it (or ⋮, or Shift+F10) to copy its shortcode, see where it is used, duplicate, export or delete it. In use counts pages and views that place it; a block a plugin uses from its own code does not count, so check before deleting one marked unused."></span>
24
+ <span data-help-title="The list" data-help="Click a block to edit it. Right-click it (or ⋮, or Shift+F10) to copy its shortcode, see where it is used, duplicate, export or delete it. In use counts pages and views that place it; a block a plugin uses from its own code does not count, so check before deleting one marked unused. Tags link blocks together - add or remove one from the right-click menu, and pick which tag to show (or manage the tags) with the cog above."></span>
21
25
  </div>
22
26
  <p class="blx-note" data-bind-hidden="!loading.value">Loading…</p>
23
27
  <p class="blx-note is-bad" data-bind-hidden="!loadError.value" data-bind-text="loadError.value"></p>
@@ -55,6 +59,10 @@
55
59
  .blx-chip { display: inline-flex; align-items: center; font-size: var(--dm-text-xs); font-weight: 600; padding: .1rem .5rem; border-radius: 999px; white-space: nowrap;
56
60
  background: color-mix(in srgb, var(--dm-text) 10%, transparent); color: var(--dm-text-muted); }
57
61
  .blx-chip.is-ok { background: color-mix(in srgb, var(--dm-success) 16%, transparent); color: var(--dm-success); }
62
+ .blx-chip.is-tag { background: color-mix(in srgb, var(--dm-primary) 12%, transparent); color: var(--dm-primary); max-width: 14rem; overflow: hidden; text-overflow: ellipsis; }
63
+ .blx-notice { margin: 0; padding: .5rem .75rem; border-radius: var(--dm-radius-md); font-size: var(--dm-text-sm);
64
+ background: color-mix(in srgb, var(--dm-info, var(--dm-primary)) 12%, transparent); color: var(--dm-text); }
65
+ .blx-link { border: 0; background: none; padding: 0; font: inherit; color: var(--dm-primary); text-decoration: underline; cursor: pointer; }
58
66
  .blx-chip.is-info { background: color-mix(in srgb, var(--dm-info, var(--dm-primary)) 14%, transparent); color: var(--dm-info, var(--dm-primary)); }
59
67
  .blx-chip.is-warn { background: color-mix(in srgb, var(--dm-warning) 18%, transparent); color: var(--dm-warning); }
60
68
  .blx-uses { min-width: 2.5rem; text-align: right; font-variant-numeric: tabular-nums; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
@@ -6,6 +6,10 @@
6
6
  <span data-bind-text="projectNote.value"></span> <a href="#/pages">Show all pages</a>
7
7
  </p>
8
8
 
9
+ <p class="pgx-notice" data-bind-hidden="!tagNote.value">
10
+ <span data-bind-text="tagNote.value"></span> <button type="button" class="pgx-link" data-on-click="showAllTags">Show all pages</button>
11
+ </p>
12
+
9
13
  <div class="pgx-card">
10
14
  <div class="pgx-bar">
11
15
  <div class="pgx-seg" role="group" aria-label="Status">
@@ -25,7 +29,7 @@
25
29
  <button type="button" data-on-click="showList" data-bind-class="mode.value === 'list' && 'is-on'" title="List">List</button>
26
30
  <button type="button" data-on-click="showTree" data-bind-class="mode.value === 'tree' && 'is-on'" title="Tree - pages under the page whose URL they sit beneath">Tree</button>
27
31
  </div>
28
- <span data-help-title="Pages" data-help="Click a page to edit it. Right-click it (or ⋮, or Shift+F10) to view it on the site, copy its address, transition between Draft, Published and Review, trim its revision history or delete it. Drafts are not public: visitors get a 404, while anyone who can edit pages sees them with a Draft banner. Tree shows each page under the page whose address it sits beneath. Your filter, sort and view are remembered."></span>
32
+ <span data-help-title="Pages" data-help="Click a page to edit it. Right-click it (or ⋮, or Shift+F10) to view it on the site, copy its address, transition between Draft, Published and Review, trim its revision history or delete it. Drafts are not public: visitors get a 404, while anyone who can edit pages sees them with a Draft banner. Tree shows each page under the page whose address it sits beneath. Tags link pages together - add or remove one from the right-click menu, and pick which tag to show (or manage the tags) with the cog above. Your filter, sort, view and tag are remembered."></span>
29
33
  </div>
30
34
  <div class="pgx-note dm-loading-slot" data-bind-hidden="!loading.value">Loading…</div>
31
35
  <p class="pgx-note is-bad" data-bind-hidden="!loadError.value" data-bind-text="loadError.value"></p>
@@ -67,6 +71,8 @@
67
71
  background: color-mix(in srgb, var(--dm-text) 10%, transparent); color: var(--dm-text-muted); }
68
72
  .pgx-chip.is-ok { background: color-mix(in srgb, var(--dm-success) 16%, transparent); color: var(--dm-success); }
69
73
  .pgx-chip.is-warn { background: color-mix(in srgb, var(--dm-warning) 18%, transparent); color: var(--dm-warning); }
74
+ .pgx-chip.is-tag { background: color-mix(in srgb, var(--dm-primary) 12%, transparent); color: var(--dm-primary); max-width: 14rem; overflow: hidden; text-overflow: ellipsis; }
75
+ .pgx-link { border: 0; background: none; padding: 0; font: inherit; color: var(--dm-primary); text-decoration: underline; cursor: pointer; }
70
76
  .pgx-chip.is-info { background: color-mix(in srgb, var(--dm-info, var(--dm-primary)) 14%, transparent); color: var(--dm-info, var(--dm-primary)); }
71
77
  .pgx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .3rem; }
72
78
  .pgx-kebab:hover { color: var(--dm-primary); }
@@ -1,22 +1,23 @@
1
- import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as A}from"../lib/plugin-chrome.js";import{helpPopovers as te}from"../lib/help-popover.js";import{onceClosed as ne,openMenuAt as ae,sidebarCount as Se,viewLifetime as Me}from"../lib/tool-kit.js";import{filterByProject as Le,getProjectFromHash as Ie,renderProjectFilterNotice as Te}from"../lib/project-context.js";import{FILTERS as je,arrangeBlocks as $e,blockRow as Ne,checkNewName as Be,copyName as oe,shortcodeFor as se,usageText as le}from"../lib/blocks-arrange.js";import{fmtDate as De}from"../lib/dates.js";const ce=/^#\/(blocks|projects\/[^/?#]+\/blocks)\/?(\?.*)?$/,Fe="#/blocks",N="blocks.draft",U="blocks.filter",Re=`
1
+ import{api as h,apiRequest as Fe}from"../api.js";import{addBannerAction as A}from"../lib/plugin-chrome.js";import{helpPopovers as ie}from"../lib/help-popover.js";import{onceClosed as re,openMenuAt as de,sidebarCount as Re,viewLifetime as qe}from"../lib/tool-kit.js";import{filterByProject as Ue,getProjectFromHash as He,renderProjectFilterNotice as Oe}from"../lib/project-context.js";import{FILTERS as Pe,arrangeBlocks as Ke,blockRow as We,checkNewName as _e,copyName as ue,shortcodeFor as pe,usageText as me}from"../lib/blocks-arrange.js";import{fmtDate as ze}from"../lib/dates.js";import{UNTAGGED as Ge,activeNote as Ye,askTagName as Je,normaliseActive as Ve,openTagSettings as Qe,same as be,tagCounts as Xe,tagMenuItems as Ze,tagsOf as D}from"../lib/list-tags.js";const he=/^#\/(blocks|projects\/[^/?#]+\/blocks)\/?(\?.*)?$/,et="#/blocks",F="blocks.draft",_="blocks.filter",fe="blocks.tag",tt=`
2
2
  <div class="blx-row" data-name="{{name}}" tabindex="0" data-bind-class="unused && 'is-unused'">
3
3
  <span class="blx-icon" aria-hidden="true"><span data-icon="box" data-icon-size="16"></span></span>
4
4
  <span class="blx-main"><strong>{{name}}</strong><span data-bind-hidden="!description">{{description}}</span><small>{{meta}}</small></span>
5
5
  <span class="blx-chips">
6
6
  <span class="blx-chip is-info" data-bind-hidden="!hasCss" title="Has its own CSS">CSS</span>
7
+ <span class="blx-chip is-tag" data-bind-hidden="!listTags" title="Tags">{{listTags}}</span>
7
8
  <span class="blx-chip" data-bind-hidden="!project" title="Project">{{project}}</span>
8
9
  <span class="blx-chip" data-bind-hidden="!bundled" title="Ships with the CMS">Built in</span>
9
10
  <span class="blx-chip is-warn" data-bind-hidden="!unused" title="No page or view places it">Unused</span>
10
11
  </span>
11
12
  <span class="blx-uses" title="Pages and views that use it">{{uses}}</span>
12
13
  <button type="button" class="blx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
13
- </div>`,g=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ie(s){const a=document.createElement("template");return a.innerHTML=s.trim(),a.content.firstElementChild}const i=Me(ce);async function qe(s){try{return await navigator.clipboard.writeText(s),!0}catch{const a=document.createElement("textarea");a.value=s,a.style.cssText="position:fixed;opacity:0;",document.body.appendChild(a),a.select();let h=!1;try{h=document.execCommand("copy")}catch{}return a.remove(),h}}export const blocksView={templateUrl:"/admin/js/templates/blocks.html",async onMount(s){i.reset();const a=s.get(0).querySelector(".blx")||s.get(0),h=()=>ce.test(location.hash)&&a.isConnected,w=Ie(),p=M.observable([]),H=M.observable(!0),B=M.observable("");let O="all";try{O=je.includes(S.get(U))?S.get(U):"all"}catch{}const y=M.observable(O),P=M.observable("");i.add(M.effect(()=>{const e=y.value;try{S.set(U,e)}catch{}}));const re=De,D=M.observable([]);i.add(M.effect(()=>{D.value=$e(p.value,{filter:y.value,query:P.value}).map(e=>Ne(e,re))}));const de=M.computed(()=>{const e=p.value,t=e.filter(n=>(n.usage?.pages?.length||0)+(n.usage?.views?.length||0)>0).length;return{all:e.length,used:t,unused:e.length-t}}),ue=M.computed(()=>p.value.length?"No blocks match.":"No blocks yet - New block in the banner makes one, or Import brings one in."),F=e=>p.peek().find(t=>t.name===e),pe=w?`#/blocks/new?project=${encodeURIComponent(w)}`:"#/blocks/new",K=e=>`#/blocks/edit/${encodeURIComponent(e)}`;async function W(){try{let e=await Ce("/blocks?usage=1",{method:"GET"});if(!h())return;if(e=Array.isArray(e)?e:[],w){const t=e.length;e=Le(e,w),Te(a,{total:t,shown:e.length,slug:w,allHref:"#/blocks",noun:"blocks"})}p.value=e,B.value=""}catch(e){B.value=`Could not load the blocks: ${e.message||e}`}finally{H.value=!1}}const T=async()=>{Se(Fe),h()&&await W()};async function me(e){const n=(e.usage?.pages?.length||0)+(e.usage?.views?.length||0)?` It is ${g(le(e).toLowerCase())} - they will show nothing where it was.`:"";if(await E.confirm(`Delete the block "${g(e.name)}"?${n} This cannot be undone.`))try{await v.blocks.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),await T()}catch(r){E.toast(r.message||"Failed to delete block.",{type:"error"})}}async function be(e){try{await v.blocks.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}async function he(e){const t=se(e),n=await qe(t);E.toast(n?`Copied ${t}`:`Could not copy - your browser refused. The shortcode is ${t}`,{type:n?"success":"error"})}const k=a.querySelector(".blx-import-file");async function z(){const e=k.files?.[0];if(!e)return;k.value="";let t;try{t=JSON.parse(await e.text())}catch{E.toast("Not a valid .dmblock.json file (could not parse JSON).",{type:"error"});return}if(!t||typeof t!="object"||!t.name||typeof t.html!="string"){E.toast("Bundle is missing a name or html field.",{type:"error"});return}try{const n=await v.blocks.importBundle(t);E.toast(`Imported "${n.name}".`,{type:"success"}),await T()}catch(n){if(n.code==="CONFLICT"){if(!await E.confirm(`A block named "${g(n.name)}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const r=await v.blocks.importBundle(t,{overwrite:!0});E.toast(`Overwrote "${r.name}".`,{type:"success"}),await T()}catch(r){E.toast(r.message||"Import failed.",{type:"error"})}return}E.toast(n.message||"Import failed.",{type:"error"})}}k.addEventListener("change",z),i.add(()=>k.removeEventListener("change",z));const x=new Set;let f=null,_=!1;function fe(e){const t=E.slideover({title:`Where ${g(e.name)} is used`,size:"sm",position:"right"}),n=ie(`
14
+ </div>`,k=l=>String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ge(l){const n=document.createElement("template");return n.innerHTML=l.trim(),n.content.firstElementChild}const i=qe(he);async function at(l){try{return await navigator.clipboard.writeText(l),!0}catch{const n=document.createElement("textarea");n.value=l,n.style.cssText="position:fixed;opacity:0;",document.body.appendChild(n),n.select();let f=!1;try{f=document.execCommand("copy")}catch{}return n.remove(),f}}export const blocksView={templateUrl:"/admin/js/templates/blocks.html",async onMount(l){i.reset();const n=l.get(0).querySelector(".blx")||l.get(0),f=()=>he.test(location.hash)&&n.isConnected,w=He(),u=M.observable([]),z=M.observable(!0),R=M.observable("");let G="all";try{G=Pe.includes(S.get(_))?S.get(_):"all"}catch{}const y=M.observable(G),Y=M.observable("");i.add(M.effect(()=>{const e=y.value;try{S.set(_,e)}catch{}}));let J="";try{J=Ve(S.get(fe))}catch{}const g=M.observable(J),x=M.observable([]);i.add(M.effect(()=>{const e=g.value;try{S.set(fe,e)}catch{}}));const ve=ze,q=M.observable([]);i.add(M.effect(()=>{q.value=Ke(u.value,{filter:y.value,query:Y.value,tag:g.value})}));const U=M.observable([]);i.add(M.effect(()=>{U.value=q.value.map(e=>We(e,ve))}));const H=M.computed(()=>Xe(x.value,u.value)),ye=M.computed(()=>Ye(g.value,q.value.length,"blocks")),ke=M.computed(()=>{const e=u.value,t=e.filter(a=>(a.usage?.pages?.length||0)+(a.usage?.views?.length||0)>0).length;return{all:e.length,used:t,unused:e.length-t}}),we=M.computed(()=>u.value.length?"No blocks match.":"No blocks yet - New block in the banner makes one, or Import brings one in."),O=e=>u.peek().find(t=>t.name===e),xe=w?`#/blocks/new?project=${encodeURIComponent(w)}`:"#/blocks/new",V=e=>`#/blocks/edit/${encodeURIComponent(e)}`;async function P(){try{let e=await Fe("/blocks?usage=1",{method:"GET"});if(!f())return;if(e=Array.isArray(e)?e:[],w){const t=e.length;e=Ue(e,w),Oe(n,{total:t,shown:e.length,slug:w,allHref:"#/blocks",noun:"blocks"})}u.value=e,R.value="",await Ee()}catch(e){R.value=`Could not load the blocks: ${e.message||e}`}finally{z.value=!1}}async function Ee(){try{x.value=(await h.listTags.list("blocks")).map(t=>t.name)}catch{}const e=g.peek();e&&e!==Ge&&!H.peek().some(t=>be(t.name,e))&&(g.value="")}async function Q(e,t){try{const a=await h.listTags.changeItem("blocks",e.name,t);u.value=u.peek().map(s=>s.name===e.name?{...s,meta:{...s.meta||{},adminTags:a}}:s),a.some(s=>!x.peek().some(p=>be(p,s)))&&(x.value=[...x.peek(),...a])}catch(a){E.toast(`Tags not saved: ${a.message||a}`,{type:"error"})}}const Se=()=>Qe({kind:"blocks",noun:"blocks",title:"Blocks settings - tags",tags:H,active:g,reload:P}),j=async()=>{Re(et),f()&&await P()};async function Ce(e){const a=(e.usage?.pages?.length||0)+(e.usage?.views?.length||0)?` It is ${k(me(e).toLowerCase())} - they will show nothing where it was.`:"";if(await E.confirm(`Delete the block "${k(e.name)}"?${a} This cannot be undone.`))try{await h.blocks.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),await j()}catch(s){E.toast(s.message||"Failed to delete block.",{type:"error"})}}async function Te(e){try{await h.blocks.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}async function Me(e){const t=pe(e),a=await at(t);E.toast(a?`Copied ${t}`:`Could not copy - your browser refused. The shortcode is ${t}`,{type:a?"success":"error"})}const C=n.querySelector(".blx-import-file");async function X(){const e=C.files?.[0];if(!e)return;C.value="";let t;try{t=JSON.parse(await e.text())}catch{E.toast("Not a valid .dmblock.json file (could not parse JSON).",{type:"error"});return}if(!t||typeof t!="object"||!t.name||typeof t.html!="string"){E.toast("Bundle is missing a name or html field.",{type:"error"});return}try{const a=await h.blocks.importBundle(t);E.toast(`Imported "${a.name}".`,{type:"success"}),await j()}catch(a){if(a.code==="CONFLICT"){if(!await E.confirm(`A block named "${k(a.name)}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const s=await h.blocks.importBundle(t,{overwrite:!0});E.toast(`Overwrote "${s.name}".`,{type:"success"}),await j()}catch(s){E.toast(s.message||"Import failed.",{type:"error"})}return}E.toast(a.message||"Import failed.",{type:"error"})}}C.addEventListener("change",X),i.add(()=>C.removeEventListener("change",X));const T=new Set;let v=null,Z=!1;function Ne(e){const t=E.slideover({title:`Where ${k(e.name)} is used`,size:"sm",position:"right"}),a=ge(`
14
15
  <div class="blx-form">
15
16
  <p class="blx-note blx-where-sum"></p>
16
17
  <ul class="blx-where"></ul>
17
18
  <div><div class="form-label">Shortcode</div><div class="blx-code"></div>
18
19
  <small class="blx-hint">Right-click the block in the list and Copy shortcode to paste it into a page.</small></div>
19
- </div>`);n.querySelector(".blx-where-sum").textContent=le(e)+".",n.querySelector(".blx-code").textContent=se(e);const r=n.querySelector(".blx-where"),b=(l,j,$)=>{const L=document.createElement("li"),o=document.createElement("a");o.href=l,o.textContent=j,o.addEventListener("click",()=>t.close());const d=document.createElement("small");d.textContent=$,L.append(o,d),r.appendChild(L)};for(const l of e.usage?.pages||[])b(`#/pages/edit${l}`,l,"page");for(const l of e.usage?.views||[])b(`#/views/edit/${encodeURIComponent(l)}`,l,"view");t.element.appendChild(n),x.add(t),ne(t,()=>x.delete(t)),t.open()}function Y(e,t=null){const n=()=>p.peek().map(c=>c.name),r=t?.name??oe(e.name,n()),b=M.observable(r),l=M.observable(!1),j=M.computed(()=>Be(b.value,p.value.map(c=>c.name))),$={name:b,saving:l,err:j,cannot:M.computed(()=>l.value||!!j.value)},L=()=>b.peek()!==oe(e.name,n())||!!t,o=E.slideover({title:`Duplicate ${g(e.name)}`,size:"sm",position:"right"}),d=ie(`
20
+ </div>`);a.querySelector(".blx-where-sum").textContent=me(e)+".",a.querySelector(".blx-code").textContent=pe(e);const s=a.querySelector(".blx-where"),p=(r,B,$)=>{const L=document.createElement("li"),o=document.createElement("a");o.href=r,o.textContent=B,o.addEventListener("click",()=>t.close());const m=document.createElement("small");m.textContent=$,L.append(o,m),s.appendChild(L)};for(const r of e.usage?.pages||[])p(`#/pages/edit${r}`,r,"page");for(const r of e.usage?.views||[])p(`#/views/edit/${encodeURIComponent(r)}`,r,"view");t.element.appendChild(a),T.add(t),re(t,()=>T.delete(t)),t.open()}function ee(e,t=null){const a=()=>u.peek().map(d=>d.name),s=t?.name??ue(e.name,a()),p=M.observable(s),r=M.observable(!1),B=M.computed(()=>_e(p.value,u.value.map(d=>d.name))),$={name:p,saving:r,err:B,cannot:M.computed(()=>r.value||!!B.value)},L=()=>p.peek()!==ue(e.name,a())||!!t,o=E.slideover({title:`Duplicate ${k(e.name)}`,size:"sm",position:"right"}),m=ge(`
20
21
  <div class="blx-form">
21
22
  <div>
22
23
  <label class="form-label" for="blx-dup-name">Name of the copy
@@ -31,4 +32,4 @@ import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as A}fro
31
32
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
32
33
  <span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
33
34
  </div>
34
- </div>`);let R=!1;async function X(){if($.cannot.peek())return;l.value=!0;const c=b.peek().trim();try{const u=await v.blocks.get(e.name);await v.blocks.put(c,{content:u.content,css:u.css||"",...u.meta?.project&&{meta:{project:u.meta.project}}}),E.toast(`Made "${c}".`,{type:"success"}),R=!0,o.close(),await T()}catch(u){E.toast(`Not duplicated: ${u.message||u}`,{type:"error"})}finally{l.value=!1}}o.element.appendChild(d);const we=te(d),ke=M.applyBindings($,d,{methods:{save:X,cancel:()=>o.close()}}),xe=c=>{c.key==="Enter"&&c.target.matches("input")&&(c.preventDefault(),X())};d.addEventListener("keydown",xe);const Z={dirty:L,values:()=>({name:b.peek()}),source:e.name};f=Z,x.add(o),ne(o,()=>{ke.dispose(),we(),x.delete(o),f===Z&&(f=null)});const Ee=o.close.bind(o);let q=!1;o.close=async c=>{if(!R&&!_&&c!==!0&&L()){if(q)return;q=!0;const u=await E.confirm("Discard the name you typed?");if(q=!1,!u)return}return R=!0,Ee()},I.scan(d),o.open();const ee=d.querySelector("#blx-dup-name");ee?.focus(),ee?.select()}function ye(e){const t=F(e);if(!t)return[];const n=(t.usage?.pages?.length||0)+(t.usage?.views?.length||0);return[{header:t.name},{label:"Edit",icon:"edit",action:()=>{location.hash=K(t.name)}},{label:"Copy shortcode",icon:"copy",action:()=>he(t)},{label:n?`Where it is used (${n})\u2026`:"Where it is used\u2026",icon:"link",action:()=>fe(t)},{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>Y(t)},{label:"Export (.dmblock.json)",icon:"download",action:()=>be(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>me(t)}]}const m=a.querySelector(".blx-list");m.innerHTML=Re;const ve=M.applyBindings({blocks:p,loading:H,loadError:B,filter:y,query:P,rows:D,counts:de,emptyText:ue},a,{methods:{showAll:()=>{y.value="all"},showUsed:()=>{y.value="used"},showUnused:()=>{y.value="unused"}}});i.add(()=>ve.dispose()),i.add(te(a,{position:"bottom"})),i.add(M.effect(()=>{D.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})}));const ge=E.contextMenu(m,{cursor:"glyph",match:".blx-row",inherit:!1,density:"compact",items:e=>ye(e.dataset.name)});i.add(()=>ge?.destroy?.());const J=e=>{F(e)&&(location.hash=K(e))},V=e=>{const t=e.target.closest(".blx-row");if(!t)return;const n=e.target.closest(".blx-kebab");if(n){ae(n);return}J(t.dataset.name)},G=e=>{const t=e.target.closest(".blx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ae(t)):e.key==="Enter"&&e.target===t&&J(t.dataset.name))};m.addEventListener("click",V),m.addEventListener("keydown",G),i.add(()=>{m.removeEventListener("click",V),m.removeEventListener("keydown",G)}),A(s,{id:"blocks-guide",icon:"book",title:"Blocks guide",onClick:()=>{location.hash="#/docs/usage/blocks"}}),A(s,{id:"blocks-import",icon:"upload",title:"Import a .dmblock.json bundle",onClick:()=>k.click()}),A(s,{id:"blocks-new",icon:"plus",title:"New block",onClick:()=>{location.hash=pe}});const Q=e=>{f?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Q),i.add(()=>window.removeEventListener("beforeunload",Q)),i.add(()=>{if(f?.dirty())try{S.set(N,{source:f.source,values:f.values(),at:new Date().toISOString()})}catch{}_=!0,[...x].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(a),await W(),!h())return;let C=null;try{C=S.get(N)}catch{}if(C?.values&&C.source){try{S.remove?S.remove(N):S.set(N,null)}catch{}const e=F(C.source);e&&await E.confirm(`You were duplicating "${g(e.name)}" and left it unfinished. Pick up where you left off?`)&&h()&&Y(e,C.values)}}};
35
+ </div>`);let K=!1;async function oe(){if($.cannot.peek())return;r.value=!0;const d=p.peek().trim();try{const c=await h.blocks.get(e.name);await h.blocks.put(d,{content:c.content,css:c.css||"",...(c.meta?.project||D(c).length)&&{meta:{...c.meta?.project&&{project:c.meta.project},...D(c).length&&{adminTags:D(c)}}}}),E.toast(`Made "${d}".`,{type:"success"}),K=!0,o.close(),await j()}catch(c){E.toast(`Not duplicated: ${c.message||c}`,{type:"error"})}finally{r.value=!1}}o.element.appendChild(m);const Be=ie(m),$e=M.applyBindings($,m,{methods:{save:oe,cancel:()=>o.close()}}),Ae=d=>{d.key==="Enter"&&d.target.matches("input")&&(d.preventDefault(),oe())};m.addEventListener("keydown",Ae);const le={dirty:L,values:()=>({name:p.peek()}),source:e.name};v=le,T.add(o),re(o,()=>{$e.dispose(),Be(),T.delete(o),v===le&&(v=null)});const De=o.close.bind(o);let W=!1;o.close=async d=>{if(!K&&!Z&&d!==!0&&L()){if(W)return;W=!0;const c=await E.confirm("Discard the name you typed?");if(W=!1,!c)return}return K=!0,De()},I.scan(m),o.open();const ce=m.querySelector("#blx-dup-name");ce?.focus(),ce?.select()}function Ie(e){const t=O(e);if(!t)return[];const a=(t.usage?.pages?.length||0)+(t.usage?.views?.length||0);return[{header:t.name},{label:"Edit",icon:"edit",action:()=>{location.hash=V(t.name)}},{label:"Copy shortcode",icon:"copy",action:()=>Me(t)},{label:a?`Where it is used (${a})\u2026`:"Where it is used\u2026",icon:"link",action:()=>Ne(t)},{type:"divider"},...Ze({all:H.peek().map(s=>s.name),current:D(t),change:s=>Q(t,s),newTag:async()=>{const s=await Je();s&&await Q(t,{add:[s]})}}),{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>ee(t)},{label:"Export (.dmblock.json)",icon:"download",action:()=>Te(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>Ce(t)}]}const b=n.querySelector(".blx-list");b.innerHTML=tt;const Le=M.applyBindings({blocks:u,loading:z,loadError:R,filter:y,query:Y,rows:U,counts:ke,emptyText:we,tagNote:ye},n,{methods:{showAllTags:()=>{g.value=""},showAll:()=>{y.value="all"},showUsed:()=>{y.value="used"},showUnused:()=>{y.value="unused"}}});i.add(()=>Le.dispose()),i.add(ie(n,{position:"bottom"})),i.add(M.effect(()=>{U.value,requestAnimationFrame(()=>{b.isConnected&&I.scan(b)})}));const je=E.contextMenu(b,{cursor:"glyph",match:".blx-row",inherit:!1,density:"compact",items:e=>Ie(e.dataset.name)});i.add(()=>je?.destroy?.());const te=e=>{O(e)&&(location.hash=V(e))},ae=e=>{const t=e.target.closest(".blx-row");if(!t)return;const a=e.target.closest(".blx-kebab");if(a){de(a);return}te(t.dataset.name)},se=e=>{const t=e.target.closest(".blx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),de(t)):e.key==="Enter"&&e.target===t&&te(t.dataset.name))};b.addEventListener("click",ae),b.addEventListener("keydown",se),i.add(()=>{b.removeEventListener("click",ae),b.removeEventListener("keydown",se)}),A(l,{id:"blocks-settings",icon:"settings",title:"Blocks settings - tags",onClick:Se}),A(l,{id:"blocks-guide",icon:"book",title:"Blocks guide",onClick:()=>{location.hash="#/docs/usage/blocks"}}),A(l,{id:"blocks-import",icon:"upload",title:"Import a .dmblock.json bundle",onClick:()=>C.click()}),A(l,{id:"blocks-new",icon:"plus",title:"New block",onClick:()=>{location.hash=xe}});const ne=e=>{v?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",ne),i.add(()=>window.removeEventListener("beforeunload",ne)),i.add(()=>{if(v?.dirty())try{S.set(F,{source:v.source,values:v.values(),at:new Date().toISOString()})}catch{}Z=!0,[...T].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(n),await P(),!f())return;let N=null;try{N=S.get(F)}catch{}if(N?.values&&N.source){try{S.remove?S.remove(F):S.set(F,null)}catch{}const e=O(N.source);e&&await E.confirm(`You were duplicating "${k(e.name)}" and left it unfinished. Pick up where you left off?`)&&f()&&ee(e,N.values)}}};
@@ -1,15 +1,16 @@
1
- import{api as y}from"../api.js";import{SHAPES as ke,whileLoading as Pe}from"../lib/loading.js";import{addBannerAction as Me}from"../lib/plugin-chrome.js";import{helpPopovers as Z}from"../lib/help-popover.js";import{onceClosed as Ee,openMenuAt as ee,settingsStore as xe,sidebarCount as te,viewLifetime as Se}from"../lib/tool-kit.js";import{filterByProject as Ce,getProjectFromHash as $e}from"../lib/project-context.js";import{arrangePages as Te,checkPrune as Le,normaliseSettings as ae,pageRow as De,statusCounts as Ae,treeRows as Re}from"../lib/pages-arrange.js";const se=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,ne="#/pages",C="pages.prune.draft",je=`
1
+ import{api as p}from"../api.js";import{SHAPES as Le,whileLoading as Ne}from"../lib/loading.js";import{addBannerAction as ne}from"../lib/plugin-chrome.js";import{helpPopovers as oe}from"../lib/help-popover.js";import{onceClosed as Re,openMenuAt as ie,settingsStore as je,sidebarCount as re,viewLifetime as Be}from"../lib/tool-kit.js";import{filterByProject as le,getProjectFromHash as Ke}from"../lib/project-context.js";import{arrangePages as qe,checkPrune as He,normaliseSettings as ce,pageRow as Ie,statusCounts as Ue,treeRows as _e}from"../lib/pages-arrange.js";import{UNTAGGED as Fe,activeNote as Oe,askTagName as Ve,openTagSettings as ze,same as de,tagCounts as Ge,tagMenuItems as We,tagsOf as Ye}from"../lib/list-tags.js";const pe=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,ue="#/pages",D="pages.prune.draft",Je=`
2
2
  <div class="pgx-row" data-path="{{urlPath}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="pgx-main"><strong>{{title}}</strong><small>{{meta}} <span class="pgx-tags">{{tagsText}}</span></small></span>
4
4
  <span class="pgx-chips">
5
5
  <span class="pgx-chip" data-bind-hidden="!visibility" title="Who can see it">{{visibility}}</span>
6
+ <span class="pgx-chip is-tag" data-bind-hidden="!listTags" title="Tags">{{listTags}}</span>
6
7
  <span class="pgx-chip is-info" data-bind-hidden="!project" title="Project">{{project}}</span>
7
8
  <span class="pgx-chip" data-bind-hidden="!bundled" title="Shipped with the CMS">Bundled</span>
8
9
  <span class="pgx-chip is-warn" data-bind-hidden="!plugin" title="Managed by this plugin">{{plugin}}</span>
9
10
  <span class="pgx-chip" data-bind-class="statusCls">{{statusText}}</span>
10
11
  </span>
11
12
  <button type="button" class="pgx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
12
- </div>`,w=l=>String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Be(l){const r=document.createElement("template");return r.innerHTML=l.trim(),r.content.firstElementChild}const oe=xe("pages.settings",ae),o=Se(se),Ke=l=>`#/pages/edit${l}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(l){o.reset();const r=l.get(0).querySelector(".pgx")||l.get(0),$=()=>se.test(location.hash)&&r.isConnected,u=$e(),T=oe.read(),n=M.observable([]),H=M.observable(0),N=M.observable(!0),L=M.observable(""),p=M.observable(T.status),D=M.observable(T.sort),m=M.observable(T.mode),U=M.observable("");o.add(M.effect(()=>oe.write(ae({status:p.value,sort:D.value,mode:m.value}))));const A=M.observable([]);o.add(M.effect(()=>{A.value=Te(n.value,{status:p.value,query:U.value,sort:D.value})}));const _=M.observable([]);o.add(M.effect(()=>{_.value=A.value.map(e=>De(e))}));const re=M.computed(()=>Ae(n.value)),ie=M.computed(()=>n.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),le=M.computed(()=>{const e=H.value;return!u||!e?"":`Showing only pages in ${u}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),F=e=>n.peek().find(t=>t.urlPath===e);async function ce(){try{const e=await Pe(r.querySelector(".dm-loading-slot"),y.pages.list(),ke.rows);if(!$())return;const t=Array.isArray(e)?e:[],a=u?Ce(t,u):t;H.value=t.length-a.length,n.value=a,L.value=""}catch(e){L.value=`Could not load the pages: ${e.message||e}`}finally{N.value=!1}}const k=e=>{location.hash=Ke(e)},de=()=>{location.hash=u?`#/pages/new?project=${encodeURIComponent(u)}`:"#/pages/new"};async function ue(e){const t=e.plugin?`"${w(e.title||e.urlPath)}" is managed by the <strong>${w(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${w(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await y.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),n.value=n.peek().filter(a=>a.urlPath!==e.urlPath),te(ne)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function pe(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}async function R(e,t){if(e.status!==t)try{const a=await y.pages.get(e.urlPath);await y.pages.update(e.urlPath,{frontmatter:{status:t},body:a.content||""}),n.value=n.peek().map(c=>c.urlPath===e.urlPath?{...c,status:t}:c),E.toast(t==="published"?"Page published.":t==="review"?"Page moved to review.":"Page moved to drafts.",{type:"success"}),te(ne)}catch(a){E.toast(`Could not change page status: ${a.message||a}`,{type:"error"})}}let d=null;const j=new Set;let V=!1;function O(e,t=null){const a=e.versionCount||0,c=M.observable(String(t?.keep??Math.min(5,a))),x=M.observable(!1),f=M.computed(()=>Le(c.value,a)),G={keep:c,saving:x,err:M.computed(()=>f.value.error),summary:M.computed(()=>{const s=f.value;return s.error?"":s.deletes?`Deletes the ${s.deletes} oldest, keeps the newest ${Math.min(s.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>x.value||!!f.value.error||f.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},ve=String(c.peek()),J=()=>String(c.peek())!==ve||!!t,i=E.slideover({title:`Trim revisions - ${w(e.title||e.urlPath)}`,size:"sm",position:"right"}),g=Be(`
13
+ </div>`,T=r=>String(r??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Qe(r){const l=document.createElement("template");return l.innerHTML=r.trim(),l.content.firstElementChild}const ge=je("pages.settings",ce),i=Be(pe),Xe=r=>`#/pages/edit${r}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(r){i.reset();const l=r.get(0).querySelector(".pgx")||r.get(0),L=()=>pe.test(location.hash)&&l.isConnected,d=Ke(),x=ge.read(),o=M.observable([]),_=M.observable(0),F=M.observable(!0),N=M.observable(""),h=M.observable(x.status),R=M.observable(x.sort),b=M.observable(x.mode),O=M.observable(""),u=M.observable(x.tag),y=M.observable([]);i.add(M.effect(()=>ge.write(ce({status:h.value,sort:R.value,mode:b.value,tag:u.value}))));const C=M.observable([]);i.add(M.effect(()=>{C.value=qe(o.value,{status:h.value,query:O.value,sort:R.value,tag:u.value})}));const V=M.observable([]);i.add(M.effect(()=>{V.value=C.value.map(e=>Ie(e))}));const he=M.computed(()=>Ue(o.value)),me=M.computed(()=>o.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),ve=M.computed(()=>{const e=_.value;return!d||!e?"":`Showing only pages in ${d}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),j=M.computed(()=>Ge(y.value,o.value)),fe=M.computed(()=>Oe(u.value,C.value.length,"pages")),z=e=>o.peek().find(t=>t.urlPath===e);async function be(){try{const e=await Ne(l.querySelector(".dm-loading-slot"),p.pages.list(),Le.rows);if(!L())return;const t=Array.isArray(e)?e:[],a=d?le(t,d):t;_.value=t.length-a.length,o.value=a,N.value="",await G()}catch(e){N.value=`Could not load the pages: ${e.message||e}`}finally{F.value=!1}}async function G(){try{y.value=(await p.listTags.list("pages")).map(t=>t.name)}catch{}const e=u.peek();e&&e!==Fe&&!j.peek().some(t=>de(t.name,e))&&(u.value="")}async function W(e,t){try{const a=await p.listTags.changeItem("pages",e.urlPath,t);o.value=o.peek().map(s=>s.urlPath===e.urlPath?{...s,adminTags:a}:s),a.some(s=>!y.peek().some(v=>de(v,s)))&&(y.value=[...y.peek(),...a])}catch(a){E.toast(`Tags not saved: ${a.message||a}`,{type:"error"})}}async function ye(){const e=await p.pages.list(),t=Array.isArray(e)?e:[];o.value=d?le(t,d):t,await G()}const we=()=>ze({kind:"pages",noun:"pages",title:"Pages settings - tags",tags:j,active:u,reload:ye}),$=e=>{location.hash=Xe(e)},ke=()=>{location.hash=d?`#/pages/new?project=${encodeURIComponent(d)}`:"#/pages/new"};async function Pe(e){const t=e.plugin?`"${T(e.title||e.urlPath)}" is managed by the <strong>${T(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${T(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await p.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),o.value=o.peek().filter(a=>a.urlPath!==e.urlPath),re(ue)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function Me(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}async function B(e,t){if(e.status!==t)try{const a=await p.pages.get(e.urlPath);await p.pages.update(e.urlPath,{frontmatter:{status:t},body:a.content||""}),o.value=o.peek().map(s=>s.urlPath===e.urlPath?{...s,status:t}:s),E.toast(t==="published"?"Page published.":t==="review"?"Page moved to review.":"Page moved to drafts.",{type:"success"}),re(ue)}catch(a){E.toast(`Could not change page status: ${a.message||a}`,{type:"error"})}}let g=null;const K=new Set;let Y=!1;function J(e,t=null){const a=e.versionCount||0,s=M.observable(String(t?.keep??Math.min(5,a))),v=M.observable(!1),k=M.computed(()=>He(s.value,a)),ee={keep:s,saving:v,err:M.computed(()=>k.value.error),summary:M.computed(()=>{const n=k.value;return n.error?"":n.deletes?`Deletes the ${n.deletes} oldest, keeps the newest ${Math.min(n.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>v.value||!!k.value.error||k.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},Se=String(s.peek()),te=()=>String(s.peek())!==Se||!!t,c=E.slideover({title:`Trim revisions - ${T(e.title||e.urlPath)}`,size:"sm",position:"right"}),f=Qe(`
13
14
  <div class="pgx-form">
14
15
  <p class="pgx-note" data-bind-text="intro"></p>
15
16
  <div>
@@ -25,4 +26,4 @@ import{api as y}from"../api.js";import{SHAPES as ke,whileLoading as Pe}from"../l
25
26
  <button type="button" class="btn btn-sm btn-danger" data-on-click="trim" data-bind-disabled="cannot.value">
26
27
  <span data-icon="archive" data-icon-size="13"></span> Trim</button>
27
28
  </div>
28
- </div>`);async function Q(){if(!G.cannot.peek()){x.value=!0;try{const s=await y.versions.prune(e.urlPath,f.peek().keep);E.toast(`Removed ${s.deleted} revision${s.deleted===1?"":"s"}; kept ${s.kept}.`,{type:"success"}),n.value=n.peek().map(b=>b.urlPath===e.urlPath?{...b,versionCount:s.kept}:b),K=!0,i.close()}catch(s){E.toast(s.message||"Could not trim the revisions.",{type:"error"})}finally{x.value=!1}}}i.element.appendChild(g);const fe=Z(g),be=M.applyBindings(G,g,{methods:{trim:Q,cancel:()=>i.close()}}),ye=s=>{s.key==="Enter"&&s.target.id==="pgx-f-keep"&&(s.preventDefault(),Q())};g.addEventListener("keydown",ye);const X={path:e.urlPath,dirty:J,values:()=>({keep:c.peek()})};d=X,j.add(i),Ee(i,()=>{be.dispose(),fe(),j.delete(i),d===X&&(d=null)});const we=i.close.bind(i);let K=!1,q=!1;i.close=async s=>{if(!K&&s!==!0&&!V&&J()){if(q)return;q=!0;const b=await E.confirm("Discard the change you have not applied?");if(q=!1,!b)return}return K=!0,we()},I.scan(g),i.open(),g.querySelector("#pgx-f-keep")?.select()}function he(e){const t=F(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>k(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>pe(t)},{label:"Transition",icon:"circle",submenu:[{label:"Published",disabled:t.status==="published",action:()=>R(t,"published")},{label:"Draft",disabled:t.status==="draft",action:()=>R(t,"draft")},{label:"Review",disabled:t.status==="review",action:()=>R(t,"review")}]},{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>O(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>ue(t)}]}const h=r.querySelector(".pgx-list");h.innerHTML=je;const ge=M.applyBindings({pages:n,loading:N,loadError:L,status:p,sort:D,mode:m,query:U,rows:_,counts:re,emptyText:ie,projectNote:le},r,{methods:{showAll:()=>{p.value="all"},showPublished:()=>{p.value="published"},showDrafts:()=>{p.value="draft"},showList:()=>{m.value="list"},showTree:()=>{m.value="tree"}}});o.add(()=>ge.dispose()),o.add(Z(r,{position:"bottom"}));const B=r.querySelector(".pgx-tree");let P=null;o.add(M.effect(()=>{const e=m.value==="tree",t=e?Re(A.value):null;try{P?.destroy?.()}catch{}P=null,B.replaceChildren(),!(!e||!t.length)&&(P=E.treeView(B,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>k(a)}),I.scan(B))})),o.add(()=>{try{P?.destroy?.()}catch{}});const me=E.contextMenu(h,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>he(e.dataset.path)});o.add(()=>me?.destroy?.());const z=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){ee(a);return}k(t.dataset.path)},W=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ee(t)):e.key==="Enter"&&e.target===t&&k(t.dataset.path))};h.addEventListener("click",z),h.addEventListener("keydown",W),o.add(()=>{h.removeEventListener("click",z),h.removeEventListener("keydown",W)}),Me(l,{id:"pages-new",icon:"plus",title:"New page",onClick:de});const Y=e=>{d?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Y),o.add(()=>window.removeEventListener("beforeunload",Y)),o.add(()=>{if(d?.dirty())try{S.set(C,{path:d.path,values:d.values(),at:new Date().toISOString()})}catch{}V=!0,[...j].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(r),await ce(),!$())return;let v=null;try{v=S.get(C)}catch{}if(v?.path&&v.values){try{S.remove?S.remove(C):S.set(C,null)}catch{}const e=F(v.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${w(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&$()&&O(e,v.values)}}};
29
+ </div>`);async function ae(){if(!ee.cannot.peek()){v.value=!0;try{const n=await p.versions.prune(e.urlPath,k.peek().keep);E.toast(`Removed ${n.deleted} revision${n.deleted===1?"":"s"}; kept ${n.kept}.`,{type:"success"}),o.value=o.peek().map(P=>P.urlPath===e.urlPath?{...P,versionCount:n.kept}:P),H=!0,c.close()}catch(n){E.toast(n.message||"Could not trim the revisions.",{type:"error"})}finally{v.value=!1}}}c.element.appendChild(f);const Ce=oe(f),$e=M.applyBindings(ee,f,{methods:{trim:ae,cancel:()=>c.close()}}),Ae=n=>{n.key==="Enter"&&n.target.id==="pgx-f-keep"&&(n.preventDefault(),ae())};f.addEventListener("keydown",Ae);const se={path:e.urlPath,dirty:te,values:()=>({keep:s.peek()})};g=se,K.add(c),Re(c,()=>{$e.dispose(),Ce(),K.delete(c),g===se&&(g=null)});const De=c.close.bind(c);let H=!1,U=!1;c.close=async n=>{if(!H&&n!==!0&&!Y&&te()){if(U)return;U=!0;const P=await E.confirm("Discard the change you have not applied?");if(U=!1,!P)return}return H=!0,De()},I.scan(f),c.open(),f.querySelector("#pgx-f-keep")?.select()}function Ee(e){const t=z(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>$(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>Me(t)},{label:"Transition",icon:"circle",submenu:[{label:"Published",disabled:t.status==="published",action:()=>B(t,"published")},{label:"Draft",disabled:t.status==="draft",action:()=>B(t,"draft")},{label:"Review",disabled:t.status==="review",action:()=>B(t,"review")}]},{type:"divider"},...We({all:j.peek().map(s=>s.name),current:Ye(t),change:s=>W(t,s),newTag:async()=>{const s=await Ve();s&&await W(t,{add:[s]})}}),{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>J(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>Pe(t)}]}const m=l.querySelector(".pgx-list");m.innerHTML=Je;const Te=M.applyBindings({pages:o,loading:F,loadError:N,status:h,sort:R,mode:b,query:O,rows:V,counts:he,emptyText:me,projectNote:ve,tagNote:fe},l,{methods:{showAllTags:()=>{u.value=""},showAll:()=>{h.value="all"},showPublished:()=>{h.value="published"},showDrafts:()=>{h.value="draft"},showList:()=>{b.value="list"},showTree:()=>{b.value="tree"}}});i.add(()=>Te.dispose()),i.add(oe(l,{position:"bottom"}));const q=l.querySelector(".pgx-tree");let A=null;i.add(M.effect(()=>{const e=b.value==="tree",t=e?_e(C.value):null;try{A?.destroy?.()}catch{}A=null,q.replaceChildren(),!(!e||!t.length)&&(A=E.treeView(q,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>$(a)}),I.scan(q))})),i.add(()=>{try{A?.destroy?.()}catch{}});const xe=E.contextMenu(m,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>Ee(e.dataset.path)});i.add(()=>xe?.destroy?.());const Q=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){ie(a);return}$(t.dataset.path)},X=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ie(t)):e.key==="Enter"&&e.target===t&&$(t.dataset.path))};m.addEventListener("click",Q),m.addEventListener("keydown",X),i.add(()=>{m.removeEventListener("click",Q),m.removeEventListener("keydown",X)}),ne(r,{id:"pages-settings",icon:"settings",title:"Pages settings - tags",onClick:we}),ne(r,{id:"pages-new",icon:"plus",title:"New page",onClick:ke});const Z=e=>{g?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Z),i.add(()=>window.removeEventListener("beforeunload",Z)),i.add(()=>{if(g?.dirty())try{S.set(D,{path:g.path,values:g.values(),at:new Date().toISOString()})}catch{}Y=!0,[...K].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await be(),!L())return;let w=null;try{w=S.get(D)}catch{}if(w?.path&&w.values){try{S.remove?S.remove(D):S.set(D,null)}catch{}const e=z(w.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${T(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&L()&&J(e,w.values)}}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.49",
3
+ "version": "1.6.50",
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",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * List tags - admin-only labels on pages and blocks (services/listTags.js).
3
+ *
4
+ * GET /api/list-tags/:kind - {tags: [{name, count}]}
5
+ * POST /api/list-tags/:kind - {name}: create a tag
6
+ * PUT /api/list-tags/:kind/item - {id, tags} replaces one item's tags; {id, add, remove} patches them
7
+ * POST /api/list-tags/:kind/rename - {from, to}
8
+ * POST /api/list-tags/:kind/delete - {name}: take it off every item
9
+ *
10
+ * :kind is `pages` or `blocks`; both sit under the pages permission, as the
11
+ * blocks routes do. Names travel in bodies, not paths, so any spelling works.
12
+ * Every change is limited to the items the caller's projects let them see.
13
+ */
14
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
15
+ import {addTag, changeItemTags, deleteTag, listTags, renameTag} from '../../services/listTags.js';
16
+ import {canSeeArtefact, canSeePage} from '../../services/projects.js';
17
+ import {getBlock} from '../../services/blocks.js';
18
+ import {getPage} from '../../services/content.js';
19
+
20
+ /** Visibility of one item from what the listers return. */
21
+ function visibleTo(user, kind) {
22
+ return kind === 'pages'
23
+ ? (p) => canSeeArtefact(user, {meta: {project: p.resolvedProject}})
24
+ : (b) => canSeeArtefact(user, b);
25
+ }
26
+
27
+ function fail(reply, err) {
28
+ if (err.code === 'BAD_KIND') return reply.status(404).send({error: err.message});
29
+ if (err.code === 'BAD_TAG' || err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
30
+ if (err.code === 'ENOENT') return reply.status(404).send({error: 'Not found'});
31
+ throw err;
32
+ }
33
+
34
+ export async function listTagsRoutes(fastify) {
35
+ const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
36
+ const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
37
+
38
+ fastify.get('/list-tags/:kind', canRead, async (request, reply) => {
39
+ try {
40
+ return {tags: await listTags(request.params.kind, visibleTo(request.user, request.params.kind))};
41
+ } catch (err) { return fail(reply, err); }
42
+ });
43
+
44
+ fastify.post('/list-tags/:kind', canUpdate, async (request, reply) => {
45
+ try {
46
+ return {name: await addTag(request.params.kind, request.body?.name)};
47
+ } catch (err) { return fail(reply, err); }
48
+ });
49
+
50
+ fastify.put('/list-tags/:kind/item', canUpdate, async (request, reply) => {
51
+ const {kind} = request.params;
52
+ const {id, tags, add, remove} = request.body || {};
53
+ if (typeof id !== 'string' || !id) return reply.status(400).send({error: 'id is required'});
54
+ const lists = [tags, add, remove].filter(v => v !== undefined);
55
+ if (!lists.length || !lists.every(Array.isArray)) return reply.status(400).send({error: 'send tags, or add/remove, as arrays'});
56
+ try {
57
+ if (kind === 'pages') {
58
+ const page = await getPage(id);
59
+ if (!page) return reply.status(404).send({error: 'Page not found'});
60
+ if (!(await canSeePage(request.user, id, page.project))) return reply.status(403).send({error: 'Access denied for this project'});
61
+ } else if (kind === 'blocks') {
62
+ const block = await getBlock(id).catch(() => null);
63
+ if (!block) return reply.status(404).send({error: 'Block not found'});
64
+ if (!canSeeArtefact(request.user, block)) return reply.status(403).send({error: 'Access denied for this project'});
65
+ }
66
+ return {tags: await changeItemTags(kind, id, Array.isArray(tags) ? {tags} : {add, remove})};
67
+ } catch (err) { return fail(reply, err); }
68
+ });
69
+
70
+ fastify.post('/list-tags/:kind/rename', canUpdate, async (request, reply) => {
71
+ const {from, to} = request.body || {};
72
+ if (typeof from !== 'string' || !from) return reply.status(400).send({error: 'from is required'});
73
+ try {
74
+ return await renameTag(request.params.kind, from, to, visibleTo(request.user, request.params.kind));
75
+ } catch (err) { return fail(reply, err); }
76
+ });
77
+
78
+ fastify.post('/list-tags/:kind/delete', canUpdate, async (request, reply) => {
79
+ const {name} = request.body || {};
80
+ if (typeof name !== 'string' || !name) return reply.status(400).send({error: 'name is required'});
81
+ try {
82
+ return await deleteTag(request.params.kind, name, visibleTo(request.user, request.params.kind));
83
+ } catch (err) { return fail(reply, err); }
84
+ });
85
+ }
@@ -181,6 +181,8 @@ export async function pagesRoutes(fastify) {
181
181
  category: p.category || null,
182
182
  visibility: p.visibility || 'public',
183
183
  tags: p.tags || [],
184
+ // Admin-only list tags (services/listTags.js) - not the public `tags`.
185
+ adminTags: Array.isArray(p.adminTags) ? p.adminTags : [],
184
186
  project: p.project ?? null,
185
187
  resolvedProject: p.resolvedProject ?? null,
186
188
  // The list's "managed by a plugin" chip and delete warning read these;
package/server/server.js CHANGED
@@ -532,6 +532,7 @@ const { formsRoutes } = await import('./routes/api/forms.js');
532
532
  const { viewsRoutes } = await import('./routes/api/views.js');
533
533
  const { actionsRoutes } = await import('./routes/api/actions.js');
534
534
  const {blocksRoutes} = await import('./routes/api/blocks.js');
535
+ const {listTagsRoutes} = await import('./routes/api/list-tags.js');
535
536
  const {componentsRoutes} = await import('./routes/api/components.js');
536
537
  const {versionsRoutes} = await import('./routes/api/versions.js');
537
538
  const {effectsRoutes} = await import('./routes/api/effects.js');
@@ -568,6 +569,7 @@ await app.register(formsRoutes, { prefix: '/api' });
568
569
  await app.register(viewsRoutes, { prefix: '/api' });
569
570
  await app.register(actionsRoutes, { prefix: '/api' });
570
571
  await app.register(blocksRoutes, {prefix: '/api'});
572
+ await app.register(listTagsRoutes, {prefix: '/api'});
571
573
  await app.register(componentsRoutes, {prefix: '/api'});
572
574
  await app.register(versionsRoutes, {prefix: '/api'});
573
575
  await app.register(effectsRoutes, {prefix: '/api'});
@@ -334,9 +334,17 @@ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
334
334
  await fs.mkdir(BLOCKS_DIR, {recursive: true});
335
335
  await fs.writeFile(blockFilePath(name), content, 'utf8');
336
336
  const metaPath = blockMetaPath(name);
337
+ // List tags (services/listTags.js) are set from the Blocks list, not by a
338
+ // content save: a caller that does not send `adminTags` keeps the stored
339
+ // ones, or an editor opened before a tagging would save over it.
340
+ let keptTags;
341
+ if (!(meta && typeof meta === 'object' && 'adminTags' in meta)) {
342
+ try { keptTags = JSON.parse(await fs.readFile(metaPath, 'utf8'))?.adminTags; } catch { /* no sidecar */ }
343
+ }
337
344
  // Merge sidecar meta - bundled flag plus any caller-supplied fields (eg meta.project).
338
345
  const sidecar = {
339
346
  ...(meta && typeof meta === 'object' ? meta : {}),
347
+ ...(Array.isArray(keptTags) && keptTags.length ? {adminTags: keptTags} : {}),
340
348
  ...(bundled ? {bundled: true} : {})
341
349
  };
342
350
  if (Object.keys(sidecar).length > 0) {
@@ -358,6 +366,31 @@ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
358
366
  return {success: true, name};
359
367
  }
360
368
 
369
+ /**
370
+ * Merge keys into a block's sidecar meta without touching its HTML or CSS.
371
+ * A key set to undefined is removed; an emptied sidecar is deleted, as
372
+ * saveBlock does. No cache invalidation: the sidecar is admin metadata
373
+ * (project, list tags) that rendering never reads.
374
+ *
375
+ * @param {string} name
376
+ * @param {object} patch
377
+ * @returns {Promise<object>} the sidecar as written
378
+ * @throws {Error} With code INVALID_NAME, or ENOENT when the block is not found
379
+ */
380
+ export async function setBlockMeta(name, patch) {
381
+ assertValidName(name);
382
+ await fs.access(blockFilePath(name));
383
+ let meta = {};
384
+ try { meta = JSON.parse(await fs.readFile(blockMetaPath(name), 'utf8')) || {}; } catch { /* no sidecar yet */ }
385
+ for (const [k, v] of Object.entries(patch || {})) {
386
+ if (v === undefined) delete meta[k];
387
+ else meta[k] = v;
388
+ }
389
+ if (Object.keys(meta).length) await fs.writeFile(blockMetaPath(name), JSON.stringify(meta, null, 2) + '\n', 'utf8');
390
+ else await fs.unlink(blockMetaPath(name)).catch(() => {});
391
+ return meta;
392
+ }
393
+
361
394
  /**
362
395
  * Delete a block and its companion files (.css, .meta.json).
363
396
  * Missing companions are ignored; only the primary .html file triggers ENOENT.
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
+ import matter from 'gray-matter';
8
9
  import {parseMarkdown, serialiseMarkdown} from './markdown.js';
9
10
  import {config} from '../config.js';
10
11
  import {hooks} from './hooks.js';
@@ -227,6 +228,37 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
227
228
  return page;
228
229
  }
229
230
 
231
+ /**
232
+ * Merge keys into a page's frontmatter as metadata, not as an edit: no
233
+ * revision, updatedAt unchanged, body byte-for-byte as it was. For admin-only
234
+ * keys (list tags) that rendering never reads. A key set to undefined is
235
+ * removed.
236
+ *
237
+ * @param {string} urlPath
238
+ * @param {object} patch
239
+ * @returns {Promise<void>}
240
+ * @throws {Error} With code ENOENT when the page does not exist
241
+ */
242
+ export async function setPageFrontmatter(urlPath, patch) {
243
+ const filePath = await resolveExistingFilePath(urlPath);
244
+ if (!filePath) {
245
+ const err = new Error(`Page not found: ${urlPath}`);
246
+ err.code = 'ENOENT';
247
+ throw err;
248
+ }
249
+ const raw = await fs.readFile(filePath, 'utf8');
250
+ // gray-matter caches parses by input string and hands back the same
251
+ // object: copy before changing it.
252
+ const parsed = matter(raw);
253
+ const data = {...parsed.data};
254
+ for (const [k, v] of Object.entries(patch || {})) {
255
+ if (v === undefined) delete data[k];
256
+ else data[k] = v;
257
+ }
258
+ await fs.writeFile(filePath, serialiseMarkdown(data, parsed.content), 'utf8');
259
+ await cache.invalidateTags(['pages:list']);
260
+ }
261
+
230
262
  /**
231
263
  * Rename (move) a page to a new URL path.
232
264
  * Creates any required parent directories for the destination.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * List tags - admin-only labels that link pages (or blocks) together so the
3
+ * Pages and Blocks lists can be narrowed to one tag. Nothing on the public
4
+ * site reads them.
5
+ *
6
+ * An item may carry several. They live on the item itself: a page's
7
+ * frontmatter `adminTags`, a block's sidecar `meta.adminTags`. A page's own
8
+ * `tags` are a different thing - editorial, public (search, the Content API) -
9
+ * and are never touched here.
10
+ *
11
+ * config/list-tags.json keeps the names per kind (`{pages: [], blocks: []}`)
12
+ * so a tag can exist before anything wears it; a tag found on an item but not
13
+ * in the file is still listed. Absence of the file = no names yet.
14
+ *
15
+ * Tagging is metadata, not an edit: a page's tags are written without a
16
+ * revision and without moving its updatedAt.
17
+ */
18
+ import {getConfig, saveConfig} from '../config.js';
19
+ import {getPage, listPages, setPageFrontmatter} from './content.js';
20
+ import {getBlock, listBlocks, setBlockMeta} from './blocks.js';
21
+
22
+ export const KINDS = ['pages', 'blocks'];
23
+ export const MAX_TAG = 40;
24
+ const CONFIG_NAME = 'list-tags';
25
+
26
+ /** One tag name as stored: trimmed, inner whitespace collapsed, capped. '' when unusable. */
27
+ export function normaliseTag(name) {
28
+ if (typeof name !== 'string') return '';
29
+ // eslint-disable-next-line no-control-regex
30
+ return name.replace(/[\u0000-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim().slice(0, MAX_TAG);
31
+ }
32
+
33
+ /** A tag list: normalised, no blanks, no case-insensitive duplicates (the first spelling wins). */
34
+ export function cleanTagList(list) {
35
+ if (!Array.isArray(list)) return [];
36
+ const seen = new Set();
37
+ const out = [];
38
+ for (const raw of list) {
39
+ const t = normaliseTag(raw);
40
+ if (!t || seen.has(t.toLowerCase())) continue;
41
+ seen.add(t.toLowerCase());
42
+ out.push(t);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
48
+
49
+ function assertKind(kind) {
50
+ if (KINDS.includes(kind)) return;
51
+ const err = new Error(`Unknown kind "${kind}"`);
52
+ err.code = 'BAD_KIND';
53
+ throw err;
54
+ }
55
+
56
+ function readRegistry() {
57
+ let raw = {};
58
+ try {
59
+ raw = getConfig(CONFIG_NAME);
60
+ } catch { /* absent or unreadable = no names yet */ }
61
+ return Object.fromEntries(KINDS.map(k => [k, cleanTagList(raw?.[k])]));
62
+ }
63
+
64
+ function writeRegistry(reg) {
65
+ saveConfig(CONFIG_NAME, Object.fromEntries(KINDS.map(k => [k, cleanTagList(reg[k])])));
66
+ }
67
+
68
+ /** The items of a kind as `{id, tags}`, through the same listers the routes use. */
69
+ async function items(kind) {
70
+ if (kind === 'pages') return (await listPages()).map(p => ({id: p.urlPath, tags: cleanTagList(p.adminTags), raw: p}));
71
+ return (await listBlocks()).map(b => ({id: b.name, tags: cleanTagList(b.meta?.adminTags), raw: b}));
72
+ }
73
+
74
+ async function writeItemTags(kind, id, tags) {
75
+ const clean = cleanTagList(tags);
76
+ if (kind === 'pages') await setPageFrontmatter(id, {adminTags: clean.length ? clean : undefined});
77
+ else await setBlockMeta(id, {adminTags: clean.length ? clean : undefined});
78
+ return clean;
79
+ }
80
+
81
+ /**
82
+ * Every tag of a kind with how many items wear it: the registry's names in
83
+ * alphabetical order, plus any name only found on items.
84
+ *
85
+ * @param {string} kind
86
+ * @param {(item: object) => boolean} [visible] - the caller's scope filter
87
+ * @returns {Promise<{name: string, count: number}[]>}
88
+ */
89
+ export async function listTags(kind, visible = () => true) {
90
+ assertKind(kind);
91
+ const names = [...readRegistry()[kind]];
92
+ const counts = new Map();
93
+ for (const it of await items(kind)) {
94
+ if (!visible(it.raw)) continue;
95
+ for (const t of it.tags) {
96
+ const known = names.find(n => same(n, t));
97
+ if (!known) names.push(t);
98
+ const key = (known || t).toLowerCase();
99
+ counts.set(key, (counts.get(key) || 0) + 1);
100
+ }
101
+ }
102
+ return cleanTagList(names)
103
+ .sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}))
104
+ .map(name => ({name, count: counts.get(name.toLowerCase()) || 0}));
105
+ }
106
+
107
+ /** Add a name to the registry (no item changes). Returns the stored spelling. */
108
+ async function addTagNow(kind, name) {
109
+ assertKind(kind);
110
+ const tag = normaliseTag(name);
111
+ if (!tag) {
112
+ const err = new Error('A tag needs a name.');
113
+ err.code = 'BAD_TAG';
114
+ throw err;
115
+ }
116
+ const reg = readRegistry();
117
+ const existing = reg[kind].find(n => same(n, tag));
118
+ if (existing) return existing;
119
+ reg[kind].push(tag);
120
+ writeRegistry(reg);
121
+ return tag;
122
+ }
123
+
124
+ /**
125
+ * Change one item's tags: `tags` replaces the list, `add` / `remove` patch the
126
+ * stored one (read afresh here, so two quick changes from the menu both land).
127
+ * Names it brings that the registry lacks are added to it.
128
+ *
129
+ * @param {string} kind
130
+ * @param {string} id - urlPath or block name
131
+ * @param {{tags?: string[], add?: string[], remove?: string[]}} change
132
+ * @returns {Promise<string[]>} the stored list
133
+ */
134
+ async function changeItemTagsNow(kind, id, {tags, add = [], remove = []} = {}) {
135
+ assertKind(kind);
136
+ let next;
137
+ if (Array.isArray(tags)) next = tags;
138
+ else {
139
+ const item = kind === 'pages' ? await getPage(id) : await getBlock(id).catch(() => null);
140
+ if (!item) {
141
+ const err = new Error(`Not found: ${id}`);
142
+ err.code = 'ENOENT';
143
+ throw err;
144
+ }
145
+ const stored = cleanTagList(kind === 'pages' ? item.adminTags : item.meta?.adminTags);
146
+ next = [...stored, ...(Array.isArray(add) ? add : [])]
147
+ .filter(t => !(Array.isArray(remove) ? remove : []).some(r => same(r, t)));
148
+ }
149
+ const clean = await writeItemTags(kind, id, next);
150
+ const reg = readRegistry();
151
+ const fresh = clean.filter(t => !reg[kind].some(n => same(n, t)));
152
+ if (fresh.length) {
153
+ reg[kind].push(...fresh);
154
+ writeRegistry(reg);
155
+ }
156
+ return clean;
157
+ }
158
+
159
+ /**
160
+ * Rename a tag on every item the caller may see. Renaming onto an existing
161
+ * tag merges the two. An item outside the caller's projects keeps the old
162
+ * name, which then still lists (found on an item).
163
+ *
164
+ * @returns {Promise<{name: string, changed: number}>}
165
+ */
166
+ async function renameTagNow(kind, from, to, visible = () => true) {
167
+ assertKind(kind);
168
+ const target = normaliseTag(to);
169
+ if (!target) {
170
+ const err = new Error('A tag needs a name.');
171
+ err.code = 'BAD_TAG';
172
+ throw err;
173
+ }
174
+ let changed = 0;
175
+ for (const it of await items(kind)) {
176
+ if (!visible(it.raw) || !it.tags.some(t => same(t, from))) continue;
177
+ await writeItemTags(kind, it.id, it.tags.map(t => (same(t, from) ? target : t)));
178
+ changed++;
179
+ }
180
+ const reg = readRegistry();
181
+ reg[kind] = reg[kind].filter(n => !same(n, from) && !same(n, target)).concat(target);
182
+ writeRegistry(reg);
183
+ return {name: target, changed};
184
+ }
185
+
186
+ /**
187
+ * Delete a tag: it comes off every item the caller may see. The items stay.
188
+ *
189
+ * @returns {Promise<{changed: number}>}
190
+ */
191
+ async function deleteTagNow(kind, name, visible = () => true) {
192
+ assertKind(kind);
193
+ let changed = 0;
194
+ for (const it of await items(kind)) {
195
+ if (!visible(it.raw) || !it.tags.some(t => same(t, name))) continue;
196
+ await writeItemTags(kind, it.id, it.tags.filter(t => !same(t, name)));
197
+ changed++;
198
+ }
199
+ const reg = readRegistry();
200
+ reg[kind] = reg[kind].filter(n => !same(n, name));
201
+ writeRegistry(reg);
202
+ return {changed};
203
+ }
204
+
205
+ // Every change runs one after another: each is a read-modify-write of item
206
+ // files and the registry, and two interleaved (a rename while someone tags a
207
+ // page) would lose one of them.
208
+ let queue = Promise.resolve();
209
+ function serial(fn) {
210
+ const run = queue.then(fn, fn);
211
+ queue = run.catch(() => {});
212
+ return run;
213
+ }
214
+
215
+ export const addTag = (kind, name) => serial(() => addTagNow(kind, name));
216
+ export const changeItemTags = (kind, id, change) => serial(() => changeItemTagsNow(kind, id, change));
217
+ /** Replace one item's tags. */
218
+ export const setItemTags = (kind, id, tags) => changeItemTags(kind, id, {tags});
219
+ export const renameTag = (kind, from, to, visible) => serial(() => renameTagNow(kind, from, to, visible));
220
+ export const deleteTag = (kind, name, visible) => serial(() => deleteTagNow(kind, name, visible));
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
+ import matter from 'gray-matter';
8
9
  import {config} from '../config.js';
9
10
  import {hooks} from './hooks.js';
10
11
  import {getUserById} from './users.js';
@@ -243,6 +244,27 @@ export async function deleteVersion(urlPath, filename) {
243
244
  await writeMeta(dir, meta.filter(m => m.filename !== filename));
244
245
  }
245
246
 
247
+ /**
248
+ * A restore brings back the page, not its list tags: tagging adds no
249
+ * revision (services/listTags.js), so no snapshot holds the current tags,
250
+ * and restoring one would quietly drop or un-rename them. The live
251
+ * `adminTags` are carried onto the restored source.
252
+ *
253
+ * @param {string} livePath
254
+ * @param {string} restored - the version's file content
255
+ * @returns {Promise<string>}
256
+ */
257
+ async function keepLiveAdminTags(livePath, restored) {
258
+ let live;
259
+ try { live = matter(await fs.readFile(livePath, 'utf8')).data?.adminTags; } catch { return restored; }
260
+ const old = matter(restored);
261
+ if (JSON.stringify(old.data?.adminTags ?? null) === JSON.stringify(live ?? null)) return restored;
262
+ const data = {...old.data};
263
+ if (Array.isArray(live) && live.length) data.adminTags = live;
264
+ else delete data.adminTags;
265
+ return matter.stringify(old.content, data);
266
+ }
267
+
246
268
  /**
247
269
  * Restore a version: snapshot current state first, then overwrite the live page.
248
270
  *
@@ -260,8 +282,9 @@ export async function restoreVersion(urlPath, filename, author) {
260
282
  await createVersion(urlPath, {author, type: 'auto', label: 'Pre-restore snapshot'});
261
283
 
262
284
  // Overwrite live page with version content
263
- const content = await fs.readFile(filePath, 'utf8');
285
+ let content = await fs.readFile(filePath, 'utf8');
264
286
  const livePath = pageFilePath(urlPath);
287
+ content = await keepLiveAdminTags(livePath, content);
265
288
  await fs.writeFile(livePath, content, 'utf8');
266
289
 
267
290
  hooks.emit('content:pageUpdated', {urlPath});