domma-cms 0.36.14 → 0.36.16
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/CLAUDE.md +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/templates/plugins.html +8 -3
- package/admin/js/views/page-editor.js +1 -1
- package/admin/js/views/plugins.js +16 -16
- package/package.json +1 -1
- package/server/routes/api/plugins.js +20 -3
- package/server/services/pluginScaffold.js +136 -0
package/CLAUDE.md
CHANGED
|
@@ -168,6 +168,7 @@ Add custom public-site JS to `public/js/site.js` or new files loaded from `publi
|
|
|
168
168
|
| `email.js` | SMTP mail sending (uses `config/site.json` SMTP settings) |
|
|
169
169
|
| `images.js` | Image processing / thumbnail generation |
|
|
170
170
|
| `plugins.js` | Plugin discovery, validation, registration, injection snippets |
|
|
171
|
+
| `pluginScaffold.js` | Scaffold new plugins from `plugins/_template` (`POST /api/plugins/scaffold`, pre-enabled, restart to activate) |
|
|
171
172
|
| `cache/index.js` | Pluggable response cache (Memory/None/Redis drivers) with tag-based invalidation. See `docs/cache.md`. |
|
|
172
173
|
|
|
173
174
|
## Storage Adapters
|
package/admin/js/api.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const a="/api";function d(){return S.get("auth_token")}function c(){return S.get("auth_refresh_token")}function l(e){S.set("auth_token",e)}function h(){S.remove("auth_token"),S.remove("auth_refresh_token"),S.remove("auth_user")}async function u(){const e=c();if(!e)throw new Error("No refresh token");const o=await fetch(`${a}/auth/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(!o.ok)throw h(),R.navigate("/login"),new Error("Token refresh failed");const{token:s}=await o.json();return l(s),s}async function t(e,o={}){let s=d();const r=i=>({...o.body!==void 0?{"Content-Type":"application/json"}:{},...o.headers,...i?{Authorization:`Bearer ${i}`}:{}});let n=await fetch(`${a}${e}`,{...o,headers:r(s)});if(n.status===401&&c())try{s=await u(),n=await fetch(`${a}${e}`,{...o,headers:r(s)})}catch{return}if(!n.ok){const i=await n.json().catch(()=>({error:"Request failed"}));throw new Error(i.error||i.message||`HTTP ${n.status}`)}return n.status===204?null:n.json()}async function p(e,o){const s=d(),r=s?{Authorization:`Bearer ${s}`}:{},n=await fetch(`${a}${e}`,{method:"POST",headers:r,body:o});if(!n.ok){const i=await n.json().catch(()=>({error:"Upload failed"}));throw new Error(i.error||i.message||`HTTP ${n.status}`)}return n.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)}),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})}),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})}),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})})},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"})},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"}),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"})},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=>p("/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"})},plugins:{list:()=>t("/plugins",{method:"GET"}),update:(e,o)=>t(`/plugins/${e}`,{method:"PUT",body:JSON.stringify(o)}),adminConfig:()=>t("/plugins/admin-config",{method:"GET"})},marketplace:{catalogue:()=>t("/plugins/marketplace",{method:"GET"}),install:(e,o)=>t("/plugins/marketplace/install",{method:"POST",body:JSON.stringify({slug:e,version:o})}),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 s=new URLSearchParams(o).toString();return t(`/collections/${e}/entries${s?"?"+s:""}`,{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,s)=>t(`/collections/${e}/entries/${o}`,{method:"PUT",body:JSON.stringify({data:s})}),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 s=new URLSearchParams(o).toString();return t(`/collections/${e}/public${s?"?"+s:""}`,{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 s=new URLSearchParams(o).toString();return t(`/views/${e}/execute${s?"?"+s:""}`,{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=d(),s=await fetch(`${a}/blocks/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!s.ok){const m=await s.json().catch(()=>({}));throw new Error(m.error||`Export failed (${s.status})`)}const r=await s.blob(),n=URL.createObjectURL(r),i=document.createElement("a");i.href=n,i.download=`${e}.dmblock.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(n)},async importBundle(e,{overwrite:o=!1}={}){const s=await fetch(`${a}/blocks/import`,{method:"POST",headers:{"Content-Type":"application/json",...d()?{Authorization:`Bearer ${d()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(s.status===409){const r=await s.json().catch(()=>({})),n=new Error(r.error||"Block already exists");throw n.code="CONFLICT",n.name=r.name,n}if(!s.ok){const r=await s.json().catch(()=>({}));throw new Error(r.error||`Import failed (${s.status})`)}return s.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=d(),s=await fetch(`${a}/components/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!s.ok){const m=await s.json().catch(()=>({}));throw new Error(m.error||`Export failed (${s.status})`)}const r=await s.blob(),n=URL.createObjectURL(r),i=document.createElement("a");i.href=n,i.download=`${e}.dmcomponent.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(n)},async importBundle(e,{overwrite:o=!1}={}){const s=await fetch(`${a}/components/import`,{method:"POST",headers:{"Content-Type":"application/json",...d()?{Authorization:`Bearer ${d()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(s.status===409){const r=await s.json().catch(()=>({})),n=new Error(r.error||"Component already exists");throw n.code="CONFLICT",n.name=r.name,n}if(!s.ok){const r=await s.json().catch(()=>({}));throw new Error(r.error||`Import failed (${s.status})`)}return s.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!!d()}export function getUser(){return S.get("auth_user")}export function setAuthData({token:e,refreshToken:o,user:s}){e&&l(e),o&&S.set("auth_refresh_token",o),s&&S.set("auth_user",s)}export function logout(){const e=c();e&&fetch(`${a}/auth/logout`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})}).catch(()=>{}),h(),R.navigate("/login")}export{t as apiRequest,u as refreshAccessToken,d as getToken,h as clearAuth};
|
|
1
|
+
const a="/api";function d(){return S.get("auth_token")}function c(){return S.get("auth_refresh_token")}function l(e){S.set("auth_token",e)}function h(){S.remove("auth_token"),S.remove("auth_refresh_token"),S.remove("auth_user")}async function u(){const e=c();if(!e)throw new Error("No refresh token");const o=await fetch(`${a}/auth/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(!o.ok)throw h(),R.navigate("/login"),new Error("Token refresh failed");const{token:s}=await o.json();return l(s),s}async function t(e,o={}){let s=d();const r=i=>({...o.body!==void 0?{"Content-Type":"application/json"}:{},...o.headers,...i?{Authorization:`Bearer ${i}`}:{}});let n=await fetch(`${a}${e}`,{...o,headers:r(s)});if(n.status===401&&c())try{s=await u(),n=await fetch(`${a}${e}`,{...o,headers:r(s)})}catch{return}if(!n.ok){const i=await n.json().catch(()=>({error:"Request failed"}));throw new Error(i.error||i.message||`HTTP ${n.status}`)}return n.status===204?null:n.json()}async function y(e,o){const s=d(),r=s?{Authorization:`Bearer ${s}`}:{},n=await fetch(`${a}${e}`,{method:"POST",headers:r,body:o});if(!n.ok){const i=await n.json().catch(()=>({error:"Upload failed"}));throw new Error(i.error||i.message||`HTTP ${n.status}`)}return n.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)}),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})}),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})}),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})})},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"})},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"}),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"})},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=>y("/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"})},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)}),adminConfig:()=>t("/plugins/admin-config",{method:"GET"})},marketplace:{catalogue:()=>t("/plugins/marketplace",{method:"GET"}),install:(e,o)=>t("/plugins/marketplace/install",{method:"POST",body:JSON.stringify({slug:e,version:o})}),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 s=new URLSearchParams(o).toString();return t(`/collections/${e}/entries${s?"?"+s:""}`,{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,s)=>t(`/collections/${e}/entries/${o}`,{method:"PUT",body:JSON.stringify({data:s})}),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 s=new URLSearchParams(o).toString();return t(`/collections/${e}/public${s?"?"+s:""}`,{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 s=new URLSearchParams(o).toString();return t(`/views/${e}/execute${s?"?"+s:""}`,{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=d(),s=await fetch(`${a}/blocks/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!s.ok){const m=await s.json().catch(()=>({}));throw new Error(m.error||`Export failed (${s.status})`)}const r=await s.blob(),n=URL.createObjectURL(r),i=document.createElement("a");i.href=n,i.download=`${e}.dmblock.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(n)},async importBundle(e,{overwrite:o=!1}={}){const s=await fetch(`${a}/blocks/import`,{method:"POST",headers:{"Content-Type":"application/json",...d()?{Authorization:`Bearer ${d()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(s.status===409){const r=await s.json().catch(()=>({})),n=new Error(r.error||"Block already exists");throw n.code="CONFLICT",n.name=r.name,n}if(!s.ok){const r=await s.json().catch(()=>({}));throw new Error(r.error||`Import failed (${s.status})`)}return s.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=d(),s=await fetch(`${a}/components/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!s.ok){const m=await s.json().catch(()=>({}));throw new Error(m.error||`Export failed (${s.status})`)}const r=await s.blob(),n=URL.createObjectURL(r),i=document.createElement("a");i.href=n,i.download=`${e}.dmcomponent.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(n)},async importBundle(e,{overwrite:o=!1}={}){const s=await fetch(`${a}/components/import`,{method:"POST",headers:{"Content-Type":"application/json",...d()?{Authorization:`Bearer ${d()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(s.status===409){const r=await s.json().catch(()=>({})),n=new Error(r.error||"Component already exists");throw n.code="CONFLICT",n.name=r.name,n}if(!s.ok){const r=await s.json().catch(()=>({}));throw new Error(r.error||`Import failed (${s.status})`)}return s.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!!d()}export function getUser(){return S.get("auth_user")}export function setAuthData({token:e,refreshToken:o,user:s}){e&&l(e),o&&S.set("auth_refresh_token",o),s&&S.set("auth_user",s)}export function logout(){const e=c();e&&fetch(`${a}/auth/logout`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})}).catch(()=>{}),h(),R.navigate("/login")}export{t as apiRequest,u as refreshAccessToken,d as getToken,h as clearAuth};
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
<div class="view-header">
|
|
2
|
-
<
|
|
3
|
-
|
|
1
|
+
<div class="view-header d-flex justify-content-between align-items-center">
|
|
2
|
+
<div>
|
|
3
|
+
<h1><span data-icon="package"></span> Plugins</h1>
|
|
4
|
+
<p class="text-muted" style="margin:0;font-size:.875rem">Plugins extend the CMS with new features. Changes take effect after a server restart.</p>
|
|
5
|
+
</div>
|
|
6
|
+
<button id="new-plugin-btn" class="btn btn-primary">
|
|
7
|
+
<span data-icon="plus"></span> New Plugin
|
|
8
|
+
</button>
|
|
4
9
|
</div>
|
|
5
10
|
|
|
6
11
|
<div id="plugin-tabs" class="tabs">
|
|
@@ -84,7 +84,7 @@ In active development.
|
|
|
84
84
|
Markdown content here.
|
|
85
85
|
[/slideover]`,"Nested [card] and [grid] shortcodes work inside the slideover body.")),se.appendChild(r);const L=document.createElement("div");L.appendChild(X("DConfig \u2014 Declarative Behaviour")),L.appendChild(W("Define click handlers and class toggles without writing JavaScript. Use the DConfig section above or embed inline with [dconfig]...[/dconfig] in the content body. Inline shortcodes win on selector conflict.")),L.appendChild(A("Toggle a class on click",'{ "#my-btn": { "events": { "click": { "target": "#panel", "toggleClass": "hidden" } } } }','Selector keys use standard CSS selectors. "target" is optional \u2014 defaults to the element itself.')),L.appendChild(A("Inline shortcode syntax",`[dconfig]
|
|
86
86
|
{ "#my-btn": { "events": { "click": { "target": "#panel", "toggleClass": "hidden" } } } }
|
|
87
|
-
[/dconfig]`,null)),we.appendChild(L);const B=document.createElement("div");B.appendChild(X("CSS Editor")),B.appendChild(W("The toolbar's </> button (left of the Split View icon) swaps the left pane between Markdown and Custom CSS. Edits are saved together with the page \u2014 one Save, one toast."));const N=document.createElement("table");N.style.cssText="width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;",[["</> button","Toggle between Markdown editor and Custom CSS editor"],["Split / Write / Preview","Standard view mode buttons to the right of </>"],["Save","Saves both page content and CSS in a single operation"]].forEach(([h,g])=>{const i=document.createElement("tr"),o=document.createElement("td");o.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;",o.appendChild(U(h));const s=document.createElement("td");s.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;color:var(--dm-text-muted,#aaa);",s.textContent=g,i.appendChild(o),i.appendChild(s),N.appendChild(i)}),B.appendChild(N),we.appendChild(B);const w=document.createElement("div");w.appendChild(X("Colour Picker")),w.appendChild(W("Insert \u2192 Colour Picker opens a centred colour picker. Pick a colour, then use Copy to copy the hex code to the clipboard or Insert to drop it at the cursor. Click outside the panel to dismiss.")),we.appendChild(w);const z=document.createElement("div");z.appendChild(X("Keyboard Shortcuts"));const b=document.createElement("table");b.style.cssText="width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;",[["Ctrl + B","Bold selected text"],["Ctrl + I","Italic selected text"],["Ctrl + K","Insert link dialog"],["Tab","Indent 2 spaces at cursor"],["Shift + Tab","Dedent 2 spaces on current line"],["Ctrl + X","Cut current line (no selection) or cut selection"],["Ctrl + C","Copy current line (no selection) or copy selection"],["Ctrl + V","Paste from clipboard"],["Ctrl + Z","Undo"],["Ctrl + Y","Redo"]].forEach(([h,g])=>{const i=document.createElement("tr"),o=document.createElement("td");o.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;white-space:nowrap;",o.appendChild(U(h));const s=document.createElement("td");s.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;color:var(--dm-text-muted,#aaa);",s.textContent=g,i.appendChild(o),i.appendChild(s),b.appendChild(i)}),z.appendChild(b),Be.appendChild(z);const k=document.createElement("div");k.appendChild(X("Tips"));const M=document.createElement("table");M.style.cssText="width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;",[['class="text-center"',"Centre text and inline elements on any shortcode"],['class="mx-auto"',"Centre a block element horizontally"],["[center]...[/center]","Shorthand wrapper for centred content"]].forEach(([h,g])=>{const i=document.createElement("tr"),o=document.createElement("td");o.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;",o.appendChild(U(h));const s=document.createElement("td");s.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;color:var(--dm-text-muted,#aaa);",s.textContent=g,i.appendChild(o),i.appendChild(s),M.appendChild(i)}),k.appendChild(M),k.appendChild(W("Most shortcodes accept a class attribute for custom styling.")),Be.appendChild(k),d.setContent($e),d.open(),E.tabs(Ae),I.scan($e)}function st(d,ce){const de=d.find("#history-tab").get(0),U=d.find("#version-list").get(0),X=d.find("#version-compare").get(0),$e=d.find("#version-compare-old").get(0),Ae=d.find("#version-compare-new").get(0),Me=d.find("#version-compare-label").get(0),_=d.find("#version-compare-back-btn").get(0),Ie=d.find("#version-restore-btn").get(0),Be=d.find("#history-save-named-btn").get(0),ve=d.find("#history-delete-selected-btn").get(0);let se=!1,Ce=null;function we(){U.style.display="",X.style.display="none",Ce=null}function qe(){U.style.display="none",X.style.display=""}async function pe(){U.textContent="";const ee=document.createElement("p");ee.style.color="var(--dm-text-muted)",ee.textContent="Loading\u2026",U.appendChild(ee);let le;try{le=(await Y.versions.list(ce)).versions||[]}catch(V){U.textContent="";const q=document.createElement("p");q.style.color="var(--dm-danger,#e55)",q.textContent="Failed to load versions: "+V.message,U.appendChild(q);return}if(U.textContent="",ve.style.display="none",!le.length){const V=document.createElement("p");V.style.color="var(--dm-text-muted)",V.textContent="No versions saved yet. Versions are created automatically on each save.",U.appendChild(V);return}const ge=document.createElement("table");ge.style.cssText="width:100%;border-collapse:collapse;font-size:.875rem;";const ae=document.createElement("thead"),W=document.createElement("tr"),A=document.createElement("th");A.style.cssText="padding:.5rem .75rem;width:2rem;border-bottom:1px solid var(--dm-border);";const oe=document.createElement("input");oe.type="checkbox",oe.setAttribute("aria-label","Select all versions"),A.appendChild(oe),W.appendChild(A),["Date","Author","Type","Label","Actions"].forEach(V=>{const q=document.createElement("th");q.style.cssText="text-align:left;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border);font-size:.8rem;color:var(--dm-text-muted);",q.textContent=V,W.appendChild(q)}),ae.appendChild(W),ge.appendChild(ae);const me=[];function ze(){const V=me.some(ue=>ue.checked),q=me.length>0&&me.every(ue=>ue.checked);ve.style.display=V?"":"none",oe.checked=q,oe.indeterminate=V&&!q}oe.addEventListener("change",()=>{me.forEach(V=>{V.checked=oe.checked}),ze()});const De=document.createElement("tbody");le.forEach(V=>{const q=document.createElement("tr");q.style.borderBottom="1px solid var(--dm-border)";const ue=document.createElement("td");ue.style.padding=".5rem .75rem";const Ee=document.createElement("input");Ee.type="checkbox",Ee.value=V.filename,Ee.addEventListener("change",ze),ue.appendChild(Ee),me.push(Ee);const ke=document.createElement("td");ke.style.padding=".5rem .75rem",ke.textContent=D(V.createdAt).format("D MMM YYYY, HH:mm");const he=document.createElement("td");he.style.padding=".5rem .75rem",he.textContent=V.author||"\u2014";const Te=document.createElement("td");Te.style.padding=".5rem .75rem";const be=document.createElement("span");be.className="badge badge-"+(V.type==="manual"?"primary":"secondary"),be.style.cssText="font-size:.75rem;padding:.2rem .5rem;border-radius:4px;",be.textContent=V.type,Te.appendChild(be);const Ne=document.createElement("td");Ne.style.cssText="padding:.5rem .75rem;color:var(--dm-text-muted);",Ne.textContent=V.label||"\u2014";const fe=document.createElement("td");fe.style.padding=".5rem .75rem";const ne=document.createElement("button");ne.className="btn btn-ghost btn-xs",ne.style.marginRight=".4rem",ne.title="Compare",ne.innerHTML='<span data-icon="columns"></span>',ne.addEventListener("click",async()=>{try{const xe=await Y.versions.get(ce,V.filename);$e.textContent=xe.content,Ae.textContent=d.find("#markdown-editor").get(0).value,Me.textContent=D(V.createdAt).format("D MMM YYYY, HH:mm")+(V.label?` \u2014 ${V.label}`:""),Ce=V.filename,qe()}catch(xe){E.toast("Failed to load version: "+xe.message,{type:"error"})}});const ie=document.createElement("button");ie.className="btn btn-ghost btn-xs",ie.style.color="var(--dm-danger,#e55)",ie.title="Delete",ie.innerHTML='<span data-icon="trash"></span>',ie.addEventListener("click",async()=>{if(await E.confirm("Delete this version? This cannot be undone."))try{await Y.versions.delete(ce,V.filename),E.toast("Version deleted.",{type:"success"}),await pe()}catch(xe){E.toast("Failed to delete: "+xe.message,{type:"error"})}}),fe.appendChild(ne),fe.appendChild(ie),q.appendChild(ue),q.appendChild(ke),q.appendChild(he),q.appendChild(Te),q.appendChild(Ne),q.appendChild(fe),De.appendChild(q)}),ge.appendChild(De),U.appendChild(ge),I.scan(U)}de.addEventListener("click",async()=>{se||(se=!0,await pe())}),_.addEventListener("click",()=>{we()}),Ie.addEventListener("click",async()=>{if(!(!Ce||!await E.confirm("Restore this version? The current page content will be overwritten (a pre-restore snapshot will be saved automatically).")))try{await Y.versions.restore(ce,Ce),E.toast("Page restored. Reloading editor\u2026",{type:"success"}),R.navigate("/pages"),setTimeout(()=>R.navigate(`/pages/edit${ce}`),300)}catch(ee){E.toast("Restore failed: "+ee.message,{type:"error"})}}),ve.addEventListener("click",async()=>{const ee=[...U.querySelectorAll("tbody input[type=checkbox]:checked")].map(le=>le.value);if(!(!ee.length||!await E.confirm(`Delete ${ee.length} version${ee.length>1?"s":""}? This cannot be undone.`)))try{await Y.versions.bulkDelete(ce,ee),E.toast(`${ee.length} version${ee.length>1?"s":""} deleted.`,{type:"success"}),await pe()}catch(le){E.toast("Delete failed: "+le.message,{type:"error"})}}),Be.addEventListener("click",()=>{const ee=E.modal({title:"Save Named Version"}),le=document.createElement("div");le.style.padding="1rem";const ge=document.createElement("label");ge.className="form-label",ge.textContent="Version Label";const ae=document.createElement("input");ae.type="text",ae.className="form-input",ae.placeholder="e.g. Initial design, Before restructure",ae.style.marginBottom=".75rem";const W=document.createElement("button");W.className="btn btn-primary",W.textContent="Save Version",W.addEventListener("click",async()=>{const A=ae.value.trim();try{await Y.versions.create(ce,A||null),E.toast("Named version saved.",{type:"success"}),ee.close(),se=!1,de.classList.contains("active")&&(se=!0,await pe())}catch(oe){E.toast("Failed to save version: "+oe.message,{type:"error"})}}),le.appendChild(ge),le.appendChild(ae),le.appendChild(W),ee.element.appendChild(le),ee.open(),setTimeout(()=>ae.focus(),100)})}let Re=!1,Ge=null,je=!1;function rt(d){return d.split(`
|
|
87
|
+
[/dconfig]`,null)),we.appendChild(L);const B=document.createElement("div");B.appendChild(X("CSS Editor")),B.appendChild(W("The toolbar's </> button (left of the Split View icon) swaps the left pane between Markdown and Custom CSS. Edits are saved together with the page \u2014 one Save, one toast."));const N=document.createElement("table");N.style.cssText="width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;",[["</> button","Toggle between Markdown editor and Custom CSS editor"],["Split / Write / Preview","Standard view mode buttons to the right of </>"],["Save","Saves both page content and CSS in a single operation"]].forEach(([h,g])=>{const i=document.createElement("tr"),o=document.createElement("td");o.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;",o.appendChild(U(h));const s=document.createElement("td");s.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;color:var(--dm-text-muted,#aaa);",s.textContent=g,i.appendChild(o),i.appendChild(s),N.appendChild(i)}),B.appendChild(N),we.appendChild(B);const w=document.createElement("div");w.appendChild(X("Colour Picker")),w.appendChild(W("Insert \u2192 Colour Picker opens a centred colour picker. Pick a colour, then use Copy to copy the hex code to the clipboard or Insert to drop it at the cursor. Click outside the panel to dismiss.")),we.appendChild(w);const z=document.createElement("div");z.appendChild(X("Keyboard Shortcuts"));const b=document.createElement("table");b.style.cssText="width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;",[["Ctrl + B","Bold selected text"],["Ctrl + I","Italic selected text"],["Ctrl + K","Insert link dialog"],["Tab","Indent 2 spaces at cursor"],["Shift + Tab","Dedent 2 spaces on current line"],["Ctrl + X","Cut current line (no selection) or cut selection"],["Ctrl + C","Copy current line (no selection) or copy selection"],["Ctrl + V","Paste from clipboard"],["Ctrl + Z","Undo"],["Ctrl + Y","Redo"]].forEach(([h,g])=>{const i=document.createElement("tr"),o=document.createElement("td");o.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;white-space:nowrap;",o.appendChild(U(h));const s=document.createElement("td");s.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;color:var(--dm-text-muted,#aaa);",s.textContent=g,i.appendChild(o),i.appendChild(s),b.appendChild(i)}),z.appendChild(b),Be.appendChild(z);const k=document.createElement("div");k.appendChild(X("Tips"));const M=document.createElement("table");M.style.cssText="width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;",[['class="text-center"',"Centre text and inline elements on any shortcode"],['class="mx-auto"',"Centre a block element horizontally"],["[center]...[/center]","Shorthand wrapper for centred content"]].forEach(([h,g])=>{const i=document.createElement("tr"),o=document.createElement("td");o.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;",o.appendChild(U(h));const s=document.createElement("td");s.style.cssText="padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;color:var(--dm-text-muted,#aaa);",s.textContent=g,i.appendChild(o),i.appendChild(s),M.appendChild(i)}),k.appendChild(M),k.appendChild(W("Most shortcodes accept a class attribute for custom styling.")),Be.appendChild(k),d.setContent($e),d.open(),E.tabs(Ae),I.scan($e)}function st(d,ce){const de=d.find("#history-tab").get(0),U=d.find("#version-list").get(0),X=d.find("#version-compare").get(0),$e=d.find("#version-compare-old").get(0),Ae=d.find("#version-compare-new").get(0),Me=d.find("#version-compare-label").get(0),_=d.find("#version-compare-back-btn").get(0),Ie=d.find("#version-restore-btn").get(0),Be=d.find("#history-save-named-btn").get(0),ve=d.find("#history-delete-selected-btn").get(0);let se=!1,Ce=null;function we(){U.style.display="",X.style.display="none",Ce=null}function qe(){U.style.display="none",X.style.display=""}async function pe(){U.textContent="";const ee=document.createElement("p");ee.style.color="var(--dm-text-muted)",ee.textContent="Loading\u2026",U.appendChild(ee);let le;try{le=(await Y.versions.list(ce)).versions||[]}catch(V){U.textContent="";const q=document.createElement("p");q.style.color="var(--dm-danger,#e55)",q.textContent="Failed to load versions: "+V.message,U.appendChild(q);return}if(U.textContent="",ve.style.display="none",!le.length){const V=document.createElement("p");V.style.color="var(--dm-text-muted)",V.textContent="No versions saved yet. Versions are created automatically on each save.",U.appendChild(V);return}const ge=document.createElement("table");ge.style.cssText="width:100%;border-collapse:collapse;font-size:.875rem;";const ae=document.createElement("thead"),W=document.createElement("tr"),A=document.createElement("th");A.style.cssText="padding:.5rem .75rem;width:2rem;border-bottom:1px solid var(--dm-border);";const oe=document.createElement("input");oe.type="checkbox",oe.setAttribute("aria-label","Select all versions"),A.appendChild(oe),W.appendChild(A),["Date","Author","Type","Label","Actions"].forEach(V=>{const q=document.createElement("th");q.style.cssText="text-align:left;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border);font-size:.8rem;color:var(--dm-text-muted);",q.textContent=V,W.appendChild(q)}),ae.appendChild(W),ge.appendChild(ae);const me=[];function ze(){const V=me.some(ue=>ue.checked),q=me.length>0&&me.every(ue=>ue.checked);ve.style.display=V?"":"none",oe.checked=q,oe.indeterminate=V&&!q}oe.addEventListener("change",()=>{me.forEach(V=>{V.checked=oe.checked}),ze()});const De=document.createElement("tbody");le.forEach(V=>{const q=document.createElement("tr");q.style.borderBottom="1px solid var(--dm-border)";const ue=document.createElement("td");ue.style.padding=".5rem .75rem";const Ee=document.createElement("input");Ee.type="checkbox",Ee.value=V.filename,Ee.addEventListener("change",ze),ue.appendChild(Ee),me.push(Ee);const ke=document.createElement("td");ke.style.padding=".5rem .75rem",ke.textContent=D(V.createdAt).format("D MMM YYYY, HH:mm");const he=document.createElement("td");he.style.padding=".5rem .75rem",he.textContent=V.author||"\u2014";const Te=document.createElement("td");Te.style.padding=".5rem .75rem";const be=document.createElement("span");be.className="badge badge-"+(V.type==="manual"?"primary":"secondary"),be.style.cssText="font-size:.75rem;padding:.2rem .5rem;border-radius:4px;",be.textContent=V.type,Te.appendChild(be);const Ne=document.createElement("td");Ne.style.cssText="padding:.5rem .75rem;color:var(--dm-text-muted);",Ne.textContent=V.label||"\u2014";const fe=document.createElement("td");fe.style.padding=".5rem .75rem";const ne=document.createElement("button");ne.className="btn btn-ghost btn-xs",ne.style.marginRight=".4rem",ne.title="Compare",ne.innerHTML='<span data-icon="columns"></span>',ne.addEventListener("click",async()=>{try{const xe=await Y.versions.get(ce,V.filename);$e.textContent=xe.content,Ae.textContent=d.find("#markdown-editor").get(0).value,Me.textContent=D(V.createdAt).format("D MMM YYYY, HH:mm")+(V.label?` \u2014 ${V.label}`:""),Ce=V.filename,qe()}catch(xe){E.toast("Failed to load version: "+xe.message,{type:"error"})}});const ie=document.createElement("button");ie.className="btn btn-ghost btn-xs",ie.style.color="var(--dm-danger,#e55)",ie.title="Delete",ie.innerHTML='<span data-icon="trash"></span>',ie.addEventListener("click",async()=>{if(await E.confirm("Delete this version? This cannot be undone."))try{await Y.versions.delete(ce,V.filename),E.toast("Version deleted.",{type:"success"}),await pe()}catch(xe){E.toast("Failed to delete: "+xe.message,{type:"error"})}}),fe.appendChild(ne),fe.appendChild(ie),q.appendChild(ue),q.appendChild(ke),q.appendChild(he),q.appendChild(Te),q.appendChild(Ne),q.appendChild(fe),De.appendChild(q)}),ge.appendChild(De),U.appendChild(ge),I.scan(U)}de.addEventListener("click",async()=>{se||(se=!0,await pe())}),_.addEventListener("click",()=>{we()}),Ie.addEventListener("click",async()=>{if(!(!Ce||!await E.confirm("Restore this version? The current page content will be overwritten (a pre-restore snapshot will be saved automatically).")))try{await Y.versions.restore(ce,Ce),E.toast("Page restored. Reloading editor\u2026",{type:"success"}),R.navigate("/pages"),setTimeout(()=>R.navigate(`/pages/edit${ce}`),300)}catch(ee){E.toast("Restore failed: "+ee.message,{type:"error"})}}),ve.addEventListener("click",async()=>{const ee=[...U.querySelectorAll("tbody input[type=checkbox]:checked")].map(le=>le.value);if(!(!ee.length||!await E.confirm(`Delete ${ee.length} version${ee.length>1?"s":""}? This cannot be undone.`)))try{await Y.versions.bulkDelete(ce,ee),E.toast(`${ee.length} version${ee.length>1?"s":""} deleted.`,{type:"success"}),await pe()}catch(le){E.toast("Delete failed: "+le.message,{type:"error"})}}),Be.addEventListener("click",()=>{const ee=E.slideover({title:"Save Named Version",size:"sm",position:"right"}),le=document.createElement("div");le.style.padding="1rem";const ge=document.createElement("label");ge.className="form-label",ge.textContent="Version Label";const ae=document.createElement("input");ae.type="text",ae.className="form-input",ae.placeholder="e.g. Initial design, Before restructure",ae.style.marginBottom=".75rem";const W=document.createElement("button");W.className="btn btn-primary",W.textContent="Save Version",W.addEventListener("click",async()=>{const A=ae.value.trim();try{await Y.versions.create(ce,A||null),E.toast("Named version saved.",{type:"success"}),ee.close(),se=!1,de.classList.contains("active")&&(se=!0,await pe())}catch(oe){E.toast("Failed to save version: "+oe.message,{type:"error"})}}),le.appendChild(ge),le.appendChild(ae),le.appendChild(W),ee.element.appendChild(le),ee.open(),setTimeout(()=>ae.focus(),100)})}let Re=!1,Ge=null,je=!1;function rt(d){return d.split(`
|
|
88
88
|
`).map(ce=>ce.trimEnd()).join(`
|
|
89
89
|
`).replace(/\n{3,}/g,`
|
|
90
90
|
|
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as g,getUser as h}from"../api.js";import{pluginMarketplaceView as v}from"./plugin-marketplace.js";export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(s){s.find("#new-plugin-btn").on("click",()=>x(s));let u=!1;s.find('[data-tab="marketplace"]').on("click",async function(){if(u)return;const a=s.find("#marketplace-mount");try{const n=await H.get(v.templateUrl),p=new DOMParser().parseFromString(n,"text/html");for(;p.body.firstChild;)a.get(0).appendChild(p.body.firstChild);await v.onMount(a),u=!0}catch{E.toast("Failed to load marketplace.",{type:"error"})}}),E.tabs(s.find("#plugin-tabs").get(0));const c=E.loader(s.get(0),{type:"dots"});let e=[];try{e=await g.plugins.list()}catch(a){E.toast(`Failed to load plugins: ${a.message}`,{type:"error"})}if(c.destroy(),!e.length){s.find("#plugins-empty").show();return}f(s,e),Domma.icons.scan()}};function x(s){const u=E.slideover({title:"New Plugin",size:"sm",position:"right"}),c=document.createElement("div");c.style.cssText="padding:.25rem 0 .5rem;";const e=h(),a=document.createElement("div");F.create({displayName:{type:"string",label:"Display Name",placeholder:"e.g. Event Manager",required:!0},slug:{type:"string",label:"Slug",placeholder:"event-manager",required:!0,help:"Folder name under plugins/ \u2014 lowercase letters, digits, hyphens."},description:{type:"string",label:"Description",placeholder:"What does this plugin do?"},author:{type:"string",label:"Author"},icon:{type:"string",label:"Icon",placeholder:"box",help:"A Domma icon name (as used in data-icon)."}},{author:e?.name||e?.username||"",icon:"box"},{showSubmitButton:!1}).renderTo(a),c.appendChild(a);const n=document.createElement("p");n.className="text-muted",n.style.cssText="font-size:.8rem;margin:.5rem 0 0;",n.textContent="The plugin is scaffolded from the built-in template with server routes, an admin view and a CLAUDE.md, and enabled automatically. Restart the server to activate it.",c.appendChild(n);const l=document.createElement("div");l.style.cssText="display:flex;justify-content:flex-end;gap:.5rem;margin-top:.75rem;";const p=document.createElement("button");p.className="btn btn-ghost",p.textContent="Cancel";const i=document.createElement("button");i.className="btn btn-primary",i.textContent="Create Plugin",l.appendChild(p),l.appendChild(i),c.appendChild(l),u.element.appendChild(c),u.open();const m=a.querySelector('input[name="displayName"]'),t=a.querySelector('input[name="slug"]');setTimeout(()=>m?.focus(),50);let o=!1;t?.addEventListener("input",()=>{o=!0}),m?.addEventListener("input",()=>{o||!t||(t.value=m.value.toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,40))});async function b(){const d={};for(const y of["displayName","slug","description","author","icon"])d[y]=a.querySelector(`input[name="${y}"]`)?.value.trim()||"";if(!d.displayName||!d.slug){E.toast("Display name and slug are required.",{type:"error"});return}i.disabled=!0,i.textContent="Creating\u2026";try{await g.plugins.scaffold(d),u.close(),E.toast(`Plugin "${d.displayName}" created. Restart the server to activate it.`,{type:"success"});const y=await g.plugins.list();y.length&&(s.find("#plugins-empty").hide(),f(s,y))}catch(y){E.toast(y.message||"Failed to create plugin.",{type:"error"}),i.disabled=!1,i.textContent="Create Plugin"}}p.addEventListener("click",()=>u.close()),i.addEventListener("click",b),t?.addEventListener("keydown",d=>{d.key==="Enter"&&b()})}function f(s,u){const c=s.find("#plugins-grid").empty();u.forEach(e=>{const a=e.enabled?"badge-success":"badge-secondary",n=e.enabled?"Enabled":"Disabled",l=e.enabled?"Disable":"Enable",p=e.enabled?"btn-ghost":"btn-primary",i=e.bundled?"var(--dm-info,#2563eb)":"var(--dm-secondary,#6b7280)",m=e.bundled?"Bundled \u2014 click to unbundle":"Not bundled \u2014 click to bundle",t=`<button class="badge badge-outline btn-bundle-plugin" data-name="${e.name}" data-bundled="${e.bundled?"1":"0"}" title="${m}" style="font-size:0.65rem;padding:1px 6px;cursor:pointer;background:none;border:1px solid;color:${i};border-color:${i};">${e.bundled?"Bundled":"Unbundled"}</button>`,o=e.author?`by ${r(e.author)}`:"",b=e.date?r(e.date):"",d=`
|
|
2
2
|
<div class="card plugin-card" data-plugin="${e.name}">
|
|
3
3
|
<div class="card-body">
|
|
4
4
|
<div class="plugin-header">
|
|
5
5
|
<div class="plugin-icon"><span data-icon="${e.icon||"package"}"></span></div>
|
|
6
6
|
<div class="plugin-meta">
|
|
7
|
-
<div class="plugin-name">${
|
|
8
|
-
<div class="plugin-version">v${
|
|
7
|
+
<div class="plugin-name">${r(e.displayName)}</div>
|
|
8
|
+
<div class="plugin-version">v${r(e.version)}${o?` · ${o}`:""}</div>
|
|
9
9
|
</div>
|
|
10
10
|
<div style="display:flex;gap:.35rem;align-items:center;">
|
|
11
11
|
${t}
|
|
12
|
-
<span class="badge ${a}">${
|
|
12
|
+
<span class="badge ${a}">${n}</span>
|
|
13
13
|
</div>
|
|
14
14
|
</div>
|
|
15
|
-
<p class="plugin-desc">${
|
|
16
|
-
${
|
|
15
|
+
<p class="plugin-desc">${r(e.description)}</p>
|
|
16
|
+
${b?`<p class="plugin-date text-muted" style="font-size:.8rem;margin:0">Released ${b}</p>`:""}
|
|
17
17
|
</div>
|
|
18
18
|
<div class="plugin-footer">
|
|
19
19
|
<span class="text-muted" style="font-size:.8rem">Restart server after changes</span>
|
|
20
20
|
<div class="plugin-footer-actions">
|
|
21
21
|
${e.settingsSchema?`<button class="btn btn-sm btn-ghost btn-plugin-settings" data-name="${e.name}"><span data-icon="settings"></span> Settings</button>`:""}
|
|
22
|
-
<button class="btn btn-sm ${
|
|
22
|
+
<button class="btn btn-sm ${p} btn-toggle-plugin"
|
|
23
23
|
data-name="${e.name}"
|
|
24
24
|
data-enabled="${e.enabled?"1":"0"}">
|
|
25
|
-
<span data-icon="${e.enabled?"pause":"play"}"></span> ${
|
|
25
|
+
<span data-icon="${e.enabled?"pause":"play"}"></span> ${l}
|
|
26
26
|
</button>
|
|
27
27
|
</div>
|
|
28
28
|
</div>
|
|
29
|
-
</div>`;
|
|
30
|
-
<label class="form-label">${
|
|
31
|
-
<select class="form-control" name="${
|
|
29
|
+
</div>`;c.append(d)}),Domma.icons.scan(),Domma.effects.reveal(".plugin-card",{animation:"fade",stagger:60,duration:400}),Domma.effects.ripple(".btn-toggle-plugin"),c.on("click",".btn-plugin-settings",async function(){const e=$(this).data("name"),a=u.find(t=>t.name===e);if(!a?.settingsSchema)return;const l=`<form id="plugin-settings-form">${a.settingsSchema.map(t=>{const o=a.settings?.[t.key]??t.default??"";if(t.type==="select"){const b=t.options.map(d=>`<option value="${r(d.value)}"${String(o)===String(d.value)?" selected":""}>${r(d.label)}</option>`).join("");return`<div class="form-group">
|
|
30
|
+
<label class="form-label">${r(t.label)}</label>
|
|
31
|
+
<select class="form-control" name="${r(t.key)}">${b}</select>
|
|
32
32
|
</div>`}return t.type==="number"?`<div class="form-group">
|
|
33
|
-
<label class="form-label">${
|
|
34
|
-
<input class="form-control" type="number" name="${
|
|
33
|
+
<label class="form-label">${r(t.label)}</label>
|
|
34
|
+
<input class="form-control" type="number" name="${r(t.key)}" value="${r(String(o))}"${t.min!=null?` min="${t.min}"`:""}${t.max!=null?` max="${t.max}"`:""}>
|
|
35
35
|
</div>`:`<div class="form-group">
|
|
36
|
-
<label class="form-label">${
|
|
37
|
-
<input class="form-control" type="text" name="${
|
|
38
|
-
</div>`}).join("")}</form>`;if(!await E.confirm(
|
|
36
|
+
<label class="form-label">${r(t.label)}</label>
|
|
37
|
+
<input class="form-control" type="text" name="${r(t.key)}" value="${r(String(o))}">
|
|
38
|
+
</div>`}).join("")}</form>`;if(!await E.confirm(l,{title:`${r(a.displayName)} Settings`,confirmText:"Save",cancelText:"Cancel",html:!0}))return;const i=document.getElementById("plugin-settings-form");if(!i)return;const m={};a.settingsSchema.forEach(t=>{const o=i.elements[t.key];o&&(m[t.key]=t.type==="number"?Number(o.value):o.value)});try{await g.plugins.update(e,{settings:m}),a.settings={...a.settings,...m},E.toast("Settings saved.",{type:"success"})}catch(t){E.toast(`Failed to save: ${t.message}`,{type:"error"})}}),c.on("click",".btn-bundle-plugin",async function(){const e=$(this),a=e.data("name"),n=e.data("bundled")!==1&&e.data("bundled")!=="1";e.prop("disabled",!0).text(n?"Bundling\u2026":"Unbundling\u2026");try{await g.plugins.update(a,{bundled:n}),E.toast(`Plugin ${n?"marked as bundled":"unbundled"}.`,{type:"success"});const l=await g.plugins.list();f(s,l)}catch(l){E.toast(`Failed: ${l.message}`,{type:"error"}),e.prop("disabled",!1)}}),c.on("click",".btn-toggle-plugin",async function(){const e=$(this),a=e.data("name"),n=e.data("enabled")!==1&&e.data("enabled")!=="1";e.prop("disabled",!0).text(n?"Enabling\u2026":"Disabling\u2026");try{await g.plugins.update(a,{enabled:n}),E.toast(`Plugin ${n?"enabled":"disabled"}. Restart the server to apply changes.`,{type:"success"});const l=await g.plugins.list();f(s,l)}catch(l){E.toast(`Failed: ${l.message}`,{type:"error"}),e.prop("disabled",!1)}})}function r(s){return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugins API
|
|
3
|
-
* GET
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* GET /api/plugins - list all discovered plugins + state (admin only)
|
|
4
|
+
* POST /api/plugins/scaffold - create a new plugin from plugins/_template (admin only)
|
|
5
|
+
* PUT /api/plugins/:name - enable/disable, update settings (admin only)
|
|
6
|
+
* GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
|
|
6
7
|
*/
|
|
7
8
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
8
9
|
import {
|
|
@@ -12,9 +13,11 @@ import {
|
|
|
12
13
|
runLifecycleHook,
|
|
13
14
|
savePluginState
|
|
14
15
|
} from '../../services/plugins.js';
|
|
16
|
+
import {scaffoldPlugin} from '../../services/pluginScaffold.js';
|
|
15
17
|
|
|
16
18
|
export async function pluginsRoutes(fastify) {
|
|
17
19
|
const canRead = { preHandler: [authenticate, requirePermission('plugins', 'read')] };
|
|
20
|
+
const canCreate = { preHandler: [authenticate, requirePermission('plugins', 'create')] };
|
|
18
21
|
const canUpdate = { preHandler: [authenticate, requirePermission('plugins', 'update')] };
|
|
19
22
|
|
|
20
23
|
// List all plugins with their current state
|
|
@@ -36,6 +39,20 @@ export async function pluginsRoutes(fastify) {
|
|
|
36
39
|
}));
|
|
37
40
|
});
|
|
38
41
|
|
|
42
|
+
// Scaffold a new plugin from plugins/_template. The plugin is created
|
|
43
|
+
// pre-enabled so it goes live on the next server restart (its routes and
|
|
44
|
+
// admin views only register at boot — same restart rule as enable/disable).
|
|
45
|
+
fastify.post('/plugins/scaffold', canCreate, async (request, reply) => {
|
|
46
|
+
const {slug, displayName, description, author, icon} = request.body || {};
|
|
47
|
+
try {
|
|
48
|
+
const result = await scaffoldPlugin({slug, displayName, description, author, icon});
|
|
49
|
+
savePluginState(result.name, { enabled: true });
|
|
50
|
+
return { success: true, name: result.name, files: result.files, restartRequired: true };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return reply.status(err.statusCode || 500).send({ error: err.message });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
39
56
|
// Enable/disable or update settings for a plugin
|
|
40
57
|
fastify.put('/plugins/:name', canUpdate, async (request, reply) => {
|
|
41
58
|
const { name } = request.params;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Scaffold Service
|
|
3
|
+
* Instantiates plugins/_template into a new plugin directory, replacing the
|
|
4
|
+
* PLUGIN_* token placeholders in every text file.
|
|
5
|
+
*
|
|
6
|
+
* Tokens replaced: PLUGIN_SLUG, PLUGIN_DISPLAY_NAME, PLUGIN_DESCRIPTION,
|
|
7
|
+
* PLUGIN_AUTHOR, PLUGIN_DATE.
|
|
8
|
+
*
|
|
9
|
+
* Thrown errors carry a `statusCode` (400 invalid input, 409 already exists,
|
|
10
|
+
* 500 template missing) so the route layer can map them directly.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs/promises';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
const PLUGINS_DIR = path.resolve('plugins');
|
|
16
|
+
const TEMPLATE_DIR = path.join(PLUGINS_DIR, '_template');
|
|
17
|
+
|
|
18
|
+
const SLUG_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
|
19
|
+
const ICON_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
|
|
20
|
+
|
|
21
|
+
// Free-text values land inside JSON strings, JS block comments, and HTML
|
|
22
|
+
// text nodes in the template, so any character that could escape one of
|
|
23
|
+
// those contexts is refused rather than escaped per-file.
|
|
24
|
+
const UNSAFE_TEXT = /[<>"\\]|\*\//;
|
|
25
|
+
|
|
26
|
+
// Extensions that get token replacement; anything else is copied verbatim.
|
|
27
|
+
const TEXT_EXTENSIONS = new Set(['.js', '.json', '.md', '.html', '.css', '.txt']);
|
|
28
|
+
|
|
29
|
+
function fail(statusCode, message) {
|
|
30
|
+
const err = new Error(message);
|
|
31
|
+
err.statusCode = statusCode;
|
|
32
|
+
return err;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate scaffold input. Returns the normalised values or throws a 400.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} input
|
|
39
|
+
* @returns {{slug: string, displayName: string, description: string, author: string, icon: string}}
|
|
40
|
+
*/
|
|
41
|
+
export function validateScaffoldInput(input = {}) {
|
|
42
|
+
const slug = String(input.slug || '').trim();
|
|
43
|
+
const displayName = String(input.displayName || '').trim();
|
|
44
|
+
const description = String(input.description || '').trim();
|
|
45
|
+
const author = String(input.author || '').trim();
|
|
46
|
+
const icon = String(input.icon || 'box').trim();
|
|
47
|
+
|
|
48
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
49
|
+
throw fail(400, 'Slug must be 2-40 characters: lowercase letters, digits and hyphens, starting with a letter.');
|
|
50
|
+
}
|
|
51
|
+
if (!displayName) throw fail(400, 'Display name is required.');
|
|
52
|
+
if (displayName.length > 60) throw fail(400, 'Display name must be 60 characters or fewer.');
|
|
53
|
+
if (description.length > 300) throw fail(400, 'Description must be 300 characters or fewer.');
|
|
54
|
+
if (author.length > 80) throw fail(400, 'Author must be 80 characters or fewer.');
|
|
55
|
+
if (!ICON_PATTERN.test(icon)) throw fail(400, 'Icon must be a lowercase icon name (letters, digits, hyphens).');
|
|
56
|
+
|
|
57
|
+
for (const [label, value] of [['Display name', displayName], ['Description', description], ['Author', author]]) {
|
|
58
|
+
if (UNSAFE_TEXT.test(value)) {
|
|
59
|
+
throw fail(400, `${label} may not contain <, >, ", backslash or */.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { slug, displayName, description, author, icon };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Copy plugins/_template to plugins/<slug>, replacing PLUGIN_* tokens.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} input - { slug, displayName, description?, author?, icon? }
|
|
70
|
+
* @returns {Promise<{name: string, files: string[]}>} Created plugin name and relative file list
|
|
71
|
+
*/
|
|
72
|
+
export async function scaffoldPlugin(input) {
|
|
73
|
+
const { slug, displayName, description, author, icon } = validateScaffoldInput(input);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await fs.access(TEMPLATE_DIR);
|
|
77
|
+
} catch {
|
|
78
|
+
throw fail(500, 'Plugin template (plugins/_template) is missing.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const targetDir = path.join(PLUGINS_DIR, slug);
|
|
82
|
+
try {
|
|
83
|
+
await fs.access(targetDir);
|
|
84
|
+
throw fail(409, `A plugin named "${slug}" already exists.`);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err.statusCode) throw err;
|
|
87
|
+
// ENOENT — target is free
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const replacements = [
|
|
91
|
+
['PLUGIN_SLUG', slug],
|
|
92
|
+
['PLUGIN_DISPLAY_NAME', displayName],
|
|
93
|
+
['PLUGIN_DESCRIPTION', description || `${displayName} plugin.`],
|
|
94
|
+
['PLUGIN_AUTHOR', author],
|
|
95
|
+
['PLUGIN_DATE', new Date().toISOString().slice(0, 10)],
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const files = [];
|
|
99
|
+
|
|
100
|
+
async function copyDir(srcDir, destDir) {
|
|
101
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
102
|
+
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const src = path.join(srcDir, entry.name);
|
|
105
|
+
const dest = path.join(destDir, entry.name);
|
|
106
|
+
if (entry.isDirectory()) {
|
|
107
|
+
await copyDir(src, dest);
|
|
108
|
+
} else if (entry.isFile()) {
|
|
109
|
+
if (TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
110
|
+
let content = await fs.readFile(src, 'utf8');
|
|
111
|
+
for (const [token, value] of replacements) {
|
|
112
|
+
content = content.split(token).join(value);
|
|
113
|
+
}
|
|
114
|
+
await fs.writeFile(dest, content, 'utf8');
|
|
115
|
+
} else {
|
|
116
|
+
await fs.copyFile(src, dest);
|
|
117
|
+
}
|
|
118
|
+
files.push(path.relative(targetDir, dest));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await copyDir(TEMPLATE_DIR, targetDir);
|
|
124
|
+
|
|
125
|
+
// The template hard-codes "box" as the icon; set the requested one via
|
|
126
|
+
// a proper JSON parse rather than string replacement.
|
|
127
|
+
if (icon !== 'box') {
|
|
128
|
+
const manifestPath = path.join(targetDir, 'plugin.json');
|
|
129
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
130
|
+
manifest.icon = icon;
|
|
131
|
+
for (const item of manifest.admin?.sidebar || []) item.icon = icon;
|
|
132
|
+
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { name: slug, files };
|
|
136
|
+
}
|