domma-cms 0.70.1 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +24 -3
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/app.js +5 -5
- package/admin/js/lib/activity-throbber.js +1 -0
- package/admin/js/lib/admin-scope.js +1 -0
- package/admin/js/lib/menu-rows.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/page-editor.html +1 -4
- package/admin/js/templates/role-editor.html +28 -0
- package/admin/js/templates/settings.html +8 -0
- package/admin/js/views/menu-editor.js +16 -16
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/role-editor.js +3 -1
- package/admin/js/views/settings.js +3 -3
- package/package.json +2 -2
- package/plugins/_template/plugin.js +5 -2
- package/plugins/blog/CLAUDE.md +20 -5
- package/plugins/blog/admin/views/kit.js +8 -10
- package/plugins/blog/lib/layouts.js +36 -0
- package/plugins/blog/lib/nav-link.js +22 -0
- package/plugins/blog/lib/page.js +13 -1
- package/plugins/blog/plugin.js +28 -9
- package/plugins/blog/plugin.json +6 -6
- package/plugins/blog/plugin.public.js +102 -80
- package/plugins/blog/tests/layouts.test.js +29 -0
- package/plugins/blog/tests/nav-link.test.js +10 -1
- package/plugins/blog/tests/public.test.js +21 -0
- package/plugins/free-tier.lock.json +13 -13
- package/server/middleware/auth.js +21 -0
- package/server/routes/api/auth.js +29 -8
- package/server/routes/api/collections.js +40 -3
- package/server/routes/api/settings.js +9 -0
- package/server/routes/api/sidebar.js +31 -10
- package/server/routes/public.js +13 -17
- package/server/server.js +10 -8
- package/server/services/adminHome.js +87 -0
- package/server/services/hooks.js +30 -6
- package/server/services/menus.js +36 -6
- package/server/services/permissionRegistry.js +10 -7
- package/server/services/pluginInstaller.js +23 -9
- package/server/services/plugins.js +333 -45
- package/server/services/roles.js +181 -5
- package/server/services/sidebar-migration.js +82 -8
- package/admin/js/lib/sidebar-grouping.test.js +0 -1
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{api as v,apiRequest as b}from"../api.js";export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(e){E.tabs(e.find("#settings-tabs").get(0));const
|
|
2
|
-
`,
|
|
3
|
-
`,
|
|
1
|
+
import{api as v,apiRequest as b}from"../api.js";import{placeTools as w}from"../lib/sidebar-grouping.js";async function C(e,f){if(!e)return;const[l,c]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),i=new Map([["/","Dashboard (built in)"]]),n=(a,d)=>{for(const t of a||[]){if(!t||t.hidden||t.restricted)continue;const s=typeof t.url=="string"?t.url:"";s.startsWith("#/")&&s.length>2&&!i.has(s.slice(1))&&i.set(s.slice(1),`${d?`${d} \u203A `:""}${t.text||s}`),n(t.items,t.url?d:t.text||d)}};n(w(l?.items||[],c||[]),"");const m=/^\/[A-Za-z0-9\-_/]*$/.test(f||"")&&f.replace(/\/+$/,"")||"/";i.has(m)||i.set(m,`${m} (not in the sidebar)`);for(const[a,d]of i){const t=document.createElement("option");t.value=a,t.textContent=d,t.selected=a===m,e.appendChild(t)}}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(e){E.tabs(e.find("#settings-tabs").get(0));const f=E.loader(e.get(0),{type:"dots"}),l=await v.settings.get().catch(()=>({}));f.destroy(),e.find("#field-site-title").val(l.title||""),e.find("#field-tagline").val(l.tagline||""),C(e.find("#field-admin-home").get(0),l.adminHome),e.find("#field-spacer-size").val(l.layoutOptions?.spacerSize??40),e.find("#field-seo-title").val(l.seo?.defaultTitle||""),e.find("#field-seo-separator").val(l.seo?.titleSeparator||" | "),e.find("#field-seo-desc").val(l.seo?.defaultDescription||""),e.find("#field-footer-copy").val(l.footer?.copyright||""),e.find("#field-footer-motion-toggle").prop("checked",l.footer?.showMotionToggle!==!1),e.find("#field-social-twitter").val(l.social?.twitter||""),e.find("#field-social-facebook").val(l.social?.facebook||""),e.find("#field-social-instagram").val(l.social?.instagram||""),e.find("#field-social-linkedin").val(l.social?.linkedin||""),e.find("#field-social-github").val(l.social?.github||""),e.find("#field-social-youtube").val(l.social?.youtube||""),e.find("#field-smtp-host").val(l.smtp?.host||""),e.find("#field-smtp-port").val(l.smtp?.port||587),e.find("#field-smtp-user").val(l.smtp?.user||""),e.find("#field-smtp-pass").val(l.smtp?.pass||""),e.find("#field-smtp-secure").prop("checked",l.smtp?.secure||!1),e.find("#field-smtp-from-address").val(l.smtp?.fromAddress||""),e.find("#field-smtp-from-name").val(l.smtp?.fromName||""),y(l.smtp?.host||""),e.find("#field-smtp-host").on("input",function(){y(this.value)});const c=l.backToTop||{};e.find("#field-btt-enabled").prop("checked",c.enabled!==!1),e.find("#field-btt-threshold").val(c.scrollThreshold??300),e.find("#field-btt-position").val(c.position||"bottom-right"),e.find("#field-btt-offset").val(c.offset??32),e.find("#field-btt-bottom-offset").val(c.bottomOffset??c.offset??32),e.find("#field-btt-label").val(c.label||""),e.find("#field-btt-smooth").prop("checked",c.smooth!==!1);const i=l.cookieConsent||{};e.find("#field-cc-enabled").prop("checked",i.enabled!==!1),e.find("#field-cc-message").val(i.message||""),e.find("#field-cc-accept-all").val(i.acceptAllText||"Accept All"),e.find("#field-cc-reject-all").val(i.rejectAllText||"Reject All"),e.find("#field-cc-customize").val(i.customizeText||"Customize"),e.find("#field-cc-save-prefs").val(i.savePreferencesText||"Save Preferences"),e.find("#field-cc-privacy-text").val(i.privacyPolicyText||"Privacy Policy"),e.find("#field-cc-privacy-url").val(i.privacyPolicyUrl||""),e.find("#field-cc-cookie-text").val(i.cookiePolicyText||"Cookie Policy"),e.find("#field-cc-cookie-url").val(i.cookiePolicyUrl||""),e.find("#field-cc-position").val(i.position||"bottom"),e.find("#field-cc-layout").val(i.layout||"bar"),e.find("#field-cc-theme").val(i.theme||"dark"),e.find("#field-cc-show-functional").prop("checked",i.showFunctional!==!1),e.find("#field-cc-show-analytics").prop("checked",i.showAnalytics!==!1),e.find("#field-cc-show-marketing").prop("checked",i.showMarketing!==!1),e.find("#field-cc-version").val(i.consentVersion||"1.0");const n=l.breadcrumbs||{};e.find("#field-breadcrumbs-enabled").prop("checked",n.enabled===!0),e.find("#field-breadcrumbs-home-label").val(n.homeLabel||"Home"),e.find("#field-breadcrumbs-position").val(n.position||"TL"),e.find("#field-bc-offset-x").val(n.offsetX??8),e.find("#field-bc-offset-y").val(n.offsetY??8);function m(t){e.find(".bc-pos-btn").each(function(){const s=this.dataset.pos===t;this.style.background=s?"var(--dm-primary, #5b8cff)":"",this.style.color=s?"var(--dm-primary-text)":""})}m(n.position||"TL"),e.find(".bc-pos-btn").on("click",function(){const t=this.dataset.pos;e.find("#field-breadcrumbs-position").val(t),m(t)});let a=null;try{const{css:t}=await b("/settings/custom-css");e.find("#field-custom-css").val(t||""),E.editor&&(a=E.editor(e.find("#field-custom-css").get(0),{mode:"code",language:"css",lineNumbers:!0,showToolbar:!1,minHeight:420,placeholder:"/* Add your custom CSS here */",characterCount:!0}))}catch{}a&&a._editorEl&&a._editorEl.addEventListener("keydown",t=>{if(!t.ctrlKey&&!t.metaKey)return;if(t.key==="s"){t.preventDefault(),e.find("#save-css-btn").get(0)?.click();return}const s=a._editorEl,r=s.selectionStart!==s.selectionEnd;if((t.key==="c"||t.key==="x")&&!r){t.preventDefault();const o=s.value,p=s.selectionStart,h=o.lastIndexOf(`
|
|
2
|
+
`,p-1)+1,u=o.indexOf(`
|
|
3
|
+
`,p),g=u===-1?o.slice(h):o.slice(h,u+1);if(navigator.clipboard.writeText(g),t.key==="x"){const k=o.slice(0,h),x=u===-1?"":o.slice(u+1);s.value=k+x,s.selectionStart=s.selectionEnd=h,s.dispatchEvent(new Event("input",{bubbles:!0})),E.toast("Line cut",{type:"info",duration:1500})}else E.toast("Line copied",{type:"info",duration:1500})}}),e.find("#save-settings-btn").on("click",async()=>{const t={title:e.find("#field-site-title").val().trim(),tagline:e.find("#field-tagline").val().trim(),...e.find("#field-admin-home option").length&&{adminHome:e.find("#field-admin-home").val()||"/"},layoutOptions:{spacerSize:parseInt(e.find("#field-spacer-size").val(),10)||40},seo:{defaultTitle:e.find("#field-seo-title").val().trim(),titleSeparator:e.find("#field-seo-separator").val()||" | ",defaultDescription:e.find("#field-seo-desc").val().trim()},footer:{copyright:e.find("#field-footer-copy").val().trim(),showMotionToggle:e.find("#field-footer-motion-toggle").prop("checked"),links:l.footer?.links||[]},social:{twitter:e.find("#field-social-twitter").val().trim(),facebook:e.find("#field-social-facebook").val().trim(),instagram:e.find("#field-social-instagram").val().trim(),linkedin:e.find("#field-social-linkedin").val().trim(),github:e.find("#field-social-github").val().trim(),youtube:e.find("#field-social-youtube").val().trim()},smtp:{host:e.find("#field-smtp-host").val().trim(),port:parseInt(e.find("#field-smtp-port").val(),10)||587,user:e.find("#field-smtp-user").val().trim(),pass:e.find("#field-smtp-pass").val(),secure:e.find("#field-smtp-secure").prop("checked"),fromAddress:e.find("#field-smtp-from-address").val().trim(),fromName:e.find("#field-smtp-from-name").val().trim()},backToTop:{enabled:e.find("#field-btt-enabled").prop("checked"),scrollThreshold:parseInt(e.find("#field-btt-threshold").val(),10)||300,position:e.find("#field-btt-position").val()||"bottom-right",offset:parseInt(e.find("#field-btt-offset").val(),10)||32,bottomOffset:parseInt(e.find("#field-btt-bottom-offset").val(),10)||32,label:e.find("#field-btt-label").val().trim(),smooth:e.find("#field-btt-smooth").prop("checked")},cookieConsent:{enabled:e.find("#field-cc-enabled").prop("checked"),message:e.find("#field-cc-message").val().trim(),acceptAllText:e.find("#field-cc-accept-all").val().trim(),rejectAllText:e.find("#field-cc-reject-all").val().trim(),customizeText:e.find("#field-cc-customize").val().trim(),savePreferencesText:e.find("#field-cc-save-prefs").val().trim(),privacyPolicyText:e.find("#field-cc-privacy-text").val().trim(),privacyPolicyUrl:e.find("#field-cc-privacy-url").val().trim(),cookiePolicyText:e.find("#field-cc-cookie-text").val().trim(),cookiePolicyUrl:e.find("#field-cc-cookie-url").val().trim(),position:e.find("#field-cc-position").val(),layout:e.find("#field-cc-layout").val(),theme:e.find("#field-cc-theme").val(),showFunctional:e.find("#field-cc-show-functional").prop("checked"),showAnalytics:e.find("#field-cc-show-analytics").prop("checked"),showMarketing:e.find("#field-cc-show-marketing").prop("checked"),consentVersion:e.find("#field-cc-version").val().trim()||"1.0"}},s=e.find("#save-settings-btn");s.prop("disabled",!0);try{await v.settings.save(t),document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),E.toast("Settings saved.",{type:"success"})}catch{E.toast("Failed to save settings.",{type:"error"})}finally{s.prop("disabled",!1)}}),e.find("#send-test-email-btn").on("click",async()=>{const t=e.find("#field-test-email-to").val().trim(),s=e.find("#test-email-result").get(0),r=e.find("#send-test-email-btn");r.prop("disabled",!0),s&&(s.textContent="Sending\u2026",s.style.color="");try{const o=await b("/settings/test-email",{method:"POST",body:JSON.stringify({to:t||void 0})});s&&(s.textContent=o.message||"Test email sent.",s.style.color="var(--dm-success,#4ade80)")}catch(o){s&&(s.textContent=o.message||"Failed to send test email.",s.style.color="var(--dm-danger,#f87171)")}finally{r.prop("disabled",!1)}}),e.find("#save-breadcrumbs-btn").on("click",async()=>{const t=e.find("#save-breadcrumbs-btn");t.prop("disabled",!0);try{const s={enabled:e.find("#field-breadcrumbs-enabled").prop("checked"),homeLabel:e.find("#field-breadcrumbs-home-label").val().trim()||"Home",position:e.find("#field-breadcrumbs-position").val()||"TL",offsetX:parseInt(e.find("#field-bc-offset-x").val(),10)||8,offsetY:parseInt(e.find("#field-bc-offset-y").val(),10)||8};await v.settings.save({breadcrumbs:s}),l.breadcrumbs=s,E.toast("Breadcrumbs settings saved.",{type:"success"})}catch{E.toast("Failed to save breadcrumbs settings.",{type:"error"})}finally{t.prop("disabled",!1)}}),e.find("#save-css-btn").on("click",async()=>{const t=a?a.getValue():e.find("#field-custom-css").val(),s=e.find("#save-css-btn");s.prop("disabled",!0);try{await b("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:t})}),E.toast("Custom CSS saved.",{type:"success"})}catch(r){E.toast(r.message||"Failed to save CSS.",{type:"error"})}finally{s.prop("disabled",!1)}});async function d(){try{const t=await b("/cache/status"),s=t.enabled?`enabled (${t.driver})`:"disabled",r=t.maxItems?` \xB7 ${t.size} / ${t.maxItems} entries`:"";e.find("#cache-status").text(s+r)}catch{e.find("#cache-status").text("unknown")}try{const{entries:t}=await b("/cache/keys");T(e.find("#cache-keys-table"),t),e.find("#cache-key-count").text(`(${t.length})`)}catch(t){e.find("#cache-keys-table").get(0).textContent="Failed to load keys: "+t.message}}await d(),e.find("#btn-clear-cache").on("click",async()=>{if(!await E.confirm("Clear the entire response cache?"))return;const t=e.find("#btn-clear-cache");t.prop("disabled",!0);try{await b("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"}),await d()}catch(s){E.toast(s.message||"Failed to clear cache.",{type:"error"})}finally{t.prop("disabled",!1)}}),e.find("#btn-refresh-cache-keys").on("click",d)}};function y(e){const f=document.getElementById("smtp-unconfigured-notice");f&&(f.style.display=String(e||"").trim()?"none":"",f.style.display===""&&Domma.icons.scan(f))}function T(e,f){const l=e.get(0);if(!l)return;for(;l.firstChild;)l.removeChild(l.firstChild);if(!f.length){const d=document.createElement("p");d.className="text-muted",d.style.fontSize=".875rem",d.textContent="Cache is empty.",l.appendChild(d);return}const c=[...f].reverse(),i=document.createElement("table");i.className="table table-compact";const n=document.createElement("thead"),m=document.createElement("tr");for(const d of["Key","Tags","Expires"]){const t=document.createElement("th");t.textContent=d,m.appendChild(t)}n.appendChild(m),i.appendChild(n);const a=document.createElement("tbody");for(const d of c){const t=document.createElement("tr"),s=document.createElement("td");s.style.fontFamily="monospace",s.style.fontSize="12px",s.textContent=d.key,t.appendChild(s);const r=document.createElement("td");r.style.fontSize="12px";for(const p of d.tags){const h=document.createElement("span");h.className="badge",h.style.marginRight="4px",h.style.fontSize="11px",h.textContent=p,r.appendChild(h)}t.appendChild(r);const o=document.createElement("td");if(o.style.fontSize="12px",o.style.color="var(--dm-text-muted, #6b7280)",d.expiresAt===null)o.textContent="no expiry";else{const p=d.expiresAt-Date.now();p<=0?o.textContent="expired":p<6e4?o.textContent=`${Math.round(p/1e3)}s`:p<36e5?o.textContent=`${Math.round(p/6e4)}m`:o.textContent=`${Math.round(p/36e5)}h`}t.appendChild(o),a.appendChild(t)}i.appendChild(a),l.appendChild(i)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domma-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.0",
|
|
4
4
|
"description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server/server.js",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"@fastify/rate-limit": "^10.3.0",
|
|
83
83
|
"@fastify/static": "9.1.1",
|
|
84
84
|
"bcryptjs": "^3.0.3",
|
|
85
|
-
"domma-js": "0.44.
|
|
85
|
+
"domma-js": "0.44.3",
|
|
86
86
|
"dotenv": "^17.2.3",
|
|
87
87
|
"fastify": "5.8.5",
|
|
88
88
|
"gray-matter": "^4.0.3",
|
|
@@ -18,6 +18,9 @@ export async function onEnable({ fastify, services }) {
|
|
|
18
18
|
// Create collections, pages, or forms here if needed.
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export async function onDisable({ fastify, services }) {
|
|
22
|
-
// Called when the plugin is disabled via admin
|
|
21
|
+
export async function onDisable({ fastify, services, hooks, uninstall }) {
|
|
22
|
+
// Called when the plugin is disabled via admin, and before it is
|
|
23
|
+
// uninstalled (`uninstall: true`). Roles this plugin registered are kept
|
|
24
|
+
// on a disable and removed on uninstall automatically; to remove one
|
|
25
|
+
// yourself: if (uninstall) await hooks.unregisterRole('my-role');
|
|
23
26
|
}
|
package/plugins/blog/CLAUDE.md
CHANGED
|
@@ -57,7 +57,10 @@ All routes are under `/api/plugins/blog`. All require authentication unless note
|
|
|
57
57
|
|
|
58
58
|
## Public Routes
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Served at the site root (no `/api/plugins/blog` prefix), under the base path in the settings (`basePath`,
|
|
61
|
+
default `/blog`). A saved change applies at once - no restart. They are not Fastify routes: one global
|
|
62
|
+
`onRequest` hook in plugin.public.js matches each GET against the live base path (`matchBlogPath()` in
|
|
63
|
+
lib/layouts.js) and answers it; anything it does not claim goes on to the site as usual.
|
|
61
64
|
|
|
62
65
|
| Method | Path | Purpose |
|
|
63
66
|
|---|---|---|
|
|
@@ -67,7 +70,8 @@ Registered at the Fastify root (no `/api/plugins/blog` prefix). Base path defaul
|
|
|
67
70
|
| GET | `{base}/tag/:tag` | Posts filtered by tag |
|
|
68
71
|
| GET | `{base}/author/:id` | Posts filtered by author user ID |
|
|
69
72
|
| GET | `{base}` | Blog index (page 1) |
|
|
70
|
-
| GET | `{base}
|
|
73
|
+
| GET | `{base}/` | 301 to `{base}` (else it would be a post with an empty slug: "Not found") |
|
|
74
|
+
| GET | `{base}/:slug` | Single post page |
|
|
71
75
|
|
|
72
76
|
## Data Model
|
|
73
77
|
|
|
@@ -111,7 +115,7 @@ Registered at the Fastify root (no `/api/plugins/blog` prefix). Base path defaul
|
|
|
111
115
|
|
|
112
116
|
| Key | Default | Purpose |
|
|
113
117
|
|---|---|---|
|
|
114
|
-
| `basePath` | `'/blog'` | URL prefix for all public blog
|
|
118
|
+
| `basePath` | `'/blog'` | URL prefix for all public blog pages; read live, tidied by `cleanBasePath()` on save |
|
|
115
119
|
| `postsPerPage` | `10` | Archive pagination page size |
|
|
116
120
|
| `commentsEnabled` | `false` | Show comment list and form on post pages |
|
|
117
121
|
| `commentModeration` | `true` | New comments start as `pending`; `false` = auto-approve |
|
|
@@ -205,7 +209,8 @@ with `?display=` and swaps the cards, since each display is different block mark
|
|
|
205
209
|
state - NOT as a marker on the item, because the Menus editor rewrites items with the keys it knows and
|
|
206
210
|
drops the rest. `onDisable` removes a top-level item with that url, and only when `navLink` says the
|
|
207
211
|
plugin added it, so a site's own Blog link is never touched. Sites where the blog was already enabled
|
|
208
|
-
get no link; there is no enable event for them.
|
|
212
|
+
get no link; there is no enable event for them. Saving a new `basePath` moves that link (`moveNavLink()`)
|
|
213
|
+
and updates `navLink.url`; links a site typed into its own pages or menus are left alone.
|
|
209
214
|
|
|
210
215
|
- **The admin already does Ctrl+S.** app.js clicks the view's primary Save button and calls
|
|
211
216
|
`preventDefault()`. The editor's own handler saved as well, so every Ctrl+S was two PUTs - and on a
|
|
@@ -243,7 +248,17 @@ with `?display=` and swaps the cards, since each display is different block mark
|
|
|
243
248
|
|
|
244
249
|
- **Named-role publish gate**: `canPublish()` uses `['blog-editor', 'admin', 'super-admin'].includes(user.role)` - NOT a level comparison. Do not refactor this to `getRoleLevel() <= N`; `blog-author` and `blog-editor` are both level 2, so a level check would give blog-authors publish access.
|
|
245
250
|
|
|
246
|
-
- **
|
|
251
|
+
- **The base path is read per request, so the public pages are an `onRequest` hook, not routes.**
|
|
252
|
+
Fastify routes are fixed once the server is ready, so pages registered at `/blog` stayed there after
|
|
253
|
+
Settings saved `/news` - the new path said "Not found" until a restart. The hook is made global with
|
|
254
|
+
`Symbol.for('skip-override')` (what fastify-plugin sets); without it an encapsulated hook would only
|
|
255
|
+
see routes inside the plugin, and it has none. It must `return` the reply it sends. Shortcodes, the
|
|
256
|
+
preview and the navbar link read the base the same way (`liveBase()` in lib/page.js).
|
|
257
|
+
|
|
258
|
+
- **Blog Pro's posts from its other blogs are never shown here.** Blog Pro (paid, `supersedes: ["blog"]`) uses
|
|
259
|
+
these same collections and adds `blogId` to a post; this blog is Pro's FIRST blog (`'main'` or no blogId).
|
|
260
|
+
`isVisible()` (lib/page.js and plugin.js) refuses any other blogId, so if a Pro licence lapses and this plugin
|
|
261
|
+
loads again, those posts are hidden rather than piled into /blog. The admin list still shows them.
|
|
247
262
|
|
|
248
263
|
- **Lazy scheduled post check**: `plugin.public.js` calls `isVisible(post)` on every `GET /:slug` request. A post transitions from `scheduled` to visible the moment `new Date(post.publishedAt) <= new Date()` - no background job needed, no cron required.
|
|
249
264
|
|
|
@@ -17,7 +17,7 @@ export function escapeHtml(s) {
|
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* The blog's public base path (`/blog` unless configured), for links to the
|
|
20
|
-
* live site.
|
|
20
|
+
* live site. Cleared when Settings saves a new one.
|
|
21
21
|
*
|
|
22
22
|
* @returns {Promise<string>}
|
|
23
23
|
*/
|
|
@@ -184,13 +184,13 @@ const SETTINGS_PANEL = `
|
|
|
184
184
|
<div class="blog-panel">
|
|
185
185
|
<div class="blog-field">
|
|
186
186
|
<label class="form-label" for="blog-set-base">Base path
|
|
187
|
-
<span data-help="The URL every blog page lives under - /blog gives /blog/my-post.
|
|
187
|
+
<span data-help="The URL every blog page lives under - /blog gives /blog/my-post. Applies as soon as you save; the navbar link Blog added moves with it, but links typed into pages do not."></span>
|
|
188
188
|
</label>
|
|
189
189
|
<input id="blog-set-base" type="text" class="form-input font-mono" placeholder="/blog" data-model="basePath.value">
|
|
190
190
|
</div>
|
|
191
191
|
<div class="blog-field">
|
|
192
192
|
<label class="form-label" for="blog-set-ppp">Posts per page
|
|
193
|
-
<span data-help="How many posts a listing page shows before paging.
|
|
193
|
+
<span data-help="How many posts a listing page shows before paging."></span>
|
|
194
194
|
</label>
|
|
195
195
|
<input id="blog-set-ppp" type="number" min="1" max="100" class="form-input" data-model="postsPerPage.value">
|
|
196
196
|
</div>
|
|
@@ -252,7 +252,6 @@ const SETTINGS_PANEL = `
|
|
|
252
252
|
<button type="button" class="btn btn-sm btn-ghost blog-panel-link" data-on-click="openBlocks">
|
|
253
253
|
<span data-icon="layout" data-icon-size="13"></span> Edit the layouts in Blocks
|
|
254
254
|
</button>
|
|
255
|
-
<p class="blog-panel-note" data-if="needsRestart.value">Base path and posts per page change after the server restarts.</p>
|
|
256
255
|
<div class="blog-panel-actions">
|
|
257
256
|
<span class="blog-panel-status" data-bind-text="status.value"></span>
|
|
258
257
|
<span class="blog-spacer"></span>
|
|
@@ -320,11 +319,7 @@ export async function settingsPanel({onClose = null} = {}) {
|
|
|
320
319
|
closable: Boolean(onClose),
|
|
321
320
|
layoutOptions: M.observable(withCurrent(layouts.postLayouts ?? [], initial.postLayout)),
|
|
322
321
|
displayOptions: M.observable(withCurrent(layouts.listDisplays ?? [], initial.listDisplay)),
|
|
323
|
-
dirty: M.computed(() => JSON.stringify(current()) !== JSON.stringify(baseline.value))
|
|
324
|
-
needsRestart: M.computed(() => {
|
|
325
|
-
const b = baseline.value;
|
|
326
|
-
return String(f.basePath.value).trim() !== b.basePath || Number(f.postsPerPage.value) !== b.postsPerPage;
|
|
327
|
-
})
|
|
322
|
+
dirty: M.computed(() => JSON.stringify(current()) !== JSON.stringify(baseline.value))
|
|
328
323
|
};
|
|
329
324
|
|
|
330
325
|
const el = fragment(SETTINGS_PANEL);
|
|
@@ -346,10 +341,13 @@ export async function settingsPanel({onClose = null} = {}) {
|
|
|
346
341
|
try {
|
|
347
342
|
const res = await H.put(`${BASE}/settings`, body);
|
|
348
343
|
if (res?.error) throw new Error(res.error);
|
|
344
|
+
// The server tidies the path ("/news/" is saved as "/news"); show what it kept.
|
|
345
|
+
const savedBase = (res?.settings ?? res?.data?.settings)?.basePath;
|
|
346
|
+
if (savedBase) f.basePath.value = body.basePath = savedBase;
|
|
349
347
|
baseline.value = {...body};
|
|
350
348
|
basePathCache = null;
|
|
351
349
|
status.value = 'Saved.';
|
|
352
|
-
E.toast('Blog settings saved.
|
|
350
|
+
E.toast('Blog settings saved. They are live now.', {type: 'success'});
|
|
353
351
|
} catch (err) {
|
|
354
352
|
E.toast(err?.message ?? 'Failed to save settings.', {type: 'error'});
|
|
355
353
|
} finally {
|
|
@@ -304,3 +304,39 @@ export function cardMeta(post, {catMap = {}, authorName = ''}) {
|
|
|
304
304
|
export function byNewest(a, b) {
|
|
305
305
|
return new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0);
|
|
306
306
|
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A base path as the routes use it: one leading slash, no trailing one, no
|
|
310
|
+
* spaces or query. Anything that leaves nothing (or just "/", which would
|
|
311
|
+
* swallow the whole site) is `/blog`.
|
|
312
|
+
*/
|
|
313
|
+
export function cleanBasePath(value) {
|
|
314
|
+
const path = String(value ?? '').trim().split(/[?#\s]/)[0].replace(/\/{2,}/g, '/').replace(/^\/*/, '/').replace(/\/+$/, '');
|
|
315
|
+
return path || '/blog';
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const ARCHIVE_PARAM = {page: 'n', category: 'slug', tag: 'tag', author: 'id'};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Which public page a path is under `base`, read at request time so a changed
|
|
322
|
+
* base path applies at once. `null` for anything that is not the blog's.
|
|
323
|
+
*
|
|
324
|
+
* @returns {{name: string, params: object}|null} name: index | slash | feed | page | category | tag | author | post
|
|
325
|
+
*/
|
|
326
|
+
export function matchBlogPath(pathname, base) {
|
|
327
|
+
if (pathname === base) return {name: 'index', params: {}};
|
|
328
|
+
if (!pathname.startsWith(`${base}/`)) return null;
|
|
329
|
+
const rest = pathname.slice(base.length + 1);
|
|
330
|
+
if (rest === '') return {name: 'slash', params: {}};
|
|
331
|
+
let parts;
|
|
332
|
+
try {
|
|
333
|
+
parts = rest.split('/').map(decodeURIComponent);
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
if (parts.length === 1) return parts[0] === 'feed.xml' ? {name: 'feed', params: {}} : {name: 'post', params: {slug: parts[0]}};
|
|
338
|
+
if (parts.length === 2 && parts[1] && Object.hasOwn(ARCHIVE_PARAM, parts[0])) {
|
|
339
|
+
return {name: parts[0], params: {[ARCHIVE_PARAM[parts[0]]]: parts[1]}};
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
@@ -70,3 +70,25 @@ export async function removeNavLink(added, menus = menuService) {
|
|
|
70
70
|
await menus.updateMenu(added.menu, {...menu, items: kept});
|
|
71
71
|
return true;
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Point the link this plugin added at a new base path, after the setting
|
|
76
|
+
* changes. Only the top-level item still linking where it was added moves.
|
|
77
|
+
*
|
|
78
|
+
* @param {{menu: string, url: string}|null} added - from addNavLink()
|
|
79
|
+
* @param {string} url - the new base path
|
|
80
|
+
* @param {object} [menus]
|
|
81
|
+
* @returns {Promise<boolean>} whether a link was moved
|
|
82
|
+
*/
|
|
83
|
+
export async function moveNavLink(added, url, menus = menuService) {
|
|
84
|
+
if (!added?.menu || !added?.url) return false;
|
|
85
|
+
const menu = await menus.getMenu(added.menu);
|
|
86
|
+
if (!menu) return false;
|
|
87
|
+
const from = trim(added.url);
|
|
88
|
+
const items = Array.isArray(menu.items) ? menu.items : [];
|
|
89
|
+
const at = items.findIndex(it => it && typeof it.url === 'string' && trim(it.url) === from);
|
|
90
|
+
if (at === -1) return false;
|
|
91
|
+
const moved = items.map((it, i) => (i === at ? {...it, url: trim(url)} : it));
|
|
92
|
+
await menus.updateMenu(added.menu, {...menu, items: moved});
|
|
93
|
+
return true;
|
|
94
|
+
}
|
package/plugins/blog/lib/page.js
CHANGED
|
@@ -10,10 +10,17 @@ import {parseMarkdown} from '../../../server/services/markdown.js';
|
|
|
10
10
|
import {getUserById} from '../../../server/services/users.js';
|
|
11
11
|
import {getPluginStates} from '../../../server/services/plugins.js';
|
|
12
12
|
import defaultConfig from '../config.js';
|
|
13
|
-
import {buildToc, byNewest, cardVars, DEFAULT_POST_LAYOUT, DISPLAY_DEFAULTS, escapeHtml, interpolate, pickPostLayout, relatedPosts, safeImageUrl} from './layouts.js';
|
|
13
|
+
import {buildToc, byNewest, cardVars, cleanBasePath, DEFAULT_POST_LAYOUT, DISPLAY_DEFAULTS, escapeHtml, interpolate, pickPostLayout, relatedPosts, safeImageUrl} from './layouts.js';
|
|
14
14
|
import {catalogue, loadBlock, renderCards, styleTag} from './render.js';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Whether readers see a post. Blog Pro files posts under several blogs
|
|
18
|
+
* (`blogId`); this blog is its FIRST one ('main', or none). Should a Pro
|
|
19
|
+
* licence lapse and this plugin take over again, posts from Pro's other blogs
|
|
20
|
+
* stay out of /blog rather than all landing in one listing - hidden, not lost.
|
|
21
|
+
*/
|
|
16
22
|
export function isVisible(post) {
|
|
23
|
+
if (post.blogId && post.blogId !== 'main') return false;
|
|
17
24
|
return post.status === 'published' ||
|
|
18
25
|
(post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
|
|
19
26
|
}
|
|
@@ -23,6 +30,11 @@ export function liveSettings(registered) {
|
|
|
23
30
|
return {...defaultConfig, ...DISPLAY_DEFAULTS, ...registered, ...(getPluginStates()?.blog?.settings ?? {})};
|
|
24
31
|
}
|
|
25
32
|
|
|
33
|
+
/** The base path as it is saved NOW - a change applies without a restart. */
|
|
34
|
+
export function liveBase(registered) {
|
|
35
|
+
return cleanBasePath(liveSettings(registered).basePath);
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
/** Always returns the actual array of entries, regardless of adapter shape. */
|
|
27
39
|
export async function loadEntries(slug) {
|
|
28
40
|
const result = await listEntries(slug, {limit: 100000, sort: 'createdAt', order: 'desc'});
|
package/plugins/blog/plugin.js
CHANGED
|
@@ -3,10 +3,10 @@ import defaultConfig from './config.js';
|
|
|
3
3
|
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
|
|
4
4
|
import {getPluginStates, savePluginState} from '../../server/services/plugins.js';
|
|
5
5
|
import {renderBlogPage} from '../../server/services/renderer.js';
|
|
6
|
-
import {DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
|
|
6
|
+
import {cleanBasePath, DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
|
|
7
7
|
import {catalogue, seedBlocks} from './lib/render.js';
|
|
8
|
-
import {liveSettings, loadEntries as loadAll, renderPostPage} from './lib/page.js';
|
|
9
|
-
import {addNavLink, removeNavLink} from './lib/nav-link.js';
|
|
8
|
+
import {liveBase, liveSettings, loadEntries as loadAll, renderPostPage} from './lib/page.js';
|
|
9
|
+
import {addNavLink, moveNavLink, removeNavLink} from './lib/nav-link.js';
|
|
10
10
|
import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
|
|
11
11
|
|
|
12
12
|
const POSTS_SLUG = 'blog-posts';
|
|
@@ -121,7 +121,9 @@ function formatDate(iso) {
|
|
|
121
121
|
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** As lib/page.js isVisible: Blog Pro's other blogs stay out of this one. */
|
|
124
125
|
function isVisible(post) {
|
|
126
|
+
if (post.blogId && post.blogId !== 'main') return false;
|
|
125
127
|
return post.status === 'published'
|
|
126
128
|
|| (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
|
|
127
129
|
}
|
|
@@ -164,8 +166,10 @@ function renderEmbedCard(post, base, { showImage = true } = {}) {
|
|
|
164
166
|
</article>`;
|
|
165
167
|
}
|
|
166
168
|
|
|
167
|
-
|
|
169
|
+
/** `baseOf()` is read per render, so links follow a changed base path at once. */
|
|
170
|
+
function registerEmbedShortcodes(registerShortcode, baseOf) {
|
|
168
171
|
registerShortcode('blog-list', async (attrStr, _body, ctx) => {
|
|
172
|
+
const basePath = baseOf();
|
|
169
173
|
const attrs = ctx.parseShortcodeAttrs(attrStr);
|
|
170
174
|
const limit = parseInt(attrs.count ?? attrs.limit ?? '5', 10) || 5;
|
|
171
175
|
const [posts, categories] = await Promise.all([loadPosts(), loadCategories()]);
|
|
@@ -182,6 +186,7 @@ function registerEmbedShortcodes(registerShortcode, basePath) {
|
|
|
182
186
|
});
|
|
183
187
|
|
|
184
188
|
registerShortcode('blog-grid', async (attrStr, _body, ctx) => {
|
|
189
|
+
const basePath = baseOf();
|
|
185
190
|
const attrs = ctx.parseShortcodeAttrs(attrStr);
|
|
186
191
|
const limit = parseInt(attrs.count ?? attrs.limit ?? '6', 10) || 6;
|
|
187
192
|
const cols = Math.max(1, Math.min(4, parseInt(attrs.cols ?? '3', 10) || 3));
|
|
@@ -194,6 +199,7 @@ function registerEmbedShortcodes(registerShortcode, basePath) {
|
|
|
194
199
|
});
|
|
195
200
|
|
|
196
201
|
registerShortcode('blog-featured', async (attrStr, _body, ctx) => {
|
|
202
|
+
const basePath = baseOf();
|
|
197
203
|
const attrs = ctx.parseShortcodeAttrs(attrStr);
|
|
198
204
|
const posts = await loadPosts();
|
|
199
205
|
let post;
|
|
@@ -219,6 +225,7 @@ function registerEmbedShortcodes(registerShortcode, basePath) {
|
|
|
219
225
|
});
|
|
220
226
|
|
|
221
227
|
registerShortcode('blog-categories', async (attrStr, _body, ctx) => {
|
|
228
|
+
const basePath = baseOf();
|
|
222
229
|
const attrs = ctx.parseShortcodeAttrs(attrStr);
|
|
223
230
|
const showCounts = attrs['show-counts'] === 'true' || attrs.counts === 'true';
|
|
224
231
|
const [posts, categories] = await Promise.all([loadPosts(), loadCategories()]);
|
|
@@ -236,10 +243,9 @@ function registerEmbedShortcodes(registerShortcode, basePath) {
|
|
|
236
243
|
export default async function blogPlugin(fastify, options) {
|
|
237
244
|
const { authenticate, requireAdmin } = options.auth;
|
|
238
245
|
const settings = { ...defaultConfig, ...(options.settings || {}) };
|
|
239
|
-
const basePath = (settings.basePath ?? '/blog').replace(/\/$/, '');
|
|
240
246
|
|
|
241
247
|
if (options.hooks?.registerShortcode) {
|
|
242
|
-
registerEmbedShortcodes(options.hooks.registerShortcode,
|
|
248
|
+
registerEmbedShortcodes(options.hooks.registerShortcode, () => liveBase(settings));
|
|
243
249
|
}
|
|
244
250
|
|
|
245
251
|
// The layouts are blocks; put the shipped ones where the Blocks tool can
|
|
@@ -291,6 +297,7 @@ export default async function blogPlugin(fastify, options) {
|
|
|
291
297
|
post.slug = post.slug || 'preview';
|
|
292
298
|
const allPosts = (await loadAll(POSTS_SLUG)).filter(p => p.id !== post.id);
|
|
293
299
|
const s = liveSettings(settings);
|
|
300
|
+
const basePath = liveBase(settings);
|
|
294
301
|
const page = await renderPostPage(post, [...allPosts, post], s, {
|
|
295
302
|
base: basePath, layout: cleanTemplate(body.layout) || null, preview: true
|
|
296
303
|
});
|
|
@@ -705,7 +712,7 @@ export default async function blogPlugin(fastify, options) {
|
|
|
705
712
|
const merged = {
|
|
706
713
|
...defaultConfig,
|
|
707
714
|
...stored,
|
|
708
|
-
...(body.basePath !== undefined && { basePath:
|
|
715
|
+
...(body.basePath !== undefined && { basePath: cleanBasePath(body.basePath) }),
|
|
709
716
|
...(body.postsPerPage !== undefined && { postsPerPage: Number(body.postsPerPage) || defaultConfig.postsPerPage }),
|
|
710
717
|
...(body.commentsEnabled !== undefined && { commentsEnabled: Boolean(body.commentsEnabled) }),
|
|
711
718
|
...(body.commentModeration !== undefined && { commentModeration: Boolean(body.commentModeration) }),
|
|
@@ -714,6 +721,19 @@ export default async function blogPlugin(fastify, options) {
|
|
|
714
721
|
};
|
|
715
722
|
|
|
716
723
|
savePluginState('blog', { settings: merged });
|
|
724
|
+
|
|
725
|
+
// The public pages follow the new base path at once (plugin.public.js);
|
|
726
|
+
// so does the navbar link, if enabling put it there.
|
|
727
|
+
const added = getPluginStates()?.blog?.navLink ?? null;
|
|
728
|
+
if (added && added.url !== merged.basePath) {
|
|
729
|
+
try {
|
|
730
|
+
if (await moveNavLink(added, merged.basePath)) {
|
|
731
|
+
savePluginState('blog', { navLink: { ...added, url: merged.basePath } });
|
|
732
|
+
}
|
|
733
|
+
} catch (err) {
|
|
734
|
+
request.log.warn(`[blog] could not move the navbar link: ${err.message}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
717
737
|
return reply.send({ ok: true, settings: merged });
|
|
718
738
|
});
|
|
719
739
|
}
|
|
@@ -724,8 +744,7 @@ export default async function blogPlugin(fastify, options) {
|
|
|
724
744
|
|
|
725
745
|
/** The blog's base path from its saved settings, as the routes will use it. */
|
|
726
746
|
function currentBasePath() {
|
|
727
|
-
|
|
728
|
-
return String(saved.basePath ?? defaultConfig.basePath ?? '/blog').replace(/\/+$/, '') || '/blog';
|
|
747
|
+
return liveBase({});
|
|
729
748
|
}
|
|
730
749
|
|
|
731
750
|
/**
|
package/plugins/blog/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blog",
|
|
3
3
|
"displayName": "Blog",
|
|
4
|
-
"version": "1.6.
|
|
4
|
+
"version": "1.6.3",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
|
|
7
7
|
"author": "Domma CMS",
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
{ "path": "/plugins/blog/settings", "view": "plugin-blog-settings", "title": "Blog Settings - Domma CMS" }
|
|
35
35
|
],
|
|
36
36
|
"views": {
|
|
37
|
-
"plugin-blog-posts": { "entry": "blog/admin/views/blog.js?v=
|
|
38
|
-
"plugin-blog-post-editor": { "entry": "blog/admin/views/post-editor.js?v=
|
|
39
|
-
"plugin-blog-categories": { "entry": "blog/admin/views/categories.js?v=
|
|
40
|
-
"plugin-blog-comments": { "entry": "blog/admin/views/comments.js?v=
|
|
41
|
-
"plugin-blog-settings": { "entry": "blog/admin/views/settings.js?v=
|
|
37
|
+
"plugin-blog-posts": { "entry": "blog/admin/views/blog.js?v=9", "exportName": "blogView" },
|
|
38
|
+
"plugin-blog-post-editor": { "entry": "blog/admin/views/post-editor.js?v=9", "exportName": "postEditorView" },
|
|
39
|
+
"plugin-blog-categories": { "entry": "blog/admin/views/categories.js?v=9", "exportName": "categoriesView" },
|
|
40
|
+
"plugin-blog-comments": { "entry": "blog/admin/views/comments.js?v=9", "exportName": "commentsView" },
|
|
41
|
+
"plugin-blog-settings": { "entry": "blog/admin/views/settings.js?v=9", "exportName": "settingsView" }
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
}
|