domma-cms 1.8.16 → 1.8.18
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/CHANGELOG.md +10 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/lib/block-editor-arrange.js +12 -3
- package/admin/js/lib/block-tags.js +1 -0
- package/admin/js/lib/editor-hop-arrange.js +1 -0
- package/admin/js/lib/editor-hop.js +19 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/shortcode-form.js +3 -3
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/block-editor.html +9 -7
- package/admin/js/templates/page-editor.html +8 -7
- package/admin/js/views/block-editor.js +5 -5
- package/admin/js/views/page-editor.js +8 -8
- package/docs/markdown-shortcodes.md +17 -0
- package/package.json +1 -1
- package/server/routes/api/blocks.js +57 -2
- package/server/routes/api/pages.js +15 -7
- package/server/services/markdown.js +48 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as v}from"../api.js";import{CardBuilder as xt}from"../lib/card-builder.js";import{TimelineBuilder as Pt}from"../lib/timeline-builder.js";import{attachEditorKeybindings as At,createToolbar as Lt,insertAtCursor as Dt}from"../lib/markdown-toolbar.js";import{EffectsBuilder as Ot}from"../lib/effects-builder.js";import{attachContextMenu as Rt}from"../lib/shortcode-context-menu.js";import{registerCustomInserters as Nt}from"../lib/shortcode-insert.js";import{populateThemeSelect as Bt}from"../lib/themes.js";import{createFoldingManager as It}from"../lib/editor-folding.js";import{openScribeComposer as Ft}from"../lib/scribe-composer.js";import{openCrudTutorial as zt}from"../lib/crud-tutorial.js";import{addBannerAction as V}from"../lib/plugin-chrome.js";import{helpPopovers as We}from"../lib/help-popover.js";import{onceClosed as Se,openMenuAt as Ye}from"../lib/tool-kit.js";import{pickMedia as $t}from"../lib/media-picker.js";import{openPageClone as jt}from"../lib/page-clone.js";import{openEditorReference as Ge}from"../lib/page-editor-reference.js";import{cleanType as Q,DEFAULT_TYPE as ee,EDITOR_FONTS as Vt,fontHref as qt,isDefaultType as Ht,LINE_HEIGHTS as Kt,SIZES as Ut,typeVars as Xe}from"../lib/editor-type.js";import{getProjectFromHash as _t}from"../lib/project-context.js";import{attachSmoothCaret as Ze,getCaretPreference as Jt,setCaretPreference as Wt}from"../lib/smooth-caret.js";import{SCREEN as Yt,bannerSection as Gt,draftIsStale as Qe,draftKey as Xt,initialProject as Zt,makeDraft as Qt,problems as Te,readDraft as ea,snapshot as j}from"../lib/page-editor-arrange.js";import{fmtDateTime as te}from"../lib/dates.js";import{heldBlockEdits as ta,hold as et,mountEditorHop as aa,noteRecent as sa,takeHeld as oa}from"../lib/editor-hop.js";import{isHop as na}from"../lib/editor-hop-arrange.js";import{blockTagDefs as ra,blocksInBody as ia}from"../lib/block-tags.js";function ae(o){const l=document.createElement("template");return l.innerHTML=o.trim(),l.content.firstElementChild}const la=te,tt=o=>te(o.createdAt)+(o.label?` - ${o.label}`:""),ca=`
|
|
2
2
|
<div class="pe-share">
|
|
3
3
|
<p class="form-hint" style="margin-top:0">Anyone with the link can view this page even while it is unpublished, without
|
|
4
4
|
signing in. The link works for this page only, expires on its own, and can be revoked at any time.</p>
|
|
@@ -30,7 +30,7 @@ import{api as g}from"../api.js";import{CardBuilder as gt}from"../lib/card-builde
|
|
|
30
30
|
.pe-share [hidden] { display: none !important; }
|
|
31
31
|
.pe-share-row { display: flex; align-items: center; gap: .6rem; padding: .5rem 0; border-bottom: 1px solid var(--dm-border-light); }
|
|
32
32
|
.pe-share-text { flex: 1 1 auto; min-width: 0; font-size: .82rem; }
|
|
33
|
-
</style>`;function
|
|
33
|
+
</style>`;function at(o){const l=qt(o);if(!l||document.querySelector(`link[data-pe-font="${CSS.escape(o)}"]`))return;const c=Object.assign(document.createElement("link"),{rel:"stylesheet",href:l});c.dataset.peFont=o,document.head.appendChild(c)}const da=`
|
|
34
34
|
<div class="pe-settings">
|
|
35
35
|
<p class="pe-settings-note">How the editor looks to you - kept in this browser, for every page.</p>
|
|
36
36
|
<label class="form-label" for="pe-set-font">Font <span data-help="The lettering in the editor (not on the site). Monospace keeps every character the same width, so shortcodes and Markdown line up."></span></label>
|
|
@@ -68,17 +68,17 @@ The quick brown fox - 0O 1lI {} [] () => ==
|
|
|
68
68
|
.pe-settings-sample { white-space: pre-wrap; padding: .75rem 1rem; border-radius: var(--dm-radius-md); border: 1px solid var(--dm-border);
|
|
69
69
|
background: var(--dm-background-alt); font-family: var(--pe-font); font-size: var(--pe-size); line-height: var(--pe-line); }
|
|
70
70
|
.pe-settings-actions { display: flex; gap: .5rem; border-top: 1px solid var(--dm-border); padding-top: .75rem; }
|
|
71
|
-
</style>`;function
|
|
71
|
+
</style>`;function pa(o){const l=E.slideover({title:"Editor settings",size:"sm",position:"right"}),c=ae(`<div>${da}</div>`),s=Q(o.peek()),i={font:M.observable(s.font),size:M.observable(String(s.size)),line:M.observable(String(s.line)),wrap:M.observable(s.wrap),caret:M.observable(Jt())},m=M.computed(()=>Q({font:i.font.value,size:i.size.value,line:i.line.value,wrap:i.wrap.value}));i.isDefault=M.computed(()=>Ht(m.value)&&i.caret.value==="auto");const p=M.effect(()=>Wt(i.caret.value)),r=c.querySelector("#pe-set-font");for(const[g,L]of[["mono","Monospace"],["sans","Other"],["serif",null]]){const D=Vt.filter(O=>O.kind===g),f=L?Object.assign(document.createElement("optgroup"),{label:L}):r.querySelector("optgroup:last-of-type");f.append(...D.map(O=>new Option(O.label,O.id))),L&&r.appendChild(f)}c.querySelector("#pe-set-size").append(...Ut.map(g=>new Option(`${g}px`,String(g)))),c.querySelector("#pe-set-line").append(...Kt.map(g=>new Option({1.3:"Tight",1.45:"Snug",1.6:"Normal",1.8:"Relaxed",2:"Double"}[g]||String(g),String(g))));const d=c.querySelector(".pe-settings-sample"),C=M.effect(()=>{const g=m.value;at(g.font);for(const[L,D]of Object.entries(Xe(g)))d.style.setProperty(L,D);d.style.whiteSpace=g.wrap?"pre-wrap":"pre",d.style.overflowX=g.wrap?"":"auto",JSON.stringify(g)!==JSON.stringify(Q(o.peek()))&&(o.value=g,se("type",g))});i.reset=()=>{i.font.value=ee.font,i.size.value=String(ee.size),i.line.value=String(ee.line),i.wrap.value=ee.wrap,i.caret.value="auto"},i.done=()=>l.close(),l.element.appendChild(c);const b=M.applyBindings(i,c),h=We(c);return Se(l,()=>{b.dispose(),h(),C?.(),p?.()}),Domma.icons?.scan?.(c),l.open(),l}async function ua(o){const l=E.slideover({title:"Share preview link",size:"md",position:"right"}),c=ae(`<div>${ca}</div>`),s={expiry:M.observable("7d"),note:M.observable(""),creating:M.observable(!1),url:M.observable(""),links:M.observable([]),loaded:M.observable(!1),listError:M.observable("")},i=async()=>{try{const p=await v.pages.previewLinks.list(o);s.links.value=p.filter(r=>!r.revokedAt&&!r.expired).map(r=>({id:r.id,title:r.label||"Untitled link",meta:`Expires ${la(r.expiresAt)} - ${r.useCount===1?"1 view":`${r.useCount||0} views`}`})),s.listError.value=""}catch(p){s.listError.value="Could not load existing links: "+(p.message||"unknown error")}finally{s.loaded.value=!0}};s.create=async()=>{s.creating.value=!0;try{const p=await v.pages.previewLinks.create(o,s.expiry.peek(),s.note.peek().trim());s.url.value=p.url,s.note.value="",E.toast("Share link created.",{type:"success"}),await i(),c.querySelector(".pe-share-url")?.select()}catch(p){E.toast("Could not create link: "+(p.message||"unknown error"),{type:"error"})}finally{s.creating.value=!1}},s.copy=async()=>{try{await navigator.clipboard.writeText(s.url.peek()),E.toast("Link copied to clipboard.",{type:"success"})}catch{c.querySelector(".pe-share-url")?.select(),E.toast("Press Ctrl+C to copy the selected link.",{type:"info"})}},s.revoke=async p=>{if(await E.confirm("Revoke this link? Anyone holding it loses access immediately."))try{await v.pages.previewLinks.revoke(p),E.toast("Link revoked.",{type:"success"}),await i()}catch(r){E.toast("Could not revoke: "+(r.message||"unknown error"),{type:"error"})}};const m=M.applyBindings(s,c);Se(l,()=>m.dispose()),l.element.appendChild(c),l.open(),await i()}function ma(o){return new Promise(l=>{const c=E.slideover({title:"Save Named Version",size:"sm",position:"right"}),s=ae(`
|
|
72
72
|
<div style="padding:1rem">
|
|
73
73
|
<label class="form-label" for="pe-version-label">Version Label</label>
|
|
74
74
|
<input id="pe-version-label" type="text" class="form-input" style="margin-bottom:.75rem"
|
|
75
75
|
placeholder="e.g. Initial design, Before restructure" data-model="label.value" data-on-keydown="key">
|
|
76
76
|
<button type="button" class="btn btn-primary" data-on-click="save" data-bind-disabled="saving.value">Save Version</button>
|
|
77
|
-
</div>`);let
|
|
78
|
-
`).map(
|
|
77
|
+
</div>`);let i=!1;const m={label:M.observable(""),saving:M.observable(!1)};m.save=async()=>{m.saving.value=!0;try{await v.versions.create(o,m.label.peek().trim()||null),E.toast("Named version saved.",{type:"success"}),i=!0,c.close()}catch(r){E.toast("Failed to save version: "+r.message,{type:"error"})}finally{m.saving.value=!1}},m.key=r=>{r.key==="Enter"&&(r.preventDefault(),m.save())};const p=M.applyBindings(m,s);Se(c,()=>{p.dispose(),l(i)}),c.element.appendChild(s),c.open(),setTimeout(()=>s.querySelector("input")?.focus(),100)})}let k=null,st=!1;function fa(){st||!window.R?.use||(st=!0,R.use((o,l,c)=>{const s=l?.path||"",i=o?.path||"",m=Yt.test(s)&&i!==s;return m&&k?.path===s&&k.cloneTarget===i?(k.cloneTarget=null,k.keepDraft(),et(k.item),c()):!m||!k||k.path!==s||!k.dirty()?c():na(s,i)?(k.keepDraft(),et(k.item),c()):E.confirm("You have unsaved changes to this page. Leave anyway? They are kept as a draft for your next visit.").then(p=>{p?(k?.keepDraft(),c()):history.replaceState(null,"","#"+s)})}),M.subscribe("router:afterChange",({to:o}={})=>{if(k&&(o?.path||"")!==k.path){const l=k;k=null,l.dispose()}}))}function va(o){return o.split(`
|
|
78
|
+
`).map(l=>l.trimEnd()).join(`
|
|
79
79
|
`).replace(/\n{3,}/g,`
|
|
80
80
|
|
|
81
|
-
`)}const
|
|
81
|
+
`)}const ot="editor_prefs";function q(){return S.get(ot)||{viewMode:"split",fullscreen:!1}}function se(o,l){const c=q();c[o]=l,S.set(ot,c)}const ba=["edit","details","seo","dconfig","live","history"],nt=["split","write","preview"],ga=o=>o.replace(/<\/head>/i,"<style>body.dm-cloaked{opacity:1 !important;transition:none !important}</style></head>");function rt(o,l,c=()=>{}){let s=0;try{s=o.contentWindow.scrollY||0}catch{}o.addEventListener("load",()=>{try{o.contentWindow.scrollTo(0,s)}catch{}c()},{once:!0}),o.srcdoc=l}export const pageEditorView={templateUrl:"/admin/js/templates/page-editor.html",async onMount(o){if(fa(),k){const e=k;k=null,e.dispose()}const l=window.location.hash.replace(/^#/,""),c=[],s=e=>(c.push(e),e);let i=()=>!1,m=()=>{};const p=window.location.hash.match(/#\/pages\/edit(\/[^?]*)/),r=p?p[1]:null,d=!!r,C={path:l,item:{kind:"page",id:r||""},dirty:()=>i(),keepDraft:()=>m(),dispose:()=>{c.splice(0).forEach(e=>{try{e()}catch{}})}};k=C;const b=o.get(0),h=()=>k===C&&b.isConnected,g=E.loader(b,{type:"dots"}),[L,D,f,O,Ce,it,lt,H]=await Promise.all([v.layouts.get().catch(()=>({})),v.settings.get().catch(()=>({})),d?v.pages.get(r).catch(()=>null):null,v.pages.list().catch(()=>[]),v.projects.list().catch(()=>[]),v.pages.tags().catch(()=>[]),v.listTags.list("pages").then(e=>e.map(t=>t.name)).catch(()=>[]),v.blocks.list().catch(()=>[])]);if(!h()){g.destroy();return}if(g.destroy(),d&&!f){E.toast("Page not found.",{type:"error"}),R.navigate("/pages");return}const w=e=>b.querySelector(e);if(Bt(w("#field-theme"),{includeDefault:!0}),await Ea(w("#field-visibility"),f?.visibility),!h())return;const ct=wa(w("#field-layout"),L,f?.layout||"default");for(const e of Ce)w("#field-project").append(new Option(e.name||e.slug,e.slug));const oe=ra(H),K=q(),a={urlPath:M.observable(r||""),title:M.observable(f?.title||""),description:M.observable(f?.description||""),layout:M.observable(ct),status:M.observable(f?.status||"draft"),sortOrder:M.observable(String(f?.sortOrder??99)),showInNav:M.observable(!!f?.showInNav),sidebar:M.observable(!!f?.sidebar),breadcrumbs:M.observable(f?f.breadcrumbs!==!1:!0),bundled:M.observable(!!f?.bundled),tags:M.observable(Array.isArray(f?.tags)?f.tags:[]),adminTags:M.observable(Array.isArray(f?.adminTags)?f.adminTags:[]),category:M.observable(f?.category||""),visibility:M.observable(typeof f?.visibility=="string"&&f.visibility?f.visibility:"public"),project:M.observable(Zt({isEdit:d,page:f,projects:Ce,fromHash:_t()||"",urlPath:r||""})),theme:M.observable(f?.theme||""),seoTitle:M.observable(f?.seo?.title||""),seoDesc:M.observable(f?.seo?.description||""),dconfig:M.observable(f?.dconfig?JSON.stringify(f.dconfig,null,2):""),css:M.observable(""),cssOpened:M.observable(!1)},n={bodyRev:M.observable(0),base:M.observable(""),saving:M.observable(!1),tab:M.observable("edit"),viewMode:M.observable(nt.includes(K.viewMode)?K.viewMode:"split"),fullscreen:M.observable(!!K.fullscreen),cssMode:M.observable(!1),folded:M.observable(!1),liveStatus:M.observable("Showing unsaved changes"),liveWidth:M.observable("full"),versions:M.observable([]),histLoading:M.observable(!1),histError:M.observable(""),compare:M.observable(null),heldNote:M.observable("")},T=w("#markdown-editor"),F=w("#css-editor");T.value=f?.content||"";const dt=Ze(T),pt=Ze(F);s(()=>dt.destroy()),s(()=>pt.destroy());const N=document.createElement("div");N.className="editor-line-numbers editor-line-numbers--foldable",T.parentElement.insertBefore(N,T);const x=It(T,N,{onFoldChange:()=>{}});let z=!1;x.setGuard(e=>{z=e});const $=()=>x.getUnfoldedContent(),xe=M.observable(Q(K.type));s(M.effect(()=>{const e=xe.value,t=w("#editor-body");if(t){at(e.font);for(const[u,y]of Object.entries(Xe(e)))t.style.setProperty(u,y);t.classList.toggle("pe-nowrap",!e.wrap),T.wrap=e.wrap?"soft":"off",F.wrap=e.wrap?"soft":"off"}}));let Pe=null;s(()=>{try{Pe?.close()}catch{}});const B=()=>(n.bodyRev.value,{urlPath:a.urlPath.value.trim(),title:a.title.value,description:a.description.value,layout:a.layout.value,status:a.status.value,sortOrder:a.sortOrder.value,showInNav:a.showInNav.value,sidebar:a.sidebar.value,breadcrumbs:a.breadcrumbs.value,bundled:a.bundled.value,tags:a.tags.value,adminTags:a.adminTags.value,category:a.category.value,visibility:a.visibility.value,project:a.project.value,theme:a.theme.value,seoTitle:a.seoTitle.value,seoDesc:a.seoDesc.value,dconfig:a.dconfig.value,body:$(),css:a.cssOpened.value?a.css.value:null}),ut=M.computed(()=>j(B())!==n.base.value),mt=M.computed(()=>Te({urlPath:a.urlPath.value,dconfig:a.dconfig.value})[0]?.text||""),ft=M.computed(()=>n.versions.value.some(e=>e.checked.value)),vt=M.computed(()=>n.versions.value.length>0&&n.versions.value.every(e=>e.checked.value)),bt=M.computed(()=>{const e=n.versions.value.filter(t=>t.checked.value).length;return e>0&&e<n.versions.value.length});i=()=>j(B())!==n.base.peek();let Ae=JSON.stringify(a.adminTags.peek());const U=e=>({title:a.title.peek().trim()||"Untitled",description:a.description.peek().trim(),layout:a.layout.peek(),status:a.status.peek(),sortOrder:parseInt(String(a.sortOrder.peek()),10)||99,showInNav:a.showInNav.peek(),sidebar:a.sidebar.peek(),...a.breadcrumbs.peek()?{}:{breadcrumbs:!1},...a.bundled.peek()?{bundled:!0}:{},tags:a.tags.peek(),...d&&JSON.stringify(a.adminTags.peek())===Ae?{}:{adminTags:a.adminTags.peek()},category:a.category.peek().trim()||null,visibility:a.visibility.peek()||"public",project:a.project.peek()||"",seo:{title:a.seoTitle.peek().trim(),description:a.seoDesc.peek().trim()},dconfig:e,...a.theme.peek()?{theme:a.theme.peek()}:{}}),ne=()=>{const e=a.dconfig.peek().trim();if(!e)return null;try{return JSON.parse(e)}catch{return null}},Le=()=>a.urlPath.peek().trim()||r||"/";let re=[];const gt=Array.isArray(D?.footer?.blocks)?D.footer.blocks.filter(e=>typeof e=="string"):[],ht=new Map((Array.isArray(H)?H:[]).map(e=>[e.name,e.updatedAt])),ie=()=>{const e=ta([...new Set([...re,...gt])],ht),t=Object.keys(e),u=t.length?`Preview shows unsaved changes to ${t.join(", ")}`:"";return u!==n.heldNote.peek()&&(n.heldNote.value=u),e},_=w("#markdown-preview");let De="";v.settings.getCustomCss().then(({css:e})=>{De=e||""}).catch(()=>{});const Oe=()=>{const e=_?.contentDocument;if(!e?.head)return;const t=a.cssOpened.peek()?a.css.peek():"";if(!t)return;let u=e.getElementById("preview-custom-css");u||(u=e.createElement("style"),u.id="preview-custom-css",e.head.appendChild(u)),u.textContent=t};let le=null;const J=()=>{clearTimeout(le),le=setTimeout(async()=>{if(!(!_||n.viewMode.peek()==="write"))try{const{html:e}=await v.pages.previewFull(Le(),U(ne()),$(),ie());h()&&rt(_,ga(e),Oe)}catch(e){_.srcdoc='<!doctype html><meta charset="utf-8"><body style="font:14px system-ui;padding:1rem;color:#b91c1c;">Preview failed: '+String(e?.message||"unknown error").replace(/[<&]/g,t=>t==="<"?"<":"&")+"</body>"}},500)},Re=w("#live-preview-frame");let ce=!0,de=null,pe=!1;const ue=async()=>{if(!(pe||!Re)){pe=!0,n.liveStatus.value="Rendering\u2026";try{const{html:e}=await v.pages.previewFull(Le(),U(ne()),$(),ie());if(!h())return;rt(Re,e),ce=!1,n.liveStatus.value="Showing unsaved changes"}catch(e){n.liveStatus.value="Preview failed - "+(e?.message||"unknown error")}finally{pe=!1}}},Ne=()=>{ce=!0,n.tab.peek()==="live"&&(clearTimeout(de),de=setTimeout(ue,600))};s(()=>{clearTimeout(le),clearTimeout(de)});const me=Lt(o.find("#markdown-editor"),o.find("#editor-toolbar"),{spacerDefault:D?.layoutOptions?.spacerSize??40});Rt(T,{blocks:()=>oe});const fe=b.querySelector(".editor-toolbar-right");fe.querySelectorAll(".editor-view-btn[data-mode]").forEach(e=>{const t=e.getAttribute("data-mode");e.classList.remove("active"),e.setAttribute("data-bind-class",`viewMode.value === '${t}' && 'active'`),e.setAttribute("data-on-click",`setMode('${t}')`)}),w("#fullscreen-btn")?.setAttribute("data-on-click","toggleFullscreen");const yt=ae(`
|
|
82
82
|
<span class="pe-toolbar-extras" style="display:contents">
|
|
83
83
|
<button type="button" class="editor-view-btn" data-tip="Edit Custom CSS" data-on-click="toggleCss"
|
|
84
84
|
data-bind-class="cssMode.value && 'active'"><span>CSS</span></button>
|
|
@@ -90,5 +90,5 @@ The quick brown fox - 0O 1lI {} [] () => ==
|
|
|
90
90
|
<span class="pe-fold-icon" hidden data-bind-hidden="!folded.value"><span data-icon="chevrons-right" data-icon-size="10"></span></span>
|
|
91
91
|
</button>
|
|
92
92
|
<span class="editor-toolbar-sep"></span>
|
|
93
|
-
</span>`);de.prepend(pt);const Oe=e=>(Array.isArray(e)?e:[]).map(t=>({value:t,label:t})),De=w("#field-tags");let J=null;J=De?E.pillbox(De,{data:Oe(tt),value:a.tags.peek(),creatable:!0,searchable:!0,placeholder:"Add tag\u2026",onChange:()=>{J&&(a.tags.value=[...J.getValue()])}}):null;const Re=w("#field-admin-tags");let W=null;W=Re?E.pillbox(Re,{data:Oe(at),value:a.adminTags.peek(),creatable:!0,searchable:!0,placeholder:"Add admin tag\u2026",onChange:()=>{W&&(a.adminTags.value=[...W.getValue()])}}):null;let pe=!1,ue=null;const G=async()=>{pe=!0,n.histLoading.value=!0,n.histError.value="";try{const e=(await g.versions.list(d)).versions||[];if(!c())return;n.versions.value=e.map(t=>({...t,when:ee(t.createdAt),author:t.author||"",label:t.label||"",checked:M.observable(!1)}))}catch(e){n.histError.value="Failed to load versions: "+e.message}finally{n.histLoading.value=!1}},ve=e=>n.versions.peek().find(t=>t.filename===e),fe=async e=>{try{const t=await g.versions.get(d,e.filename);ue=e.filename,n.compare.value={label:Ge(e),old:t.content,current:z()}}catch(t){E.toast("Failed to load version: "+t.message,{type:"error"})}},Ne=async e=>{if(!(!e||!await E.confirm("Restore this version? The current page content will be overwritten (a pre-restore snapshot will be saved automatically).")))try{await g.versions.restore(d,e),E.toast("Page restored. Reloading editor\u2026",{type:"success"}),R.navigate("/pages"),setTimeout(()=>R.navigate(`/pages/edit${d}`),300)}catch(t){E.toast("Restore failed: "+t.message,{type:"error"})}},ut=async e=>{if(await E.confirm("Delete this version? This cannot be undone."))try{await g.versions.delete(d,e.filename),E.toast("Version deleted.",{type:"success"}),await G()}catch(t){E.toast("Failed to delete: "+t.message,{type:"error"})}},Fe=()=>{n.bodyRev.value=n.bodyRev.peek()+1},Ie=()=>{a.cssOpened.peek()||(a.css.value=xe,a.cssOpened.value=!0,ht(F))},Be=e=>{for(const t of["urlPath","title","description","layout","status","sortOrder","category","visibility","project","theme","seoTitle","seoDesc","dconfig"])a[t].value=e[t]==null?"":String(e[t]);for(const t of["showInNav","sidebar","breadcrumbs","bundled"])a[t].value=!!e[t];a.tags.value=Array.isArray(e.tags)?e.tags:[],J?.setValue(a.tags.peek()),Array.isArray(e.adminTags)&&(a.adminTags.value=e.adminTags),W?.setValue(a.adminTags.peek()),x.unfoldAll(),I=!0,k.value=e.body==null?"":String(e.body),I=!1,x.refresh(),e.css!=null&&(Ie(),a.css.value=String(e.css)),Fe()};n.base.value=V(D());const Y=Vt(d||"");v=()=>{if(r())try{S.set(Y,Ht(D(),n.base.peek()))}catch{}};const me=()=>{try{S.remove?S.remove(Y):S.set(Y,null)}catch{}},$e=e=>{r()&&(v(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",$e),s(()=>window.removeEventListener("beforeunload",$e));const ze=async()=>{if(n.saving.peek())return;const e=D(),t=we(e);if(t.length){E.toast(t[0].text,{type:"warning"}),p.querySelectorAll("#editor-meta-tabs .tab-item")[Qt.indexOf(t[0].field==="dconfig"?"dconfig":"details")]?.click();return}const f=(e.dconfig||"").trim(),y=K(f?JSON.parse(f):null),A=a.cssOpened.peek();n.saving.value=!0;try{if(h){const N={frontmatter:y,body:e.body};e.urlPath!==d&&(N.newUrlPath=e.urlPath),await g.pages.update(d,N)}else await g.pages.create({urlPath:e.urlPath,frontmatter:y,body:e.body});A&&await g.settings.saveCustomCss(a.css.peek()),E.toast(A?"Page and CSS saved.":"Page saved.",{type:"success"}),me(),Te=JSON.stringify(a.adminTags.peek()),n.base.value=V(D()),h?e.urlPath!==d&&R.navigate(`/pages/edit${e.urlPath}`):R.navigate("/pages")}catch(N){E.toast(`Save failed: ${N.message||"Unknown error"}`,{type:"error"})}finally{n.saving.value=!1}},je=p.querySelector(".dm-plugin-body")||p,vt={...a,...n,dirty:rt,problem:it,anySelected:lt,allSelected:ct,someSelected:dt,isEdit:h,savedPath:d||"",hasPages:Ee.length>0},ft={save:()=>ze(),discard:async()=>{!r()||!await E.confirm("Discard the changes you have not saved?")||(Be(JSON.parse(n.base.peek())),me())},switchPage:e=>{const t=e?.target?.value;t&&R.navigate("/pages/edit"+t)},showTab:e=>{n.tab.value=e,e==="live"&&ne&&le(),e==="history"&&h&&!pe&&G()},setMode:e=>{n.cssMode.peek()&&(n.cssMode.value=!1),n.viewMode.value=e,ae("viewMode",e),e!=="write"&&_()},toggleFullscreen:()=>{n.fullscreen.value=!n.fullscreen.peek(),ae("fullscreen",n.fullscreen.peek())},toggleCss:()=>{n.cssMode.peek()?(n.cssMode.value=!1,n.viewMode.value=Qe.includes(q().viewMode)?q().viewMode:"split",x.refresh()):(Ie(),n.cssMode.value=!0,n.viewMode.value="split")},formatMarkdown:()=>{const e=k.selectionStart,t=Zt(z());x.unfoldAll(),I=!0,k.value=t,I=!1,k.selectionStart=k.selectionEnd=Math.min(e,t.length),k.dispatchEvent(new Event("input")),x.refresh(),E.toast("Markdown formatted.",{type:"success"})},toggleFold:()=>{n.folded.peek()?x.unfoldAll():x.foldAll(),n.folded.value=!n.folded.peek()},setWidth:e=>{n.liveWidth.value=e;const t=w("#live-preview-stage");t&&(t.scrollTop=0)},refreshLive:()=>le(),share:async()=>{if(!h){E.toast("Save the page first - a share link needs a page to point at.",{type:"warning"});return}await Gt(d)},toggleAll:e=>{const t=!!e?.target?.checked;n.versions.peek().forEach(f=>{f.checked.value=t})},deleteSelected:async()=>{const e=n.versions.peek().filter(f=>f.checked.peek()).map(f=>f.filename),t=e.length;if(!(!t||!await E.confirm(`Delete ${t} version${t>1?"s":""}? This cannot be undone.`)))try{await g.versions.bulkDelete(d,e),E.toast(`${t} version${t>1?"s":""} deleted.`,{type:"success"}),await G()}catch(f){E.toast("Delete failed: "+f.message,{type:"error"})}},saveNamed:async()=>{await Yt(d)&&c()&&(n.tab.peek()==="history"?await G():pe=!1)},closeCompare:()=>{n.compare.value=null,ue=null},restoreCompared:()=>Ne(ue)};de.querySelectorAll("[data-tip]").forEach(e=>E.tooltip(e,{content:e.getAttribute("data-tip"),position:"top"}));const mt=M.applyBindings(vt,je,{methods:ft});s(()=>mt.dispose()),E.tabs(w("#editor-meta-tabs"));let ge=null;s(()=>clearTimeout(ge)),k.addEventListener("input",()=>{x.refresh(),O.scrollTop=k.scrollTop,!I&&(clearTimeout(ge),ge=setTimeout(Fe,120),_(),Le())}),k.addEventListener("scroll",()=>{O.scrollTop=k.scrollTop}),F.addEventListener("scroll",()=>{O.scrollTop=F.scrollTop});let Ve=!0;s(M.effect(()=>{for(const e of Object.keys(a))e!=="css"&&e!=="cssOpened"&&a[e].value;if(n.bodyRev.value,Ve){Ve=!1,_();return}_(),Le()}));let be=null;if(s(()=>clearTimeout(be)),s(M.effect(()=>{const e=a.css.value;n.cssMode.value&&(ta(O,e,F),clearTimeout(be),be=setTimeout(Pe,400))})),h){const e=w(".pe-version-list"),t=E.contextMenu(e,{cursor:"glyph",match:"tr[data-file]",inherit:!1,density:"compact",items:f=>{const y=ve(f.dataset.file);return y?[{header:Ge(y)},{label:"Compare with the editor",icon:"columns",action:()=>fe(y)},{label:"Restore this version\u2026",icon:"rotate-ccw",action:()=>Ne(y.filename)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>ut(y)}]:[]}});s(()=>t?.destroy?.()),e.addEventListener("click",f=>{const y=f.target.closest("tr[data-file]");if(!y||f.target.matches("input"))return;const A=f.target.closest(".pe-kebab");if(A){Ue(A);return}const N=ve(y.dataset.file);N&&fe(N)}),e.addEventListener("keydown",f=>{const y=f.target.closest("tr[data-file]");if(y){if(f.key==="F10"&&f.shiftKey)f.preventDefault(),Ue(y);else if(f.key==="Enter"&&f.target===y){const A=ve(y.dataset.file);A&&fe(A)}}})}aa(w("#editor-body"),w("#editor-divider"),s),Mt({image:e=>(n.cssMode.value=!1,sa(e)),scribe:e=>Ct(e),card:e=>gt.open(e,{onMediaBrowse:()=>g.media.list().catch(()=>[])}),timeline:e=>bt.open(e,{onPreview:async t=>{try{return await g.pages.preview(t)}catch{return null}},onListCollections:()=>g.collections.list().catch(()=>[]),onGetCollection:t=>g.collections.get(t).catch(()=>null)})}),ce.onEffects(e=>kt.open(e)),ce.onHelp(()=>_e()),ce.onTutorial(e=>xt(e));const qe=e=>{(e.ctrlKey||e.metaKey)&&!e.shiftKey&&!e.altKey&&e.key.toLowerCase()==="s"&&c()&&(e.preventDefault(),ze())};document.addEventListener("keydown",qe),s(()=>document.removeEventListener("keydown",qe));const X=p.querySelector(".dm-plugin-banner-name");s(M.effect(()=>{const e=zt(h,a.title.value,d);if(!c()||(document.title=`${e} - Domma CMS`,!X))return;const t=X.lastChild;t&&t.nodeType===3&&X.querySelector(".dm-plugin-banner-sep")?t.textContent=e:X.textContent=`Pages \u203A ${e}`})),j(o,{id:"page-editor-back",icon:"arrow-left",title:"Back to Pages",onClick:()=>R.navigate("/pages")}),j(o,{id:"page-editor-help",icon:"help-circle",title:"Editor reference",onClick:()=>_e()}),h&&j(o,{id:"page-editor-view",icon:"external-link",title:"View on the site",onClick:()=>window.open(a.urlPath.peek()||d,"_blank","noopener")});const he=j(o,{id:"page-editor-clone",icon:"copy",title:"Clone page",onClick:()=>{if(!h||n.saving.peek()||!c())return;const e=D(),t=we({...e,urlPath:d});if(t.length){E.toast(t[0].text,{type:"warning"});return}const f=At({page:m,frontmatter:{...K(se()),adminTags:a.adminTags.peek()},body:e.body,source:d,pages:P,onCloned:y=>{c()&&(u.cloneTarget=`/pages/edit${y}`,R.navigate(u.cloneTarget))}});s(()=>f.close())}});he&&(he.disabled=!h,h||(he.title="Save the page before cloning it")),j(o,{id:"page-editor-settings",icon:"settings",title:"Editor settings - font, size, spacing",onClick:()=>{Se=Wt(Me)}}),s(Ke(je,{position:"bottom"})),Domma.icons.scan();let He=null;try{He=S.get(Y)}catch{}const B=Kt(He);if(B&&(me(),V(B.state)!==n.base.peek())){const e=B.at?` (${ee(B.at)})`:"",t=jt(B,n.base.peek())?" The saved page has changed since - restoring puts your version back over it (the saved one stays in History).":"";await E.confirm(`You left unsaved changes to this page${e}. Restore them?${t}`)&&c()&&Be(B.state)}}};function ta(o,i,l){o.replaceChildren(...String(i).split(`
|
|
94
|
-
`).map((s,
|
|
93
|
+
</span>`);fe.prepend(yt);const Be=e=>(Array.isArray(e)?e:[]).map(t=>({value:t,label:t})),Ie=w("#field-tags");let W=null;W=Ie?E.pillbox(Ie,{data:Be(it),value:a.tags.peek(),creatable:!0,searchable:!0,placeholder:"Add tag\u2026",onChange:()=>{W&&(a.tags.value=[...W.getValue()])}}):null;const Fe=w("#field-admin-tags");let Y=null;Y=Fe?E.pillbox(Fe,{data:Be(lt),value:a.adminTags.peek(),creatable:!0,searchable:!0,placeholder:"Add admin tag\u2026",onChange:()=>{Y&&(a.adminTags.value=[...Y.getValue()])}}):null;let ve=!1,be=null;const G=async()=>{ve=!0,n.histLoading.value=!0,n.histError.value="";try{const e=(await v.versions.list(r)).versions||[];if(!h())return;n.versions.value=e.map(t=>({...t,when:te(t.createdAt),author:t.author||"",label:t.label||"",checked:M.observable(!1)}))}catch(e){n.histError.value="Failed to load versions: "+e.message}finally{n.histLoading.value=!1}},ge=e=>n.versions.peek().find(t=>t.filename===e),he=async e=>{try{const t=await v.versions.get(r,e.filename);be=e.filename,n.compare.value={label:tt(e),old:t.content,current:$()}}catch(t){E.toast("Failed to load version: "+t.message,{type:"error"})}},ze=async e=>{if(!(!e||!await E.confirm("Restore this version? The current page content will be overwritten (a pre-restore snapshot will be saved automatically).")))try{await v.versions.restore(r,e),E.toast("Page restored. Reloading editor\u2026",{type:"success"}),R.navigate("/pages"),setTimeout(()=>R.navigate(`/pages/edit${r}`),300)}catch(t){E.toast("Restore failed: "+t.message,{type:"error"})}},kt=async e=>{if(await E.confirm("Delete this version? This cannot be undone."))try{await v.versions.delete(r,e.filename),E.toast("Version deleted.",{type:"success"}),await G()}catch(t){E.toast("Failed to delete: "+t.message,{type:"error"})}},$e=()=>{n.bodyRev.value=n.bodyRev.peek()+1},je=()=>{a.cssOpened.peek()||(a.css.value=De,a.cssOpened.value=!0,At(F))},ye=e=>{for(const t of["urlPath","title","description","layout","status","sortOrder","category","visibility","project","theme","seoTitle","seoDesc","dconfig"])a[t].value=e[t]==null?"":String(e[t]);for(const t of["showInNav","sidebar","breadcrumbs","bundled"])a[t].value=!!e[t];a.tags.value=Array.isArray(e.tags)?e.tags:[],W?.setValue(a.tags.peek()),Array.isArray(e.adminTags)&&(a.adminTags.value=e.adminTags),Y?.setValue(a.adminTags.peek()),x.unfoldAll(),z=!0,T.value=e.body==null?"":String(e.body),z=!1,x.refresh(),e.css!=null&&(je(),a.css.value=String(e.css)),$e()};n.base.value=j(B());const X=Xt(r||"");m=()=>{if(i())try{S.set(X,Qt(B(),n.base.peek()))}catch{}};const ke=()=>{try{S.remove?S.remove(X):S.set(X,null)}catch{}},Ve=e=>{i()&&(m(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ve),s(()=>window.removeEventListener("beforeunload",Ve));const qe=async()=>{if(n.saving.peek())return;const e=B(),t=Te(e);if(t.length){E.toast(t[0].text,{type:"warning"}),b.querySelectorAll("#editor-meta-tabs .tab-item")[ba.indexOf(t[0].field==="dconfig"?"dconfig":"details")]?.click();return}const u=(e.dconfig||"").trim(),y=U(u?JSON.parse(u):null),A=a.cssOpened.peek();n.saving.value=!0;try{if(d){const I={frontmatter:y,body:e.body};e.urlPath!==r&&(I.newUrlPath=e.urlPath),await v.pages.update(r,I)}else await v.pages.create({urlPath:e.urlPath,frontmatter:y,body:e.body});if(A&&await v.settings.saveCustomCss(a.css.peek()),E.toast(A?"Page and CSS saved.":"Page saved.",{type:"success"}),!h())return;ke(),d&&sa({kind:"page",id:e.urlPath,label:a.title.peek().trim()||"Untitled"}),Ae=JSON.stringify(a.adminTags.peek()),n.base.value=j(B()),d?e.urlPath!==r&&R.navigate(`/pages/edit${e.urlPath}`):R.navigate("/pages")}catch(I){E.toast(`Save failed: ${I.message||"Unknown error"}`,{type:"error"})}finally{n.saving.value=!1}},He=b.querySelector(".dm-plugin-body")||b,wt={...a,...n,dirty:ut,problem:mt,anySelected:ft,allSelected:vt,someSelected:bt,isEdit:d,savedPath:r||""},Et={save:()=>qe(),discard:async()=>{!i()||!await E.confirm("Discard the changes you have not saved?")||(ye(JSON.parse(n.base.peek())),ke())},showTab:e=>{n.tab.value=e,e==="live"&&ce&&ue(),e==="history"&&d&&!ve&&G()},setMode:e=>{n.cssMode.peek()&&(n.cssMode.value=!1),n.viewMode.value=e,se("viewMode",e),e!=="write"&&J()},toggleFullscreen:()=>{n.fullscreen.value=!n.fullscreen.peek(),se("fullscreen",n.fullscreen.peek())},toggleCss:()=>{n.cssMode.peek()?(n.cssMode.value=!1,n.viewMode.value=nt.includes(q().viewMode)?q().viewMode:"split",x.refresh()):(je(),n.cssMode.value=!0,n.viewMode.value="split")},formatMarkdown:()=>{const e=T.selectionStart,t=va($());x.unfoldAll(),z=!0,T.value=t,z=!1,T.selectionStart=T.selectionEnd=Math.min(e,t.length),T.dispatchEvent(new Event("input")),x.refresh(),E.toast("Markdown formatted.",{type:"success"})},toggleFold:()=>{n.folded.peek()?x.unfoldAll():x.foldAll(),n.folded.value=!n.folded.peek()},setWidth:e=>{n.liveWidth.value=e;const t=w("#live-preview-stage");t&&(t.scrollTop=0)},refreshLive:()=>ue(),share:async()=>{if(!d){E.toast("Save the page first - a share link needs a page to point at.",{type:"warning"});return}await ua(r)},toggleAll:e=>{const t=!!e?.target?.checked;n.versions.peek().forEach(u=>{u.checked.value=t})},deleteSelected:async()=>{const e=n.versions.peek().filter(u=>u.checked.peek()).map(u=>u.filename),t=e.length;if(!(!t||!await E.confirm(`Delete ${t} version${t>1?"s":""}? This cannot be undone.`)))try{await v.versions.bulkDelete(r,e),E.toast(`${t} version${t>1?"s":""} deleted.`,{type:"success"}),await G()}catch(u){E.toast("Delete failed: "+u.message,{type:"error"})}},saveNamed:async()=>{await ma(r)&&h()&&(n.tab.peek()==="history"?await G():ve=!1)},closeCompare:()=>{n.compare.value=null,be=null},restoreCompared:()=>ze(be)};fe.querySelectorAll("[data-tip]").forEach(e=>E.tooltip(e,{content:e.getAttribute("data-tip"),position:"top"}));const Mt=M.applyBindings(wt,He,{methods:Et});s(()=>Mt.dispose());const Ke=aa(w("#page-hop"),{current:{kind:"page",id:r||"",label:f?.title||r||""},pages:O,blocks:H});s(()=>Ke.dispose()),E.tabs(w("#editor-meta-tabs"));let we=null;s(()=>clearTimeout(we)),T.addEventListener("input",()=>{x.refresh(),N.scrollTop=T.scrollTop,!z&&(clearTimeout(we),we=setTimeout($e,120),J(),Ne())}),T.addEventListener("scroll",()=>{N.scrollTop=T.scrollTop}),F.addEventListener("scroll",()=>{N.scrollTop=F.scrollTop});let Ue=!0;s(M.effect(()=>{for(const e of Object.keys(a))e!=="css"&&e!=="cssOpened"&&a[e].value;if(n.bodyRev.value,Ue){Ue=!1,J();return}J(),Ne()}));const St=oe.map(e=>e.name),Tt=oe.filter(e=>e.shortTag).map(e=>e.name);s(M.effect(()=>{n.bodyRev.value,re=ia($(),St,Tt),Ke.setRelated(re.map(e=>({kind:"block",id:e,label:e}))),ie()}));let Ee=null;if(s(()=>clearTimeout(Ee)),s(M.effect(()=>{const e=a.css.value;n.cssMode.value&&(ha(N,e,F),clearTimeout(Ee),Ee=setTimeout(Oe,400))})),d){const e=w(".pe-version-list"),t=E.contextMenu(e,{cursor:"glyph",match:"tr[data-file]",inherit:!1,density:"compact",items:u=>{const y=ge(u.dataset.file);return y?[{header:tt(y)},{label:"Compare with the editor",icon:"columns",action:()=>he(y)},{label:"Restore this version\u2026",icon:"rotate-ccw",action:()=>ze(y.filename)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>kt(y)}]:[]}});s(()=>t?.destroy?.()),e.addEventListener("click",u=>{const y=u.target.closest("tr[data-file]");if(!y||u.target.matches("input"))return;const A=u.target.closest(".pe-kebab");if(A){Ye(A);return}const I=ge(y.dataset.file);I&&he(I)}),e.addEventListener("keydown",u=>{const y=u.target.closest("tr[data-file]");if(y){if(u.key==="F10"&&u.shiftKey)u.preventDefault(),Ye(y);else if(u.key==="Enter"&&u.target===y){const A=ge(y.dataset.file);A&&he(A)}}})}ya(w("#editor-body"),w("#editor-divider"),s),Nt({image:e=>(n.cssMode.value=!1,ka(e)),scribe:e=>Ft(e),card:e=>xt.open(e,{onMediaBrowse:()=>v.media.list().catch(()=>[])}),timeline:e=>Pt.open(e,{onPreview:async t=>{try{return await v.pages.preview(t)}catch{return null}},onListCollections:()=>v.collections.list().catch(()=>[]),onGetCollection:t=>v.collections.get(t).catch(()=>null)})}),me.onEffects(e=>Ot.open(e)),me.onHelp(()=>Ge()),me.onTutorial(e=>zt(e));const _e=e=>{(e.ctrlKey||e.metaKey)&&!e.shiftKey&&!e.altKey&&e.key.toLowerCase()==="s"&&h()&&(e.preventDefault(),qe())};document.addEventListener("keydown",_e),s(()=>document.removeEventListener("keydown",_e));const Z=b.querySelector(".dm-plugin-banner-name");s(M.effect(()=>{const e=Gt(d,a.title.value,r);if(!h()||(document.title=`${e} - Domma CMS`,!Z))return;const t=Z.lastChild;t&&t.nodeType===3&&Z.querySelector(".dm-plugin-banner-sep")?t.textContent=e:Z.textContent=`Pages \u203A ${e}`})),V(o,{id:"page-editor-back",icon:"arrow-left",title:"Back to Pages",onClick:()=>R.navigate("/pages")}),V(o,{id:"page-editor-help",icon:"help-circle",title:"Editor reference",onClick:()=>Ge()}),d&&V(o,{id:"page-editor-view",icon:"external-link",title:"View on the site",onClick:()=>window.open(a.urlPath.peek()||r,"_blank","noopener")});const Me=V(o,{id:"page-editor-clone",icon:"copy",title:"Clone page",onClick:()=>{if(!d||n.saving.peek()||!h())return;const e=B(),t=Te({...e,urlPath:r});if(t.length){E.toast(t[0].text,{type:"warning"});return}const u=jt({page:f,frontmatter:{...U(ne()),adminTags:a.adminTags.peek()},body:e.body,source:r,pages:O,onCloned:y=>{h()&&(C.cloneTarget=`/pages/edit${y}`,R.navigate(C.cloneTarget))}});s(()=>u.close())}});Me&&(Me.disabled=!d,d||(Me.title="Save the page before cloning it")),V(o,{id:"page-editor-settings",icon:"settings",title:"Editor settings - font, size, spacing",onClick:()=>{Pe=pa(xe)}}),s(We(He,{position:"bottom"})),Domma.icons.scan();let Je=null;try{Je=S.get(X)}catch{}const P=ea(Je),Ct=oa(C.item);if(P){if(ke(),j(P.state)!==n.base.peek()&&Ct&&!Qe(P,n.base.peek()))ye(P.state),E.toast("Your unsaved changes to this page are back.",{type:"info"});else if(j(P.state)!==n.base.peek()){const e=P.at?` (${te(P.at)})`:"",t=Qe(P,n.base.peek())?" The saved page has changed since - restoring puts your version back over it (the saved one stays in History).":"";await E.confirm(`You left unsaved changes to this page${e}. Restore them?${t}`)&&h()&&ye(P.state)}}}};function ha(o,l,c){o.replaceChildren(...String(l).split(`
|
|
94
|
+
`).map((s,i)=>{const m=document.createElement("span");m.className="editor-line-number-row";const p=document.createElement("span");p.className="fold-toggle fold-toggle--empty";const r=document.createElement("span");return r.className="editor-line-num",r.textContent=String(i+1),m.append(p,r),m})),o.scrollTop=c.scrollTop}function ya(o,l,c){if(!o||!l)return;const s=15,i=85,m=b=>Math.min(i,Math.max(s,b));let p=m(parseFloat(q().splitRatio)||50);const r=b=>{o.style.setProperty("--dm-editor-split",p+"%"),l.setAttribute("aria-valuenow",String(Math.round(p))),b&&se("splitRatio",p)};r(!1);const d=b=>{const h=o.getBoundingClientRect();h.width&&(p=m((b.clientX-h.left)/h.width*100)),r(!1)},C=()=>{document.removeEventListener("pointermove",d),document.removeEventListener("pointerup",C),o.classList.remove("is-resizing"),l.classList.remove("is-dragging"),r(!0)};c(C),l.addEventListener("pointerdown",b=>{window.matchMedia("(max-width: 768px)").matches||(b.preventDefault(),o.classList.add("is-resizing"),l.classList.add("is-dragging"),document.addEventListener("pointermove",d),document.addEventListener("pointerup",C))}),l.addEventListener("dblclick",()=>{p=50,r(!0)}),l.addEventListener("keydown",b=>{const h=b.shiftKey?10:2;let g=p;if(b.key==="ArrowLeft")g=p-h;else if(b.key==="ArrowRight")g=p+h;else if(b.key==="Home")g=s;else if(b.key==="End")g=i;else if(b.key==="Enter"||b.key===" ")g=50;else return;b.preventDefault(),p=m(g),r(!0)})}async function ka(o){const l=o.selectionStart,c=o.selectionEnd,s=await $t({title:"Upload or insert an image"});if(!s||!o.isConnected)return;let i=s.split("/").pop().split("?")[0];try{i=decodeURIComponent(i)}catch{}const m=i.replace(/[\\[\]]/g,"\\$&"),p=s.replace(/\(/g,"%28").replace(/\)/g,"%29").replace(/ /g,"%20");o.setSelectionRange(l,c),Dt(o,``),o.dispatchEvent(new Event("input",{bubbles:!0}))}function wa(o,l,c){const s=Object.entries(l||{});return o?(o.replaceChildren(...s.map(([i,m])=>new Option(m?.label||i,i))),s.some(([i])=>i===c)?c:s[0]?.[0]||c):c}async function Ea(o,l){if(!o)return;let c=[];try{const d=await v.get("/collections/roles/entries?limit=100");c=Array.isArray(d)?d:d?.entries||[]}catch{}const s=c.map(d=>({name:d.data?.name,label:d.data?.label||d.data?.name,level:d.data?.level??99})).filter(d=>d.name&&d.level!==0).sort((d,C)=>C.level-d.level),i=(d,C)=>Object.assign(document.createElement("option"),{value:d,textContent:C}),m=()=>{typeof l=="string"&&l&&![...o.options].some(d=>d.value===l)&&o.append(i(l,`${l} (custom)`))};if(!s.length)return m();const p=document.createElement("optgroup");p.label="Role and above",s.forEach(d=>p.append(i(d.name,`${d.label} and above`)));const r=document.createElement("optgroup");r.label="Role only",s.forEach(d=>r.append(i(`=${d.name}`,`${d.label} only`))),o.replaceChildren(i("public","Public - everyone"),p,r,i("private","Private - super-admin only")),m()}
|
|
@@ -1264,6 +1264,23 @@ Short tags are self-closing only, and only work for block names made of lowercas
|
|
|
1264
1264
|
do not clash with a built-in shortcode name (a block called `card` or `menu`, for example, can only be used through
|
|
1265
1265
|
`[block template="…"]`).
|
|
1266
1266
|
|
|
1267
|
+
**In the page editor:** right-click and choose **Add block…** to insert any block with all its parameters written
|
|
1268
|
+
out (`[cta-banner title="" tagline="" … /]`), the cursor in the first one. Right-clicking inside a block's tag - the
|
|
1269
|
+
short tag or `[block template="…"]` - lists that block's own parameters to fill in there and then; a parameter the
|
|
1270
|
+
block's doc comment writes as `dark | light | gold` is offered as a choice. **Edit parameters…** opens them in a
|
|
1271
|
+
form, and **Open the block** goes to the block editor. The parameters come from the template's `{{placeholders}}`;
|
|
1272
|
+
the hints and choices from the "Parameters:" list in the block's opening comment:
|
|
1273
|
+
|
|
1274
|
+
```
|
|
1275
|
+
<!--
|
|
1276
|
+
cta-banner - Reusable statement call-to-action block.
|
|
1277
|
+
|
|
1278
|
+
Parameters (all optional):
|
|
1279
|
+
title - Heading text (default: empty)
|
|
1280
|
+
variant - dark | light | gold (default: dark)
|
|
1281
|
+
-->
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1267
1284
|
**Example:**
|
|
1268
1285
|
|
|
1269
1286
|
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Blocks API
|
|
3
3
|
* CRUD for reusable HTML block templates stored in content/blocks/*.html.
|
|
4
|
-
* GET /api/blocks - list all blocks (each with hasCss, description, example
|
|
4
|
+
* GET /api/blocks - list all blocks (each with hasCss, description, example,
|
|
5
|
+
* fields (its parameters) and shortTag;
|
|
5
6
|
* ?usage=1 adds where each is used: pages and views)
|
|
6
7
|
* GET /api/blocks/_count - the sidebar badge (`_` is outside the block-name alphabet)
|
|
7
8
|
* GET /api/blocks/:name - get block content
|
|
@@ -16,6 +17,7 @@ import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
|
16
17
|
import {deleteBlock, getBlock, listBlocks, saveBlock} from '../../services/blocks.js';
|
|
17
18
|
import {config, getConfig} from '../../config.js';
|
|
18
19
|
import {fmtDate} from '../../services/dates.js';
|
|
20
|
+
import {BUILTIN_SHORTCODES} from '../../services/markdown.js';
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* A block's own doc comment - the one every block opens with - read for the
|
|
@@ -59,6 +61,58 @@ export function blockDoc(html, name = '') {
|
|
|
59
61
|
return {description: description.slice(0, 200), example: example.slice(0, 300)};
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The parameters a block takes, for the page editor's short-tag menu: every
|
|
66
|
+
* `{{field}}` / `{{field:FORMAT}}` / `{{{field}}}` the template uses (outside
|
|
67
|
+
* HTML comments, which the renderer strips), in order, with what the doc
|
|
68
|
+
* comment's "Parameters" list says about each - `name - hint (default: x)`,
|
|
69
|
+
* where a hint written `a | b | c` is a choice. Fields starting `_` are
|
|
70
|
+
* collection meta (`_createdAt`), never set from a tag, so they are left out.
|
|
71
|
+
* Plain text; a field the doc does not mention gets no hint.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} html
|
|
74
|
+
* @returns {{name: string, raw: boolean, hint: string, default: string, options: string[]}[]}
|
|
75
|
+
*/
|
|
76
|
+
export function blockFields(html) {
|
|
77
|
+
const src = String(html || '');
|
|
78
|
+
const docs = new Map();
|
|
79
|
+
const m = /^\s*<!--([\s\S]*?)-->/.exec(src);
|
|
80
|
+
if (m) {
|
|
81
|
+
const lines = m[1].split('\n').map(l => l.trim());
|
|
82
|
+
const at = lines.findIndex(l => /^parameters\b.*:$/i.test(l));
|
|
83
|
+
for (let i = at + 1; at >= 0 && i < lines.length && lines[i]; i++) {
|
|
84
|
+
const p = /^([\w]+)\s+[-–]\s+(.*)$/.exec(lines[i]);
|
|
85
|
+
if (!p) continue;
|
|
86
|
+
let hint = p[2].trim();
|
|
87
|
+
let def = '';
|
|
88
|
+
const d = /\s*\(default:\s*([^)]*)\)\s*$/i.exec(hint);
|
|
89
|
+
if (d) {
|
|
90
|
+
hint = hint.slice(0, d.index).trim();
|
|
91
|
+
def = d[1].trim().replace(/^"(.*)"$/, '$1');
|
|
92
|
+
if (/^empty$/i.test(def)) def = '';
|
|
93
|
+
}
|
|
94
|
+
const options = /^[\w-]+(?:\s*\|\s*[\w-]+)+$/.test(hint) ? hint.split('|').map(s => s.trim()) : [];
|
|
95
|
+
docs.set(p[1], {hint: hint.slice(0, 160), default: def.slice(0, 120), options});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const body = src.replace(/<!--[\s\S]*?-->/g, '');
|
|
99
|
+
const out = new Map();
|
|
100
|
+
for (const [, rawKey, key] of body.matchAll(/\{\{\{(\w+)\}\}\}|\{\{([\w_]+)(?::[^{}]+)?\}\}/g)) {
|
|
101
|
+
const name = rawKey ?? key;
|
|
102
|
+
if (name.startsWith('_')) continue;
|
|
103
|
+
const had = out.get(name);
|
|
104
|
+
if (had) { had.raw = had.raw || rawKey !== undefined; continue; }
|
|
105
|
+
out.set(name, {name, raw: rawKey !== undefined, hint: '', default: '', options: [], ...docs.get(name)});
|
|
106
|
+
}
|
|
107
|
+
// The doc's order where it has one (it is written in order of importance), then the rest as used.
|
|
108
|
+
const rank = [...docs.keys()];
|
|
109
|
+
const at = (n) => (rank.includes(n) ? rank.indexOf(n) : rank.length);
|
|
110
|
+
return [...out.values()].sort((a, b) => at(a.name) - at(b.name)).slice(0, 60);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A block name the page can use as its own tag (`[name … /]`) - not one a built-in shortcode owns. */
|
|
114
|
+
export const isShortTag = (name) => /^[a-z0-9][a-z0-9-]*$/.test(name) && !BUILTIN_SHORTCODES.has(name);
|
|
115
|
+
|
|
62
116
|
/** Escape a block name for a RegExp. Names are [a-z0-9-] so only `-` needs care. */
|
|
63
117
|
const reName = (n) => n.replace(/-/g, '\\-');
|
|
64
118
|
|
|
@@ -173,7 +227,8 @@ export async function blocksRoutes(fastify) {
|
|
|
173
227
|
let full = null;
|
|
174
228
|
try { full = await getBlock(b.name); } catch { /* skip */ }
|
|
175
229
|
if (!canSeeArtefact(user, full)) continue;
|
|
176
|
-
out.push({...b, hasCss: Boolean(full?.css), cssSize: full?.css?.length || 0, ...blockDoc(full?.content, b.name)
|
|
230
|
+
out.push({...b, hasCss: Boolean(full?.css), cssSize: full?.css?.length || 0, ...blockDoc(full?.content, b.name),
|
|
231
|
+
fields: blockFields(full?.content), shortTag: isShortTag(b.name)});
|
|
177
232
|
}
|
|
178
233
|
return out;
|
|
179
234
|
}
|
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
* PUT /api/pages/* - update page
|
|
8
8
|
* DELETE /api/pages/* - delete page
|
|
9
9
|
* POST /api/pages/preview - render markdown body to HTML (no page shell)
|
|
10
|
-
* POST /api/pages/preview/full - render UNSAVED frontmatter+body as a complete page
|
|
10
|
+
* POST /api/pages/preview/full - render UNSAVED frontmatter+body as a complete page;
|
|
11
|
+
* `blocks` {name: {content, css}} = unsaved block edits to use
|
|
11
12
|
* GET /api/pages/preview-links - list share links (optionally ?urlPath=)
|
|
12
13
|
* POST /api/pages/preview-links - mint a share link for one page
|
|
13
14
|
* DELETE /api/pages/preview-links/:id - revoke a share link
|
|
14
15
|
*/
|
|
15
16
|
import {buildPreviewPage, createPage, deletePage, getPage, listPages, renamePage, updatePage} from '../../services/content.js';
|
|
16
|
-
import {parseMarkdown} from '../../services/markdown.js';
|
|
17
|
+
import {parseMarkdown, readBlockOverrides, withBlockOverrides} from '../../services/markdown.js';
|
|
17
18
|
import {renderPage} from '../../services/renderer.js';
|
|
18
19
|
import {
|
|
19
20
|
createPreviewLink,
|
|
@@ -97,11 +98,18 @@ export async function pagesRoutes(fastify) {
|
|
|
97
98
|
return reply.status(400).send({error: 'frontmatter must be an object'});
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
// Unsaved block edits (the block editor's "show on this page", a page editor with
|
|
102
|
+
// block changes held): this render only. The output is sanitised as any page is.
|
|
103
|
+
const overrides = readBlockOverrides(request.body?.blocks);
|
|
104
|
+
if (overrides.error) return reply.status(400).send({error: overrides.error});
|
|
105
|
+
|
|
106
|
+
const html = await withBlockOverrides(overrides.blocks, async () => {
|
|
107
|
+
const page = await buildPreviewPage(urlPath, frontmatter, body, {user: request.user || null});
|
|
108
|
+
return renderPage(page, {
|
|
109
|
+
baseUrl: `${request.protocol}://${request.host}`,
|
|
110
|
+
user: request.user || null,
|
|
111
|
+
noTrack: true
|
|
112
|
+
});
|
|
105
113
|
});
|
|
106
114
|
return {html};
|
|
107
115
|
});
|
|
@@ -8,6 +8,7 @@ import matter from 'gray-matter';
|
|
|
8
8
|
import {marked} from 'marked';
|
|
9
9
|
import sanitizeHtml from 'sanitize-html';
|
|
10
10
|
import {readdir, readFile} from 'fs/promises';
|
|
11
|
+
import {AsyncLocalStorage} from 'node:async_hooks';
|
|
11
12
|
import path from 'path';
|
|
12
13
|
import {fileURLToPath} from 'url';
|
|
13
14
|
import {applyTransforms, getSanitizeExtensions, getShortcodeProcessors} from './hooks.js';
|
|
@@ -141,6 +142,49 @@ function buildShortcodeFilter(attrs) {
|
|
|
141
142
|
return filter;
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Unsaved block edits for ONE render: the editor previews ask for a page as it
|
|
147
|
+
* would look with the block they are editing (POST /api/pages/preview/full,
|
|
148
|
+
* `blocks`). Held in an AsyncLocalStorage, so every place that loads a block -
|
|
149
|
+
* the short tag, [block], a display's block="…", the footer blocks - sees the
|
|
150
|
+
* same edit without each call site being handed it, and no other request ever
|
|
151
|
+
* does. Never used for a cached or public render.
|
|
152
|
+
*/
|
|
153
|
+
const blockOverrides = new AsyncLocalStorage();
|
|
154
|
+
|
|
155
|
+
/** The longest unsaved template or stylesheet a preview may send (characters), and how many blocks. The request as a whole is still held to Fastify's body limit. */
|
|
156
|
+
export const BLOCK_OVERRIDE_LIMITS = {count: 20, chars: 200_000};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check a preview's `blocks` map: {name: {content, css}} with plain block names
|
|
160
|
+
* and strings, within the limits. Returns the clean map, or a message.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} raw
|
|
163
|
+
* @returns {{blocks: Map<string, {content: string, css: string}>}|{error: string}}
|
|
164
|
+
*/
|
|
165
|
+
export function readBlockOverrides(raw) {
|
|
166
|
+
if (raw == null) return {blocks: new Map()};
|
|
167
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return {error: 'blocks must be an object'};
|
|
168
|
+
const entries = Object.entries(raw);
|
|
169
|
+
if (entries.length > BLOCK_OVERRIDE_LIMITS.count) return {error: `at most ${BLOCK_OVERRIDE_LIMITS.count} blocks`};
|
|
170
|
+
const blocks = new Map();
|
|
171
|
+
for (const [name, v] of entries) {
|
|
172
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) return {error: `"${name.slice(0, 40)}" is not a block name`};
|
|
173
|
+
if (!v || typeof v !== 'object' || typeof v.content !== 'string' || (v.css != null && typeof v.css !== 'string')) {
|
|
174
|
+
return {error: `blocks.${name} needs content (and css) as text`};
|
|
175
|
+
}
|
|
176
|
+
const css = v.css ?? '';
|
|
177
|
+
if (v.content.length > BLOCK_OVERRIDE_LIMITS.chars || css.length > BLOCK_OVERRIDE_LIMITS.chars) return {error: `blocks.${name} is too large`};
|
|
178
|
+
blocks.set(name, {content: v.content, css});
|
|
179
|
+
}
|
|
180
|
+
return {blocks};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Run `fn` with these unsaved blocks in place of the saved ones (an empty map = as saved). */
|
|
184
|
+
export function withBlockOverrides(blocks, fn) {
|
|
185
|
+
return blocks?.size ? blockOverrides.run(blocks, fn) : fn();
|
|
186
|
+
}
|
|
187
|
+
|
|
144
188
|
/**
|
|
145
189
|
* Load a block template by name from content/blocks/{name}.html.
|
|
146
190
|
* Throws if the file does not exist.
|
|
@@ -164,7 +208,8 @@ function buildShortcodeFilter(attrs) {
|
|
|
164
208
|
*/
|
|
165
209
|
async function loadBlockTemplate(blockName) {
|
|
166
210
|
const safe = path.basename(blockName).replace(/[^a-z0-9-]/g, '');
|
|
167
|
-
const
|
|
211
|
+
const unsaved = blockOverrides.getStore()?.get(safe);
|
|
212
|
+
const raw = unsaved ? unsaved.content : await readFile(path.join(BLOCKS_DIR, `${safe}.html`), 'utf8');
|
|
168
213
|
return stripTemplateComments(raw);
|
|
169
214
|
}
|
|
170
215
|
|
|
@@ -191,6 +236,8 @@ export function stripTemplateComments(tpl) {
|
|
|
191
236
|
*/
|
|
192
237
|
async function loadBlockCss(blockName) {
|
|
193
238
|
const safe = path.basename(blockName).replace(/[^a-z0-9-]/g, '');
|
|
239
|
+
const unsaved = blockOverrides.getStore()?.get(safe);
|
|
240
|
+
if (unsaved) return unsaved.css;
|
|
194
241
|
try {
|
|
195
242
|
return await readFile(path.join(BLOCKS_DIR, `${safe}.css`), 'utf8');
|
|
196
243
|
} catch {
|