domma-cms 1.8.35 → 1.8.37
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 +12 -0
- package/admin/index.html +1 -1
- package/admin/js/app.js +1 -1
- package/admin/js/lib/seo-analyse.js +1 -1
- package/admin/js/lib/seo-settings.js +2 -2
- package/admin/js/templates/docs/usage-search-engines.html +84 -0
- package/admin/js/templates/documentation.html +4 -0
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/seo.js +15 -14
- package/docs/DommaFeatures.md +9 -2
- package/docs/plugin-development.md +1 -1
- package/package.json +12 -12
- package/plugins/booking/CLAUDE.md +5 -0
- package/plugins/booking/admin/views/settings.js +1 -1
- package/plugins/booking/docs/guide.md +2 -0
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/plugin.public.js +15 -2
- package/plugins/booking/tests/seo.test.js +71 -0
- package/plugins/free-tier.lock.json +18 -16
- package/plugins/shopping-cart/CLAUDE.md +9 -0
- package/plugins/shopping-cart/admin/views/settings.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +2 -1
- package/plugins/shopping-cart/plugin.js +18 -0
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/plugin.public.js +39 -4
- package/plugins/shopping-cart/tests/seo.test.js +95 -0
- package/public/js/site.js +1 -1
- package/public/js/vendor/dompurify/LICENSE +202 -0
- package/public/js/vendor/dompurify/LICENSE-MPL +373 -0
- package/public/js/vendor/dompurify/purify.min.js +7 -0
- package/scripts/build.js +3 -0
- package/server/routes/api/collections.js +2 -0
- package/server/routes/api/effects.js +5 -1
- package/server/routes/api/forms.js +2 -0
- package/server/routes/api/views.js +2 -0
- package/server/routes/public.js +20 -1
- package/server/server.js +5 -4
- package/server/services/docs.js +1 -0
- package/server/services/hooks.js +3 -2
- package/server/services/markdown.js +200 -120
- package/server/services/nojsLinks.js +130 -0
- package/server/services/renderer.js +23 -6
- package/server/services/sitemap.js +100 -2
- package/server/templates/page.html +17 -12
package/admin/js/views/seo.js
CHANGED
|
@@ -38,20 +38,20 @@ import{apiRequest as ys}from"../api.js";import{addBannerAction as xs}from"../lib
|
|
|
38
38
|
<span class="sox-status" data-bind-data-status="status" data-bind-text="glyph"></span>
|
|
39
39
|
<span class="sox-check-main"><strong data-bind-text="title"></strong><small data-bind-text="detail"></small><small data-bind-text="fix" data-bind-hidden="!fix"></small></span>
|
|
40
40
|
</div>
|
|
41
|
-
</div>`;export const seoView={templateUrl:"/admin/js/templates/seo.html",async onMount(i){const l=await Bs();if(!Fe.test(location.hash))return;const q=seoView._lifetime??(seoView._lifetime=l.viewLifetime(Fe));q.reset();const m=i.get(0).querySelector(".sox")||i.get(0),n=()=>Fe.test(location.hash)&&m.isConnected,P=e=>(q.add(e),e),
|
|
41
|
+
</div>`;export const seoView={templateUrl:"/admin/js/templates/seo.html",async onMount(i){const l=await Bs();if(!Fe.test(location.hash))return;const q=seoView._lifetime??(seoView._lifetime=l.viewLifetime(Fe));q.reset();const m=i.get(0).querySelector(".sox")||i.get(0),n=()=>Fe.test(location.hash)&&m.isConnected,P=e=>(q.add(e),e),g=(e,t)=>l.apiRequest(`${js}${e}`,t),St=location.hash.match(/^#\/seo\/([a-z0-9-]+)/)?.[1]||"overview",F=M.observable(St),Ee=M.observable(!0),Te=M.observable(""),U=M.observable(!0),x=M.observable(null),ve=M.observable({results:[]}),te=M.observable("all"),ge=M.observable(""),Ce=M.observable(""),N=M.observable({rules:[],chains:[],onRename:!0}),Oe=M.observable(""),ye=M.observable(""),xe=M.observable(""),Ve=M.observable("301"),_=M.observable({entries:[],left:[],sources:[],labels:{},robots:""}),de=M.observable("in"),Le=M.observable(""),Xe=M.observable([]),Et=M.computed(()=>!xt.includes(F.value)),Ze=M.observable(!0),et=M.observable(!0),tt=M.observable(!0);let J=null;const se=e=>_.value.labels?.[e]||(e==="pages"?"Pages":e),Tt=M.computed(()=>(x.value?.score??null)===null?"\u2014":`${x.value.score}%`),Ct=M.computed(()=>`${x.value?.score??0}%`),Ot=M.computed(()=>kt(x.value?.score)),Lt=M.computed(()=>{const e=x.value;return e?{pages:String(e.pages),failing:String(e.failing),duplicates:String(e.duplicates),redirects:String(e.redirects)}:{pages:"\u2014",failing:"\u2014",duplicates:"\u2014",redirects:"\u2014"}}),Rt=M.computed(()=>x.value?.failing?String(x.value.failing):""),At=M.computed(()=>{const e=x.value;return e?.running?`Checking\u2026 ${e.progress?.done||0} of ${e.progress?.total||"?"}`:e?.at?`Checked ${$t(e.at)}`:"Not checked yet"}),st=M.computed(()=>[...new Set((ve.value.results||[]).map(e=>e.source))]),at=M.observable([]);let we=[];const Pt=M.computed(()=>{const e=Is(ve.value.results,{source:ge.value});return{all:fe(e.all),problems:fe(e.problems),hidden:fe(e.hidden)}});P(M.effect(()=>{we=Ps(ve.value.results,{show:te.value,source:ge.value,query:Ce.value}),at.value=we.slice(0,1e3).map((e,t)=>({id:`p${t}`,path:e.urlPath,sourceText:[se(e.source),e.noindex?"hidden from search engines":e.unlisted?"not in the sitemap":""].filter(Boolean).join(" \xB7 "),title:e.title||e.label||"",scoreText:e.error?"!":typeof e.score=="number"?String(e.score):"\u2014",tone:e.error?"bad":e.noindex?"info":kt(e.score),problems:e.error||(e.problems?.length?e.problems.join(" \xB7 "):"Nothing to fix")}))}));const It=M.computed(()=>x.value?.at?Ce.value.trim()?"No page matches.":te.value==="problems"?"Nothing needs attention.":"No pages.":x.value?.running?"Checking every page - this takes a moment\u2026":"Not checked yet. Check now with the button top right."),ot=M.observable([]);let W=[];P(M.effect(()=>{const e=x.value;if(W=[],e){const t=e.siteSeo||{};for(const s of e.issues?.duplicateTitles||[])W.push({kind:"dupe",text:`Title on ${s.paths.length} pages: "${s.text}"`,detail:s.paths.join(", "),paths:s.paths,tone:"poor",badge:"duplicate"});for(const s of e.issues?.duplicateDescriptions||[]){const o=t.defaultDescription&&s.text===t.defaultDescription;W.push({kind:"dupe",text:o?`${s.paths.length} pages use the site's default description`:`Description on ${s.paths.length} pages: "${s.text}"`,detail:s.paths.join(", "),paths:s.paths,tone:"poor",badge:o?"default":"duplicate"})}t.defaultImage||W.push({kind:"setting",text:"No default share image",detail:"Pages without their own image share with none. Set one in Settings \u203A SEO.",link:"#/settings",tone:"poor",badge:"setting"}),(!e.origin||/localhost|127\.0\.0\.1/.test(e.origin))&&W.push({kind:"setting",text:"The site address is not set",detail:"Canonical links and the sitemap use whatever address a visitor came in on. Set the site address in Settings.",link:"#/settings",tone:"poor",badge:"setting"}),e.chains&&W.push({kind:"chains",text:`${e.chains} redirect${e.chains===1?" leads":"s lead"} to another redirect`,detail:"Each hop is slower and loses a little standing. The Redirects tab shows which.",tone:"poor",badge:"redirects"})}ot.value=W.map((t,s)=>({id:`s${s}`,text:t.text,detail:t.detail||"",tone:t.tone,badge:t.badge}))}));const Re=M.observable([]);let ue=[];P(M.effect(()=>{const e=Oe.value.trim().toLowerCase(),t=new Set(N.value.chains.map(s=>s.id));ue=N.value.rules.filter(s=>!e||`${s.from} ${s.to} ${s.note}`.toLowerCase().includes(e)),Re.value=ue.map((s,o)=>({id:`r${o}`,from:s.from,to:s.code===410?"(gone)":s.to,note:[s.auto?"Added when the page moved":s.note,t.has(s.id)?"leads to another redirect":""].filter(Boolean).join(" \xB7 "),code:String(s.code),tone:s.code===410?"bad":t.has(s.id)?"poor":s.code===301||s.code===308?"good":"info",hits:s.hits?`${s.hits.toLocaleString("en-GB")} use${s.hits===1?"":"s"}`:"unused",lastHit:s.lastHit?`Last used ${$t(s.lastHit)}`:"Not used yet"}))}));const jt=M.computed(()=>N.value.rules.length?String(N.value.rules.length):""),Nt=M.computed(()=>`${ue.length+0*Re.value.length} of ${N.value.rules.length}`),Dt=M.computed(()=>Oe.value.trim()?"No redirect matches.":"No redirects yet."),qt=M.computed(()=>{const e=N.value.chains.length;return e?`${e} redirect${e===1?" leads":"s lead"} to another. Right-click one and choose "Go straight there" to make it one hop.`:""}),Ut=M.computed(()=>N.value.onRename?"When a page is renamed, a redirect from its old address is added for you.":"Redirects are not added when a page is renamed (see Settings)."),nt=M.observable([]);P(M.effect(()=>{nt.value=_.value.sources.map((e,t)=>({id:`x${t}`,label:se(e.id),detail:`${e.listed} of ${e.count} listed`,state:e.on?"on":"off",tone:e.on?"good":""}))}));const Ae=M.observable([]);let ae=[];P(M.effect(()=>{const e=Le.value.trim().toLowerCase();ae=(de.value==="in"?_.value.entries:_.value.left).filter(s=>!e||`${s.urlPath} ${s.title}`.toLowerCase().includes(e)),Ae.value=ae.slice(0,2e3).map((s,o)=>({id:`e${o}`,path:s.urlPath,title:s.title||"",source:se(s.source),note:s.why||[s.lastmod?`Changed ${Mt(s.lastmod)}`:"",s.changefreq||"",s.priority!==void 0?`priority ${s.priority}`:""].filter(Boolean).join(" \xB7 ")}))}));const Bt=M.computed(()=>_.value.robots||""),_t=M.computed(()=>({in:fe((_.value.entries||[]).length),out:fe((_.value.left||[]).length)})),Ht=M.computed(()=>`${ae.length+0*Ae.value.length} address${ae.length===1?"":"es"}`),Wt=M.computed(()=>_.value.hooks?Le.value.trim()?"No address matches.":de.value==="in"?"The sitemap is empty.":"Nothing is left out.":"This version of Domma CMS lists only its own pages in the sitemap."),ke=e=>/403|forbidden/i.test(String(e?.message));async function oe(){try{const e=await g("/overview");if(!n()||!e)return;x.value=e,Te.value="",e.running&&it()}catch(e){n()&&(Te.value=ke(e)?"You do not have access to SEO.":`Could not load SEO: ${e.message||e}`)}}const Pe=(e,t,s,o=Ls.rows)=>e.peek()?Rs(m.querySelector(t),s,o).finally(()=>{n()&&(e.value=!1)}):s;async function Ie(){try{const e=await Pe(Ze,".sox-pages-loading",g("/audit"));n()&&e&&(ve.value=e)}catch{}}async function pe(){try{const e=await Pe(et,".sox-rules-loading",g("/redirects"));if(!n()||!e)return;N.value=e,Jt(e.rules||[])}catch{}}async function je(){try{const e=await Pe(tt,".sox-entries-loading",g("/sitemap"));n()&&e&&(_.value=e)}catch(e){n()&&!ke(e)&&E.toast(`Could not load the sitemap: ${e.message||e}`,{type:"error"})}}async function Ne(){Ee.value=!0,await Promise.all([oe(),Ie(),pe(),je()]),n()&&(Ee.value=!1)}function it(){J||(J=setInterval(async()=>{if(!n()){clearInterval(J),J=null;return}await oe(),x.peek()?.running||(clearInterval(J),J=null,await Ie(),wt())},1500))}P(()=>{J&&clearInterval(J)});async function rt(){try{const e=await g("/audit",{method:"POST",body:JSON.stringify({})});n()&&(x.value={...x.peek(),...e,running:!0}),it()}catch(e){n()&&E.toast(`Could not start the check: ${e.message||e}`,{type:"error"})}}async function De(e,t,s=pe){try{const o=await e();return n()&&(t&&E.toast(t,{type:"success"}),await Promise.all([s(),oe()])),o}catch(o){return n()&&(ke(o)?(U.value=!1,E.toast("Only people who may manage SEO can change this.",{type:"warning"})):E.toast(`Not done: ${o.message||o}`,{type:"error"})),null}}const Kt=(e,t=`Redirect from ${e.from} added.`)=>De(()=>g("/redirects",{method:"POST",body:JSON.stringify(e)}),t),Gt=(e,t)=>De(()=>g(`/redirects/${encodeURIComponent(e.join(","))}`,{method:"DELETE"}),`${t} removed.`);async function zt(e){try{const t=await g(`/redirects/test?path=${encodeURIComponent(e.replace(/\/\*$/,"/example"))}`);if(!n())return;const s=t.hops||[];E.toast(s.length?s.map(o=>`${o.from} \u2192 ${o.code===410?"gone":o.to} (${o.code})`).join(" \xB7 "):"No redirect applies.",{type:s.length>1?"warning":"info",duration:7e3})}catch(t){n()&&E.toast(`Could not test: ${t.message||t}`,{type:"error"})}}const lt=e=>({from:e.from||"",to:e.code===410?"":e.to||"",code:String(e.code||301),note:e.note||""});function qe(e,{asNew:t=!1,adopt:s=null}={}){const o=t?{from:"",to:"",code:"301",note:""}:lt(e),a={from:M.observable(o.from),to:M.observable(o.to),code:M.observable(o.code),note:M.observable(o.note)},p=t?"":z(lt(e));dt(t?"New redirect":`Redirect from ${e.from}`,a,`
|
|
42
42
|
<div class="sox-form-grid">
|
|
43
43
|
<div class="sox-wide"><label class="form-label" for="sox-r-from">From</label><input id="sox-r-from" class="form-input sox-mono" data-model="from.value"></div>
|
|
44
44
|
<div class="sox-wide"><label class="form-label" for="sox-r-to">To</label><input id="sox-r-to" class="form-input sox-mono" data-model="to.value" data-bind-disabled="code.value === '410'"></div>
|
|
45
45
|
<div><label class="form-label" for="sox-r-code">Kind</label><select id="sox-r-code" class="form-input" data-model="code.value">
|
|
46
46
|
${Object.entries(qs).map(([r,d])=>`<option value="${r}">${d}</option>`).join("")}</select></div>
|
|
47
47
|
<div><label class="form-label" for="sox-r-note">Note</label><input id="sox-r-note" class="form-input" data-model="note.value"></div>
|
|
48
|
-
</div>`,()=>{const r={from:a.from.value,to:a.to.value,code:Number(a.code.value),note:a.note.value},d=N.peek().rules.filter(h=>t||h.id!==e.id);return{body:r,errors:bt(r,d).errors}},async r=>{t?await
|
|
48
|
+
</div>`,()=>{const r={from:a.from.value,to:a.to.value,code:Number(a.code.value),note:a.note.value},d=N.peek().rules.filter(h=>t||h.id!==e.id);return{body:r,errors:bt(r,d).errors}},async r=>{t?await g("/redirects",{method:"POST",body:JSON.stringify(r)}):await g(`/redirects/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify(r)}),n()&&(E.toast(t?`Redirect from ${r.from} added.`:"Redirect saved.",{type:"success"}),await Promise.all([pe(),oe()]))},t?"Add":"Save",{kind:"redirect",id:e.id,what:t?`the redirect from ${s?.from||e.from||"?"}`:`the redirect from ${e.from}`,was:p,adopt:s})}function Ft(){const e={csv:M.observable("")};dt("Import redirects",e,`
|
|
49
49
|
<p class="sox-note">One redirect per line: <span class="sox-mono">from, to</span>, and optionally the kind (301, 302, 307, 308, 410) and a note. A header line is skipped. Paste from a spreadsheet, or another CMS's export.</p>
|
|
50
|
-
<textarea class="form-input sox-mono" rows="14" data-model="csv.value" placeholder="/old-page, /new-page /news/*, /blog/*, 301 /retired, , 410"></textarea>`,()=>({body:{csv:e.csv.value},errors:e.csv.value.trim()?{}:{csv:"Paste some redirects first."}}),async t=>{const s=await
|
|
50
|
+
<textarea class="form-input sox-mono" rows="14" data-model="csv.value" placeholder="/old-page, /new-page /news/*, /blog/*, 301 /retired, , 410"></textarea>`,()=>({body:{csv:e.csv.value},errors:e.csv.value.trim()?{}:{csv:"Paste some redirects first."}}),async t=>{const s=await g("/redirects/import",{method:"POST",body:JSON.stringify(t)});if(!n())return;const o=s.skipped||[];E.toast(`${s.added} added${o.length?`, ${o.length} skipped - line ${o[0].line}: ${o[0].why}`:"."}`,{type:o.length?"warning":"success",duration:o.length?9e3:4e3}),await Promise.all([pe(),oe()])},"Import",{kind:"import",what:"the redirects you were importing"})}let ct=!1;async function Jt(e){if(ct||!U.peek())return;ct=!0;const t=Ge(A.user(),"redirect","");if(!t)return;const s=new Set(e.map(o=>String(o.id)));for(const o of A.keys(`${t}.`)){let a;try{a=decodeURIComponent(o.slice(t.length+1))}catch{continue}if(s.has(a))continue;const p=yt(A.get(o),{from:"",to:"",code:"301",note:""});if(!p){A.drop(o);continue}if(await Qe(ze({what:`the redirect from ${p.values.from||"?"}`,when:p.at?Ke(p.at):"",gone:"Keep them to add it again as a new redirect."}))){A.drop(o);continue}n()&&qe({id:a},{asNew:!0,adopt:p.values});return}}function Ue(e,{kind:t,id:s="",what:o,read:a,write:p,was:r=""}){const d=Ge(A.user(),t,s);let h=z(a()),C=r,w=!0,O=!1,j=!1;const L=()=>z(a())!==h,f=()=>{const u=As({dirty:L(),owns:w});u==="write"?A.set(d,{values:a(),was:C,at:new Date().toISOString()}):u==="remove"&&A.drop(d)},$=u=>{O||(f(),L()&&(u.preventDefault(),u.returnValue=""))};window.addEventListener("beforeunload",$);const T=e.close.bind(e);let B=()=>{};const H=()=>{O=!0,window.removeEventListener("beforeunload",$),B()};return e.close=async u=>{if(O)return T(u);if(!(u!==!0&&!w)){if(u!==!0&&L()){if(j)return;j=!0;const b=await Ye("Throw away the changes you have not saved?",{title:"Unsaved changes",confirmText:"Throw away",cancelText:"Keep editing"});if(j=!1,!b||O)return;w&&A.drop(d)}else u!==!0&&f();return H(),T(u)}},B=q.add(()=>{if(!O){f(),H();try{T(!0)}catch{}}}),{saved(u,b=C){h=z(u),C=b,w=!0,f()},adopt(u,b=""){p(u),C=b,w=!0},async offer(u=r){const b=A.get(d),c=yt(b,a());return c?(w=!1,await Qe(ze({what:o,when:c.at?Ke(c.at):"",stale:c.was!==u}))?(A.drop(d),w=!0,!1):O?!1:(w=!0,p(c.values),C=c.was,!0)):(b&&A.drop(d),!1)}}}let Me=!1;function dt(e,t,s,o,a,p="Save",r=null){if(Me)return;Me=!0;let d=null;try{const h=M.observable(!1),C=M.computed(()=>o()),w=M.computed(()=>Object.values(C.value.errors).join(" ")),O={...t,saving:h,errText:w,cannot:M.computed(()=>h.value||!!w.value)},j=()=>Object.fromEntries(Object.entries(t).map(([u,b])=>[u,b.peek()])),L=u=>{for(const[b,c]of Object.entries(t))b in u&&(c.value=u[b])};d=E.slideover({title:e,size:"md",position:"right"});const f=Je(`<div class="sox-form">${s}
|
|
51
51
|
<p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
|
|
52
52
|
<div class="sox-actions"><small class="sox-muted">Ctrl+Enter ${p.toLowerCase()}s.</small><span class="sox-spacer"></span>
|
|
53
53
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
54
|
-
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">${R(p)}</button></div></div>`);let $=null;async function T(){if(O.cannot.peek())return;h.value=!0;const u=j();
|
|
54
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">${R(p)}</button></div></div>`);let $=null;async function T(){if(O.cannot.peek())return;h.value=!0;const u=j();f.inert=!0;try{await a(C.peek().body),$?.saved(u,""),d.close(!0)}catch(b){f.inert=!1,n()&&E.toast(`Not saved: ${b.message||b}`,{type:"error"})}finally{h.value=!1}}d.element.appendChild(f);const B=l.helpPopovers(f),H=M.applyBindings(O,f,{methods:{save:T,cancel:()=>d.close()}});f.addEventListener("keydown",u=>{(u.ctrlKey||u.metaKey)&&u.key==="Enter"&&(u.preventDefault(),T())}),l.onceClosed(d,()=>{H.dispose(),B(),Me=!1}),r?($=Ue(d,{...r,read:j,write:L}),r.adopt?$.adopt(r.adopt,""):$.offer(r.was)):P(()=>{try{d.close(!0)}catch{}}),I.scan(f),d.open(),f.querySelector("input, textarea")?.focus()}catch(h){Me=!1;try{d?.close(!0)}catch{}E.toast(`Could not open ${e}: ${h.message||h}`,{type:"error"})}}let ne=!1;const Yt=e=>({title:String(e.title??"").trim().slice(0,200),description:String(e.description??"").trim().slice(0,400),image:String(e.image??"").trim().slice(0,500),noindex:e.noindex===!0,focus:String(e.focus??"").trim().slice(0,100)});async function X(e){if(ne)return;ne=!0;let t;try{t=await g(`/item?path=${encodeURIComponent(e)}`)}catch(s){if(ne=!1,!n())return;const o=s?.status===404?Ge(A.user(),"item",e):null,a=o?A.get(o):null;if(a?.values){await Qe(ze({what:e,when:a.at?Ke(a.at):"",gone:"Keep them and they are offered again if the address is served again."}))&&A.drop(o);return}E.toast(`Could not open ${e}: ${s.message||s}`,{type:"error"});return}if(!n()){ne=!1;return}try{Qt(e,t)}catch(s){ne=!1,E.toast(`Could not open ${e}: ${s.message||s}`,{type:"error"})}}function Qt(e,t){const s={title:M.observable(t.fields.title),description:M.observable(t.fields.description),focus:M.observable(t.fields.focus),image:M.observable(t.fields.image),noindex:M.observable(t.fields.noindex)},o=t.limits||{},a=t.page?.title||t.live?.h1||"",p=t.kind==="page"?`${a}${t.defaults.separator}${t.defaults.siteTitle}`:t.live?.title||"",r=t.kind==="page"?t.defaults.description||"":t.live?.description||"",d=t.kind==="page"?t.defaults.image:t.live?.image||"",h=M.computed(()=>s.title.value.trim()||p),C=M.computed(()=>s.description.value.trim()||r),w=M.computed(()=>s.image.value.trim()||d),O=t.origin||location.origin,j=M.computed(()=>Ss({title:h.value,description:C.value,url:`${O}${e}`,limits:o})),L=(y,v,ce)=>{const V=y.length;return{text:`${V} / ${ce}`,width:`${Math.min(100,V/ce*100)}%`,tone:V?V<v||V>ce?"poor":"good":"bad"}},f=M.computed(()=>L(h.value,o.titleMin||30,o.titleMax||60)),$=M.computed(()=>L(C.value,o.descMin||70,o.descMax||160)),T=(()=>{try{return new URL(O).host}catch{return location.host}})(),B=M.computed(()=>w.value?`url("${w.value.replace(/["\\\n\r]/g,"")}")`:"none"),H=M.observable([]),u=y=>{const v=[],ce=y?.checks||[],V={fail:0,warn:1,pass:2,info:3};for(const He of[...new Set(ce.map(G=>G.group))]){v.push({id:`g-${He}`,head:He,title:"",detail:"",fix:"",status:"",glyph:""});for(const G of ce.filter(We=>We.group===He).sort((We,gs)=>V[We.status]-V[gs.status]))v.push({id:`c-${G.id}`,head:"",title:G.title,detail:G.detail,fix:G.status==="pass"?"":G.fix,status:G.status,glyph:Ds[G.status]||"?"})}H.value=v};u(t.analysis);const b=M.observable(t.analysis?`Score ${t.analysis.score}% \xB7 ${t.analysis.failing} failing, ${t.analysis.warnings} could be better`:t.status===200?"":`Not live (answers ${t.status||"nothing"}) - the checks run once it is published.`),c=()=>({title:s.title.peek(),description:s.description.peek(),focus:s.focus.peek(),image:s.image.peek(),noindex:s.noindex.peek()}),k=y=>{for(const v of Object.keys(s))v in y&&(s[v].value=y[v])},D=M.observable(!1),Z=M.computed(()=>s.image.value.trim()&&!/^(https?:\/\/|\/)/i.test(s.image.value.trim())?"The image: a path like /media/photo.jpg, or a full web address.":""),le={...s,saving:D,errText:Z,checks:H,scoreLine:b,serp:j,titleMeter:f,descMeter:$,shareBg:B,effTitle:h,effDesc:C,host:T,hasImage:M.computed(()=>!!w.value),defTitle:p,defDesc:r,canManage:U,cannot:M.computed(()=>D.value||!!Z.value||!U.value)},ee=E.slideover({title:`SEO \xB7 ${e}`,size:"lg",position:"right"}),ps=t.kind==="url"?'<p class="sox-note is-warn">A plugin serves this address (a blog post, a listing). What you set here takes the place of its own SEO fields.</p>':t.page?.status!=="published"?`<p class="sox-note is-warn">This page is a ${R(t.page?.status||"draft")} - not live yet.</p>`:"",ms=t.redirect?`<p class="sox-note is-bad">A redirect sends visitors from here to ${R(t.redirect.to||"(gone)")} - they never see this page.</p>`:"",Q=Je(`
|
|
55
55
|
<div class="sox-form">
|
|
56
56
|
${ps}${ms}
|
|
57
57
|
<section><h4>As it will look <span data-help="The search result is drawn the way Google draws it, cut where it cuts (roughly - it measures pixels). The share card is what Facebook, LinkedIn, WhatsApp and others show when someone shares the link."></span>
|
|
@@ -92,14 +92,14 @@ import{apiRequest as ys}from"../api.js";import{addBannerAction as xs}from"../lib
|
|
|
92
92
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
|
|
93
93
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value"><span data-icon="save" data-icon-size="13"></span> Save and check</button>
|
|
94
94
|
</div>
|
|
95
|
-
</div>`);Q.querySelector(".sox-checks").innerHTML=Ys,Vt(Q.querySelector("#sox-media"));let Se=null;async function ht(){if(le.cannot.peek())return;D.value=!0;const y=c();try{if(await
|
|
95
|
+
</div>`);Q.querySelector(".sox-checks").innerHTML=Ys,Vt(Q.querySelector("#sox-media"));let Se=null;async function ht(){if(le.cannot.peek())return;D.value=!0;const y=c();try{if(await g("/item",{method:"PUT",body:JSON.stringify({path:e,...y})}),Se?.saved(y,z(Yt(y))),!n())return;E.toast("Saved.",{type:"success"});const v=await g(`/item?path=${encodeURIComponent(e)}`).catch(()=>null);if(!n())return;v?.fields&&Se?.saved(y,z(v.fields)),v?.analysis&&(u(v.analysis),b.value=`Score ${v.analysis.score}% \xB7 ${v.analysis.failing} failing, ${v.analysis.warnings} could be better`),Ie(),oe(),wt()}catch(v){if(!n())return;ke(v)&&(U.value=!1),E.toast(`Not saved: ${v.message||v}`,{type:"error"})}finally{D.value=!1}}ee.element.appendChild(Q);const hs=l.helpPopovers(Q),fs=M.applyBindings(le,Q,{methods:{save:ht,cancel:()=>ee.close(),pickImage:async()=>{const y=await gt({title:"Choose a share image",current:s.image.peek()});y&&(s.image.value=y)}}}),bs=Q.querySelector(".sox-share-img"),vs=M.effect(()=>{bs.style.backgroundImage=B.value});Q.addEventListener("keydown",y=>{(y.ctrlKey||y.metaKey)&&y.key==="Enter"&&(y.preventDefault(),ht())}),l.onceClosed(ee,()=>{fs.dispose(),hs(),vs?.(),ne=!1});const ft=z(t.fields);Se=Ue(ee,{kind:"item",id:e,what:e,read:c,write:k,was:ft}),I.scan(Q),ee.open(),Se.offer(ft)}let Be=null;async function Vt(e){try{Be??(Be=await l.apiRequest("/media").then(t=>Array.isArray(t)?t:t?.files||t?.items||[]));for(const t of Be){const s=t.url||t.path||(t.filename?`/media/${t.filename}`:"");s&&/\.(jpe?g|png|webp|gif|avif|svg)$/i.test(s)&&e.appendChild(Object.assign(document.createElement("option"),{value:s}))}}catch{Be=[]}}let ie=!1;async function Xt(){if(ie)return;ie=!0;let e,t;try{const s=await g("/settings");e=vt(s.settings),t=z(s.settings)}catch(s){ie=!1,n()&&E.toast(`Could not load the settings: ${s.message||s}`,{type:"error"});return}if(!n()){ie=!1;return}try{Zt(e,t)}catch(s){ie=!1,E.toast(`Could not open the settings: ${s.message||s}`,{type:"error"})}}function Zt(e,t){const s=e.organisation,o=x.peek()?.siteSeo?.defaultTitle||"The site's name",a={orgOn:M.observable(s.enabled),orgType:M.observable(s.type),orgName:M.observable(s.name),orgUrl:M.observable(s.url),orgLogo:M.observable(s.logo),orgEmail:M.observable(s.email),orgPhone:M.observable(s.phone),orgSame:M.observable(s.sameAs.join(`
|
|
96
96
|
`)),street:M.observable(s.address.street),locality:M.observable(s.address.locality),region:M.observable(s.address.region),postcode:M.observable(s.address.postcode),country:M.observable(s.address.country),articles:M.observable(e.articles),exclude:M.observable(e.sitemap.exclude.join(`
|
|
97
|
-
`)),blockAll:M.observable(e.robots.blockAll),robotsExtra:M.observable(e.robots.extra),onRename:M.observable(e.redirects.onRename),every:M.observable(String(e.audit.everyHours)),tMin:M.observable(String(e.audit.titleMin)),tMax:M.observable(String(e.audit.titleMax)),dMin:M.observable(String(e.audit.descMin)),dMax:M.observable(String(e.audit.descMax)),words:M.observable(String(e.audit.minWords))},p=()=>({organisation:{enabled:a.orgOn.value,type:a.orgType.value,name:a.orgName.value,url:a.orgUrl.value,logo:a.orgLogo.value,email:a.orgEmail.value,phone:a.orgPhone.value,sameAs:a.orgSame.value,address:{street:a.street.value,locality:a.locality.value,region:a.region.value,postcode:a.postcode.value,country:a.country.value}},articles:a.articles.value,sitemap:{exclude:a.exclude.value,sources:e.sitemap.sources},robots:{blockAll:a.blockAll.value,extra:a.robotsExtra.value},redirects:{onRename:a.onRename.value},audit:{everyHours:a.every.value,titleMin:a.tMin.value,titleMax:a.tMax.value,descMin:a.dMin.value,descMax:a.dMax.value,minWords:a.words.value}}),r=()=>Object.fromEntries(Object.entries(a).map(([c,k])=>[c,k.peek()])),d=c=>{for(const[k,D]of Object.entries(a))k in c&&(D.value=c[k])},h=M.observable(!1),C=M.computed(()=>Ts(p(),e)),w=M.computed(()=>Object.values(C.value.errors).join(" ")),O={...a,saving:h,errText:w,cannot:M.computed(()=>h.value||!!w.value),isPerson:M.computed(()=>a.orgType.value==="Person")},j=(c,k,D,Z="",le="")=>`<div><label class="form-label" for="sox-s-${c}">${D}${Z?` <span data-help="${R(Z)}"></span>`:""}</label>
|
|
98
|
-
<input id="sox-s-${c}" class="form-input" data-model="${k}.value" ${le}></div>`,L=(c,k,D,Z,le,ee="")=>j(c,k,D,ee,`type="number" min="${Z}" max="${le}"`),
|
|
97
|
+
`)),blockAll:M.observable(e.robots.blockAll),llms:M.observable(e.llms.enabled),robotsExtra:M.observable(e.robots.extra),onRename:M.observable(e.redirects.onRename),every:M.observable(String(e.audit.everyHours)),tMin:M.observable(String(e.audit.titleMin)),tMax:M.observable(String(e.audit.titleMax)),dMin:M.observable(String(e.audit.descMin)),dMax:M.observable(String(e.audit.descMax)),words:M.observable(String(e.audit.minWords))},p=()=>({organisation:{enabled:a.orgOn.value,type:a.orgType.value,name:a.orgName.value,url:a.orgUrl.value,logo:a.orgLogo.value,email:a.orgEmail.value,phone:a.orgPhone.value,sameAs:a.orgSame.value,address:{street:a.street.value,locality:a.locality.value,region:a.region.value,postcode:a.postcode.value,country:a.country.value}},articles:a.articles.value,sitemap:{exclude:a.exclude.value,sources:e.sitemap.sources},robots:{blockAll:a.blockAll.value,extra:a.robotsExtra.value},llms:{enabled:a.llms.value},redirects:{onRename:a.onRename.value},audit:{everyHours:a.every.value,titleMin:a.tMin.value,titleMax:a.tMax.value,descMin:a.dMin.value,descMax:a.dMax.value,minWords:a.words.value}}),r=()=>Object.fromEntries(Object.entries(a).map(([c,k])=>[c,k.peek()])),d=c=>{for(const[k,D]of Object.entries(a))k in c&&(D.value=c[k])},h=M.observable(!1),C=M.computed(()=>Ts(p(),e)),w=M.computed(()=>Object.values(C.value.errors).join(" ")),O={...a,saving:h,errText:w,cannot:M.computed(()=>h.value||!!w.value),isPerson:M.computed(()=>a.orgType.value==="Person")},j=(c,k,D,Z="",le="")=>`<div><label class="form-label" for="sox-s-${c}">${D}${Z?` <span data-help="${R(Z)}"></span>`:""}</label>
|
|
98
|
+
<input id="sox-s-${c}" class="form-input" data-model="${k}.value" ${le}></div>`,L=(c,k,D,Z,le,ee="")=>j(c,k,D,ee,`type="number" min="${Z}" max="${le}"`),f=(c,k,D="")=>`<label class="sox-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="${c}.value"> ${k}${D?` <span data-help="${R(D)}"></span>`:""}</label>`,$=E.slideover({title:"SEO settings",size:"md",position:"right"}),T=Je(`
|
|
99
99
|
<div class="sox-form">
|
|
100
100
|
<p class="sox-note">The site's default title, description and share image are in <a href="#/settings">Settings \u203A SEO</a>.</p>
|
|
101
101
|
<section><h4>Who is behind the site <span data-help="Structured data on the home page, so search engines can show the name, logo and contact details - a knowledge panel, or a local business in maps."></span></h4><div class="sox-form-grid">
|
|
102
|
-
${
|
|
102
|
+
${f("orgOn","Tell search engines who runs the site")}
|
|
103
103
|
<div><label class="form-label" for="sox-s-type">It is</label><select id="sox-s-type" class="form-input" data-model="orgType.value">
|
|
104
104
|
${Cs.map(c=>`<option value="${c}">${{Organization:"An organisation",LocalBusiness:"A local business",Person:"A person"}[c]}</option>`).join("")}</select></div>
|
|
105
105
|
${j("name","orgName","Name","Empty uses the site's name.",`placeholder="${R(o)}"`)}
|
|
@@ -115,17 +115,18 @@ import{apiRequest as ys}from"../api.js";import{addBannerAction as xs}from"../lib
|
|
|
115
115
|
<input class="form-input" placeholder="Postcode" aria-label="Postcode" data-model="postcode.value"><input class="form-input" placeholder="Country (GB)" aria-label="Country" data-model="country.value"></div></div>
|
|
116
116
|
<div class="sox-wide"><label class="form-label" for="sox-s-same">Profiles elsewhere <span data-help="Your pages on Facebook, LinkedIn, Instagram, X and so on - one address per line. Search engines use them to know they are you."></span></label>
|
|
117
117
|
<textarea id="sox-s-same" rows="3" class="form-input sox-mono" data-model="orgSame.value" placeholder="https://www.linkedin.com/company/\u2026"></textarea></div>
|
|
118
|
-
${
|
|
118
|
+
${f("articles","Mark blog posts as articles","Adds BlogPosting structured data to each post - headline, image, publisher - which search engines use for article results.")}
|
|
119
119
|
</div></section>
|
|
120
|
-
<section><h4>Sitemap and
|
|
120
|
+
<section><h4>Sitemap, robots.txt and llms.txt</h4><div class="sox-form-grid">
|
|
121
121
|
<div class="sox-wide"><label class="form-label" for="sox-s-ex">Leave out of the sitemap <span data-help="One address per line. Ending with / leaves out everything under it: /members/ covers /members/area. To hide one page from search engines altogether, open it on the Pages tab instead."></span></label>
|
|
122
122
|
<textarea id="sox-s-ex" rows="3" class="form-input sox-mono" data-model="exclude.value" placeholder="/thank-you /members/"></textarea></div>
|
|
123
|
-
${
|
|
123
|
+
${f("blockAll","Ask every search engine to stay away","robots.txt then says Disallow: / to everyone. For a site that is not ready yet - remember to switch it off at launch. It is a request: well-behaved crawlers honour it.")}
|
|
124
124
|
<div class="sox-wide"><label class="form-label" for="sox-s-rob">More robots.txt rules <span data-help="Lines like Disallow: /private/ join the rules for every crawler. Start with User-agent: to add rules for one crawler, e.g. User-agent: GPTBot then Disallow: / to keep an AI crawler out."></span></label>
|
|
125
125
|
<textarea id="sox-s-rob" rows="3" class="form-input sox-mono" data-model="robotsExtra.value" placeholder="Disallow: /private/"></textarea></div>
|
|
126
|
+
${f("llms","Publish /llms.txt for AI assistants","A plain list of the pages in the sitemap, with their descriptions, at /llms.txt - the format AI assistants look for. Nothing kept out of the sitemap appears in it, and it is withdrawn while search engines are asked to stay away.")}
|
|
126
127
|
</div></section>
|
|
127
128
|
<section><h4>Redirects</h4><div class="sox-form-grid">
|
|
128
|
-
${
|
|
129
|
+
${f("onRename","Add a redirect when a page is renamed","The old address keeps working, and any redirect that pointed at it is re-aimed at the new one - so there are never chains.")}
|
|
129
130
|
</div></section>
|
|
130
131
|
<section><h4>The checks</h4><div class="sox-form-grid">
|
|
131
132
|
${L("every","every","Check every page every (hours)",0,168,"0 checks only when you ask.")}
|
|
@@ -141,7 +142,7 @@ import{apiRequest as ys}from"../api.js";import{addBannerAction as xs}from"../lib
|
|
|
141
142
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
142
143
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value"><span data-icon="save" data-icon-size="13"></span> Save</button>
|
|
143
144
|
</div>
|
|
144
|
-
</div>`);let B=null;async function H(){if(O.cannot.peek())return;h.value=!0;const c=r();T.inert=!0;try{const k=await
|
|
145
|
+
</div>`);let B=null;async function H(){if(O.cannot.peek())return;h.value=!0;const c=r();T.inert=!0;try{const k=await g("/settings",{method:"PUT",body:JSON.stringify(C.peek().settings)});if(B?.saved(c,z(k?.settings??null)),$.close(!0),!n())return;E.toast("Settings saved.",{type:"success"}),Ne()}catch(k){T.inert=!1,n()&&E.toast(`Not saved: ${k.message||k}`,{type:"error"})}finally{h.value=!1}}$.element.appendChild(T);const u=l.helpPopovers(T),b=M.applyBindings(O,T,{methods:{save:H,cancel:()=>$.close(),pickLogo:async()=>{const c=await gt({title:"Choose a logo",current:a.orgLogo.peek()});c&&(a.orgLogo.value=c)}}});T.addEventListener("keydown",c=>{(c.ctrlKey||c.metaKey)&&c.key==="Enter"&&(c.preventDefault(),H())}),l.onceClosed($,()=>{b.dispose(),u(),ie=!1}),B=Ue($,{kind:"settings",what:"the SEO settings",read:r,write:d,was:t}),I.scan(T),$.open(),B.offer(t)}async function es(e,t){try{const s=await g("/settings"),o=vt(s.settings);if(o.sitemap.sources[e]=t,await g("/settings",{method:"PUT",body:JSON.stringify(o)}),!n())return;E.toast(`${se(e)} ${t?"back in":"left out of"} the sitemap.`,{type:"success"}),je()}catch(s){n()&&E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}const K=e=>Number(String(e?.dataset?.row||"").slice(1));function ts(e){const t=we[K(e)];return t?[{header:t.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>X(t.urlPath)},{label:"Open the page",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Redirect it elsewhere\u2026",icon:"corner-up-right",disabled:!U.peek(),action:()=>{Y("redirects"),ye.value=t.urlPath,xe.value=""}},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>be(t.urlPath,"the address")},{label:"Copy the problems",icon:"clipboard",disabled:!t.problems?.length&&!t.error,action:()=>be(`${t.urlPath}
|
|
145
146
|
${t.error||t.problems.join(`
|
|
146
147
|
`)}`,"the problems")}]:[]}function ss(e){const t=W[K(e)];return t?t.kind==="dupe"?[{header:"Open one of them"},...t.paths.slice(0,12).map(s=>({label:s,icon:"edit-3",action:()=>X(s)})),{type:"divider"},{label:"Copy the addresses",icon:"copy",action:()=>be(t.paths.join(`
|
|
147
|
-
`),"the addresses")}]:t.kind==="chains"?[{label:"Show the redirects",icon:"corner-up-right",action:()=>Y("redirects")}]:[{label:"Go to Settings",icon:"settings",action:()=>{location.hash=t.link}}]:[]}function as(e){const t=W[K(e)];t?.kind==="dupe"?X(t.paths[0]):t?.kind==="chains"?Y("redirects"):t?.link&&(location.hash=t.link)}function os(e){const t=ue[K(e)];if(!t)return[];const s=N.peek().rules,o=N.peek().chains.some(p=>p.id===t.id);let a=null;if(o){a=t.to;for(let p=0;p<10;p++){const r=Es(s,a);if(!r)break;if(r.code===410){a=null;break}if(a=r.location,/^https?:/i.test(a))break}}return[{header:`${t.from} \u2192 ${t.code===410?"gone":t.to}`},{label:"Edit\u2026",icon:"edit-3",disabled:!U.peek(),action:()=>qe(t)},{label:"Test it",icon:"play",action:()=>zt(t.from)},{label:"Go straight there",icon:"fast-forward",disabled:!a||!U.peek(),action:()=>De(()=>
|
|
148
|
+
`),"the addresses")}]:t.kind==="chains"?[{label:"Show the redirects",icon:"corner-up-right",action:()=>Y("redirects")}]:[{label:"Go to Settings",icon:"settings",action:()=>{location.hash=t.link}}]:[]}function as(e){const t=W[K(e)];t?.kind==="dupe"?X(t.paths[0]):t?.kind==="chains"?Y("redirects"):t?.link&&(location.hash=t.link)}function os(e){const t=ue[K(e)];if(!t)return[];const s=N.peek().rules,o=N.peek().chains.some(p=>p.id===t.id);let a=null;if(o){a=t.to;for(let p=0;p<10;p++){const r=Es(s,a);if(!r)break;if(r.code===410){a=null;break}if(a=r.location,/^https?:/i.test(a))break}}return[{header:`${t.from} \u2192 ${t.code===410?"gone":t.to}`},{label:"Edit\u2026",icon:"edit-3",disabled:!U.peek(),action:()=>qe(t)},{label:"Test it",icon:"play",action:()=>zt(t.from)},{label:"Go straight there",icon:"fast-forward",disabled:!a||!U.peek(),action:()=>De(()=>g(`/redirects/${encodeURIComponent(t.id)}`,{method:"PUT",body:JSON.stringify({to:a})}),`${t.from} now goes straight to ${a}.`)},{label:"Open the old address",icon:"external-link",disabled:t.from.endsWith("/*"),action:()=>window.open(t.from,"_blank","noopener")},{type:"divider"},{label:"Copy",icon:"copy",action:()=>be(`${t.from} ${t.to} ${t.code}`,"the redirect")},{label:"Remove",icon:"trash-2",danger:!0,disabled:!U.peek(),action:async()=>{await Ye(`Remove the redirect from ${t.from}? Visitors to it will get "not found".`)&&Gt([t.id],"Redirect")}}]}function ns(e){const t=ae[K(e)];return t?[{header:t.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>X(t.urlPath)},{label:"Open",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>be(t.urlPath,"the address")}]:[]}function is(e){const t=_.peek().sources[K(e)];return t?[{header:se(t.id)},{label:t.on?"Leave all of it out":"List it again",icon:t.on?"eye-off":"eye",disabled:!U.peek(),action:()=>es(t.id,!t.on)}]:[]}function me(e,t,s){const o=E.contextMenu(e,{cursor:"glyph",match:"[data-row]",inherit:!1,density:"compact",items:r=>t(r)});P(()=>o?.destroy?.());const a=r=>{const d=r.target.closest("[data-row]");if(!(!d||r.target.closest("a"))){if(r.target.closest(".sox-kebab")){l.openMenuAt(d);return}s?.(d)}},p=r=>{const d=r.target.closest("[data-row]");d&&(r.key==="F10"&&r.shiftKey?(r.preventDefault(),l.openMenuAt(d)):r.key==="Enter"&&r.target===d&&(r.preventDefault(),r.stopPropagation(),setTimeout(()=>{n()&&d.isConnected&&s?.(d)},0)))};e.addEventListener("click",a),e.addEventListener("keydown",p),P(()=>{e.removeEventListener("click",a),e.removeEventListener("keydown",p)})}const rs=m.querySelector(".sox-ext"),_e=m.querySelector(".sox-ext-tabs");let re=null,$e="",he=0;const ut=()=>{const e=re;re=null;try{e?.dispose?.()}catch{}e?.box?.remove()};async function ls(){let e=[];try{e=(await g("/extensions"))?.tabs||[]}catch{}n()&&(Xe.value=e,_e.replaceChildren(...e.map(t=>{const s=document.createElement("button");return s.type="button",s.setAttribute("role","tab"),s.dataset.ext=t.key,s.innerHTML=`<span data-icon="${R(t.icon||"box")}" data-icon-size="14"></span> ${R(t.label)} <span class="sox-pro">PRO</span>`,t.help&&(s.title=t.help),s.addEventListener("click",()=>Y(t.key)),s})),I.scan(_e),pt())}async function pt(){if(!n())return;for(const a of _e.querySelectorAll("button"))a.classList.toggle("is-on",a.dataset.ext===F.peek());const e=Xe.peek().find(a=>a.key===F.peek());if(re&&re.key!==e?.key&&ut(),!e){$e="",he++,xt.includes(F.peek())||Y("overview");return}if(re?.key===e.key||$e===e.key)return;const t=++he;$e=e.key;const s=document.createElement("div");s.className="sox-panel sox-ext-body",rs.replaceChildren(s);const o=()=>t===he&&n()&&F.peek()===e.key;try{const a=await import(e.module);if(!o())return;const p=await a.mount(s,{kit:l,api:l.apiRequest,scope:P,here:n,openItem:X,reload:Ne,E,M,I});if(!o()){try{p?.()}catch{}s.remove();return}re={key:e.key,dispose:p,box:s}}catch(a){o()&&(s.innerHTML=`<p class="sox-note is-bad">${R(e.label)} could not be shown: ${R(a.message||a)}</p>`)}finally{t===he&&($e="")}}P(()=>{he++,ut()}),m.querySelector(".sox-pages").innerHTML=Ks,m.querySelector(".sox-site").innerHTML=Gs,m.querySelector(".sox-rules").innerHTML=zs,m.querySelector(".sox-entries").innerHTML=Fs,m.querySelector(".sox-sources").innerHTML=Js;const mt=m.querySelector(".sox-select");P(M.effect(()=>{const e=st.value;mt.replaceChildren(new Option("Everything",""),...e.map(t=>new Option(se(t),t))),mt.value=ge.peek()}));function Y(e){F.peek()!==e&&(F.value=e,history.replaceState(null,"",`#/seo${e==="overview"?"":`/${e}`}`),e==="redirects"&&pe(),e==="sitemap"&&je()),pt()}const cs={tab:F,busy:Ee,error:Te,canManage:U,scoreText:Tt,scoreWidth:Ct,scoreTone:Ot,ov:Lt,attention:Rt,asOf:At,sourceOptions:st,source:ge,show:te,query:Ce,pageRows:at,pagesEmpty:It,siteRows:ot,ruleRows:Re,ruleCount:jt,ruleText:Nt,rulesEmpty:Dt,chainText:qt,renameText:Ut,ruleQuery:Oe,newFrom:ye,newTo:xe,newCode:Ve,sourceRows:nt,smRows:Ae,smShow:de,smQuery:Le,smText:Ht,smEmpty:Wt,robots:Bt,extOn:Et,counts:Pt,smCounts:_t,pagesLoading:Ze,rulesLoading:et,smLoading:tt},ds={tab_overview:()=>Y("overview"),tab_redirects:()=>Y("redirects"),tab_sitemap:()=>Y("sitemap"),show_all:()=>{te.value="all"},show_problems:()=>{te.value="problems"},show_hidden:()=>{te.value="hidden"},sm_in:()=>{de.value="in"},sm_out:()=>{de.value="out"},importCsv:Ft,exportRules:()=>{const e=N.peek().rules;if(!e.length){E.toast("Nothing to export.",{type:"info"});return}Ws(`redirects-${location.hostname}-${new Date().toISOString().slice(0,10)}.csv`,Hs(e.map(t=>({from:t.from,to:t.to,code:t.code,note:t.note||"",hits:t.hits||0,last_used:t.lastHit||""}))))},addRule:()=>{const e={from:ye.peek(),to:xe.peek(),code:Number(Ve.peek())},{errors:t}=bt(e,N.peek().rules);return Object.keys(t).length?(E.toast(Object.values(t).join(" "),{type:"warning"}),!1):(Kt(e).then(s=>{s&&(ye.value="",xe.value="")}),!1)}},us=M.applyBindings(cs,m,{methods:ds});P(()=>us.dispose()),P(l.helpPopovers(m,{position:"bottom"})),me(m.querySelector(".sox-pages"),ts,e=>{const t=we[K(e)];t&&X(t.urlPath)}),me(m.querySelector(".sox-site"),ss,as),me(m.querySelector(".sox-rules"),os,e=>{const t=ue[K(e)];t&&U.peek()&&qe(t)}),me(m.querySelector(".sox-entries"),ns,e=>{const t=ae[K(e)];t&&X(t.urlPath)}),me(m.querySelector(".sox-sources"),is),l.addBannerAction(i,{id:"seo-check",icon:"refresh-cw",title:"Check every page again",onClick:rt}),l.addBannerAction(i,{id:"seo-settings",icon:"settings",title:"SEO settings",onClick:Xt}),I.scan(m),await Promise.all([Ne(),ls()]),n()&&I.scan(m),n()&&!x.peek()?.at&&!x.peek()?.running&&rt()}};
|
package/docs/DommaFeatures.md
CHANGED
|
@@ -380,8 +380,15 @@ headings (Layouts → Page contents settings, and `[contents]` per page).
|
|
|
380
380
|
- **sitemap.xml** - built from published, public, indexable pages (not `/404`, not `noindex`), plus plugin sources such as blog posts; cached and refreshed on any page change.
|
|
381
381
|
- Where: `/sitemap.xml`; `server/services/sitemap.js`
|
|
382
382
|
- Since: plugin sitemap sources 0.89.0
|
|
383
|
-
- **robots.txt** - allows the site, disallows `/admin/` and `/api/`, points at the sitemap; extra lines and a "block all" switch in SEO settings.
|
|
384
|
-
- Where: `/robots.txt`
|
|
383
|
+
- **robots.txt** - allows the site, disallows `/admin/` and `/api/`, points at the sitemap; extra lines and a "block all" switch in SEO settings. Ahead of the `/api/` block it allows the public read endpoints a page's scripts call while it renders (`/api/effects/settings`, `/api/forms/*/public`, `/api/components/*.js`, the `/api/collections/*/public` and `/api/views/*/public` listings), so Google, which renders pages with JavaScript, can fetch them; the longest matching rule wins. Those JSON answers carry `X-Robots-Tag: noindex`, so they are fetched for rendering but never listed. Nothing that needs a sign-in or writes is allowed (`PUBLIC_API_ALLOW` in `sitemap.js`).
|
|
384
|
+
- Where: `/robots.txt`; `server/services/sitemap.js`; checked by `tests/routes/llms-robots.test.js`
|
|
385
|
+
- Since: the Allow lines 1.8.36
|
|
386
|
+
- **llms.txt** - an automatic `/llms.txt` (the llmstxt.org format) for AI assistants: `# Site title`, `> description` (Settings > SEO default description), then `## Pages` and one section per plugin sitemap source (blog posts and so on), each line `- [Title](absolute url): description`. Built from the same published, public, indexable set as the sitemap (SEO exclusions included), with absolute links from the site address in Site Settings (else the request's origin). Cached and invalidated with the sitemap and with Site Settings saves. On by default with its own switch in SEO settings; answers 404 while "Ask every search engine to stay away" is on. With the SEO Tool switched off it is still served, as the sitemap is.
|
|
387
|
+
- Where: `/llms.txt`; **Tools > SEO > Settings** ("Publish /llms.txt for AI assistants"); `config/seo.json` `llms.enabled`; `buildLlmsTxt()` in `server/services/sitemap.js`
|
|
388
|
+
- Since: 1.8.36
|
|
389
|
+
- **Readable without JavaScript** - pages are rendered on the server, so robots that run no JavaScript (GPTBot, ClaudeBot and most AI crawlers) read the words, headings, body links, meta tags and JSON-LD. The navbar and footer (menus with their nested items, copyright, social links) are also written as plain links inside `#site-navbar` and `#site-footer`, built from the same per-viewer menu data the browser gets and visually hidden (`.sr-only.cms-nojs-links`, so nothing flashes or shifts); site.js removes them once it has drawn the real ones. An interactive `[collection]` in client mode lists every entry in its no-JS fallback (or its `limit`, at most 200; the rest stay in the browser's payload), an interactive `[view]` has the same fallback, and a `[form]` embed carries the form's title and description as hidden text. Scripts load at the end of the body; the ones nothing needs mid-parse carry `defer` (collection-browser, form-logic-engine, effects, search, analytics, back-to-top, cookie consent - the first two sit above site.js because deferred and module scripts run in one queue in document order). DOMPurify ships in `public/js/vendor/dompurify/` at a pinned version (admin and public pages), so no public page script comes from a CDN. Still built in the browser: form fields, a server-mode collection browser beyond its first page (no page links without JavaScript), search results and the Calendar month view.
|
|
390
|
+
- Where: `server/templates/page.html`, `server/services/renderer.js`, `server/services/nojsLinks.js`, `server/services/markdown.js`; checked by `tests/services/page-template-scripts.test.js`, `tests/services/nojs-links.test.js`, `tests/markdown/nojs-fallbacks.test.js`; admin **Documentation > Usage > Search Engines & AI**
|
|
391
|
+
- Since: 1.8.36
|
|
385
392
|
- **SEO tool** - an audit of every sitemap URL, fetched in-process and scored (title and description length, single H1, heading order, word count, image alt text, internal links, focus keyphrase placement and density, noindex, canonical, share image, structured data, language), with site-wide duplicate title/description detection and a scheduled re-audit. Open a page to see its search result and share card as they will look and edit its fields. **Redirects** tab (301, 302, 307, 308, 410; CSV import; hit counts; automatic redirect on page rename). **Sitemap** tab shows what is listed and left out, plus robots.txt. Organisation / LocalBusiness / Person structured data for the home page and BlogPosting for blog posts. Can be switched off as a Tool; SEO Pro plugs into its registry.
|
|
386
393
|
- Where: **Tools > SEO** (`#/seo`, in the Tools folder by default); `config/seo.json`; `content/seo/redirects.json`, `overrides.json`; `server/services/seo/`
|
|
387
394
|
- Who: the `seo` permission (View / Manage). The level-0 role holds it, and the seeded `admin` role is granted it once at boot with the other built-in Tools (removed by hand, it stays removed); other roles need it ticked under System > Roles.
|
|
@@ -344,7 +344,7 @@ roles and notifications are stamped with it). The sections below cover the main
|
|
|
344
344
|
| `registerRelatedItems(kind, provider)` | Show items beside a core contact or note, with menu actions (0.79.0). Kind `message` (0.103.0): actions on an email's menu in the mail reader, `{email}`/`{name}`/`{subject}` in the url; any action can carry `permission: {resource, action}` |
|
|
345
345
|
| `enableInstantCalls()` | A paid Tool with calls switches on the CMS's instant calls ("Call Sam now…" on contacts and emails, `#/calls/new`, `/api/calls`, `/_call/<id>`). Bound to the plugin's name; refused unless the manifest is `tier: "paid"` AND `closedSource: true` (so it loads only under a licence). Disabling the plugin takes its hold off at once. Returns true when on. Call it with `?.()` - older cores lack it |
|
|
346
346
|
| `registerContactAccess(fn)` | A plugin that shares contacts says which contacts that are not the viewer's own they may read: `fn(entries, {id, roles})` resolves to the readable entry ids. Core code taking a contact id (instant calls) uses it through `readableContactIds()` |
|
|
347
|
-
| `registerSitemapSource({id, label, list})` / `sitemapChanged()` | Add public URLs to `/sitemap.xml
|
|
347
|
+
| `registerSitemapSource({id, label, list})` / `sitemapChanged()` | Add public URLs to `/sitemap.xml` (and `/llms.txt`, where an item's optional `description` follows its link, 1.8.36); call `sitemapChanged()` when they change (otherwise they show within ten minutes) (0.89.0) |
|
|
348
348
|
| `notify({...})` / `resolveNotification(key)` | Raise or clear an admin notification (source ids are `<plugin>:<id>`) (0.80.0) |
|
|
349
349
|
| `registerNotificationSource({id, label, severity, defaultOn, every, atBoot, check, audience})` | A switchable notification source, optionally scheduled (`boot`, `15m`, `hourly`, `daily`) (0.80.0) |
|
|
350
350
|
| `registerLoginCheck({id, phase, check, verify?})` | A sign-in check: `phase: 'before'` (e.g. lockout, before the password is checked) or `'after'` (e.g. two-factor, can ask one more question) (0.83.0) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domma-cms",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.37",
|
|
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",
|
|
@@ -77,23 +77,23 @@
|
|
|
77
77
|
},
|
|
78
78
|
"dependencies": {
|
|
79
79
|
"@fastify/compress": "^8.3.1",
|
|
80
|
-
"@fastify/cors": "^11.
|
|
81
|
-
"@fastify/helmet": "^13.0
|
|
82
|
-
"@fastify/jwt": "^10.
|
|
83
|
-
"@fastify/multipart": "^
|
|
80
|
+
"@fastify/cors": "^11.3.1",
|
|
81
|
+
"@fastify/helmet": "^13.2.0",
|
|
82
|
+
"@fastify/jwt": "^10.2.5",
|
|
83
|
+
"@fastify/multipart": "^10.1.5",
|
|
84
84
|
"@fastify/rate-limit": "^10.3.0",
|
|
85
|
-
"@fastify/static": "
|
|
85
|
+
"@fastify/static": "10.1.7",
|
|
86
86
|
"bcryptjs": "^3.0.3",
|
|
87
87
|
"domma-js": "0.53.3",
|
|
88
88
|
"dotenv": "^17.2.3",
|
|
89
|
-
"fastify": "5.
|
|
89
|
+
"fastify": "5.12.7",
|
|
90
90
|
"gray-matter": "^4.0.3",
|
|
91
|
-
"imapflow": "^2.0
|
|
92
|
-
"mailparser": "^3.
|
|
91
|
+
"imapflow": "^2.8.0",
|
|
92
|
+
"mailparser": "^3.12.2",
|
|
93
93
|
"marked": "^15.0.0",
|
|
94
|
-
"nodemailer": "
|
|
95
|
-
"sanitize-html": "^2.
|
|
96
|
-
"sharp": "^0.
|
|
94
|
+
"nodemailer": "10.2.0",
|
|
95
|
+
"sanitize-html": "^2.18.1",
|
|
96
|
+
"sharp": "^0.35.5",
|
|
97
97
|
"uuid": "13.0.1"
|
|
98
98
|
},
|
|
99
99
|
"optionalDependencies": {
|
|
@@ -197,3 +197,8 @@ only to roles that hold it.
|
|
|
197
197
|
## 1.7.1
|
|
198
198
|
|
|
199
199
|
- **Email (openEmail).** Email actions go through `admin/lib/email.js`: `emailTo()` for menu actions (several people always in Bcc), `routeMailLinks(root)` for visible `mailto:` links. Core's `/admin/js/lib/mail-compose.js` (CMS 1.6.61+) opens Email Pro's compose window when the user has a mailbox there; older cores keep a cleaned, capped `mailto:` (over 1800 chars the addresses are copied instead). Stored addresses are encoded into links, or shown as text when invalid. Never set `location.href = 'mailto:…'` directly.
|
|
200
|
+
|
|
201
|
+
## 1.7.2 - a meta description on every booking page (2026-10-22)
|
|
202
|
+
`metaDescription(text, fallback)` in plugin.public.js: the Introduction (index) or the service's description,
|
|
203
|
+
else "Book an appointment with <site> online: <services>." / "Book <service> (<length>) with <site> online.";
|
|
204
|
+
one line, 160 characters at most. The Introduction's help says search engines show it. Test: `tests/seo.test.js`.
|
|
@@ -86,7 +86,7 @@ export async function openSettings(ctx, section = 'page') {
|
|
|
86
86
|
<div><label class="form-label">Address <span data-help-title="Address" data-help="Where the booking page lives on your site. /booking gives /booking, a page per service at /booking/service-name, and the customer's own page at /booking/manage/…. Links in emails already sent keep working only at the old address."></span></label>
|
|
87
87
|
<input class="form-input" data-model="bookingPath.value" maxlength="60">${err('bookingPath')}</div>
|
|
88
88
|
<div class="bk-wide"><div class="bk-copy"><code data-bind-text="pageUrl.value"></code><button type="button" class="btn btn-sm btn-ghost" data-on-click="copyUrl" title="Copy the address"><span data-icon="copy" data-icon-size="13"></span></button></div></div>
|
|
89
|
-
<div class="bk-wide"><label class="form-label">Introduction <span data-help-title="Introduction" data-help="A line or two under the heading: what to expect, where you are, how to prepare."></span></label>
|
|
89
|
+
<div class="bk-wide"><label class="form-label">Introduction <span data-help-title="Introduction" data-help="A line or two under the heading: what to expect, where you are, how to prepare. Search engines show it too; left empty, they get a line made from your site name and services."></span></label>
|
|
90
90
|
<textarea class="form-input" rows="2" data-model="intro.value" maxlength="2000"></textarea></div>
|
|
91
91
|
<div><label class="form-label">Time zone <span data-help-title="Time zone" data-help="Opening hours, the Diary and every time in emails are in this zone - where the business is, not where the customer is."></span></label>
|
|
92
92
|
<select class="form-select" data-model="timeZone.value">${zones(s.timeZone).map(z => `<option value="${esc(z)}">${esc(z.replace(/_/g, ' '))}</option>`).join('')}</select>${err('timeZone')}</div>
|
|
@@ -36,6 +36,8 @@ The **Book...** options only appear for people who are allowed to manage booking
|
|
|
36
36
|
|
|
37
37
|
If you also use the Calendar Tool, bookings appear there under **Bookings**. A person's own Calendar events block their booking times, so nobody can book them while they are busy. To use this, link the person to their user account in **People & rooms**. You can switch it off per person or for the whole of Booking.
|
|
38
38
|
|
|
39
|
+
Search engines show the **Introduction** (booking settings) as the booking page's description, and a service's description on its own page. Left empty, they get a line made from your site's name and services.
|
|
40
|
+
|
|
39
41
|
## Putting the booking form on a page
|
|
40
42
|
|
|
41
43
|
| Shortcode | What it shows |
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "booking",
|
|
3
3
|
"displayName": "Booking",
|
|
4
|
-
"version": "1.7.
|
|
4
|
+
"version": "1.7.2",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-10-
|
|
8
|
+
"date": "2026-10-22",
|
|
9
9
|
"icon": "clipboard-check",
|
|
10
10
|
"uses": {
|
|
11
11
|
"contacts": "Customers become contacts; Book… on a contact and a Bookings panel on the contact card",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import {fileURLToPath} from 'url';
|
|
17
17
|
import {renderBlogPage} from '../../server/services/renderer.js';
|
|
18
|
+
import {getConfig} from '../../server/config.js';
|
|
18
19
|
import {allBookables, allServices, getBooking, liveSettings, publicBooking, tokenOk} from './lib/store.js';
|
|
19
20
|
import {callWindow, whoIsCalling} from './lib/call.js';
|
|
20
21
|
import {dayLabel, lengthLabel} from './public/lib/page-format.js';
|
|
@@ -40,6 +41,17 @@ const RECOVER_CALL = '<script>(function(){try{if(location.search)return;var s=se
|
|
|
40
41
|
|
|
41
42
|
export const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The page's search-engine description (1.7.2): the owner's own words (the
|
|
46
|
+
* Introduction, a service's description), else a line made from the site's
|
|
47
|
+
* name - an empty one left /booking with no meta description at all.
|
|
48
|
+
*/
|
|
49
|
+
export function metaDescription(text, fallback) {
|
|
50
|
+
const out = String(text || '').replace(/\s+/g, ' ').trim() || String(fallback || '').trim();
|
|
51
|
+
return out.length > 160 ? `${out.slice(0, 157).trimEnd()}...` : out;
|
|
52
|
+
}
|
|
53
|
+
const siteName = () => String(getConfig('site')?.title || '').trim();
|
|
54
|
+
|
|
43
55
|
/** Plain text with blank lines between paragraphs, as HTML. */
|
|
44
56
|
const paras = (text, cls = '') => String(text || '').split(/\n\s*\n/).map(p => p.trim()).filter(Boolean)
|
|
45
57
|
.map(p => `<p${cls ? ` class="${cls}"` : ''}>${esc(p).replace(/\n/g, '<br>')}</p>`).join('');
|
|
@@ -77,7 +89,8 @@ export default async function bookingPublic(fastify) {
|
|
|
77
89
|
return send(reply, request, s, {
|
|
78
90
|
page: 'index',
|
|
79
91
|
title: s.pageTitle || 'Book',
|
|
80
|
-
description:
|
|
92
|
+
description: metaDescription(s.intro, `Book an appointment${siteName() ? ` with ${siteName()}` : ''} online`
|
|
93
|
+
+ `${services.length ? `: ${services.map(x => x.name).join(', ')}` : ''}.`),
|
|
81
94
|
urlPath: s.bookingPath,
|
|
82
95
|
body: `<header class="bk-hero"><h1>${esc(s.pageTitle || 'Book')}</h1>${paras(s.intro, 'bk-intro')}</header>`
|
|
83
96
|
+ app({base: s.bookingPath, service: '', with: '', embedded: false}, serviceCards(services, s.bookingPath))
|
|
@@ -94,7 +107,7 @@ export default async function bookingPublic(fastify) {
|
|
|
94
107
|
return send(reply, request, s, {
|
|
95
108
|
page: 'service',
|
|
96
109
|
title: `${svc.name} - ${s.pageTitle || 'Book'}`,
|
|
97
|
-
description:
|
|
110
|
+
description: metaDescription(svc.description, `Book ${svc.name} (${lengthLabel(svc.duration)})${siteName() ? ` with ${siteName()}` : ''} online.`),
|
|
98
111
|
urlPath: `${s.bookingPath}/${slug}`,
|
|
99
112
|
body: `<header class="bk-hero"><p class="bk-crumbs"><a href="${esc(s.bookingPath)}">${esc(s.pageTitle || 'Book')}</a></p>`
|
|
100
113
|
+ `<h1>${esc(svc.name)}</h1><p class="bk-intro">${esc(lengthLabel(svc.duration))}${svc.description ? ` · ${esc(svc.description)}` : ''}</p></header>`
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.7.2: the booking page always has a meta description - the Introduction, a
|
|
3
|
+
* service's description, else a line made from the site's name. Runs against
|
|
4
|
+
* the real collection service in the checkout `make test` runs in; the
|
|
5
|
+
* collections are removed after and config/plugins.json restored.
|
|
6
|
+
*/
|
|
7
|
+
import {after, before, test} from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import Fastify from 'fastify';
|
|
13
|
+
import booking from '../plugin.js';
|
|
14
|
+
import bookingPublic, {metaDescription} from '../plugin.public.js';
|
|
15
|
+
import {setMailer} from '../lib/mail.js';
|
|
16
|
+
import {getConfig} from '../../../server/config.js';
|
|
17
|
+
|
|
18
|
+
const PLUGINS = path.resolve('config/plugins.json');
|
|
19
|
+
const DIRS = ['booking-bookables', 'booking-services', 'booking-bookings'].map(c => path.resolve('content/collections', c));
|
|
20
|
+
let savedPlugins;
|
|
21
|
+
let app;
|
|
22
|
+
let pub;
|
|
23
|
+
let dataDir;
|
|
24
|
+
|
|
25
|
+
before(async () => {
|
|
26
|
+
savedPlugins = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
|
|
27
|
+
for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
|
|
28
|
+
setMailer(async () => {});
|
|
29
|
+
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'booking-seo-'));
|
|
30
|
+
app = Fastify();
|
|
31
|
+
const authenticate = async (req) => { req.user = {id: 'u1', name: 'Sam', role: 'admin'}; };
|
|
32
|
+
await app.register(booking, {prefix: '', auth: {authenticate, requirePermission: () => async () => {}}, settings: {}, dataDir,
|
|
33
|
+
hooks: {notify() {}, registerShortcode() {}}});
|
|
34
|
+
pub = Fastify();
|
|
35
|
+
await pub.register(bookingPublic);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
after(async () => {
|
|
39
|
+
setMailer(null);
|
|
40
|
+
await app?.close();
|
|
41
|
+
await pub?.close();
|
|
42
|
+
for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
|
|
43
|
+
if (savedPlugins === null) await fs.rm(PLUGINS, {force: true});
|
|
44
|
+
else await fs.writeFile(PLUGINS, savedPlugins);
|
|
45
|
+
if (dataDir) await fs.rm(dataDir, {recursive: true, force: true});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const description = async (url) => ((await pub.inject({method: 'GET', url})).body.match(/<meta name="description" content="([^"]*)"/) || [])[1];
|
|
49
|
+
|
|
50
|
+
test('metaDescription: the owner\'s words, else the made one; one line, 160 at most', () => {
|
|
51
|
+
assert.equal(metaDescription(' Call\n in ', 'x'), 'Call in');
|
|
52
|
+
assert.equal(metaDescription('', 'Book online.'), 'Book online.');
|
|
53
|
+
assert.equal(metaDescription('a'.repeat(400), '').length, 160);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('/booking and a service page are described even with no Introduction', async () => {
|
|
57
|
+
const site = String(getConfig('site')?.title || '').trim();
|
|
58
|
+
let r = await app.inject({method: 'PUT', url: '/settings', payload: {intro: ''}});
|
|
59
|
+
assert.equal(r.statusCode, 200, r.body);
|
|
60
|
+
assert.equal(await description('/booking'), `Book an appointment${site ? ` with ${site}` : ''} online.`);
|
|
61
|
+
|
|
62
|
+
r = await app.inject({method: 'POST', url: '/bookables', payload: {name: 'Alex', kind: 'person'}});
|
|
63
|
+
const alex = r.json();
|
|
64
|
+
r = await app.inject({method: 'POST', url: '/services', payload: {name: 'Consultation', duration: 60, bookables: [alex.id]}});
|
|
65
|
+
assert.equal(r.statusCode, 201, r.body);
|
|
66
|
+
assert.match(await description('/booking'), /: Consultation\.$/, 'names what can be booked');
|
|
67
|
+
assert.equal(await description(`/booking/${r.json().slug}`), `Book Consultation (1 hour)${site ? ` with ${site}` : ''} online.`);
|
|
68
|
+
|
|
69
|
+
await app.inject({method: 'PUT', url: '/settings', payload: {intro: 'Appointments in Exeter,\nweekdays.'}});
|
|
70
|
+
assert.equal(await description('/booking'), 'Appointments in Exeter, weekdays.', 'the Introduction wins');
|
|
71
|
+
});
|