domma-cms 0.22.5 → 0.23.0

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 CHANGED
@@ -262,23 +262,25 @@ The admin sidebar is now menu-data-driven — no more hardcoded `sidebar-config.
262
262
 
263
263
  Projects group related artefacts under a named slug — sidebar navigation, per-user access scope, and a tag the scaffolder stamps on recipe-produced artefacts. Cross-cutting: touches nine services (`pages`, `collections`, `forms`, `actions`, `menus`, `blocks`, `views`, `roles`, `users`).
264
264
 
265
- **Data model.** Project records live in the preset collection `content/collections/projects/` (same pattern as `user-types` and `roles`). Each record has `slug`, `name`, `description`, `icon`, `rootUrl`, `sortOrder`. The slug is immutable. Nine artefact types accept an optional `meta.project: '<slug>'` field — set it to file the artefact under a project; omit it to keep the artefact site-wide.
265
+ **Data model.** Project records live in the preset collection `content/collections/projects/` (same pattern as `user-types` and `roles`). Each record has `slug`, `name`, `description`, `icon`, `rootUrl`, `sortOrder` (plus system-only `protected`). The slug is immutable. Nine artefact types accept an optional `meta.project: '<slug>'` field — set it to file the artefact under a project; omit it and the artefact belongs to the **core** project by exclusion.
266
+
267
+ **The core project.** Seeded at boot (`seedCoreProject()`, create-if-absent) with `slug: 'core'`, `rootUrl: '/'`, `protected: true`. It owns everything not claimed by another project — by resolution fallback, never by stamping: `resolveArtefactProject(artefact)` returns `meta.project || 'core'`, and `getProjectForPage` falls back to `'core'` when no `rootUrl` prefix matches. Core is **not removable** (delete and untag-all are refused at service and route level; `CORE_PROJECT_SLUG` constant, same pattern as `BASE_ROLE_NAMES`), its `rootUrl`/`protected` are locked, and it is **never an access boundary** — core artefacts are visible to all users regardless of `projects: []` scope.
266
268
 
267
269
  **Page URL inheritance.** Page frontmatter has three states:
268
270
 
269
- - `project: null` → explicit opt-out; page stays untagged regardless of URL.
270
271
  - `project: '<slug>'` → explicit override.
271
- - Field missingresolver picks the project whose `rootUrl` is the longest prefix match of the page's URL; no match means untagged.
272
+ - `project: null`resolves to `core` (legacy opt-out, deprecated).
273
+ - Field missing → resolver picks the project whose `rootUrl` is the longest prefix match of the page's URL; no match means `core`.
272
274
 
273
275
  The single source of truth is `getProjectForPage(urlPath, frontmatterProject)` in `server/services/projects.js`; every page-load path (renderer, sidebar, API list endpoints) calls it.
274
276
 
275
277
  **User access scope.** Users gain `projects: ['<slug>', ...]` — the **access scope**, distinct from `user.meta.project` (the **administrative ownership** that decides where the user appears in the sidebar). Two fields, two jobs:
276
278
 
277
279
  - `user.meta.project` — sidebar placement under a Project section.
278
- - `user.projects: []` — what artefacts the user can see; **empty array = no restriction**; non-empty restricts to listed projects + untagged. Super-admins bypass entirely.
280
+ - `user.projects: []` — what artefacts the user can see; **empty array = no restriction**; non-empty restricts to listed projects + core artefacts. Super-admins bypass entirely.
279
281
 
280
282
  **Permissions.** New family `projects.{read, create, update, delete}` in `permissionRegistry`. Existing roles are not back-filled — admin grants `projects.read` via the role editor (same gotcha as Menus).
281
283
 
282
284
  **Scaffolder integration.** A recipe with a `project: {...}` block creates the project record on apply (idempotent — re-apply doesn't clobber edits) using `tokens.namespace` as the slug, then stamps `meta.project: <namespace>` on every produced artefact. Seeded users also get `projects: ['<namespace>']` so they can log in and immediately see their project.
283
285
 
284
- **Filtering on list endpoints.** Every artefact list path runs `list.filter(item => canSeeArtefact(user, item))`. Get/write endpoints return 403 when the artefact's project isn't in the user's scope. The Projects section in the admin sidebar only renders when `listProjectsForUser(user).length > 0`.
286
+ **Filtering on list endpoints.** Every artefact list path runs `list.filter(item => canSeeArtefact(user, item))`. Get/write endpoints return 403 when the artefact's project isn't in the user's scope. The Projects section in the admin sidebar always renders `listProjectsForUser(user)` always includes core, so every site shows at least the Core project.
@@ -0,0 +1 @@
1
+ export const TOOLS_FOLDER_TEXT="Tools",PLUGINS_FOLDER_TEXT="Plugins",MANAGE_PLUGINS_URL="#/plugins",MANAGE_PLUGINS_ITEM={text:"Manage Plugins",url:MANAGE_PLUGINS_URL,icon:"sliders",permission:"plugins"};export function stripItemByUrl(e,s){const t=[];for(const n of e||[]){if(!n||n.url===s)continue;const o={...n};Array.isArray(n.items)&&n.items.length&&(o.items=stripItemByUrl(n.items,s)),t.push(o)}return t}export function groupPluginItems(e){const s=[],t=[],n=[];for(const i of e||[])if(!(!i||!i.item)){if(i.parent){s.push(i);continue}i.item.core===!0?t.push(i.item):n.push(i.item)}const o=t.length?{text:TOOLS_FOLDER_TEXT,icon:"tool",items:t}:null,r={text:PLUGINS_FOLDER_TEXT,icon:"package",items:[{...MANAGE_PLUGINS_ITEM},...n]};return{toolsFolder:o,pluginsFolder:r,parented:s}}export function insertFoldersBeforeSystem(e,s){const t=(s||[]).filter(Boolean);if(!t.length)return e;const n=["system","documentation"],o=e.findIndex(r=>n.includes(String(r&&r.text||"").toLowerCase()));return o===-1?[...e,...t]:[...e.slice(0,o),...t,...e.slice(o)]}export function pruneEmptySynthesisedFolders(e){const s=new Set([TOOLS_FOLDER_TEXT,PLUGINS_FOLDER_TEXT]);return(e||[]).filter(t=>!t||!s.has(t.text)?!0:Array.isArray(t.items)&&t.items.length>0)}
@@ -0,0 +1 @@
1
+ import{test as l}from"node:test";import t from"node:assert/strict";import{groupPluginItems as u,stripItemByUrl as a,insertFoldersBeforeSystem as i,pruneEmptySynthesisedFolders as p,TOOLS_FOLDER_TEXT as m,PLUGINS_FOLDER_TEXT as g}from"./sidebar-grouping.js";const n=(s,e=null)=>({parent:e,item:s});l("groupPluginItems routes core items to Tools, optional to Plugins",()=>{const{toolsFolder:s,pluginsFolder:e}=u([n({text:"Analytics",url:"#/plugins/analytics",core:!0}),n({text:"Todo",url:"#/plugins/todo",core:!1}),n({text:"Notes",url:"#/plugins/notes"})]);t.equal(s.text,m),t.deepEqual(s.items.map(o=>o.text),["Analytics"]),t.equal(e.text,g),t.deepEqual(e.items.map(o=>o.text),["Manage Plugins","Todo","Notes"]),t.equal(e.items[0].url,"#/plugins"),t.equal(e.items[0].permission,"plugins")}),l("groupPluginItems omits Tools folder when no core plugins",()=>{const{toolsFolder:s,pluginsFolder:e}=u([n({text:"Todo",url:"#/plugins/todo",core:!1})]);t.equal(s,null),t.deepEqual(e.items.map(o=>o.text),["Manage Plugins","Todo"])}),l("groupPluginItems still returns Plugins folder (with Manage Plugins) when no plugins at all",()=>{const{toolsFolder:s,pluginsFolder:e}=u([]);t.equal(s,null),t.deepEqual(e.items.map(o=>o.text),["Manage Plugins"])}),l("groupPluginItems keeps explicitly-parented items separate",()=>{const{parented:s,pluginsFolder:e}=u([n({text:"Nested",url:"#/x"},"Content"),n({text:"Todo",url:"#/plugins/todo",core:!1})]);t.equal(s.length,1),t.equal(s[0].parent,"Content"),t.deepEqual(e.items.map(o=>o.text),["Manage Plugins","Todo"])}),l("stripItemByUrl removes the management link at any depth",()=>{const s=[{text:"Overview",items:[{text:"Dashboard",url:"#/"}]},{text:"System",items:[{text:"Users",url:"#/users"},{text:"Plugins",url:"#/plugins"}]}],o=a(s,"#/plugins").find(r=>r.text==="System");t.deepEqual(o.items.map(r=>r.text),["Users"]),t.equal(s.find(r=>r.text==="System").items.length,2)}),l("insertFoldersBeforeSystem places folders just before System",()=>{const e=i([{text:"Overview"},{text:"Data"},{text:"System"},{text:"Documentation"}],[{text:"Tools"},{text:"Plugins"},null]);t.deepEqual(e.map(o=>o.text),["Overview","Data","Tools","Plugins","System","Documentation"])}),l("insertFoldersBeforeSystem appends when no System/Documentation anchor",()=>{const e=i([{text:"Overview"},{text:"Data"}],[{text:"Plugins"}]);t.deepEqual(e.map(o=>o.text),["Overview","Data","Plugins"])}),l("pruneEmptySynthesisedFolders drops an empty Plugins folder but keeps built-ins",()=>{const e=p([{text:"Overview",items:[]},{text:"Tools",items:[{text:"Analytics"}]},{text:"Plugins",items:[]}]);t.deepEqual(e.map(o=>o.text),["Overview","Tools"])});
@@ -1,4 +1,4 @@
1
- import{api as c}from"../api.js";import{colourToCss as P}from"/public/js/menu-decor.mjs";function f(e){return String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}const N=[{text:"Dashboard",url:"#/",icon:"home"},{text:"Menus",url:"#/menus",icon:"menu"}];function L(e,i){return i?!e||!e.length?!1:e.includes(i)?!0:["read","create","update","delete"].some(t=>e.includes(`${i}.${t}`)):!0}function F(e,i){const t=[];for(const n of e){if(n.hidden||n.permission&&!L(i,n.permission))continue;const s=Array.isArray(n.items)&&n.items.length?F(n.items,i):[];t.push({...n,items:s})}return t}function T(e,i){if(!i||!i.length)return e;const t=(n,s)=>{for(const d of n){if((d.text||"").toLowerCase()===s.toLowerCase())return d;if(Array.isArray(d.items)){const r=t(d.items,s);if(r)return r}}return null};for(const n of i)if(n.parent){const s=t(e,n.parent);s?(s.items=Array.isArray(s.items)?s.items:[],s.items.push(n.item)):e.push(n.item)}else e.push(n.item);return e}async function W(e){if(!L(e,"projects"))return[];let i=[];try{i=await c.projects.list()}catch{return[]}const t={};await Promise.all(i.map(async s=>{try{const d=await c.projects.artefacts(s.slug);t[s.slug]=d}catch{t[s.slug]={}}}));const n=[{key:"pages",text:"Pages",icon:"file-text",path:"/pages"},{key:"collections",text:"Collections",icon:"database",path:"/collections"},{key:"forms",text:"Forms",icon:"layout",path:"/forms"},{key:"actions",text:"Actions",icon:"zap",path:"/actions"},{key:"menus",text:"Menus",icon:"menu",path:"/menus"},{key:"blocks",text:"Blocks",icon:"box",path:"/blocks"},{key:"views",text:"Views",icon:"eye",path:"/views"},{key:"roles",text:"Roles",icon:"shield",path:"/roles"},{key:"users",text:"Users",icon:"users",path:"/users"}];return i.map(s=>{const r="#/projects/"+encodeURIComponent(s.slug),h=t[s.slug]||{},u=[{text:"Overview",url:r,icon:s.icon||"folder"}];for(const l of n){const p=h[l.key],y=Array.isArray(p)?p.length:0;y<=0||u.push({text:`${l.text} (${y})`,url:r+l.path,icon:l.icon})}return u.push({text:"Settings",url:r+"/settings",icon:"settings"}),{text:s.name||s.slug,icon:s.icon||"folder",items:u}})}function D(e,i){for(const t of e)if((t.text||"").toLowerCase()==="projects"){t.items=Array.isArray(t.items)?t.items.slice():[],t.items.push(...i);return}}function U(e,i){if(!e.url||!i)return"";const t=i[e.url];return t==null||t<=0?"":`<span class="sidebar-badge">${f(String(t))}</span>`}function K(e){const i=(e.text||"").replace(/\s*\(\d+\)\s*$/,"").toLowerCase().replace(/\s+/g,"-"),t=(e.url||"").match(/^#\/projects\/([^/]+)/);return`sidebar.expanded.${t?`project-${decodeURIComponent(t[1])}.`:""}${i}`}function z(e,i){if(e&&e.type==="separator")return'<hr class="sidebar-divider">';const t=e.icon?`<span data-icon="${f(e.icon)}"></span>`:"",n=f(e.text||""),s=e.url?f(e.url):"",d=Array.isArray(e.items)&&e.items.length>0,r=e.colour?P(e.colour):"",h=r?` style="color:${f(r)}"`:"";let u="";if(e.badge&&e.badge.text!=null&&e.badge.text!==""){const l=e.badge.variant?P(e.badge.variant):"";u=`<span class="dm-menu-badge"${l?` style="background:${f(l)};color:#fff"`:""}>${f(String(e.badge.text))}</span>`}if(d){const l=K(e),p=S.get(l)!==!1,y=e.items.map(g=>z(g,i)).join("");return`<details data-state-key="${f(l)}"${p?" open":""}>
2
- <summary${h}>${t} <span class="sidebar-text">${n}</span>${u}</summary>
3
- <div class="sidebar-children">${y}</div>
4
- </details>`}return`<a href="${s}" class="sidebar-link" data-url="${s}"${h}>${t} <span class="sidebar-text">${n}</span>${U(e,i)}${u}</a>`}async function X(){const e=g=>g.then(b=>Array.isArray(b)?b.length:Array.isArray(b?.entries)?b.entries.length:0).catch(()=>0),[i,t,n,s,d,r,h,u,l,p,y]=await Promise.all([e(c.pages.list()),e(c.media.list()),e(c.collections.list()),e(c.forms.list()),e(c.views.list()),e(c.actions.list()),e(c.blocks.list()),e(c.components.list()),e(c.users.list()),e(c.plugins.list()),c.system?.notifications?.unreadCount?.().then(g=>g?.count??0).catch(()=>0)??Promise.resolve(0)]);return{"#/pages":i,"#/media":t,"#/collections":n,"#/forms":s,"#/views":d,"#/actions":r,"#/blocks":h,"#/components":u,"#/users":l,"#/plugins":p,"#/system/notifications":y}}async function O(){try{const e=await c.settings.get();return e?.adminBrand?.title||e?.title||"Admin"}catch{return"Admin"}}export async function renderAdminSidebar({mount:e,permissions:i}){const t=$(e).get(0);if(!t)return;let n,s=null,d=null,r=null;try{const a=await c.menus.get("admin-sidebar");n=Array.isArray(a?.items)?a.items:null,s=a?.variant||null,d=a?.position||null,r=a?.style||null}catch{n=null}(!n||!n.length)&&(console.warn("[admin-sidebar] No admin-sidebar menu found; using fallback tree"),n=N.slice());let h=[];try{h=await H.get("/api/sidebar/registered-items")||[]}catch{}n=T(n,h);const u=await W(i);u.length&&D(n,u),n=F(n,i);const[l,p]=await Promise.all([X().catch(()=>({})),O()]),y=s?` dm-admin-sidebar--${f(s)}`:"";function g(a,m="px"){if(a==null)return a;const o=String(a).trim();return/^\d+(\.\d+)?$/.test(o)?`${o}${m}`:o}let b="";if(r){const a=[];r.fontFamily&&a.push(`font-family: ${r.fontFamily}, sans-serif`),r.fontSize&&a.push(`font-size: ${g(r.fontSize)}`),r.fontWeight&&a.push(`font-weight: ${r.fontWeight}`),r.letterSpacing&&a.push(`letter-spacing: ${g(r.letterSpacing,"em")}`);const m=[];if(a.length&&m.push(`#admin-sidebar .dm-admin-sidebar, #admin-sidebar .dm-admin-sidebar .sidebar-link, #admin-sidebar .dm-admin-sidebar summary, #admin-sidebar .dm-admin-sidebar .sidebar-text { ${a.join("; ")} }`),r.iconSize){const o=g(r.iconSize);m.push(`#admin-sidebar .dm-admin-sidebar [data-icon], #admin-sidebar .dm-admin-sidebar [data-icon] svg { width: ${o} !important; height: ${o} !important; }`)}m.length&&(b=`<style data-admin-sidebar-style>${m.join(" ")}</style>`)}const B=`<div class="dm-admin-sidebar-header"><span data-icon="layout"></span> ${f(p)}</div>`,R=`${b}<nav class="dm-admin-sidebar${y}">${B}${n.map(a=>z(a,l)).join("")}</nav>`,k=document.createRange();k.selectNodeContents(t);const I=k.createContextualFragment(R);t.replaceChildren(I),Domma.icons.scan(t);const x=Number.parseInt(S.get("sidebar.width"),10);Number.isFinite(x)&&x>=180&&x<=480&&(t.style.width=x+"px");const v=document.createElement("div");v.className="dm-admin-sidebar-handle",v.setAttribute("aria-label","Resize sidebar"),t.appendChild(v),t.querySelectorAll("details").forEach(a=>{a.addEventListener("toggle",function(){const m=this.getAttribute("data-state-key");m&&S.set(m,this.open);const o=this.querySelector(":scope > .sidebar-children");if(o)if(this.open){const A=o.scrollHeight;o.style.maxHeight="0",requestAnimationFrame(()=>{o.style.maxHeight=A+"px",o.addEventListener("transitionend",function q(){o.style.maxHeight="none",o.removeEventListener("transitionend",q)})})}else{const A=o.scrollHeight;o.style.maxHeight=A+"px",requestAnimationFrame(()=>{o.style.maxHeight="0"})}})});let w=!1,C=0,j=0;v.addEventListener("mousedown",a=>{w=!0,C=a.clientX,j=t.getBoundingClientRect().width,document.body.style.cursor="col-resize",document.body.style.userSelect="none",a.preventDefault()}),document.addEventListener("mousemove",a=>{if(!w)return;const m=Math.max(180,Math.min(480,j+(a.clientX-C)));t.style.width=m+"px"}),document.addEventListener("mouseup",()=>{w&&(w=!1,document.body.style.cursor="",document.body.style.userSelect="",S.set("sidebar.width",parseInt(t.style.width,10)))});function E(){const a=location.hash||"#/";$(t).find(".sidebar-link").removeClass("active"),$(t).find(`.sidebar-link[data-url="${a}"]`).addClass("active")}E(),M.subscribe("router:afterChange",E)}
1
+ import{api as c}from"../api.js";import{colourToCss as F}from"/public/js/menu-decor.mjs";import{groupPluginItems as W,stripItemByUrl as D,insertFoldersBeforeSystem as _,pruneEmptySynthesisedFolders as K,MANAGE_PLUGINS_URL as X}from"./sidebar-grouping.js";function f(e){return String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}const G=[{text:"Dashboard",url:"#/",icon:"home"},{text:"Menus",url:"#/menus",icon:"menu"}];function B(e,i){return i?!e||!e.length?!1:e.includes(i)?!0:["read","create","update","delete"].some(t=>e.includes(`${i}.${t}`)):!0}function z(e,i){const t=[];for(const n of e){if(n.hidden||n.permission&&!B(i,n.permission))continue;const s=Array.isArray(n.items)&&n.items.length?z(n.items,i):[];t.push({...n,items:s})}return t}function O(e,i){if(!i||!i.length)return e;const t=(n,s)=>{for(const d of n){if((d.text||"").toLowerCase()===s.toLowerCase())return d;if(Array.isArray(d.items)){const a=t(d.items,s);if(a)return a}}return null};for(const n of i)if(n.parent){const s=t(e,n.parent);s?(s.items=Array.isArray(s.items)?s.items:[],s.items.push(n.item)):e.push(n.item)}else e.push(n.item);return e}async function V(e){if(!B(e,"projects"))return[];let i=[];try{i=await c.projects.list()}catch{return[]}const t={};await Promise.all(i.map(async s=>{try{const d=await c.projects.artefacts(s.slug);t[s.slug]=d}catch{t[s.slug]={}}}));const n=[{key:"pages",text:"Pages",icon:"file-text",path:"/pages"},{key:"collections",text:"Collections",icon:"database",path:"/collections"},{key:"forms",text:"Forms",icon:"layout",path:"/forms"},{key:"actions",text:"Actions",icon:"zap",path:"/actions"},{key:"menus",text:"Menus",icon:"menu",path:"/menus"},{key:"blocks",text:"Blocks",icon:"box",path:"/blocks"},{key:"views",text:"Views",icon:"eye",path:"/views"},{key:"roles",text:"Roles",icon:"shield",path:"/roles"},{key:"users",text:"Users",icon:"users",path:"/users"}];return i.map(s=>{const a="#/projects/"+encodeURIComponent(s.slug),p=t[s.slug]||{},u=[{text:"Overview",url:a,icon:s.icon||"folder"}];for(const l of n){const h=p[l.key],g=Array.isArray(h)?h.length:0;g<=0||u.push({text:`${l.text} (${g})`,url:a+l.path,icon:l.icon})}return u.push({text:"Settings",url:a+"/settings",icon:"settings"}),{text:s.name||s.slug,icon:s.icon||"folder",items:u}})}function Y(e,i){for(const t of e)if((t.text||"").toLowerCase()==="projects"){t.items=Array.isArray(t.items)?t.items.slice():[],t.items.push(...i);return}}function J(e,i){if(!e.url||!i)return"";const t=i[e.url];return t==null||t<=0?"":`<span class="sidebar-badge">${f(String(t))}</span>`}function Q(e){const i=(e.text||"").replace(/\s*\(\d+\)\s*$/,"").toLowerCase().replace(/\s+/g,"-"),t=(e.url||"").match(/^#\/projects\/([^/]+)/);return`sidebar.expanded.${t?`project-${decodeURIComponent(t[1])}.`:""}${i}`}function I(e,i){if(e&&e.type==="separator")return'<hr class="sidebar-divider">';const t=e.icon?`<span data-icon="${f(e.icon)}"></span>`:"",n=f(e.text||""),s=e.url?f(e.url):"",d=Array.isArray(e.items)&&e.items.length>0,a=e.colour?F(e.colour):"",p=a?` style="color:${f(a)}"`:"";let u="";if(e.badge&&e.badge.text!=null&&e.badge.text!==""){const l=e.badge.variant?F(e.badge.variant):"";u=`<span class="dm-menu-badge"${l?` style="background:${f(l)};color:#fff"`:""}>${f(String(e.badge.text))}</span>`}if(d){const l=Q(e),h=S.get(l)!==!1,g=e.items.map(y=>I(y,i)).join("");return`<details data-state-key="${f(l)}"${h?" open":""}>
2
+ <summary${p}>${t} <span class="sidebar-text">${n}</span>${u}</summary>
3
+ <div class="sidebar-children">${g}</div>
4
+ </details>`}return`<a href="${s}" class="sidebar-link" data-url="${s}"${p}>${t} <span class="sidebar-text">${n}</span>${J(e,i)}${u}</a>`}async function Z(){const e=y=>y.then(b=>Array.isArray(b)?b.length:Array.isArray(b?.entries)?b.entries.length:0).catch(()=>0),[i,t,n,s,d,a,p,u,l,h,g]=await Promise.all([e(c.pages.list()),e(c.media.list()),e(c.collections.list()),e(c.forms.list()),e(c.views.list()),e(c.actions.list()),e(c.blocks.list()),e(c.components.list()),e(c.users.list()),e(c.plugins.list()),c.system?.notifications?.unreadCount?.().then(y=>y?.count??0).catch(()=>0)??Promise.resolve(0)]);return{"#/pages":i,"#/media":t,"#/collections":n,"#/forms":s,"#/views":d,"#/actions":a,"#/blocks":p,"#/components":u,"#/users":l,"#/plugins":h,"#/system/notifications":g}}async function ee(){try{const e=await c.settings.get();return e?.adminBrand?.title||e?.title||"Admin"}catch{return"Admin"}}export async function renderAdminSidebar({mount:e,permissions:i}){const t=$(e).get(0);if(!t)return;let n,s=null,d=null,a=null;try{const r=await c.menus.get("admin-sidebar");n=Array.isArray(r?.items)?r.items:null,s=r?.variant||null,d=r?.position||null,a=r?.style||null}catch{n=null}(!n||!n.length)&&(console.warn("[admin-sidebar] No admin-sidebar menu found; using fallback tree"),n=G.slice());let p=[];try{p=await H.get("/api/sidebar/registered-items")||[]}catch{}n=D(n,X);const{toolsFolder:u,pluginsFolder:l,parented:h}=W(p);n=O(n,h),n=_(n,[u,l]);const g=await V(i);g.length&&Y(n,g),n=z(n,i),n=K(n);const[y,b]=await Promise.all([Z().catch(()=>({})),ee()]),R=s?` dm-admin-sidebar--${f(s)}`:"";function w(r,m="px"){if(r==null)return r;const o=String(r).trim();return/^\d+(\.\d+)?$/.test(o)?`${o}${m}`:o}let C="";if(a){const r=[];a.fontFamily&&r.push(`font-family: ${a.fontFamily}, sans-serif`),a.fontSize&&r.push(`font-size: ${w(a.fontSize)}`),a.fontWeight&&r.push(`font-weight: ${a.fontWeight}`),a.letterSpacing&&r.push(`letter-spacing: ${w(a.letterSpacing,"em")}`);const m=[];if(r.length&&m.push(`#admin-sidebar .dm-admin-sidebar, #admin-sidebar .dm-admin-sidebar .sidebar-link, #admin-sidebar .dm-admin-sidebar summary, #admin-sidebar .dm-admin-sidebar .sidebar-text { ${r.join("; ")} }`),a.iconSize){const o=w(a.iconSize);m.push(`#admin-sidebar .dm-admin-sidebar [data-icon], #admin-sidebar .dm-admin-sidebar [data-icon] svg { width: ${o} !important; height: ${o} !important; }`)}m.length&&(C=`<style data-admin-sidebar-style>${m.join(" ")}</style>`)}const N=`<div class="dm-admin-sidebar-header"><span data-icon="layout"></span> ${f(b)}</div>`,U=`${C}<nav class="dm-admin-sidebar${R}">${N}${n.map(r=>I(r,y)).join("")}</nav>`,j=document.createRange();j.selectNodeContents(t);const q=j.createContextualFragment(U);t.replaceChildren(q),Domma.icons.scan(t);const x=Number.parseInt(S.get("sidebar.width"),10);Number.isFinite(x)&&x>=180&&x<=480&&(t.style.width=x+"px");const A=document.createElement("div");A.className="dm-admin-sidebar-handle",A.setAttribute("aria-label","Resize sidebar"),t.appendChild(A),t.querySelectorAll("details").forEach(r=>{r.addEventListener("toggle",function(){const m=this.getAttribute("data-state-key");m&&S.set(m,this.open);const o=this.querySelector(":scope > .sidebar-children");if(o)if(this.open){const k=o.scrollHeight;o.style.maxHeight="0",requestAnimationFrame(()=>{o.style.maxHeight=k+"px",o.addEventListener("transitionend",function T(){o.style.maxHeight="none",o.removeEventListener("transitionend",T)})})}else{const k=o.scrollHeight;o.style.maxHeight=k+"px",requestAnimationFrame(()=>{o.style.maxHeight="0"})}})});let v=!1,E=0,P=0;A.addEventListener("mousedown",r=>{v=!0,E=r.clientX,P=t.getBoundingClientRect().width,document.body.style.cursor="col-resize",document.body.style.userSelect="none",r.preventDefault()}),document.addEventListener("mousemove",r=>{if(!v)return;const m=Math.max(180,Math.min(480,P+(r.clientX-E)));t.style.width=m+"px"}),document.addEventListener("mouseup",()=>{v&&(v=!1,document.body.style.cursor="",document.body.style.userSelect="",S.set("sidebar.width",parseInt(t.style.width,10)))});function L(){const r=location.hash||"#/";$(t).find(".sidebar-link").removeClass("active"),$(t).find(`.sidebar-link[data-url="${r}"]`).addClass("active")}L(),M.subscribe("router:afterChange",L)}
@@ -44,7 +44,7 @@
44
44
  </div>
45
45
  </div>
46
46
 
47
- <div class="card" style="border-color:#c33;">
47
+ <div class="card" id="ps-danger-zone" style="border-color:#c33;">
48
48
  <div class="card-header"><h3>Danger zone</h3></div>
49
49
  <div class="card-body">
50
50
  <p>Untagging removes <code>meta.project</code> from every artefact currently tagged with this project. The artefacts remain (untagged); the project record is unchanged.</p>
@@ -1 +1 @@
1
- import{api as a}from"../api.js";import{makeIconInput as h}from"../lib/shortcode-modal.js";export const projectSettingsView={templateUrl:"/admin/js/templates/project-settings.html",async onMount(e){const c=location.hash.split("?")[0].match(/^#\/projects\/([^/]+)\/settings$/),s=c?decodeURIComponent(c[1]):null;if(!s){location.hash="#/projects";return}let o;try{o=await a.projects.get(s)}catch(t){E.toast(`Failed to load project: ${t.message||t}`,{type:"error"}),location.hash="#/projects";return}const f=encodeURIComponent(s);e.find("#ps-title").text(`Settings \u2014 ${o.name||s}`),e.find("#ps-back").attr("href","#/projects/"+f);const u=e.find("#ps-slug"),n=e.find("#ps-name"),i=e.find("#ps-description"),l=e.find("#ps-rootUrl"),p=e.find("#ps-sortOrder");u.val(o.slug||""),n.val(o.name||""),i.val(o.description||""),l.val(o.rootUrl||""),p.val(o.sortOrder??0);const g=e.find("#ps-icon-mount").get(0),r=h("e.g. folder, users, box",o.icon||"folder");r.input.id="ps-icon",r.input.classList.add("form-input"),g.appendChild(r.el),Domma.icons.scan(e.get(0)),e.find("#ps-save").on("click",async()=>{const t={name:n.val().trim(),description:i.val().trim(),icon:r.input.value.trim()||"folder",sortOrder:Number.parseInt(p.val(),10)||0},d=l.val().trim();d&&(t.rootUrl=d);try{await a.projects.update(s,t),E.toast("Saved.",{type:"success"})}catch(m){E.toast(`Save failed: ${m.message||m}`,{type:"error"})}}),e.find("#ps-untag-all").on("click",async()=>{if(await E.confirm(`Untag all artefacts from project "${s}"? They will become site-wide.`))try{const t=await a.projects.untagAll(s);E.toast(`Untagged: ${JSON.stringify(t.untagged)}`,{type:"success"}),setTimeout(()=>location.reload(),600)}catch(t){E.toast(`Untag failed: ${t.message||t}`,{type:"error"})}}),e.find("#ps-delete").on("click",async()=>{if(await E.confirm(`Delete project "${s}"? The server refuses if any artefacts are still tagged.`))try{await a.projects.remove(s),E.toast("Project deleted.",{type:"success"}),location.hash="#/projects"}catch(t){E.toast(`Delete refused: ${t.message||t}`,{type:"error"})}})}};
1
+ import{api as c}from"../api.js";import{makeIconInput as g}from"../lib/shortcode-modal.js";export const projectSettingsView={templateUrl:"/admin/js/templates/project-settings.html",async onMount(e){const i=location.hash.split("?")[0].match(/^#\/projects\/([^/]+)\/settings$/),o=i?decodeURIComponent(i[1]):null;if(!o){location.hash="#/projects";return}let s;try{s=await c.projects.get(o)}catch(t){E.toast(`Failed to load project: ${t.message||t}`,{type:"error"}),location.hash="#/projects";return}const m=encodeURIComponent(o);e.find("#ps-title").text(`Settings \u2014 ${s.name||o}`),e.find("#ps-back").attr("href","#/projects/"+m);const f=e.find("#ps-slug"),l=e.find("#ps-name"),p=e.find("#ps-description"),a=e.find("#ps-rootUrl"),d=e.find("#ps-sortOrder");if(f.val(s.slug||""),l.val(s.name||""),p.val(s.description||""),a.val(s.rootUrl||""),d.val(s.sortOrder??0),s.protected){a.attr("disabled","disabled");const t=a.get(0)?.nextElementSibling;t?.classList.contains("form-hint")&&(t.textContent="Locked \u2014 built-in project"),e.find("#ps-danger-zone").css("display","none")}const u=e.find("#ps-icon-mount").get(0),n=g("e.g. folder, users, box",s.icon||"folder");n.input.id="ps-icon",n.input.classList.add("form-input"),u.appendChild(n.el),Domma.icons.scan(e.get(0)),e.find("#ps-save").on("click",async()=>{const t={name:l.val().trim(),description:p.val().trim(),icon:n.input.value.trim()||"folder",sortOrder:Number.parseInt(d.val(),10)||0};if(!s.protected){const r=a.val().trim();r&&(t.rootUrl=r)}try{await c.projects.update(o,t),E.toast("Saved.",{type:"success"})}catch(r){E.toast(`Save failed: ${r.message||r}`,{type:"error"})}}),e.find("#ps-untag-all").on("click",async()=>{if(await E.confirm(`Untag all artefacts from project "${o}"? They will become site-wide.`))try{const t=await c.projects.untagAll(o);E.toast(`Untagged: ${JSON.stringify(t.untagged)}`,{type:"success"}),setTimeout(()=>location.reload(),600)}catch(t){E.toast(`Untag failed: ${t.message||t}`,{type:"error"})}}),e.find("#ps-delete").on("click",async()=>{if(await E.confirm(`Delete project "${o}"? The server refuses if any artefacts are still tagged.`))try{await c.projects.remove(o),E.toast("Project deleted.",{type:"success"}),location.hash="#/projects"}catch(t){E.toast(`Delete refused: ${t.message||t}`,{type:"error"})}})}};
@@ -1,7 +1,7 @@
1
- import{api as o}from"../api.js";function s(a){return String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const projectsView={templateUrl:"/admin/js/templates/projects.html",async onMount(a){const n=await o.projects.list().catch(()=>[]),c=await Promise.all(n.map(t=>o.projects.artefacts(t.slug).then(e=>Object.values(e).reduce((l,p)=>l+p.length,0)).catch(()=>0))),r=n.map((t,e)=>({...t,artefactCount:c[e]}));T.create("#projects-table",{data:r,emptyMessage:'No projects yet. Click "New project" to create one.',columns:[{key:"slug",title:"Slug",sortable:!0},{key:"name",title:"Name",sortable:!0},{key:"icon",title:"Icon",render:t=>t?`<span data-icon="${s(t)}"></span>`:""},{key:"rootUrl",title:"Root URL",render:t=>t?`<code>${s(t)}</code>`:'<span class="text-muted">\u2014</span>'},{key:"artefactCount",title:"Artefacts",render:t=>`<span class="badge badge-info">${t??0}</span>`},{key:"_actions",title:"Actions",render:(t,e)=>`
1
+ import{api as s}from"../api.js";function o(a){return String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const projectsView={templateUrl:"/admin/js/templates/projects.html",async onMount(a){const n=await s.projects.list().catch(()=>[]),c=await Promise.all(n.map(t=>s.projects.artefacts(t.slug).then(e=>Object.values(e).reduce((l,p)=>l+p.length,0)).catch(()=>0))),r=n.map((t,e)=>({...t,artefactCount:c[e]}));T.create("#projects-table",{data:r,emptyMessage:'No projects yet. Click "New project" to create one.',columns:[{key:"slug",title:"Slug",sortable:!0},{key:"name",title:"Name",sortable:!0,render:(t,e)=>o(t)+(e.protected?' <span class="badge badge-secondary" data-tooltip="Built-in project \u2014 cannot be deleted">System</span>':"")},{key:"icon",title:"Icon",render:t=>t?`<span data-icon="${o(t)}"></span>`:""},{key:"rootUrl",title:"Root URL",render:t=>t?`<code>${o(t)}</code>`:'<span class="text-muted">\u2014</span>'},{key:"artefactCount",title:"Artefacts",render:t=>`<span class="badge badge-info">${t??0}</span>`},{key:"_actions",title:"Actions",render:(t,e)=>`
2
2
  <span style="display:inline-flex;gap:0.25rem;">
3
3
  <a class="btn btn-sm btn-ghost" href="#/projects/${encodeURIComponent(e.slug)}" data-tooltip="Overview"><span data-icon="eye"></span></a>
4
4
  <a class="btn btn-sm btn-ghost" href="#/projects/${encodeURIComponent(e.slug)}/settings" data-tooltip="Settings"><span data-icon="settings"></span></a>
5
- <button class="btn btn-sm btn-danger btn-delete-project" data-slug="${s(e.slug)}" data-tooltip="Delete (refused while tagged)"><span data-icon="trash"></span></button>
5
+ ${e.protected?"":`<button class="btn btn-sm btn-danger btn-delete-project" data-slug="${o(e.slug)}" data-tooltip="Delete (refused while tagged)"><span data-icon="trash"></span></button>`}
6
6
  </span>
7
- `}]}),Domma.icons.scan(a.get(0)),document.querySelectorAll("#projects-table [data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})}),a.off("click",".btn-delete-project").on("click",".btn-delete-project",async function(){const t=$(this).data("slug");if(await E.confirm(`Delete project "${t}"? This cannot be undone.`))try{await o.projects.remove(t),E.toast("Project deleted.",{type:"success"}),location.reload()}catch(e){E.toast(`Delete refused: ${e.message||e}`,{type:"error"})}})}};
7
+ `}]}),Domma.icons.scan(a.get(0)),document.querySelectorAll("#projects-table [data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})}),a.off("click",".btn-delete-project").on("click",".btn-delete-project",async function(){const t=$(this).data("slug");if(await E.confirm(`Delete project "${t}"? This cannot be undone.`))try{await s.projects.remove(t),E.toast("Project deleted.",{type:"success"}),location.reload()}catch(e){E.toast(`Delete refused: ${e.message||e}`,{type:"error"})}})}};
@@ -139,12 +139,6 @@
139
139
  "icon": "layout",
140
140
  "permission": "layouts"
141
141
  },
142
- {
143
- "text": "Plugins",
144
- "url": "#/plugins",
145
- "icon": "package",
146
- "permission": "plugins"
147
- },
148
142
  {
149
143
  "text": "My Profile",
150
144
  "url": "#/my-profile",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.22.5",
3
+ "version": "0.23.0",
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",
@@ -26,6 +26,7 @@
26
26
  "CLAUDE.md"
27
27
  ],
28
28
  "scripts": {
29
+ "test": "node --test --test-concurrency=1 'tests/**/*.test.js'",
29
30
  "build": "node scripts/build.js",
30
31
  "create-plugin": "node scripts/create-plugin.js",
31
32
  "delete-users": "node -e \"import('fs').then(({readdirSync,rmSync})=>{const d='content/users';readdirSync(d).filter(f=>f.endsWith('.json')).forEach(f=>{rmSync(d+'/'+f);console.log('deleted',f)})})\"",
@@ -74,7 +75,7 @@
74
75
  "@fastify/rate-limit": "^10.3.0",
75
76
  "@fastify/static": "9.1.1",
76
77
  "bcryptjs": "^3.0.3",
77
- "domma-js": "^0.27.0",
78
+ "domma-js": "^0.27.1",
78
79
  "dotenv": "^17.2.3",
79
80
  "fastify": "5.8.5",
80
81
  "gray-matter": "^4.0.3",
@@ -53,7 +53,7 @@
53
53
  <div class="card">
54
54
  <div class="card-header d-flex justify-content-between align-items-center">
55
55
  <strong>Top pages</strong>
56
- <input id="filter-input" type="search" class="form-control form-control-sm" placeholder="Filter URL…" style="max-width: 240px;">
56
+ <input id="filter-input" type="search" class="form-input form-input-sm" placeholder="Filter URL…" style="max-width: 240px;">
57
57
  </div>
58
58
  <div class="card-body">
59
59
  <div id="analytics-table"></div>