domma-cms 1.6.48 → 1.6.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/index.html +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/admin-brand.js +1 -0
- package/admin/js/lib/block-editor-arrange.js +1 -1
- package/admin/js/lib/blocks-arrange.js +1 -1
- package/admin/js/lib/list-tags-arrange.js +1 -0
- package/admin/js/lib/list-tags.js +37 -0
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +6 -6
- package/admin/js/templates/blocks.html +9 -1
- package/admin/js/templates/docs/usage-site-settings.html +7 -0
- package/admin/js/templates/pages.html +7 -1
- package/admin/js/templates/settings.html +2 -1
- package/admin/js/views/blocks.js +5 -4
- package/admin/js/views/pages.js +4 -3
- package/package.json +1 -1
- package/server/routes/api/auth.js +3 -1
- package/server/routes/api/list-tags.js +85 -0
- package/server/routes/api/pages.js +2 -0
- package/server/server.js +2 -0
- package/server/services/adminBrand.js +32 -0
- package/server/services/blocks.js +33 -0
- package/server/services/content.js +32 -0
- package/server/services/listTags.js +220 -0
- package/server/services/versions.js +24 -1
|
@@ -28,6 +28,13 @@
|
|
|
28
28
|
is none (a <code>favicon</code> in <code>config/site.json</code> takes first
|
|
29
29
|
place).</td>
|
|
30
30
|
</tr>
|
|
31
|
+
<tr>
|
|
32
|
+
<td>Branding</td>
|
|
33
|
+
<td>The public site's brand (name, logo or icon, link) and the admin's own. The
|
|
34
|
+
<strong>Admin name</strong> is shown in the admin's top bar, its sidebar and the
|
|
35
|
+
browser tab, for everyone who signs in; empty, it uses the site title. The admin logo
|
|
36
|
+
takes priority over the admin icon.</td>
|
|
37
|
+
</tr>
|
|
31
38
|
<tr>
|
|
32
39
|
<td>SEO</td>
|
|
33
40
|
<td>Default page title, title separator and default description. The full SEO Tool is
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
<span data-bind-text="projectNote.value"></span> <a href="#/pages">Show all pages</a>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
|
+
<p class="pgx-notice" data-bind-hidden="!tagNote.value">
|
|
10
|
+
<span data-bind-text="tagNote.value"></span> <button type="button" class="pgx-link" data-on-click="showAllTags">Show all pages</button>
|
|
11
|
+
</p>
|
|
12
|
+
|
|
9
13
|
<div class="pgx-card">
|
|
10
14
|
<div class="pgx-bar">
|
|
11
15
|
<div class="pgx-seg" role="group" aria-label="Status">
|
|
@@ -25,7 +29,7 @@
|
|
|
25
29
|
<button type="button" data-on-click="showList" data-bind-class="mode.value === 'list' && 'is-on'" title="List">List</button>
|
|
26
30
|
<button type="button" data-on-click="showTree" data-bind-class="mode.value === 'tree' && 'is-on'" title="Tree - pages under the page whose URL they sit beneath">Tree</button>
|
|
27
31
|
</div>
|
|
28
|
-
<span data-help-title="Pages" data-help="Click a page to edit it. Right-click it (or ⋮, or Shift+F10) to view it on the site, copy its address, transition between Draft, Published and Review, trim its revision history or delete it. Drafts are not public: visitors get a 404, while anyone who can edit pages sees them with a Draft banner. Tree shows each page under the page whose address it sits beneath. Your filter, sort and
|
|
32
|
+
<span data-help-title="Pages" data-help="Click a page to edit it. Right-click it (or ⋮, or Shift+F10) to view it on the site, copy its address, transition between Draft, Published and Review, trim its revision history or delete it. Drafts are not public: visitors get a 404, while anyone who can edit pages sees them with a Draft banner. Tree shows each page under the page whose address it sits beneath. Tags link pages together - add or remove one from the right-click menu, and pick which tag to show (or manage the tags) with the cog above. Your filter, sort, view and tag are remembered."></span>
|
|
29
33
|
</div>
|
|
30
34
|
<div class="pgx-note dm-loading-slot" data-bind-hidden="!loading.value">Loading…</div>
|
|
31
35
|
<p class="pgx-note is-bad" data-bind-hidden="!loadError.value" data-bind-text="loadError.value"></p>
|
|
@@ -67,6 +71,8 @@
|
|
|
67
71
|
background: color-mix(in srgb, var(--dm-text) 10%, transparent); color: var(--dm-text-muted); }
|
|
68
72
|
.pgx-chip.is-ok { background: color-mix(in srgb, var(--dm-success) 16%, transparent); color: var(--dm-success); }
|
|
69
73
|
.pgx-chip.is-warn { background: color-mix(in srgb, var(--dm-warning) 18%, transparent); color: var(--dm-warning); }
|
|
74
|
+
.pgx-chip.is-tag { background: color-mix(in srgb, var(--dm-primary) 12%, transparent); color: var(--dm-primary); max-width: 14rem; overflow: hidden; text-overflow: ellipsis; }
|
|
75
|
+
.pgx-link { border: 0; background: none; padding: 0; font: inherit; color: var(--dm-primary); text-decoration: underline; cursor: pointer; }
|
|
70
76
|
.pgx-chip.is-info { background: color-mix(in srgb, var(--dm-info, var(--dm-primary)) 14%, transparent); color: var(--dm-info, var(--dm-primary)); }
|
|
71
77
|
.pgx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .3rem; }
|
|
72
78
|
.pgx-kebab:hover { color: var(--dm-primary); }
|
|
@@ -105,7 +105,8 @@
|
|
|
105
105
|
</div>
|
|
106
106
|
|
|
107
107
|
</div><p class="text-muted">The logo takes priority over the icon. Set the browser icon in the Favicon tab.</p></div>
|
|
108
|
-
<div class="stx-card"><div class="stx-card-title">Admin
|
|
108
|
+
<div class="stx-card"><div class="stx-card-title">Admin
|
|
109
|
+
<span data-help-title="Admin name and mark" data-help="The name and logo or icon at the top of the admin - in the top bar, the sidebar and the browser tab - for everyone who signs in. Leave the name empty to use the site title."></span></div><div class="stx-grid">
|
|
109
110
|
<div><label class="form-label" for="stx-adminBrandTitle">Admin name</label>
|
|
110
111
|
<input id="stx-adminBrandTitle" type="text" class="form-input" placeholder="Uses the site title when empty" data-model="adminBrandTitle.value">
|
|
111
112
|
</div>
|
package/admin/js/views/blocks.js
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as h,apiRequest as Fe}from"../api.js";import{addBannerAction as A}from"../lib/plugin-chrome.js";import{helpPopovers as ie}from"../lib/help-popover.js";import{onceClosed as re,openMenuAt as de,sidebarCount as Re,viewLifetime as qe}from"../lib/tool-kit.js";import{filterByProject as Ue,getProjectFromHash as He,renderProjectFilterNotice as Oe}from"../lib/project-context.js";import{FILTERS as Pe,arrangeBlocks as Ke,blockRow as We,checkNewName as _e,copyName as ue,shortcodeFor as pe,usageText as me}from"../lib/blocks-arrange.js";import{fmtDate as ze}from"../lib/dates.js";import{UNTAGGED as Ge,activeNote as Ye,askTagName as Je,normaliseActive as Ve,openTagSettings as Qe,same as be,tagCounts as Xe,tagMenuItems as Ze,tagsOf as D}from"../lib/list-tags.js";const he=/^#\/(blocks|projects\/[^/?#]+\/blocks)\/?(\?.*)?$/,et="#/blocks",F="blocks.draft",_="blocks.filter",fe="blocks.tag",tt=`
|
|
2
2
|
<div class="blx-row" data-name="{{name}}" tabindex="0" data-bind-class="unused && 'is-unused'">
|
|
3
3
|
<span class="blx-icon" aria-hidden="true"><span data-icon="box" data-icon-size="16"></span></span>
|
|
4
4
|
<span class="blx-main"><strong>{{name}}</strong><span data-bind-hidden="!description">{{description}}</span><small>{{meta}}</small></span>
|
|
5
5
|
<span class="blx-chips">
|
|
6
6
|
<span class="blx-chip is-info" data-bind-hidden="!hasCss" title="Has its own CSS">CSS</span>
|
|
7
|
+
<span class="blx-chip is-tag" data-bind-hidden="!listTags" title="Tags">{{listTags}}</span>
|
|
7
8
|
<span class="blx-chip" data-bind-hidden="!project" title="Project">{{project}}</span>
|
|
8
9
|
<span class="blx-chip" data-bind-hidden="!bundled" title="Ships with the CMS">Built in</span>
|
|
9
10
|
<span class="blx-chip is-warn" data-bind-hidden="!unused" title="No page or view places it">Unused</span>
|
|
10
11
|
</span>
|
|
11
12
|
<span class="blx-uses" title="Pages and views that use it">{{uses}}</span>
|
|
12
13
|
<button type="button" class="blx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
|
|
13
|
-
</div>`,
|
|
14
|
+
</div>`,k=l=>String(l??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function ge(l){const n=document.createElement("template");return n.innerHTML=l.trim(),n.content.firstElementChild}const i=qe(he);async function at(l){try{return await navigator.clipboard.writeText(l),!0}catch{const n=document.createElement("textarea");n.value=l,n.style.cssText="position:fixed;opacity:0;",document.body.appendChild(n),n.select();let f=!1;try{f=document.execCommand("copy")}catch{}return n.remove(),f}}export const blocksView={templateUrl:"/admin/js/templates/blocks.html",async onMount(l){i.reset();const n=l.get(0).querySelector(".blx")||l.get(0),f=()=>he.test(location.hash)&&n.isConnected,w=He(),u=M.observable([]),z=M.observable(!0),R=M.observable("");let G="all";try{G=Pe.includes(S.get(_))?S.get(_):"all"}catch{}const y=M.observable(G),Y=M.observable("");i.add(M.effect(()=>{const e=y.value;try{S.set(_,e)}catch{}}));let J="";try{J=Ve(S.get(fe))}catch{}const g=M.observable(J),x=M.observable([]);i.add(M.effect(()=>{const e=g.value;try{S.set(fe,e)}catch{}}));const ve=ze,q=M.observable([]);i.add(M.effect(()=>{q.value=Ke(u.value,{filter:y.value,query:Y.value,tag:g.value})}));const U=M.observable([]);i.add(M.effect(()=>{U.value=q.value.map(e=>We(e,ve))}));const H=M.computed(()=>Xe(x.value,u.value)),ye=M.computed(()=>Ye(g.value,q.value.length,"blocks")),ke=M.computed(()=>{const e=u.value,t=e.filter(a=>(a.usage?.pages?.length||0)+(a.usage?.views?.length||0)>0).length;return{all:e.length,used:t,unused:e.length-t}}),we=M.computed(()=>u.value.length?"No blocks match.":"No blocks yet - New block in the banner makes one, or Import brings one in."),O=e=>u.peek().find(t=>t.name===e),xe=w?`#/blocks/new?project=${encodeURIComponent(w)}`:"#/blocks/new",V=e=>`#/blocks/edit/${encodeURIComponent(e)}`;async function P(){try{let e=await Fe("/blocks?usage=1",{method:"GET"});if(!f())return;if(e=Array.isArray(e)?e:[],w){const t=e.length;e=Ue(e,w),Oe(n,{total:t,shown:e.length,slug:w,allHref:"#/blocks",noun:"blocks"})}u.value=e,R.value="",await Ee()}catch(e){R.value=`Could not load the blocks: ${e.message||e}`}finally{z.value=!1}}async function Ee(){try{x.value=(await h.listTags.list("blocks")).map(t=>t.name)}catch{}const e=g.peek();e&&e!==Ge&&!H.peek().some(t=>be(t.name,e))&&(g.value="")}async function Q(e,t){try{const a=await h.listTags.changeItem("blocks",e.name,t);u.value=u.peek().map(s=>s.name===e.name?{...s,meta:{...s.meta||{},adminTags:a}}:s),a.some(s=>!x.peek().some(p=>be(p,s)))&&(x.value=[...x.peek(),...a])}catch(a){E.toast(`Tags not saved: ${a.message||a}`,{type:"error"})}}const Se=()=>Qe({kind:"blocks",noun:"blocks",title:"Blocks settings - tags",tags:H,active:g,reload:P}),j=async()=>{Re(et),f()&&await P()};async function Ce(e){const a=(e.usage?.pages?.length||0)+(e.usage?.views?.length||0)?` It is ${k(me(e).toLowerCase())} - they will show nothing where it was.`:"";if(await E.confirm(`Delete the block "${k(e.name)}"?${a} This cannot be undone.`))try{await h.blocks.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),await j()}catch(s){E.toast(s.message||"Failed to delete block.",{type:"error"})}}async function Te(e){try{await h.blocks.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}async function Me(e){const t=pe(e),a=await at(t);E.toast(a?`Copied ${t}`:`Could not copy - your browser refused. The shortcode is ${t}`,{type:a?"success":"error"})}const C=n.querySelector(".blx-import-file");async function X(){const e=C.files?.[0];if(!e)return;C.value="";let t;try{t=JSON.parse(await e.text())}catch{E.toast("Not a valid .dmblock.json file (could not parse JSON).",{type:"error"});return}if(!t||typeof t!="object"||!t.name||typeof t.html!="string"){E.toast("Bundle is missing a name or html field.",{type:"error"});return}try{const a=await h.blocks.importBundle(t);E.toast(`Imported "${a.name}".`,{type:"success"}),await j()}catch(a){if(a.code==="CONFLICT"){if(!await E.confirm(`A block named "${k(a.name)}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const s=await h.blocks.importBundle(t,{overwrite:!0});E.toast(`Overwrote "${s.name}".`,{type:"success"}),await j()}catch(s){E.toast(s.message||"Import failed.",{type:"error"})}return}E.toast(a.message||"Import failed.",{type:"error"})}}C.addEventListener("change",X),i.add(()=>C.removeEventListener("change",X));const T=new Set;let v=null,Z=!1;function Ne(e){const t=E.slideover({title:`Where ${k(e.name)} is used`,size:"sm",position:"right"}),a=ge(`
|
|
14
15
|
<div class="blx-form">
|
|
15
16
|
<p class="blx-note blx-where-sum"></p>
|
|
16
17
|
<ul class="blx-where"></ul>
|
|
17
18
|
<div><div class="form-label">Shortcode</div><div class="blx-code"></div>
|
|
18
19
|
<small class="blx-hint">Right-click the block in the list and Copy shortcode to paste it into a page.</small></div>
|
|
19
|
-
</div>`);
|
|
20
|
+
</div>`);a.querySelector(".blx-where-sum").textContent=me(e)+".",a.querySelector(".blx-code").textContent=pe(e);const s=a.querySelector(".blx-where"),p=(r,B,$)=>{const L=document.createElement("li"),o=document.createElement("a");o.href=r,o.textContent=B,o.addEventListener("click",()=>t.close());const m=document.createElement("small");m.textContent=$,L.append(o,m),s.appendChild(L)};for(const r of e.usage?.pages||[])p(`#/pages/edit${r}`,r,"page");for(const r of e.usage?.views||[])p(`#/views/edit/${encodeURIComponent(r)}`,r,"view");t.element.appendChild(a),T.add(t),re(t,()=>T.delete(t)),t.open()}function ee(e,t=null){const a=()=>u.peek().map(d=>d.name),s=t?.name??ue(e.name,a()),p=M.observable(s),r=M.observable(!1),B=M.computed(()=>_e(p.value,u.value.map(d=>d.name))),$={name:p,saving:r,err:B,cannot:M.computed(()=>r.value||!!B.value)},L=()=>p.peek()!==ue(e.name,a())||!!t,o=E.slideover({title:`Duplicate ${k(e.name)}`,size:"sm",position:"right"}),m=ge(`
|
|
20
21
|
<div class="blx-form">
|
|
21
22
|
<div>
|
|
22
23
|
<label class="form-label" for="blx-dup-name">Name of the copy
|
|
@@ -31,4 +32,4 @@ import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as A}fro
|
|
|
31
32
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
|
|
32
33
|
<span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
|
|
33
34
|
</div>
|
|
34
|
-
</div>`);let
|
|
35
|
+
</div>`);let K=!1;async function oe(){if($.cannot.peek())return;r.value=!0;const d=p.peek().trim();try{const c=await h.blocks.get(e.name);await h.blocks.put(d,{content:c.content,css:c.css||"",...(c.meta?.project||D(c).length)&&{meta:{...c.meta?.project&&{project:c.meta.project},...D(c).length&&{adminTags:D(c)}}}}),E.toast(`Made "${d}".`,{type:"success"}),K=!0,o.close(),await j()}catch(c){E.toast(`Not duplicated: ${c.message||c}`,{type:"error"})}finally{r.value=!1}}o.element.appendChild(m);const Be=ie(m),$e=M.applyBindings($,m,{methods:{save:oe,cancel:()=>o.close()}}),Ae=d=>{d.key==="Enter"&&d.target.matches("input")&&(d.preventDefault(),oe())};m.addEventListener("keydown",Ae);const le={dirty:L,values:()=>({name:p.peek()}),source:e.name};v=le,T.add(o),re(o,()=>{$e.dispose(),Be(),T.delete(o),v===le&&(v=null)});const De=o.close.bind(o);let W=!1;o.close=async d=>{if(!K&&!Z&&d!==!0&&L()){if(W)return;W=!0;const c=await E.confirm("Discard the name you typed?");if(W=!1,!c)return}return K=!0,De()},I.scan(m),o.open();const ce=m.querySelector("#blx-dup-name");ce?.focus(),ce?.select()}function Ie(e){const t=O(e);if(!t)return[];const a=(t.usage?.pages?.length||0)+(t.usage?.views?.length||0);return[{header:t.name},{label:"Edit",icon:"edit",action:()=>{location.hash=V(t.name)}},{label:"Copy shortcode",icon:"copy",action:()=>Me(t)},{label:a?`Where it is used (${a})\u2026`:"Where it is used\u2026",icon:"link",action:()=>Ne(t)},{type:"divider"},...Ze({all:H.peek().map(s=>s.name),current:D(t),change:s=>Q(t,s),newTag:async()=>{const s=await Je();s&&await Q(t,{add:[s]})}}),{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>ee(t)},{label:"Export (.dmblock.json)",icon:"download",action:()=>Te(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>Ce(t)}]}const b=n.querySelector(".blx-list");b.innerHTML=tt;const Le=M.applyBindings({blocks:u,loading:z,loadError:R,filter:y,query:Y,rows:U,counts:ke,emptyText:we,tagNote:ye},n,{methods:{showAllTags:()=>{g.value=""},showAll:()=>{y.value="all"},showUsed:()=>{y.value="used"},showUnused:()=>{y.value="unused"}}});i.add(()=>Le.dispose()),i.add(ie(n,{position:"bottom"})),i.add(M.effect(()=>{U.value,requestAnimationFrame(()=>{b.isConnected&&I.scan(b)})}));const je=E.contextMenu(b,{cursor:"glyph",match:".blx-row",inherit:!1,density:"compact",items:e=>Ie(e.dataset.name)});i.add(()=>je?.destroy?.());const te=e=>{O(e)&&(location.hash=V(e))},ae=e=>{const t=e.target.closest(".blx-row");if(!t)return;const a=e.target.closest(".blx-kebab");if(a){de(a);return}te(t.dataset.name)},se=e=>{const t=e.target.closest(".blx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),de(t)):e.key==="Enter"&&e.target===t&&te(t.dataset.name))};b.addEventListener("click",ae),b.addEventListener("keydown",se),i.add(()=>{b.removeEventListener("click",ae),b.removeEventListener("keydown",se)}),A(l,{id:"blocks-settings",icon:"settings",title:"Blocks settings - tags",onClick:Se}),A(l,{id:"blocks-guide",icon:"book",title:"Blocks guide",onClick:()=>{location.hash="#/docs/usage/blocks"}}),A(l,{id:"blocks-import",icon:"upload",title:"Import a .dmblock.json bundle",onClick:()=>C.click()}),A(l,{id:"blocks-new",icon:"plus",title:"New block",onClick:()=>{location.hash=xe}});const ne=e=>{v?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",ne),i.add(()=>window.removeEventListener("beforeunload",ne)),i.add(()=>{if(v?.dirty())try{S.set(F,{source:v.source,values:v.values(),at:new Date().toISOString()})}catch{}Z=!0,[...T].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(n),await P(),!f())return;let N=null;try{N=S.get(F)}catch{}if(N?.values&&N.source){try{S.remove?S.remove(F):S.set(F,null)}catch{}const e=O(N.source);e&&await E.confirm(`You were duplicating "${k(e.name)}" and left it unfinished. Pick up where you left off?`)&&f()&&ee(e,N.values)}}};
|
package/admin/js/views/pages.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as p}from"../api.js";import{SHAPES as Le,whileLoading as Ne}from"../lib/loading.js";import{addBannerAction as ne}from"../lib/plugin-chrome.js";import{helpPopovers as oe}from"../lib/help-popover.js";import{onceClosed as Re,openMenuAt as ie,settingsStore as je,sidebarCount as re,viewLifetime as Be}from"../lib/tool-kit.js";import{filterByProject as le,getProjectFromHash as Ke}from"../lib/project-context.js";import{arrangePages as qe,checkPrune as He,normaliseSettings as ce,pageRow as Ie,statusCounts as Ue,treeRows as _e}from"../lib/pages-arrange.js";import{UNTAGGED as Fe,activeNote as Oe,askTagName as Ve,openTagSettings as ze,same as de,tagCounts as Ge,tagMenuItems as We,tagsOf as Ye}from"../lib/list-tags.js";const pe=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,ue="#/pages",D="pages.prune.draft",Je=`
|
|
2
2
|
<div class="pgx-row" data-path="{{urlPath}}" tabindex="0" data-bind-class="rowCls">
|
|
3
3
|
<span class="pgx-main"><strong>{{title}}</strong><small>{{meta}} <span class="pgx-tags">{{tagsText}}</span></small></span>
|
|
4
4
|
<span class="pgx-chips">
|
|
5
5
|
<span class="pgx-chip" data-bind-hidden="!visibility" title="Who can see it">{{visibility}}</span>
|
|
6
|
+
<span class="pgx-chip is-tag" data-bind-hidden="!listTags" title="Tags">{{listTags}}</span>
|
|
6
7
|
<span class="pgx-chip is-info" data-bind-hidden="!project" title="Project">{{project}}</span>
|
|
7
8
|
<span class="pgx-chip" data-bind-hidden="!bundled" title="Shipped with the CMS">Bundled</span>
|
|
8
9
|
<span class="pgx-chip is-warn" data-bind-hidden="!plugin" title="Managed by this plugin">{{plugin}}</span>
|
|
9
10
|
<span class="pgx-chip" data-bind-class="statusCls">{{statusText}}</span>
|
|
10
11
|
</span>
|
|
11
12
|
<button type="button" class="pgx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
12
|
-
</div>`,
|
|
13
|
+
</div>`,T=r=>String(r??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Qe(r){const l=document.createElement("template");return l.innerHTML=r.trim(),l.content.firstElementChild}const ge=je("pages.settings",ce),i=Be(pe),Xe=r=>`#/pages/edit${r}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(r){i.reset();const l=r.get(0).querySelector(".pgx")||r.get(0),L=()=>pe.test(location.hash)&&l.isConnected,d=Ke(),x=ge.read(),o=M.observable([]),_=M.observable(0),F=M.observable(!0),N=M.observable(""),h=M.observable(x.status),R=M.observable(x.sort),b=M.observable(x.mode),O=M.observable(""),u=M.observable(x.tag),y=M.observable([]);i.add(M.effect(()=>ge.write(ce({status:h.value,sort:R.value,mode:b.value,tag:u.value}))));const C=M.observable([]);i.add(M.effect(()=>{C.value=qe(o.value,{status:h.value,query:O.value,sort:R.value,tag:u.value})}));const V=M.observable([]);i.add(M.effect(()=>{V.value=C.value.map(e=>Ie(e))}));const he=M.computed(()=>Ue(o.value)),me=M.computed(()=>o.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),ve=M.computed(()=>{const e=_.value;return!d||!e?"":`Showing only pages in ${d}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),j=M.computed(()=>Ge(y.value,o.value)),fe=M.computed(()=>Oe(u.value,C.value.length,"pages")),z=e=>o.peek().find(t=>t.urlPath===e);async function be(){try{const e=await Ne(l.querySelector(".dm-loading-slot"),p.pages.list(),Le.rows);if(!L())return;const t=Array.isArray(e)?e:[],a=d?le(t,d):t;_.value=t.length-a.length,o.value=a,N.value="",await G()}catch(e){N.value=`Could not load the pages: ${e.message||e}`}finally{F.value=!1}}async function G(){try{y.value=(await p.listTags.list("pages")).map(t=>t.name)}catch{}const e=u.peek();e&&e!==Fe&&!j.peek().some(t=>de(t.name,e))&&(u.value="")}async function W(e,t){try{const a=await p.listTags.changeItem("pages",e.urlPath,t);o.value=o.peek().map(s=>s.urlPath===e.urlPath?{...s,adminTags:a}:s),a.some(s=>!y.peek().some(v=>de(v,s)))&&(y.value=[...y.peek(),...a])}catch(a){E.toast(`Tags not saved: ${a.message||a}`,{type:"error"})}}async function ye(){const e=await p.pages.list(),t=Array.isArray(e)?e:[];o.value=d?le(t,d):t,await G()}const we=()=>ze({kind:"pages",noun:"pages",title:"Pages settings - tags",tags:j,active:u,reload:ye}),$=e=>{location.hash=Xe(e)},ke=()=>{location.hash=d?`#/pages/new?project=${encodeURIComponent(d)}`:"#/pages/new"};async function Pe(e){const t=e.plugin?`"${T(e.title||e.urlPath)}" is managed by the <strong>${T(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${T(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await p.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),o.value=o.peek().filter(a=>a.urlPath!==e.urlPath),re(ue)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function Me(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}async function B(e,t){if(e.status!==t)try{const a=await p.pages.get(e.urlPath);await p.pages.update(e.urlPath,{frontmatter:{status:t},body:a.content||""}),o.value=o.peek().map(s=>s.urlPath===e.urlPath?{...s,status:t}:s),E.toast(t==="published"?"Page published.":t==="review"?"Page moved to review.":"Page moved to drafts.",{type:"success"}),re(ue)}catch(a){E.toast(`Could not change page status: ${a.message||a}`,{type:"error"})}}let g=null;const K=new Set;let Y=!1;function J(e,t=null){const a=e.versionCount||0,s=M.observable(String(t?.keep??Math.min(5,a))),v=M.observable(!1),k=M.computed(()=>He(s.value,a)),ee={keep:s,saving:v,err:M.computed(()=>k.value.error),summary:M.computed(()=>{const n=k.value;return n.error?"":n.deletes?`Deletes the ${n.deletes} oldest, keeps the newest ${Math.min(n.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>v.value||!!k.value.error||k.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},Se=String(s.peek()),te=()=>String(s.peek())!==Se||!!t,c=E.slideover({title:`Trim revisions - ${T(e.title||e.urlPath)}`,size:"sm",position:"right"}),f=Qe(`
|
|
13
14
|
<div class="pgx-form">
|
|
14
15
|
<p class="pgx-note" data-bind-text="intro"></p>
|
|
15
16
|
<div>
|
|
@@ -25,4 +26,4 @@ import{api as y}from"../api.js";import{SHAPES as ke,whileLoading as Pe}from"../l
|
|
|
25
26
|
<button type="button" class="btn btn-sm btn-danger" data-on-click="trim" data-bind-disabled="cannot.value">
|
|
26
27
|
<span data-icon="archive" data-icon-size="13"></span> Trim</button>
|
|
27
28
|
</div>
|
|
28
|
-
</div>`);async function
|
|
29
|
+
</div>`);async function ae(){if(!ee.cannot.peek()){v.value=!0;try{const n=await p.versions.prune(e.urlPath,k.peek().keep);E.toast(`Removed ${n.deleted} revision${n.deleted===1?"":"s"}; kept ${n.kept}.`,{type:"success"}),o.value=o.peek().map(P=>P.urlPath===e.urlPath?{...P,versionCount:n.kept}:P),H=!0,c.close()}catch(n){E.toast(n.message||"Could not trim the revisions.",{type:"error"})}finally{v.value=!1}}}c.element.appendChild(f);const Ce=oe(f),$e=M.applyBindings(ee,f,{methods:{trim:ae,cancel:()=>c.close()}}),Ae=n=>{n.key==="Enter"&&n.target.id==="pgx-f-keep"&&(n.preventDefault(),ae())};f.addEventListener("keydown",Ae);const se={path:e.urlPath,dirty:te,values:()=>({keep:s.peek()})};g=se,K.add(c),Re(c,()=>{$e.dispose(),Ce(),K.delete(c),g===se&&(g=null)});const De=c.close.bind(c);let H=!1,U=!1;c.close=async n=>{if(!H&&n!==!0&&!Y&&te()){if(U)return;U=!0;const P=await E.confirm("Discard the change you have not applied?");if(U=!1,!P)return}return H=!0,De()},I.scan(f),c.open(),f.querySelector("#pgx-f-keep")?.select()}function Ee(e){const t=z(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>$(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>Me(t)},{label:"Transition",icon:"circle",submenu:[{label:"Published",disabled:t.status==="published",action:()=>B(t,"published")},{label:"Draft",disabled:t.status==="draft",action:()=>B(t,"draft")},{label:"Review",disabled:t.status==="review",action:()=>B(t,"review")}]},{type:"divider"},...We({all:j.peek().map(s=>s.name),current:Ye(t),change:s=>W(t,s),newTag:async()=>{const s=await Ve();s&&await W(t,{add:[s]})}}),{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>J(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>Pe(t)}]}const m=l.querySelector(".pgx-list");m.innerHTML=Je;const Te=M.applyBindings({pages:o,loading:F,loadError:N,status:h,sort:R,mode:b,query:O,rows:V,counts:he,emptyText:me,projectNote:ve,tagNote:fe},l,{methods:{showAllTags:()=>{u.value=""},showAll:()=>{h.value="all"},showPublished:()=>{h.value="published"},showDrafts:()=>{h.value="draft"},showList:()=>{b.value="list"},showTree:()=>{b.value="tree"}}});i.add(()=>Te.dispose()),i.add(oe(l,{position:"bottom"}));const q=l.querySelector(".pgx-tree");let A=null;i.add(M.effect(()=>{const e=b.value==="tree",t=e?_e(C.value):null;try{A?.destroy?.()}catch{}A=null,q.replaceChildren(),!(!e||!t.length)&&(A=E.treeView(q,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>$(a)}),I.scan(q))})),i.add(()=>{try{A?.destroy?.()}catch{}});const xe=E.contextMenu(m,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>Ee(e.dataset.path)});i.add(()=>xe?.destroy?.());const Q=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){ie(a);return}$(t.dataset.path)},X=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ie(t)):e.key==="Enter"&&e.target===t&&$(t.dataset.path))};m.addEventListener("click",Q),m.addEventListener("keydown",X),i.add(()=>{m.removeEventListener("click",Q),m.removeEventListener("keydown",X)}),ne(r,{id:"pages-settings",icon:"settings",title:"Pages settings - tags",onClick:we}),ne(r,{id:"pages-new",icon:"plus",title:"New page",onClick:ke});const Z=e=>{g?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Z),i.add(()=>window.removeEventListener("beforeunload",Z)),i.add(()=>{if(g?.dirty())try{S.set(D,{path:g.path,values:g.values(),at:new Date().toISOString()})}catch{}Y=!0,[...K].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await be(),!L())return;let w=null;try{w=S.get(D)}catch{}if(w?.path&&w.values){try{S.remove?S.remove(D):S.set(D,null)}catch{}const e=z(w.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${T(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&L()&&J(e,w.values)}}};
|
package/package.json
CHANGED
|
@@ -24,6 +24,7 @@ import {config, getConfig} from '../../config.js';
|
|
|
24
24
|
import {getRegisteredSidebarItems, hooks} from '../../services/hooks.js';
|
|
25
25
|
import {getMenu} from '../../services/menus.js';
|
|
26
26
|
import {resolveAdminHome} from '../../services/adminHome.js';
|
|
27
|
+
import {adminBrandFor} from '../../services/adminBrand.js';
|
|
27
28
|
import {authenticate, checkVisibility} from '../../middleware/auth.js';
|
|
28
29
|
import {getAdminScope, getPermissionsForRoles, getRoleLevel} from '../../services/roles.js';
|
|
29
30
|
import {getEffectiveRegistry, GROUP_ORDER} from '../../services/permissionRegistry.js';
|
|
@@ -252,7 +253,8 @@ export async function authRoutes(fastify) {
|
|
|
252
253
|
});
|
|
253
254
|
// Dashboard Pro's "Open on my boards" - for a user the admin does not confine.
|
|
254
255
|
if (!adminScope && isDashboardPro() && (await readPrefs(request.user)).home) adminHome = '/dashboards';
|
|
255
|
-
|
|
256
|
+
// adminBrand: the admin's name and mark for the top bar, sidebar header and tab title.
|
|
257
|
+
return {permissions, adminScope, adminHome, adminBrand: adminBrandFor(getConfig('site'))};
|
|
256
258
|
});
|
|
257
259
|
|
|
258
260
|
// GET /api/auth/permissions-registry - returns the full permissions registry,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List tags - admin-only labels on pages and blocks (services/listTags.js).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/list-tags/:kind - {tags: [{name, count}]}
|
|
5
|
+
* POST /api/list-tags/:kind - {name}: create a tag
|
|
6
|
+
* PUT /api/list-tags/:kind/item - {id, tags} replaces one item's tags; {id, add, remove} patches them
|
|
7
|
+
* POST /api/list-tags/:kind/rename - {from, to}
|
|
8
|
+
* POST /api/list-tags/:kind/delete - {name}: take it off every item
|
|
9
|
+
*
|
|
10
|
+
* :kind is `pages` or `blocks`; both sit under the pages permission, as the
|
|
11
|
+
* blocks routes do. Names travel in bodies, not paths, so any spelling works.
|
|
12
|
+
* Every change is limited to the items the caller's projects let them see.
|
|
13
|
+
*/
|
|
14
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
15
|
+
import {addTag, changeItemTags, deleteTag, listTags, renameTag} from '../../services/listTags.js';
|
|
16
|
+
import {canSeeArtefact, canSeePage} from '../../services/projects.js';
|
|
17
|
+
import {getBlock} from '../../services/blocks.js';
|
|
18
|
+
import {getPage} from '../../services/content.js';
|
|
19
|
+
|
|
20
|
+
/** Visibility of one item from what the listers return. */
|
|
21
|
+
function visibleTo(user, kind) {
|
|
22
|
+
return kind === 'pages'
|
|
23
|
+
? (p) => canSeeArtefact(user, {meta: {project: p.resolvedProject}})
|
|
24
|
+
: (b) => canSeeArtefact(user, b);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fail(reply, err) {
|
|
28
|
+
if (err.code === 'BAD_KIND') return reply.status(404).send({error: err.message});
|
|
29
|
+
if (err.code === 'BAD_TAG' || err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
30
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Not found'});
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function listTagsRoutes(fastify) {
|
|
35
|
+
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
|
|
36
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
|
|
37
|
+
|
|
38
|
+
fastify.get('/list-tags/:kind', canRead, async (request, reply) => {
|
|
39
|
+
try {
|
|
40
|
+
return {tags: await listTags(request.params.kind, visibleTo(request.user, request.params.kind))};
|
|
41
|
+
} catch (err) { return fail(reply, err); }
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
fastify.post('/list-tags/:kind', canUpdate, async (request, reply) => {
|
|
45
|
+
try {
|
|
46
|
+
return {name: await addTag(request.params.kind, request.body?.name)};
|
|
47
|
+
} catch (err) { return fail(reply, err); }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
fastify.put('/list-tags/:kind/item', canUpdate, async (request, reply) => {
|
|
51
|
+
const {kind} = request.params;
|
|
52
|
+
const {id, tags, add, remove} = request.body || {};
|
|
53
|
+
if (typeof id !== 'string' || !id) return reply.status(400).send({error: 'id is required'});
|
|
54
|
+
const lists = [tags, add, remove].filter(v => v !== undefined);
|
|
55
|
+
if (!lists.length || !lists.every(Array.isArray)) return reply.status(400).send({error: 'send tags, or add/remove, as arrays'});
|
|
56
|
+
try {
|
|
57
|
+
if (kind === 'pages') {
|
|
58
|
+
const page = await getPage(id);
|
|
59
|
+
if (!page) return reply.status(404).send({error: 'Page not found'});
|
|
60
|
+
if (!(await canSeePage(request.user, id, page.project))) return reply.status(403).send({error: 'Access denied for this project'});
|
|
61
|
+
} else if (kind === 'blocks') {
|
|
62
|
+
const block = await getBlock(id).catch(() => null);
|
|
63
|
+
if (!block) return reply.status(404).send({error: 'Block not found'});
|
|
64
|
+
if (!canSeeArtefact(request.user, block)) return reply.status(403).send({error: 'Access denied for this project'});
|
|
65
|
+
}
|
|
66
|
+
return {tags: await changeItemTags(kind, id, Array.isArray(tags) ? {tags} : {add, remove})};
|
|
67
|
+
} catch (err) { return fail(reply, err); }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
fastify.post('/list-tags/:kind/rename', canUpdate, async (request, reply) => {
|
|
71
|
+
const {from, to} = request.body || {};
|
|
72
|
+
if (typeof from !== 'string' || !from) return reply.status(400).send({error: 'from is required'});
|
|
73
|
+
try {
|
|
74
|
+
return await renameTag(request.params.kind, from, to, visibleTo(request.user, request.params.kind));
|
|
75
|
+
} catch (err) { return fail(reply, err); }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
fastify.post('/list-tags/:kind/delete', canUpdate, async (request, reply) => {
|
|
79
|
+
const {name} = request.body || {};
|
|
80
|
+
if (typeof name !== 'string' || !name) return reply.status(400).send({error: 'name is required'});
|
|
81
|
+
try {
|
|
82
|
+
return await deleteTag(request.params.kind, name, visibleTo(request.user, request.params.kind));
|
|
83
|
+
} catch (err) { return fail(reply, err); }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -181,6 +181,8 @@ export async function pagesRoutes(fastify) {
|
|
|
181
181
|
category: p.category || null,
|
|
182
182
|
visibility: p.visibility || 'public',
|
|
183
183
|
tags: p.tags || [],
|
|
184
|
+
// Admin-only list tags (services/listTags.js) - not the public `tags`.
|
|
185
|
+
adminTags: Array.isArray(p.adminTags) ? p.adminTags : [],
|
|
184
186
|
project: p.project ?? null,
|
|
185
187
|
resolvedProject: p.resolvedProject ?? null,
|
|
186
188
|
// The list's "managed by a plugin" chip and delete warning read these;
|
package/server/server.js
CHANGED
|
@@ -532,6 +532,7 @@ const { formsRoutes } = await import('./routes/api/forms.js');
|
|
|
532
532
|
const { viewsRoutes } = await import('./routes/api/views.js');
|
|
533
533
|
const { actionsRoutes } = await import('./routes/api/actions.js');
|
|
534
534
|
const {blocksRoutes} = await import('./routes/api/blocks.js');
|
|
535
|
+
const {listTagsRoutes} = await import('./routes/api/list-tags.js');
|
|
535
536
|
const {componentsRoutes} = await import('./routes/api/components.js');
|
|
536
537
|
const {versionsRoutes} = await import('./routes/api/versions.js');
|
|
537
538
|
const {effectsRoutes} = await import('./routes/api/effects.js');
|
|
@@ -568,6 +569,7 @@ await app.register(formsRoutes, { prefix: '/api' });
|
|
|
568
569
|
await app.register(viewsRoutes, { prefix: '/api' });
|
|
569
570
|
await app.register(actionsRoutes, { prefix: '/api' });
|
|
570
571
|
await app.register(blocksRoutes, {prefix: '/api'});
|
|
572
|
+
await app.register(listTagsRoutes, {prefix: '/api'});
|
|
571
573
|
await app.register(componentsRoutes, {prefix: '/api'});
|
|
572
574
|
await app.register(versionsRoutes, {prefix: '/api'});
|
|
573
575
|
await app.register(effectsRoutes, {prefix: '/api'});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The admin's own name and mark - what the top bar, the sidebar header and the
|
|
3
|
+
* browser tab show. Site Settings › Branding › Admin name / logo / icon, with
|
|
4
|
+
* the site title standing in for an empty name, so a site called ACME Ltd says
|
|
5
|
+
* ACME Ltd in its admin without anyone setting anything.
|
|
6
|
+
*
|
|
7
|
+
* It travels with GET /api/auth/permissions, so every signed-in user sees it -
|
|
8
|
+
* reading Site Settings needs the settings permission, which a confined role
|
|
9
|
+
* (a job-board candidate) does not have.
|
|
10
|
+
*
|
|
11
|
+
* @module server/services/adminBrand
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The fallback when a site has neither an admin name nor a title. */
|
|
15
|
+
export const DEFAULT_ADMIN_TITLE = 'Domma CMS';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} [site] - config/site.json
|
|
19
|
+
* @returns {{title: string, logo: string, icon: string}}
|
|
20
|
+
*/
|
|
21
|
+
export function adminBrandFor(site = {}) {
|
|
22
|
+
const own = site?.adminBrand && typeof site.adminBrand === 'object' ? site.adminBrand : {};
|
|
23
|
+
// Whitespace collapsed: a title reads back that way from the browser tab.
|
|
24
|
+
const str = (v) => (typeof v === 'string' ? v.replace(/[ \t\n\f\r]+/g, ' ').trim() : '');
|
|
25
|
+
const logo = str(own.logo);
|
|
26
|
+
return {
|
|
27
|
+
title: (str(own.title) || str(site?.title) || DEFAULT_ADMIN_TITLE).slice(0, 120),
|
|
28
|
+
// Same rule as the sidebar header: a site path or an http(s) address, nothing else.
|
|
29
|
+
logo: /^(?:\/(?!\/)|https?:\/\/)/i.test(logo) ? logo : '',
|
|
30
|
+
icon: /^[a-z0-9-]{1,40}$/.test(str(own.icon)) ? str(own.icon) : ''
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -334,9 +334,17 @@ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
|
|
|
334
334
|
await fs.mkdir(BLOCKS_DIR, {recursive: true});
|
|
335
335
|
await fs.writeFile(blockFilePath(name), content, 'utf8');
|
|
336
336
|
const metaPath = blockMetaPath(name);
|
|
337
|
+
// List tags (services/listTags.js) are set from the Blocks list, not by a
|
|
338
|
+
// content save: a caller that does not send `adminTags` keeps the stored
|
|
339
|
+
// ones, or an editor opened before a tagging would save over it.
|
|
340
|
+
let keptTags;
|
|
341
|
+
if (!(meta && typeof meta === 'object' && 'adminTags' in meta)) {
|
|
342
|
+
try { keptTags = JSON.parse(await fs.readFile(metaPath, 'utf8'))?.adminTags; } catch { /* no sidecar */ }
|
|
343
|
+
}
|
|
337
344
|
// Merge sidecar meta - bundled flag plus any caller-supplied fields (eg meta.project).
|
|
338
345
|
const sidecar = {
|
|
339
346
|
...(meta && typeof meta === 'object' ? meta : {}),
|
|
347
|
+
...(Array.isArray(keptTags) && keptTags.length ? {adminTags: keptTags} : {}),
|
|
340
348
|
...(bundled ? {bundled: true} : {})
|
|
341
349
|
};
|
|
342
350
|
if (Object.keys(sidecar).length > 0) {
|
|
@@ -358,6 +366,31 @@ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
|
|
|
358
366
|
return {success: true, name};
|
|
359
367
|
}
|
|
360
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Merge keys into a block's sidecar meta without touching its HTML or CSS.
|
|
371
|
+
* A key set to undefined is removed; an emptied sidecar is deleted, as
|
|
372
|
+
* saveBlock does. No cache invalidation: the sidecar is admin metadata
|
|
373
|
+
* (project, list tags) that rendering never reads.
|
|
374
|
+
*
|
|
375
|
+
* @param {string} name
|
|
376
|
+
* @param {object} patch
|
|
377
|
+
* @returns {Promise<object>} the sidecar as written
|
|
378
|
+
* @throws {Error} With code INVALID_NAME, or ENOENT when the block is not found
|
|
379
|
+
*/
|
|
380
|
+
export async function setBlockMeta(name, patch) {
|
|
381
|
+
assertValidName(name);
|
|
382
|
+
await fs.access(blockFilePath(name));
|
|
383
|
+
let meta = {};
|
|
384
|
+
try { meta = JSON.parse(await fs.readFile(blockMetaPath(name), 'utf8')) || {}; } catch { /* no sidecar yet */ }
|
|
385
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
386
|
+
if (v === undefined) delete meta[k];
|
|
387
|
+
else meta[k] = v;
|
|
388
|
+
}
|
|
389
|
+
if (Object.keys(meta).length) await fs.writeFile(blockMetaPath(name), JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
|
390
|
+
else await fs.unlink(blockMetaPath(name)).catch(() => {});
|
|
391
|
+
return meta;
|
|
392
|
+
}
|
|
393
|
+
|
|
361
394
|
/**
|
|
362
395
|
* Delete a block and its companion files (.css, .meta.json).
|
|
363
396
|
* Missing companions are ignored; only the primary .html file triggers ENOENT.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'fs/promises';
|
|
7
7
|
import path from 'path';
|
|
8
|
+
import matter from 'gray-matter';
|
|
8
9
|
import {parseMarkdown, serialiseMarkdown} from './markdown.js';
|
|
9
10
|
import {config} from '../config.js';
|
|
10
11
|
import {hooks} from './hooks.js';
|
|
@@ -227,6 +228,37 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
|
|
|
227
228
|
return page;
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Merge keys into a page's frontmatter as metadata, not as an edit: no
|
|
233
|
+
* revision, updatedAt unchanged, body byte-for-byte as it was. For admin-only
|
|
234
|
+
* keys (list tags) that rendering never reads. A key set to undefined is
|
|
235
|
+
* removed.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} urlPath
|
|
238
|
+
* @param {object} patch
|
|
239
|
+
* @returns {Promise<void>}
|
|
240
|
+
* @throws {Error} With code ENOENT when the page does not exist
|
|
241
|
+
*/
|
|
242
|
+
export async function setPageFrontmatter(urlPath, patch) {
|
|
243
|
+
const filePath = await resolveExistingFilePath(urlPath);
|
|
244
|
+
if (!filePath) {
|
|
245
|
+
const err = new Error(`Page not found: ${urlPath}`);
|
|
246
|
+
err.code = 'ENOENT';
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
250
|
+
// gray-matter caches parses by input string and hands back the same
|
|
251
|
+
// object: copy before changing it.
|
|
252
|
+
const parsed = matter(raw);
|
|
253
|
+
const data = {...parsed.data};
|
|
254
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
255
|
+
if (v === undefined) delete data[k];
|
|
256
|
+
else data[k] = v;
|
|
257
|
+
}
|
|
258
|
+
await fs.writeFile(filePath, serialiseMarkdown(data, parsed.content), 'utf8');
|
|
259
|
+
await cache.invalidateTags(['pages:list']);
|
|
260
|
+
}
|
|
261
|
+
|
|
230
262
|
/**
|
|
231
263
|
* Rename (move) a page to a new URL path.
|
|
232
264
|
* Creates any required parent directories for the destination.
|