domma-cms 0.22.6 → 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 +7 -5
- package/admin/js/templates/project-settings.html +1 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/projects.js +3 -3
- package/package.json +3 -2
- package/server/routes/api/forms.js +765 -746
- package/server/routes/api/projects.js +9 -2
- package/server/server.js +2 -0
- package/server/services/forms.js +345 -255
- package/server/services/presetCollections.js +2 -1
- package/server/services/projects.js +115 -24
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
|
|
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
|
-
-
|
|
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 +
|
|
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
|
|
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.
|
|
@@ -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
|
|
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
|
|
1
|
+
import{api as s}from"../api.js";function o(a){return String(a??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}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
|
-
|
|
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
|
|
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"})}})}};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domma-cms",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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",
|