domma-cms 1.14.0 → 1.16.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/lib/admin-brand.js +1 -1
  4. package/admin/js/lib/google-analytics-panel.js +9 -0
  5. package/admin/js/lib/google-business-callback.js +1 -0
  6. package/admin/js/lib/google-business-import.js +3 -0
  7. package/admin/js/lib/google-business-panel.js +32 -0
  8. package/admin/js/lib/seo-defaults.js +1 -0
  9. package/admin/js/lib/sidebar-renderer.js +5 -5
  10. package/admin/js/lib/site-search-section.js +1 -0
  11. package/admin/js/lib/site-settings-arrange.js +3 -3
  12. package/admin/js/lib/topbar-account.js +1 -1
  13. package/admin/js/lib/topbar-cache.js +6 -0
  14. package/admin/js/lib/topbar-notifications.js +4 -4
  15. package/admin/js/templates/docs/usage-site-settings.html +20 -7
  16. package/admin/js/templates/menu-editor.html +1 -1
  17. package/admin/js/templates/settings.html +104 -63
  18. package/admin/js/views/analytics.js +8 -6
  19. package/admin/js/views/search.js +2 -2
  20. package/admin/js/views/seo.js +50 -42
  21. package/admin/js/views/settings.js +3 -3
  22. package/docs/DommaFeatures.md +3 -2
  23. package/docs/configuration.md +53 -8
  24. package/docs/google-business-profile.md +44 -0
  25. package/docs/screenshots/google-business-profile-review.png +0 -0
  26. package/docs/screenshots/notifications-cache.png +0 -0
  27. package/docs/screenshots/site-settings-search.png +0 -0
  28. package/package.json +1 -1
  29. package/public/css/motion.css +1 -0
  30. package/public/js/motion-control.mjs +1 -0
  31. package/public/js/site.js +1 -1
  32. package/server/routes/api/analytics.js +19 -0
  33. package/server/routes/api/google-business.js +64 -0
  34. package/server/routes/api/seo.js +14 -3
  35. package/server/routes/api/settings.js +23 -2
  36. package/server/services/adminBrand.js +1 -0
  37. package/server/services/googleBusiness.js +225 -0
  38. package/server/services/motionSettings.js +107 -0
  39. package/server/services/renderer.js +36 -6
  40. package/server/templates/page.html +2 -2
@@ -1,18 +1,18 @@
1
- import{apiRequest as ys}from"../api.js";import{addBannerAction as xs}from"../lib/plugin-chrome.js";import{helpPopovers as ws}from"../lib/help-popover.js";import{onceClosed as ks,openMenuAt as Ms,viewLifetime as $s}from"../lib/tool-kit.js";import{snippet as Ss}from"../lib/seo-analyse.js";import{checkRule as bt,matchRedirect as Es}from"../lib/seo-redirects.js";import{checkSettings as Ts,mergeDefaults as vt,ORG_TYPES as Cs}from"../lib/seo-settings.js";import{pickMedia as gt}from"../lib/media-picker.js";import{fmtDate as Os,fmtDateTime as Ke}from"../lib/dates.js";import{SHAPES as Ls,whileLoading as Rs}from"../lib/loading.js";import{countText as fe,draftAction as As,draftKey as Ge,filterPages as Ps,fingerprint as z,pageCounts as Is,restoreQuestion as ze,usableDraft as yt}from"../lib/seo-arrange.js";const js="/seo",Fe=/^#\/seo(\/[a-z0-9-]+)?\/?(\?.*)?$/,xt=["overview","redirects","sitemap"],Ns="#/seo",Ds={pass:"\u2713",warn:"!",fail:"\u2715",info:"i"},qs={301:"301 moved",302:"302 for now",307:"307 for now",308:"308 moved",410:"410 gone"},Us={apiRequest:ys,addBannerAction:xs,helpPopovers:ws,onceClosed:ks,openMenuAt:Ms,viewLifetime:$s},Bs=async()=>Us,R=i=>String(i??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Je(i){const l=document.createElement("template");return l.innerHTML=i.trim(),l.content.firstElementChild}async function be(i,l){try{await navigator.clipboard.writeText(i),E.toast(`Copied ${l}.`,{type:"success"})}catch{E.toast(`Could not copy - ${i}`,{type:"warning",duration:6e3})}}const A={user(){try{return S.get("auth_user")?.id||""}catch{return""}},get(i){try{return i?S.get(i)??null:null}catch{return null}},set(i,l){try{i&&S.set(i,l)}catch{}},drop(i){try{i&&(S.remove?S.remove(i):S.set(i,null))}catch{}},keys(i){try{return(S.keys?.()||[]).filter(l=>l.startsWith(i))}catch{return[]}}},_s=()=>new Promise(i=>setTimeout(i,0)),Ye=async(i,l)=>(await _s(),E.confirm(R(i),l)),Qe=i=>Ye(i.text,{title:i.title,confirmText:i.confirmText,cancelText:i.cancelText});function wt(){try{window.dispatchEvent(new CustomEvent("dm:sidebar-count",{detail:{url:Ns}}))}catch{}}const kt=i=>typeof i!="number"?"":i>=85?"good":i>=60?"poor":"bad",Mt=i=>i?Os(i):"",$t=i=>{if(!i)return"";const l=Math.round((Date.now()-Date.parse(i))/6e4);if(l<1)return"just now";if(l<60)return`${l} min ago`;const q=Math.round(l/60);return q<48?`${q} hour${q===1?"":"s"} ago`:Mt(i)};function Hs(i){const l=m=>{let n=String(m??"");return/^[=+\-@\t\r]/.test(n)&&(n=`'${n}`),/[",\n\r]/.test(n)?`"${n.replace(/"/g,'""')}"`:n},q=Object.keys(i[0]||{});return`\uFEFF${[q.join(","),...i.map(m=>q.map(n=>l(m[n])).join(","))].join(`\r
1
+ import{apiRequest as ys}from"../api.js";import{addBannerAction as xs}from"../lib/plugin-chrome.js";import{helpPopovers as ws}from"../lib/help-popover.js";import{onceClosed as ks,openMenuAt as Ms,viewLifetime as $s}from"../lib/tool-kit.js";import{snippet as Ss}from"../lib/seo-analyse.js";import{checkRule as xt,matchRedirect as Es}from"../lib/seo-redirects.js";import{checkSettings as Ts,mergeDefaults as wt,ORG_TYPES as Cs}from"../lib/seo-settings.js";import{pickMedia as ze}from"../lib/media-picker.js";import{checkSeoDefaults as Os,seoDefaults as Ls}from"../lib/seo-defaults.js";import{mountGoogleBusiness as Rs}from"../lib/google-business-panel.js";import{fmtDate as Is,fmtDateTime as Fe}from"../lib/dates.js";import{SHAPES as As,whileLoading as Ps}from"../lib/loading.js";import{countText as ve,draftAction as js,draftKey as Je,filterPages as Ds,fingerprint as J,pageCounts as Ns,restoreQuestion as Ye,usableDraft as kt}from"../lib/seo-arrange.js";const qs="/seo",Qe=/^#\/seo(\/[a-z0-9-]+)?\/?(\?.*)?$/,Mt=["overview","redirects","sitemap"],Us="#/seo",Bs={pass:"\u2713",warn:"!",fail:"\u2715",info:"i"},_s={301:"301 moved",302:"302 for now",307:"307 for now",308:"308 moved",410:"410 gone"},Hs={apiRequest:ys,addBannerAction:xs,helpPopovers:ws,onceClosed:ks,openMenuAt:Ms,viewLifetime:$s},Ws=async()=>Hs,L=i=>String(i??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Ve(i){const d=document.createElement("template");return d.innerHTML=i.trim(),d.content.firstElementChild}async function ge(i,d){try{await navigator.clipboard.writeText(i),E.toast(`Copied ${d}.`,{type:"success"})}catch{E.toast(`Could not copy - ${i}`,{type:"warning",duration:6e3})}}const R={user(){try{return S.get("auth_user")?.id||""}catch{return""}},get(i){try{return i?S.get(i)??null:null}catch{return null}},set(i,d){try{i&&S.set(i,d)}catch{}},drop(i){try{i&&(S.remove?S.remove(i):S.set(i,null))}catch{}},keys(i){try{return(S.keys?.()||[]).filter(d=>d.startsWith(i))}catch{return[]}}},Ks=()=>new Promise(i=>setTimeout(i,0)),Xe=async(i,d)=>(await Ks(),E.confirm(L(i),d)),Ze=i=>Xe(i.text,{title:i.title,confirmText:i.confirmText,cancelText:i.cancelText});function $t(){try{window.dispatchEvent(new CustomEvent("dm:sidebar-count",{detail:{url:Us}}))}catch{}}const St=i=>typeof i!="number"?"":i>=85?"good":i>=60?"poor":"bad",Et=i=>i?Is(i):"",Tt=i=>{if(!i)return"";const d=Math.round((Date.now()-Date.parse(i))/6e4);if(d<1)return"just now";if(d<60)return`${d} min ago`;const U=Math.round(d/60);return U<48?`${U} hour${U===1?"":"s"} ago`:Et(i)};function Gs(i){const d=m=>{let n=String(m??"");return/^[=+\-@\t\r]/.test(n)&&(n=`'${n}`),/[",\n\r]/.test(n)?`"${n.replace(/"/g,'""')}"`:n},U=Object.keys(i[0]||{});return`\uFEFF${[U.join(","),...i.map(m=>U.map(n=>d(m[n])).join(","))].join(`\r
2
2
  `)}\r
3
- `}function Ws(i,l,q="text/csv;charset=utf-8"){const m=URL.createObjectURL(new Blob([l],{type:q})),n=Object.assign(document.createElement("a"),{href:m,download:i});document.body.appendChild(n),n.click(),n.remove(),setTimeout(()=>URL.revokeObjectURL(m),1e4)}const Ks=`
3
+ `}function zs(i,d,U="text/csv;charset=utf-8"){const m=URL.createObjectURL(new Blob([d],{type:U})),n=Object.assign(document.createElement("a"),{href:m,download:i});document.body.appendChild(n),n.click(),n.remove(),setTimeout(()=>URL.revokeObjectURL(m),1e4)}const Fs=`
4
4
  <div class="sox-row" data-bind-data-row="id" tabindex="0" data-bind-title="path">
5
5
  <span class="sox-pill" data-bind-data-tone="tone" data-bind-text="scoreText"></span>
6
6
  <span class="sox-cell"><span data-bind-text="path"></span><small data-bind-text="sourceText"></small></span>
7
7
  <span class="sox-cell sox-hide-sm" data-bind-text="title" data-bind-title="title"></span>
8
8
  <span class="sox-cell sox-muted sox-hide-sm" data-bind-text="problems" data-bind-title="problems"></span>
9
9
  <button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
10
- </div>`,Gs=`
10
+ </div>`,Js=`
11
11
  <div class="sox-row" data-bind-data-row="id" tabindex="0">
12
12
  <span class="sox-cell"><span data-bind-text="text" data-bind-title="text"></span><small data-bind-text="detail" data-bind-title="detail"></small></span>
13
13
  <span class="sox-pill" data-bind-data-tone="tone" data-bind-text="badge"></span>
14
14
  <button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
15
- </div>`,zs=`
15
+ </div>`,Ys=`
16
16
  <div class="sox-row" data-bind-data-row="id" tabindex="0" data-bind-title="note">
17
17
  <span class="sox-cell sox-mono" data-bind-text="from" data-bind-title="from"></span>
18
18
  <span class="sox-arrow" aria-hidden="true">\u2192</span>
@@ -20,42 +20,42 @@ import{apiRequest as ys}from"../api.js";import{addBannerAction as xs}from"../lib
20
20
  <span class="sox-pill sox-hide-sm" data-bind-data-tone="tone" data-bind-text="code"></span>
21
21
  <span class="sox-cell sox-muted sox-hide-sm" data-bind-text="hits" data-bind-title="lastHit"></span>
22
22
  <button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
23
- </div>`,Fs=`
23
+ </div>`,Qs=`
24
24
  <div class="sox-row" data-bind-data-row="id" tabindex="0">
25
25
  <span class="sox-cell"><span data-bind-text="path" data-bind-title="path"></span><small data-bind-text="title"></small></span>
26
26
  <span class="sox-cell sox-muted sox-hide-sm" data-bind-text="source"></span>
27
27
  <span class="sox-cell sox-muted sox-hide-sm" data-bind-text="note" data-bind-title="note"></span>
28
28
  <button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
29
- </div>`,Js=`
29
+ </div>`,Vs=`
30
30
  <div class="sox-row" data-bind-data-row="id" tabindex="0">
31
31
  <span class="sox-cell"><span data-bind-text="label"></span><small data-bind-text="detail"></small></span>
32
32
  <span class="sox-pill" data-bind-data-tone="tone" data-bind-text="state"></span>
33
33
  <button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
34
- </div>`,Ys=`
34
+ </div>`,Xs=`
35
35
  <div>
36
36
  <div class="sox-group" data-bind-hidden="!head" data-bind-text="head"></div>
37
37
  <div class="sox-check" data-bind-hidden="!title">
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),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,`
41
+ </div>`;export const seoView={templateUrl:"/admin/js/templates/seo.html",async onMount(i){const d=await Ws();if(!Qe.test(location.hash))return;const U=seoView._lifetime??(seoView._lifetime=d.viewLifetime(Qe));U.reset();const m=i.get(0).querySelector(".sox")||i.get(0),n=()=>Qe.test(location.hash)&&m.isConnected,A=e=>(U.add(e),e),g=(e,t)=>d.apiRequest(`${qs}${e}`,t),Ct=location.hash.match(/^#\/seo\/([a-z0-9-]+)/)?.[1]||"overview",Y=M.observable(Ct),Ce=M.observable(!0),Oe=M.observable(""),B=M.observable(!0),w=M.observable(null),ye=M.observable({results:[]}),te=M.observable("all"),xe=M.observable(""),Le=M.observable(""),N=M.observable({rules:[],chains:[],onRename:!0}),Re=M.observable(""),we=M.observable(""),ke=M.observable(""),et=M.observable("301"),W=M.observable({entries:[],left:[],sources:[],labels:{},robots:""}),de=M.observable("in"),Ie=M.observable(""),tt=M.observable([]),Ot=M.computed(()=>!Mt.includes(Y.value)),st=M.observable(!0),at=M.observable(!0),ot=M.observable(!0);let Q=null;const se=e=>W.value.labels?.[e]||(e==="pages"?"Pages":e),Lt=M.computed(()=>(w.value?.score??null)===null?"\u2014":`${w.value.score}%`),Rt=M.computed(()=>`${w.value?.score??0}%`),It=M.computed(()=>St(w.value?.score)),At=M.computed(()=>{const e=w.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"}}),Pt=M.computed(()=>w.value?.failing?String(w.value.failing):""),jt=M.computed(()=>{const e=w.value;return e?.running?`Checking\u2026 ${e.progress?.done||0} of ${e.progress?.total||"?"}`:e?.at?`Checked ${Tt(e.at)}`:"Not checked yet"}),nt=M.computed(()=>[...new Set((ye.value.results||[]).map(e=>e.source))]),it=M.observable([]);let Me=[];const Dt=M.computed(()=>{const e=Ns(ye.value.results,{source:xe.value});return{all:ve(e.all),problems:ve(e.problems),hidden:ve(e.hidden)}});A(M.effect(()=>{Me=Ds(ye.value.results,{show:te.value,source:xe.value,query:Le.value}),it.value=Me.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":St(e.score),problems:e.error||(e.problems?.length?e.problems.join(" \xB7 "):"Nothing to fix")}))}));const Nt=M.computed(()=>w.value?.at?Le.value.trim()?"No page matches.":te.value==="problems"?"Nothing needs attention.":"No pages.":w.value?.running?"Checking every page - this takes a moment\u2026":"Not checked yet. Check now with the button top right."),lt=M.observable([]);let G=[];A(M.effect(()=>{const e=w.value;if(G=[],e){const t=e.siteSeo||{};for(const s of e.issues?.duplicateTitles||[])G.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 a=t.defaultDescription&&s.text===t.defaultDescription;G.push({kind:"dupe",text:a?`${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:a?"default":"duplicate"})}t.defaultImage||G.push({kind:"setting",text:"No default share image",detail:"Pages without their own image share with none. Set one in SEO settings.",link:"#/seo?settings=1",tone:"poor",badge:"setting"}),(!e.origin||/localhost|127\.0\.0\.1/.test(e.origin))&&G.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&&G.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"})}lt.value=G.map((t,s)=>({id:`s${s}`,text:t.text,detail:t.detail||"",tone:t.tone,badge:t.badge}))}));const Ae=M.observable([]);let ue=[];A(M.effect(()=>{const e=Re.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)),Ae.value=ue.map((s,a)=>({id:`r${a}`,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 ${Tt(s.lastHit)}`:"Not used yet"}))}));const qt=M.computed(()=>N.value.rules.length?String(N.value.rules.length):""),Ut=M.computed(()=>`${ue.length+0*Ae.value.length} of ${N.value.rules.length}`),Bt=M.computed(()=>Re.value.trim()?"No redirect matches.":"No redirects yet."),_t=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.`:""}),Ht=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)."),rt=M.observable([]);A(M.effect(()=>{rt.value=W.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 Pe=M.observable([]);let ae=[];A(M.effect(()=>{const e=Ie.value.trim().toLowerCase();ae=(de.value==="in"?W.value.entries:W.value.left).filter(s=>!e||`${s.urlPath} ${s.title}`.toLowerCase().includes(e)),Pe.value=ae.slice(0,2e3).map((s,a)=>({id:`e${a}`,path:s.urlPath,title:s.title||"",source:se(s.source),note:s.why||[s.lastmod?`Changed ${Et(s.lastmod)}`:"",s.changefreq||"",s.priority!==void 0?`priority ${s.priority}`:""].filter(Boolean).join(" \xB7 ")}))}));const Wt=M.computed(()=>W.value.robots||""),Kt=M.computed(()=>({in:ve((W.value.entries||[]).length),out:ve((W.value.left||[]).length)})),Gt=M.computed(()=>`${ae.length+0*Pe.value.length} address${ae.length===1?"":"es"}`),zt=M.computed(()=>W.value.hooks?Ie.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."),$e=e=>/403|forbidden/i.test(String(e?.message));async function oe(){try{const e=await g("/overview");if(!n()||!e)return;w.value=e,Oe.value="",e.running&&ct()}catch(e){n()&&(Oe.value=$e(e)?"You do not have access to SEO.":`Could not load SEO: ${e.message||e}`)}}const je=(e,t,s,a=As.rows)=>e.peek()?Ps(m.querySelector(t),s,a).finally(()=>{n()&&(e.value=!1)}):s;async function De(){try{const e=await je(st,".sox-pages-loading",g("/audit"));n()&&e&&(ye.value=e)}catch{}}async function pe(){try{const e=await je(at,".sox-rules-loading",g("/redirects"));if(!n()||!e)return;N.value=e,Vt(e.rules||[])}catch{}}async function Ne(){try{const e=await je(ot,".sox-entries-loading",g("/sitemap"));n()&&e&&(W.value=e)}catch(e){n()&&!$e(e)&&E.toast(`Could not load the sitemap: ${e.message||e}`,{type:"error"})}}async function qe(){Ce.value=!0,await Promise.all([oe(),De(),pe(),Ne()]),n()&&(Ce.value=!1)}function ct(){Q||(Q=setInterval(async()=>{if(!n()){clearInterval(Q),Q=null;return}await oe(),w.peek()?.running||(clearInterval(Q),Q=null,await De(),$t())},1500))}A(()=>{Q&&clearInterval(Q)});async function dt(){try{const e=await g("/audit",{method:"POST",body:JSON.stringify({})});n()&&(w.value={...w.peek(),...e,running:!0}),ct()}catch(e){n()&&E.toast(`Could not start the check: ${e.message||e}`,{type:"error"})}}async function Ue(e,t,s=pe){try{const a=await e();return n()&&(t&&E.toast(t,{type:"success"}),await Promise.all([s(),oe()])),a}catch(a){return n()&&($e(a)?(B.value=!1,E.toast("Only people who may manage SEO can change this.",{type:"warning"})):E.toast(`Not done: ${a.message||a}`,{type:"error"})),null}}const Ft=(e,t=`Redirect from ${e.from} added.`)=>Ue(()=>g("/redirects",{method:"POST",body:JSON.stringify(e)}),t),Jt=(e,t)=>Ue(()=>g(`/redirects/${encodeURIComponent(e.join(","))}`,{method:"DELETE"}),`${t} removed.`);async function Yt(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(a=>`${a.from} \u2192 ${a.code===410?"gone":a.to} (${a.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 ut=e=>({from:e.from||"",to:e.code===410?"":e.to||"",code:String(e.code||301),note:e.note||""});function Be(e,{asNew:t=!1,adopt:s=null}={}){const a=t?{from:"",to:"",code:"301",note:""}:ut(e),l={from:M.observable(a.from),to:M.observable(a.to),code:M.observable(a.code),note:M.observable(a.note)},o=t?"":J(ut(e));mt(t?"New redirect":`Redirect from ${e.from}`,l,`
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
- ${Object.entries(qs).map(([r,d])=>`<option value="${r}">${d}</option>`).join("")}</select></div>
46
+ ${Object.entries(_s).map(([r,c])=>`<option value="${r}">${c}</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 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,`
48
+ </div>`,()=>{const r={from:l.from.value,to:l.to.value,code:Number(l.code.value),note:l.note.value},c=N.peek().rules.filter(y=>t||y.id!==e.id);return{body:r,errors:xt(r,c).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:o,adopt:s})}function Qt(){const e={csv:M.observable("")};mt("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&#10;/news/*, /blog/*, 301&#10;/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}
50
+ <textarea class="form-input sox-mono" rows="14" data-model="csv.value" placeholder="/old-page, /new-page&#10;/news/*, /blog/*, 301&#10;/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 a=s.skipped||[];E.toast(`${s.added} added${a.length?`, ${a.length} skipped - line ${a[0].line}: ${a[0].why}`:"."}`,{type:a.length?"warning":"success",duration:a.length?9e3:4e3}),await Promise.all([pe(),oe()])},"Import",{kind:"import",what:"the redirects you were importing"})}let pt=!1;async function Vt(e){if(pt||!B.peek())return;pt=!0;const t=Je(R.user(),"redirect","");if(!t)return;const s=new Set(e.map(a=>String(a.id)));for(const a of R.keys(`${t}.`)){let l;try{l=decodeURIComponent(a.slice(t.length+1))}catch{continue}if(s.has(l))continue;const o=kt(R.get(a),{from:"",to:"",code:"301",note:""});if(!o){R.drop(a);continue}if(await Ze(Ye({what:`the redirect from ${o.values.from||"?"}`,when:o.at?Fe(o.at):"",gone:"Keep them to add it again as a new redirect."}))){R.drop(a);continue}n()&&Be({id:l},{asNew:!0,adopt:o.values});return}}function _e(e,{kind:t,id:s="",what:a,read:l,write:o,was:r=""}){const c=Je(R.user(),t,s);let y=J(l()),$=r,k=!0,O=!1,_=!1;const H=()=>J(l())!==y,f=()=>{const u=js({dirty:H(),owns:k});u==="write"?R.set(c,{values:l(),was:$,at:new Date().toISOString()}):u==="remove"&&R.drop(c)},T=u=>{O||(f(),H()&&(u.preventDefault(),u.returnValue=""))};window.addEventListener("beforeunload",T);const P=e.close.bind(e);let j=()=>{};const C=()=>{O=!0,window.removeEventListener("beforeunload",T),j()};return e.close=async u=>{if(O)return P(u);if(!(u!==!0&&!k)){if(u!==!0&&H()){if(_)return;_=!0;const h=await Xe("Throw away the changes you have not saved?",{title:"Unsaved changes",confirmText:"Throw away",cancelText:"Keep editing"});if(_=!1,!h||O)return;k&&R.drop(c)}else u!==!0&&f();return C(),P(u)}},j=U.add(()=>{if(!O){f(),C();try{P(!0)}catch{}}}),{saved(u,h=$){y=J(u),$=h,k=!0,f()},adopt(u,h=""){o(u),$=h,k=!0},async offer(u=r){const h=R.get(c),X=t==="settings"?Object.fromEntries(["defaultTitle","titleSeparator","defaultDescription","defaultImage"].map(b=>[b,l()[b]])):{},fe=h?.values?{...h,values:{...X,...h.values}}:h,q=kt(fe,l());return q?(k=!1,await Ze(Ye({what:a,when:q.at?Fe(q.at):"",stale:q.was!==u}))?(R.drop(c),k=!0,!1):O?!1:(k=!0,o(q.values),$=q.was,!0)):(h&&R.drop(c),!1)}}}let Se=!1;function mt(e,t,s,a,l,o="Save",r=null){if(Se)return;Se=!0;let c=null;try{const y=M.observable(!1),$=M.computed(()=>a()),k=M.computed(()=>Object.values($.value.errors).join(" ")),O={...t,saving:y,errText:k,cannot:M.computed(()=>y.value||!!k.value)},_=()=>Object.fromEntries(Object.entries(t).map(([u,h])=>[u,h.peek()])),H=u=>{for(const[h,X]of Object.entries(t))h in u&&(X.value=u[h])};c=E.slideover({title:e,size:"md",position:"right"});const f=Ve(`<div class="sox-form">${s}
51
51
  <p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
52
- <div class="sox-actions"><small class="sox-muted">Ctrl+Enter ${p.toLowerCase()}s.</small><span class="sox-spacer"></span>
52
+ <div class="sox-actions"><small class="sox-muted">Ctrl+Enter ${o.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();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(`
54
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">${L(o)}</button></div></div>`);let T=null;async function P(){if(O.cannot.peek())return;y.value=!0;const u=_();f.inert=!0;try{await l($.peek().body),T?.saved(u,""),c.close(!0)}catch(h){f.inert=!1,n()&&E.toast(`Not saved: ${h.message||h}`,{type:"error"})}finally{y.value=!1}}c.element.appendChild(f);const j=d.helpPopovers(f),C=M.applyBindings(O,f,{methods:{save:P,cancel:()=>c.close()}});f.addEventListener("keydown",u=>{(u.ctrlKey||u.metaKey)&&u.key==="Enter"&&(u.preventDefault(),P())}),d.onceClosed(c,()=>{C.dispose(),j(),Se=!1}),r?(T=_e(c,{...r,read:_,write:H}),r.adopt?T.adopt(r.adopt,""):T.offer(r.was)):A(()=>{try{c.close(!0)}catch{}}),I.scan(f),c.open(),f.querySelector("input, textarea")?.focus()}catch(y){Se=!1;try{c?.close(!0)}catch{}E.toast(`Could not open ${e}: ${y.message||y}`,{type:"error"})}}let ne=!1;const Xt=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 ee(e){if(ne)return;ne=!0;let t;try{t=await g(`/item?path=${encodeURIComponent(e)}`)}catch(s){if(ne=!1,!n())return;const a=s?.status===404?Je(R.user(),"item",e):null,l=a?R.get(a):null;if(l?.values){await Ze(Ye({what:e,when:l.at?Fe(l.at):"",gone:"Keep them and they are offered again if the address is served again."}))&&R.drop(a);return}E.toast(`Could not open ${e}: ${s.message||s}`,{type:"error"});return}if(!n()){ne=!1;return}try{Zt(e,t)}catch(s){ne=!1,E.toast(`Could not open ${e}: ${s.message||s}`,{type:"error"})}}function Zt(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)},a=t.limits||{},l=t.page?.title||t.live?.h1||"",o=t.kind==="page"?`${l}${t.defaults.separator}${t.defaults.siteTitle}`:t.live?.title||"",r=t.kind==="page"?t.defaults.description||"":t.live?.description||"",c=t.kind==="page"?t.defaults.image:t.live?.image||"",y=M.computed(()=>s.title.value.trim()||o),$=M.computed(()=>s.description.value.trim()||r),k=M.computed(()=>s.image.value.trim()||c),O=t.origin||location.origin,_=M.computed(()=>Ss({title:y.value,description:$.value,url:`${O}${e}`,limits:a})),H=(x,v,ce)=>{const Z=x.length;return{text:`${Z} / ${ce}`,width:`${Math.min(100,Z/ce*100)}%`,tone:Z?Z<v||Z>ce?"poor":"good":"bad"}},f=M.computed(()=>H(y.value,a.titleMin||30,a.titleMax||60)),T=M.computed(()=>H($.value,a.descMin||70,a.descMax||160)),P=(()=>{try{return new URL(O).host}catch{return location.host}})(),j=M.computed(()=>k.value?`url("${k.value.replace(/["\\\n\r]/g,"")}")`:"none"),C=M.observable([]),u=x=>{const v=[],ce=x?.checks||[],Z={fail:0,warn:1,pass:2,info:3};for(const Ke of[...new Set(ce.map(F=>F.group))]){v.push({id:`g-${Ke}`,head:Ke,title:"",detail:"",fix:"",status:"",glyph:""});for(const F of ce.filter(Ge=>Ge.group===Ke).sort((Ge,gs)=>Z[Ge.status]-Z[gs.status]))v.push({id:`c-${F.id}`,head:"",title:F.title,detail:F.detail,fix:F.status==="pass"?"":F.fix,status:F.status,glyph:Bs[F.status]||"?"})}C.value=v};u(t.analysis);const h=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.`),X=()=>({title:s.title.peek(),description:s.description.peek(),focus:s.focus.peek(),image:s.image.peek(),noindex:s.noindex.peek()}),fe=x=>{for(const v of Object.keys(s))v in x&&(s[v].value=x[v])},q=M.observable(!1),p=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.":""),b={...s,saving:q,errText:p,checks:C,scoreLine:h,serp:_,titleMeter:f,descMeter:T,shareBg:j,effTitle:y,effDesc:$,host:P,hasImage:M.computed(()=>!!k.value),defTitle:o,defDesc:r,canManage:B,cannot:M.computed(()=>q.value||!!p.value||!B.value)},D=E.slideover({title:`SEO \xB7 ${e}`,size:"lg",position:"right"}),re=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 ${L(t.page?.status||"draft")} - not live yet.</p>`:"",be=t.redirect?`<p class="sox-note is-bad">A redirect sends visitors from here to ${L(t.redirect.to||"(gone)")} - they never see this page.</p>`:"",K=Ve(`
55
55
  <div class="sox-form">
56
- ${ps}${ms}
56
+ ${re}${be}
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>
58
- <span class="sox-spacer"></span><a class="btn btn-xs btn-ghost" href="${R(e)}" target="_blank" rel="noopener"><span data-icon="external-link" data-icon-size="12"></span> Open</a></h4>
58
+ <span class="sox-spacer"></span><a class="btn btn-xs btn-ghost" href="${L(e)}" target="_blank" rel="noopener"><span data-icon="external-link" data-icon-size="12"></span> Open</a></h4>
59
59
  <div class="sox-previews">
60
60
  <div class="sox-serp" aria-label="Search result preview">
61
61
  <div class="sox-serp-crumbs" data-bind-text="serp.value.crumbs"></div>
@@ -70,15 +70,15 @@ import{apiRequest as ys}from"../api.js";import{addBannerAction as xs}from"../lib
70
70
  </section>
71
71
  <section><h4>Search and sharing</h4><div class="sox-form-grid">
72
72
  <div class="sox-wide"><label class="form-label" for="sox-i-title">Title <span data-help="The blue link in search results and the headline of a share. Say what the page is about, words that matter first. Empty uses the page title and the site name."></span></label>
73
- <input id="sox-i-title" class="form-input" data-model="title.value" placeholder="${R(p)}" maxlength="200">
73
+ <input id="sox-i-title" class="form-input" data-model="title.value" placeholder="${L(o)}" maxlength="200">
74
74
  <div class="sox-meter" data-bind-data-tone="titleMeter.value.tone"><span class="sox-bar"><span data-bind-style-width="titleMeter.value.width"></span></span><span data-bind-text="titleMeter.value.text"></span></div></div>
75
75
  <div class="sox-wide"><label class="form-label" for="sox-i-desc">Description <span data-help="The grey text under the link. One or two sentences that make someone want to click. Empty uses the site's default - which every other such page shares."></span></label>
76
- <textarea id="sox-i-desc" rows="3" class="form-input" data-model="description.value" placeholder="${R(r)}" maxlength="400"></textarea>
76
+ <textarea id="sox-i-desc" rows="3" class="form-input" data-model="description.value" placeholder="${L(r)}" maxlength="400"></textarea>
77
77
  <div class="sox-meter" data-bind-data-tone="descMeter.value.tone"><span class="sox-bar"><span data-bind-style-width="descMeter.value.width"></span></span><span data-bind-text="descMeter.value.text"></span></div></div>
78
78
  <div><label class="form-label" for="sox-i-focus">Focus keyphrase <span data-help="The search this page should be found for - 'garden tools', 'plumber in Leeds'. The checks then look for it in the title, description, heading, address and opening. It is not shown anywhere."></span></label>
79
79
  <input id="sox-i-focus" class="form-input" data-model="focus.value" maxlength="100"></div>
80
80
  <div><label class="form-label" for="sox-i-image">Share image <span data-help="Shown when the page is shared. 1200 \xD7 630 pixels is ideal. Choose it from Media (or upload one), or type a full address. Empty uses the site's default image."></span></label>
81
- <div class="sox-pick"><input id="sox-i-image" class="form-input sox-mono" data-model="image.value" list="sox-media" placeholder="${R(d||"/media/\u2026")}">
81
+ <div class="sox-pick"><input id="sox-i-image" class="form-input sox-mono" data-model="image.value" list="sox-media" placeholder="${L(c||"/media/\u2026")}">
82
82
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="pickImage"><span data-icon="image"></span> Choose\u2026</button></div></div>
83
83
  <label class="sox-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="noindex.value"> Hide from search engines
84
84
  <span data-help="Asks search engines not to list this page, and leaves it out of the sitemap. For thank-you pages, private landing pages and the like. It stays reachable by its address."></span></label>
@@ -92,49 +92,57 @@ 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 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
- `)),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),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(`
95
+ </div>`);K.querySelector(".sox-checks").innerHTML=Xs,es(K.querySelector("#sox-media"));let Te=null;async function gt(){if(b.cannot.peek())return;q.value=!0;const x=X();try{if(await g("/item",{method:"PUT",body:JSON.stringify({path:e,...x})}),Te?.saved(x,J(Xt(x))),!n())return;E.toast("Saved.",{type:"success"});const v=await g(`/item?path=${encodeURIComponent(e)}`).catch(()=>null);if(!n())return;v?.fields&&Te?.saved(x,J(v.fields)),v?.analysis&&(u(v.analysis),h.value=`Score ${v.analysis.score}% \xB7 ${v.analysis.failing} failing, ${v.analysis.warnings} could be better`),De(),oe(),$t()}catch(v){if(!n())return;$e(v)&&(B.value=!1),E.toast(`Not saved: ${v.message||v}`,{type:"error"})}finally{q.value=!1}}D.element.appendChild(K);const hs=d.helpPopovers(K),fs=M.applyBindings(b,K,{methods:{save:gt,cancel:()=>D.close(),pickImage:async()=>{const x=await ze({title:"Choose a share image",current:s.image.peek()});x&&(s.image.value=x)}}}),bs=K.querySelector(".sox-share-img"),vs=M.effect(()=>{bs.style.backgroundImage=j.value});K.addEventListener("keydown",x=>{(x.ctrlKey||x.metaKey)&&x.key==="Enter"&&(x.preventDefault(),gt())}),d.onceClosed(D,()=>{fs.dispose(),hs(),vs?.(),ne=!1});const yt=J(t.fields);Te=_e(D,{kind:"item",id:e,what:e,read:X,write:fe,was:yt}),I.scan(K),D.open(),Te.offer(yt)}let He=null;async function es(e){try{He??(He=await d.apiRequest("/media").then(t=>Array.isArray(t)?t:t?.files||t?.items||[]));for(const t of He){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{He=[]}}let ie=!1;async function ht(){if(ie)return;ie=!0;let e,t,s;try{const a=await g("/settings");e=wt(a.settings),t=a.defaults||Ls(),s=J({settings:a.settings,defaults:t})}catch(a){ie=!1,n()&&E.toast(`Could not load the settings: ${a.message||a}`,{type:"error"});return}if(!n()){ie=!1;return}try{ts(e,s,t)}catch(a){ie=!1,E.toast(`Could not open the settings: ${a.message||a}`,{type:"error"})}}function ts(e,t,s){const a=e.organisation,l=w.peek()?.siteSeo?.defaultTitle||"The site's name",o={defaultTitle:M.observable(s.defaultTitle),titleSeparator:M.observable(s.titleSeparator),defaultDescription:M.observable(s.defaultDescription),defaultImage:M.observable(s.defaultImage),orgOn:M.observable(a.enabled),orgType:M.observable(a.type),orgName:M.observable(a.name),orgUrl:M.observable(a.url),orgLogo:M.observable(a.logo),orgEmail:M.observable(a.email),orgPhone:M.observable(a.phone),orgSame:M.observable(a.sameAs.join(`
96
+ `)),street:M.observable(a.address.street),locality:M.observable(a.address.locality),region:M.observable(a.address.region),postcode:M.observable(a.address.postcode),country:M.observable(a.address.country),articles:M.observable(e.articles),exclude:M.observable(e.sitemap.exclude.join(`
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))},r=()=>({organisation:{enabled:o.orgOn.value,type:o.orgType.value,name:o.orgName.value,url:o.orgUrl.value,logo:o.orgLogo.value,email:o.orgEmail.value,phone:o.orgPhone.value,sameAs:o.orgSame.value,address:{street:o.street.value,locality:o.locality.value,region:o.region.value,postcode:o.postcode.value,country:o.country.value}},articles:o.articles.value,sitemap:{exclude:o.exclude.value,sources:e.sitemap.sources},robots:{blockAll:o.blockAll.value,extra:o.robotsExtra.value},llms:{enabled:o.llms.value},redirects:{onRename:o.onRename.value},audit:{everyHours:o.every.value,titleMin:o.tMin.value,titleMax:o.tMax.value,descMin:o.dMin.value,descMax:o.dMax.value,minWords:o.words.value}}),c=()=>Object.fromEntries(Object.entries(o).map(([p,b])=>[p,b.peek()])),y=p=>{for(const[b,D]of Object.entries(o))b in p&&(D.value=p[b])},$=M.observable(!1),k=M.computed(()=>Ts(r(),e)),O=M.computed(()=>Os(Object.fromEntries(Object.keys(s).map(p=>[p,o[p].value])))),_=M.computed(()=>[Object.values(k.value.errors).join(" "),O.value.error].filter(Boolean).join(" ")),H={...o,saving:$,errText:_,cannot:M.computed(()=>$.value||!!_.value),isPerson:M.computed(()=>o.orgType.value==="Person")},f=(p,b,D,re="",be="")=>`<div><label class="form-label" for="sox-s-${p}">${D}${re?` <span data-help="${L(re)}"></span>`:""}</label>
98
+ <input id="sox-s-${p}" class="form-input" data-model="${b}.value" ${be}></div>`,T=(p,b,D,re,be,K="")=>f(p,b,D,K,`type="number" min="${re}" max="${be}"`),P=(p,b,D="")=>`<label class="sox-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="${p}.value"> ${b}${D?` <span data-help="${L(D)}"></span>`:""}</label>`,j=E.slideover({title:"SEO settings",size:"md",position:"right"}),C=Ve(`
99
99
  <div class="sox-form">
100
- <p class="sox-note">The site's default title, description and share image are in <a href="#/settings">Settings \u203A SEO</a>.</p>
100
+ <section><h4>Site defaults</h4><p class="sox-note">Used by pages without their own SEO values.</p><div class="sox-form-grid">
101
+ ${f("default-title","defaultTitle","Default site title")}
102
+ ${f("separator","titleSeparator","Title separator")}
103
+ ${f("default-description","defaultDescription","Default meta description")}
104
+ <div><label class="form-label" for="sox-default-image">Default share image</label>
105
+ <div class="sox-pick"><input id="sox-default-image" class="form-input" data-model="defaultImage.value" placeholder="/media/share.jpg">
106
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="pickDefaultImage">Choose image</button></div></div>
107
+ </div></section>
108
+ <section data-google-business></section>
101
109
  <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
- ${f("orgOn","Tell search engines who runs the site")}
110
+ ${P("orgOn","Tell search engines who runs the site")}
103
111
  <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
- ${Cs.map(c=>`<option value="${c}">${{Organization:"An organisation",LocalBusiness:"A local business",Person:"A person"}[c]}</option>`).join("")}</select></div>
105
- ${j("name","orgName","Name","Empty uses the site's name.",`placeholder="${R(o)}"`)}
106
- ${j("url","orgUrl","Web address","Empty uses the site address.",'placeholder="https://\u2026"')}
112
+ ${Cs.map(p=>`<option value="${p}">${{Organization:"An organisation",LocalBusiness:"A local business",Person:"A person"}[p]}</option>`).join("")}</select></div>
113
+ ${f("name","orgName","Name","Empty uses the site's name.",`placeholder="${L(l)}"`)}
114
+ ${f("url","orgUrl","Web address","Empty uses the site address.",'placeholder="https://\u2026"')}
107
115
  <div><label class="form-label" for="sox-s-logo">Logo (or photo) <span data-help="A square image works best. Choose it from Media (or upload one), or type a full address."></span></label>
108
116
  <div class="sox-pick"><input id="sox-s-logo" class="form-input sox-mono" data-model="orgLogo.value" placeholder="/media/logo.png">
109
117
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="pickLogo"><span data-icon="image"></span> Choose\u2026</button></div></div>
110
- ${j("email","orgEmail","Email","",'type="email"')}
111
- ${j("phone","orgPhone","Phone")}
118
+ ${f("email","orgEmail","Email","",'type="email"')}
119
+ ${f("phone","orgPhone","Phone")}
112
120
  <div class="sox-wide" data-bind-hidden="isPerson.value"><label class="form-label">Address <span data-help="For a local business, the address customers visit. Leave empty for an organisation without one."></span></label>
113
121
  <div class="sox-form-grid"><input class="form-input sox-wide" placeholder="Street" aria-label="Street" data-model="street.value">
114
122
  <input class="form-input" placeholder="Town or city" aria-label="Town or city" data-model="locality.value"><input class="form-input" placeholder="County or region" aria-label="County or region" data-model="region.value">
115
123
  <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
124
  <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
125
  <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
- ${f("articles","Mark blog posts as articles","Adds BlogPosting structured data to each post - headline, image, publisher - which search engines use for article results.")}
126
+ ${P("articles","Mark blog posts as articles","Adds BlogPosting structured data to each post - headline, image, publisher - which search engines use for article results.")}
119
127
  </div></section>
120
128
  <section><h4>Sitemap, robots.txt and llms.txt</h4><div class="sox-form-grid">
121
129
  <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
130
  <textarea id="sox-s-ex" rows="3" class="form-input sox-mono" data-model="exclude.value" placeholder="/thank-you&#10;/members/"></textarea></div>
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.")}
131
+ ${P("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
132
  <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
133
  <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.")}
134
+ ${P("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.")}
127
135
  </div></section>
128
136
  <section><h4>Redirects</h4><div class="sox-form-grid">
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.")}
137
+ ${P("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.")}
130
138
  </div></section>
131
139
  <section><h4>The checks</h4><div class="sox-form-grid">
132
- ${L("every","every","Check every page every (hours)",0,168,"0 checks only when you ask.")}
133
- ${L("words","words","Words a page should have",0,5e3)}
134
- ${L("tmin","tMin","Shortest title",10,70)}
135
- ${L("tmax","tMax","Longest title",30,100,"Google cuts titles at about 60 characters.")}
136
- ${L("dmin","dMin","Shortest description",30,160)}
137
- ${L("dmax","dMax","Longest description",80,320,"Google cuts descriptions at about 155-160 characters.")}
140
+ ${T("every","every","Check every page every (hours)",0,168,"0 checks only when you ask.")}
141
+ ${T("words","words","Words a page should have",0,5e3)}
142
+ ${T("tmin","tMin","Shortest title",10,70)}
143
+ ${T("tmax","tMax","Longest title",30,100,"Google cuts titles at about 60 characters.")}
144
+ ${T("dmin","dMin","Shortest description",30,160)}
145
+ ${T("dmax","dMax","Longest description",80,320,"Google cuts descriptions at about 155-160 characters.")}
138
146
  </div></section>
139
147
  <p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
140
148
  <div class="sox-actions">
@@ -142,7 +150,7 @@ import{apiRequest as ys}from"../api.js";import{addBannerAction as xs}from"../lib
142
150
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
143
151
  <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>
144
152
  </div>
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}
153
+ </div>`);let u=null;async function h(){if(H.cannot.peek())return;$.value=!0;const p=c();C.inert=!0;try{const b=await g("/settings",{method:"PUT",body:JSON.stringify({...k.peek().settings,siteDefaults:O.peek().defaults})});if(u?.saved(p,J({settings:b?.settings,defaults:b?.defaults})),j.close(!0),!n())return;E.toast("Settings saved.",{type:"success"}),qe()}catch(b){C.inert=!1,n()&&E.toast(`Not saved: ${b.message||b}`,{type:"error"})}finally{$.value=!1}}j.element.appendChild(C);const X=d.helpPopovers(C),fe=M.applyBindings(H,C,{methods:{save:h,cancel:()=>j.close(),pickDefaultImage:async()=>{const p=await ze({title:"Choose a default share image",current:o.defaultImage.peek()});p&&(o.defaultImage.value=p)},pickLogo:async()=>{const p=await ze({title:"Choose a logo",current:o.orgLogo.peek()});p&&(o.orgLogo.value=p)}}}),q=Rs(C.querySelector("[data-google-business]"),{read:c,apply:y});C.addEventListener("keydown",p=>{(p.ctrlKey||p.metaKey)&&p.key==="Enter"&&(p.preventDefault(),h())}),d.onceClosed(j,()=>{q(),fe.dispose(),X(),ie=!1}),u=_e(j,{kind:"settings",what:"the SEO settings",read:c,write:y,was:t}),I.scan(C),j.open(),u.offer(t)}async function ss(e,t){try{const s=await g("/settings"),a=wt(s.settings);if(a.sitemap.sources[e]=t,await g("/settings",{method:"PUT",body:JSON.stringify(a)}),!n())return;E.toast(`${se(e)} ${t?"back in":"left out of"} the sitemap.`,{type:"success"}),Ne()}catch(s){n()&&E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}const z=e=>Number(String(e?.dataset?.row||"").slice(1));function as(e){const t=Me[z(e)];return t?[{header:t.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>ee(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:!B.peek(),action:()=>{V("redirects"),we.value=t.urlPath,ke.value=""}},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>ge(t.urlPath,"the address")},{label:"Copy the problems",icon:"clipboard",disabled:!t.problems?.length&&!t.error,action:()=>ge(`${t.urlPath}
146
154
  ${t.error||t.problems.join(`
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(`
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()}};
155
+ `)}`,"the problems")}]:[]}function os(e){const t=G[z(e)];return t?t.kind==="dupe"?[{header:"Open one of them"},...t.paths.slice(0,12).map(s=>({label:s,icon:"edit-3",action:()=>ee(s)})),{type:"divider"},{label:"Copy the addresses",icon:"copy",action:()=>ge(t.paths.join(`
156
+ `),"the addresses")}]:t.kind==="chains"?[{label:"Show the redirects",icon:"corner-up-right",action:()=>V("redirects")}]:[{label:"Go to Settings",icon:"settings",action:()=>{location.hash=t.link}}]:[]}function ns(e){const t=G[z(e)];t?.kind==="dupe"?ee(t.paths[0]):t?.kind==="chains"?V("redirects"):t?.link&&(location.hash=t.link)}function is(e){const t=ue[z(e)];if(!t)return[];const s=N.peek().rules,a=N.peek().chains.some(o=>o.id===t.id);let l=null;if(a){l=t.to;for(let o=0;o<10;o++){const r=Es(s,l);if(!r)break;if(r.code===410){l=null;break}if(l=r.location,/^https?:/i.test(l))break}}return[{header:`${t.from} \u2192 ${t.code===410?"gone":t.to}`},{label:"Edit\u2026",icon:"edit-3",disabled:!B.peek(),action:()=>Be(t)},{label:"Test it",icon:"play",action:()=>Yt(t.from)},{label:"Go straight there",icon:"fast-forward",disabled:!l||!B.peek(),action:()=>Ue(()=>g(`/redirects/${encodeURIComponent(t.id)}`,{method:"PUT",body:JSON.stringify({to:l})}),`${t.from} now goes straight to ${l}.`)},{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:()=>ge(`${t.from} ${t.to} ${t.code}`,"the redirect")},{label:"Remove",icon:"trash-2",danger:!0,disabled:!B.peek(),action:async()=>{await Xe(`Remove the redirect from ${t.from}? Visitors to it will get "not found".`)&&Jt([t.id],"Redirect")}}]}function ls(e){const t=ae[z(e)];return t?[{header:t.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>ee(t.urlPath)},{label:"Open",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>ge(t.urlPath,"the address")}]:[]}function rs(e){const t=W.peek().sources[z(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:!B.peek(),action:()=>ss(t.id,!t.on)}]:[]}function me(e,t,s){const a=E.contextMenu(e,{cursor:"glyph",match:"[data-row]",inherit:!1,density:"compact",items:r=>t(r)});A(()=>a?.destroy?.());const l=r=>{const c=r.target.closest("[data-row]");if(!(!c||r.target.closest("a"))){if(r.target.closest(".sox-kebab")){d.openMenuAt(c);return}s?.(c)}},o=r=>{const c=r.target.closest("[data-row]");c&&(r.key==="F10"&&r.shiftKey?(r.preventDefault(),d.openMenuAt(c)):r.key==="Enter"&&r.target===c&&(r.preventDefault(),r.stopPropagation(),setTimeout(()=>{n()&&c.isConnected&&s?.(c)},0)))};e.addEventListener("click",l),e.addEventListener("keydown",o),A(()=>{e.removeEventListener("click",l),e.removeEventListener("keydown",o)})}const cs=m.querySelector(".sox-ext"),We=m.querySelector(".sox-ext-tabs");let le=null,Ee="",he=0;const ft=()=>{const e=le;le=null;try{e?.dispose?.()}catch{}e?.box?.remove()};async function ds(){let e=[];try{e=(await g("/extensions"))?.tabs||[]}catch{}n()&&(tt.value=e,We.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="${L(t.icon||"box")}" data-icon-size="14"></span> ${L(t.label)} <span class="sox-pro">PRO</span>`,t.help&&(s.title=t.help),s.addEventListener("click",()=>V(t.key)),s})),I.scan(We),bt())}async function bt(){if(!n())return;for(const l of We.querySelectorAll("button"))l.classList.toggle("is-on",l.dataset.ext===Y.peek());const e=tt.peek().find(l=>l.key===Y.peek());if(le&&le.key!==e?.key&&ft(),!e){Ee="",he++,Mt.includes(Y.peek())||V("overview");return}if(le?.key===e.key||Ee===e.key)return;const t=++he;Ee=e.key;const s=document.createElement("div");s.className="sox-panel sox-ext-body",cs.replaceChildren(s);const a=()=>t===he&&n()&&Y.peek()===e.key;try{const l=await import(e.module);if(!a())return;const o=await l.mount(s,{kit:d,api:d.apiRequest,scope:A,here:n,openItem:ee,reload:qe,E,M,I});if(!a()){try{o?.()}catch{}s.remove();return}le={key:e.key,dispose:o,box:s}}catch(l){a()&&(s.innerHTML=`<p class="sox-note is-bad">${L(e.label)} could not be shown: ${L(l.message||l)}</p>`)}finally{t===he&&(Ee="")}}A(()=>{he++,ft()}),m.querySelector(".sox-pages").innerHTML=Fs,m.querySelector(".sox-site").innerHTML=Js,m.querySelector(".sox-rules").innerHTML=Ys,m.querySelector(".sox-entries").innerHTML=Qs,m.querySelector(".sox-sources").innerHTML=Vs;const vt=m.querySelector(".sox-select");A(M.effect(()=>{const e=nt.value;vt.replaceChildren(new Option("Everything",""),...e.map(t=>new Option(se(t),t))),vt.value=xe.peek()}));function V(e){Y.peek()!==e&&(Y.value=e,history.replaceState(null,"",`#/seo${e==="overview"?"":`/${e}`}`),e==="redirects"&&pe(),e==="sitemap"&&Ne()),bt()}const us={tab:Y,busy:Ce,error:Oe,canManage:B,scoreText:Lt,scoreWidth:Rt,scoreTone:It,ov:At,attention:Pt,asOf:jt,sourceOptions:nt,source:xe,show:te,query:Le,pageRows:it,pagesEmpty:Nt,siteRows:lt,ruleRows:Ae,ruleCount:qt,ruleText:Ut,rulesEmpty:Bt,chainText:_t,renameText:Ht,ruleQuery:Re,newFrom:we,newTo:ke,newCode:et,sourceRows:rt,smRows:Pe,smShow:de,smQuery:Ie,smText:Gt,smEmpty:zt,robots:Wt,extOn:Ot,counts:Dt,smCounts:Kt,pagesLoading:st,rulesLoading:at,smLoading:ot},ps={tab_overview:()=>V("overview"),tab_redirects:()=>V("redirects"),tab_sitemap:()=>V("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:Qt,exportRules:()=>{const e=N.peek().rules;if(!e.length){E.toast("Nothing to export.",{type:"info"});return}zs(`redirects-${location.hostname}-${new Date().toISOString().slice(0,10)}.csv`,Gs(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:we.peek(),to:ke.peek(),code:Number(et.peek())},{errors:t}=xt(e,N.peek().rules);return Object.keys(t).length?(E.toast(Object.values(t).join(" "),{type:"warning"}),!1):(Ft(e).then(s=>{s&&(we.value="",ke.value="")}),!1)}},ms=M.applyBindings(us,m,{methods:ps});A(()=>ms.dispose()),A(d.helpPopovers(m,{position:"bottom"})),me(m.querySelector(".sox-pages"),as,e=>{const t=Me[z(e)];t&&ee(t.urlPath)}),me(m.querySelector(".sox-site"),os,ns),me(m.querySelector(".sox-rules"),is,e=>{const t=ue[z(e)];t&&B.peek()&&Be(t)}),me(m.querySelector(".sox-entries"),ls,e=>{const t=ae[z(e)];t&&ee(t.urlPath)}),me(m.querySelector(".sox-sources"),rs),d.addBannerAction(i,{id:"seo-check",icon:"refresh-cw",title:"Check every page again",onClick:dt}),d.addBannerAction(i,{id:"seo-settings",icon:"settings",title:"SEO settings",onClick:ht}),I.scan(m),await Promise.all([qe(),ds()]),n()&&new URLSearchParams(location.hash.split("?")[1]||"").get("settings")==="1"&&ht(),n()&&I.scan(m),n()&&!w.peek()?.at&&!w.peek()?.running&&dt()}};
@@ -1,11 +1,11 @@
1
- import{api as B,apiRequest as C}from"../api.js";import{placeTools as Ie}from"../lib/sidebar-grouping.js";import{addBannerAction as me}from"../lib/plugin-chrome.js";import{helpPopovers as Ae}from"../lib/help-popover.js";import{openMenuAt as Y,sidebarCount as fe}from"../lib/tool-kit.js";import{changedTabs as Pe,describeCacheKey as J,draftOf as $e,expiresIn as je,footerShowcaseErrors as Be,formFromSettings as De,gaIdError as he,homeValue as He,payloadFromForm as qe,siteUrlError as ve,settingsProblems as Fe,snapshot as be,TAB_FIELDS as Ue}from"../lib/site-settings-arrange.js";import{pickMedia as _e}from"../lib/media-picker.js";import{faviconGenerator as Ke}from"../lib/favicon-generator.js";import{makeIconInput as Ne}from"../lib/shortcode-modal.js";import{fmtDateTime as ze}from"../lib/dates.js";const Q=/^\/settings(\?.*)?$/,ge="#/settings",w="settings.draft",Ge=`
1
+ import{api as D,apiRequest as x}from"../api.js";import{placeTools as Ae}from"../lib/sidebar-grouping.js";import{addBannerAction as be}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{openMenuAt as G,sidebarCount as ye}from"../lib/tool-kit.js";import{changedTabs as Be,describeCacheKey as J,draftOf as De,expiresIn as He,footerShowcaseErrors as je,formFromDraft as qe,formFromSettings as Ue,homeValue as Fe,payloadFromForm as _e,siteUrlError as ge,settingsProblems as Ke,snapshot as Q,TAB_FIELDS as ze}from"../lib/site-settings-arrange.js";import{pickMedia as Ne}from"../lib/media-picker.js";import{faviconGenerator as Ve}from"../lib/favicon-generator.js";import{makeIconInput as We}from"../lib/shortcode-modal.js";import{fmtDateTime as Ye}from"../lib/dates.js";import{siteSearchSection as Ge}from"../lib/site-search-section.js";const X=/^\/settings(\?.*)?$/,we="#/settings",w="settings.draft",Je=`
2
2
  <div class="stx-problem">
3
3
  <span class="stx-chip" data-bind-class="tone === 'danger' ? 'is-bad' : 'is-warn'">{{label}}</span>
4
4
  <span>{{text}}</span>
5
5
  <button type="button" class="btn btn-sm btn-ghost" data-go="{{tab}}">Go there</button>
6
- </div>`,Ve=`
6
+ </div>`,Qe=`
7
7
  <div class="stx-row" data-id="{{id}}" tabindex="0">
8
8
  <span class="stx-main"><strong>{{path}}</strong><small>{{meta}}</small></span>
9
9
  <span class="stx-chip">{{expires}}</span>
10
10
  <button type="button" class="stx-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
11
- </div>`,We=["general","branding","favicon","seo","footer","email","pageNavigation","cookies","analytics","breadcrumbs","cache"];async function Ye(l,u){const[r,k]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),n=new Map([["/","Dashboard (built in)"]]),i=(v,d)=>{for(const o of v||[]){if(!o||o.hidden||o.restricted)continue;const m=typeof o.url=="string"?o.url:"";m.startsWith("#/")&&m.length>2&&!n.has(m.slice(1))&&n.set(m.slice(1),`${d?`${d} \u203A `:""}${o.text||m}`),i(o.items,o.url?d:o.text||d)}};i(Ie(r?.items||[],k||[]),""),n.has(u)||n.set(u,`${u} (not in the sidebar)`),l.replaceChildren();for(const[v,d]of n){const o=document.createElement("option");o.value=v,o.textContent=d,l.appendChild(o)}}let f=null,ye=!1;function Je(){ye||!window.R?.use||(ye=!0,R.use((l,u,r)=>!(Q.test(u?.path||"")&&!Q.test(l?.path||""))||!f?.dirty()?r():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(n=>{n?r():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:l}={})=>{if(f&&!Q.test(l?.path||"")){const u=f;f=null,u.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(l){if(Je(),f){const e=f;f=null,e.dispose()}const u=[],r=e=>(u.push(e),e);let k=!1;const n=l.get(0).querySelector(".stx")||l.get(0),i=()=>!k&&n.isConnected,v=E.loader(l.get(0),{type:"dots"});let d;try{d=await B.settings.get()}catch(e){v.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(v.destroy(),!n.isConnected)return;const o=M.observable(d||{}),m=De(d||{}),s={};for(const[e,t]of Object.entries(m))s[e]=M.observable(t);const D=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),X=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),b=M.observable(m),q=M.computed(()=>be(b.value)),x=M.computed(()=>be(D())!==q.value),T=M.observable(!1),L=M.observable(""),Z=M.observable(!1);B.plugins.policy().then(e=>{i()&&(Z.value=e?.signedOnly===!1)}).catch(()=>{});const F=M.observable(!1),ee=M.computed(()=>{const e=new Set(Pe(D(),b.value));return Object.fromEntries(Object.keys(Ue).map(t=>[t,e.has(t)]))}),U=M.observable([]);r(M.effect(()=>{U.value=Fe(D()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const te=M.observable(""),O=M.observable(!1),A=M.observable(""),P=M.observable(""),p=M.observable(null),_=M.observable([]),K=M.observable(""),N=M.observable(!0),z=M.observable(""),ae=M.observable([]);r(M.effect(()=>{const e=K.value.trim().toLowerCase(),t=Date.now();ae.value=[..._.value].reverse().filter(a=>!e||String(a.key).toLowerCase().includes(e)||(a.tags||[]).some(c=>String(c).toLowerCase().includes(e))).map(a=>{const c=J(a.key);return{id:a.key,path:c.path,meta:[c.page?`as ${c.role}`:"not a page",(a.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:je(a.expiresAt,t)}})}));const se=Ke(s.favicon,s.brandLogo,i),we={...se.data,...s,saving:T,dirty:x,tabs:ee,problems:U,testTo:te,testing:O,testResult:A,testTone:P,cacheQuery:K,cacheLoading:N,cacheError:z,cacheRows:ae,errorText:L,problemsText:M.computed(()=>U.value.length?"yes":""),siteUrlError:M.computed(()=>ve(s.siteUrl.value)),gaIdError:M.computed(()=>he(s.gaId.value)),gaForcesBanner:M.computed(()=>!!String(s.gaId.value||"").trim()&&s.ccEnabled.value!==!0),noSmtp:M.computed(()=>!String(s.smtpHost.value||"").trim()),mayAllowInsecureTLS:Z,dirtyEmail:M.computed(()=>ee.value.email),seoLength:M.computed(()=>{const e=String(s.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cacheOn:M.computed(()=>p.value?.enabled===!0),cacheState:M.computed(()=>{const e=p.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=p.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=p.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>p.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>K.value.trim()?"Nothing matches.":p.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function G(){if(T.peek())return;const e=X(),t=b.peek(),a=ve(e.siteUrl);if(a){E.toast(`Site URL: ${a}`,{type:"error"});return}const c=he(e.gaId);if(c){E.toast(`Google Analytics: ${c}`,{type:"error"});return}const y=Be(e)[0];if(y){E.toast(y,{type:"error"});return}T.value=!0,L.value="";const W=String(e.title).trim()!==String(t.title).trim();try{await B.settings.save(qe(e,o.peek(),{adminHomeReady:F.peek()}));const j=await B.settings.get().catch(()=>null);if(!i())return;j&&(o.value=j);const Re={...e,smtpPass:""};s.smtpPass.value="",b.value=Re;try{S.remove?S.remove(w):S.set(w,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),(W||["adminBrandTitle","adminBrandLogo","adminBrandIcon"].some(pe=>e[pe]!==t[pe]))&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),fe(ge),E.toast("Settings saved.",{type:"success"})}catch(j){L.value=j.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{T.value=!1}}async function ke(){await E.confirm("Throw away your unsaved settings?")&&(ne(b.peek()),L.value="")}function ne(e){for(const[t,a]of Object.entries(e))s[t]&&(s[t].value=a)}async function Ee(){if(!O.peek()){O.value=!0,P.value="",A.value="Sending\u2026";try{const e=await C("/settings/test-email",{method:"POST",body:JSON.stringify({to:te.peek().trim()||void 0})});if(!i())return;A.value=e?.message||"Test email sent.",P.value="is-ok"}catch(e){if(!i())return;A.value=e.message||"Could not send the test email.",P.value="is-bad"}finally{O.value=!1}}}async function $(){N.value=!0;try{const[e,t]=await Promise.all([C("/cache/status").catch(()=>null),C("/cache/keys")]);if(!i())return;p.value=e,_.value=Array.isArray(t?.entries)?t.entries:[],z.value=""}catch(e){if(!i())return;z.value=`Could not read the cache: ${e.message||e}`}finally{N.value=!1}}async function Se(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await C("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}i()&&await $()}}async function Me(){const e=p.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{p.value=await C("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),fe(ge)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}i()&&await $()}}const oe=e=>_.peek().find(t=>t.key===e);function Ce(e){const t=oe(e);if(!t)return[];const a=J(t.key);return[{header:a.path},{label:"Open the page",icon:"external-link",disabled:!a.page,action:()=>window.open(a.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...a.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(a.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}n.querySelector(".stx-problems").innerHTML=Ge;const g=n.querySelector(".stx-list");g.innerHTML=Ve;const xe=E.tabs(n.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Te=M.applyBindings(we,n,{methods:{...se.methods,save:G,discard:ke,sendTest:Ee,refreshCache:$,clearCache:Se,toggleCache:Me,pickBrandImage:async e=>{const t=await _e({title:"Choose a branding image",current:s[e].peek()});t&&i()&&(s[e].value=t)},clearBrandImage:e=>{s[e].value=""},corner:e=>{s.bcPosition.value=e}}});r(()=>Te.dispose());for(const e of n.querySelectorAll("[data-brand-icon]")){const t=e.dataset.brandIcon,a=Ne("Choose an icon",s[t].peek());a.input.id=`stx-${t}`,a.input.classList.add("form-input");const c=()=>{s[t].value=a.input.value};a.input.addEventListener("input",c),r(()=>a.input.removeEventListener("input",c)),r(M.effect(()=>{a.input.value=s[t].value})),e.appendChild(a.el)}r(Ae(n,{position:"bottom"}));const Le=e=>{const t=e.target.closest("[data-go]");if(!t)return;const a=We.indexOf(t.dataset.go);a>=0&&xe.activate(a)};n.querySelector(".stx-problems").addEventListener("click",Le);const V=n.querySelector("#stx-admin-home");Ye(V,He(s.adminHome.peek())).then(()=>{i()&&(V.value=s.adminHome.peek(),F.value=!0)}).catch(()=>{});const Oe=E.contextMenu(g,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Ce(e.dataset.id)});r(()=>Oe?.destroy?.());const re=e=>{const t=oe(e.dataset.id),a=t&&J(t.key);a?.page?window.open(a.path,"_blank","noopener"):Y(e)},ie=e=>{const t=e.target.closest(".stx-row");if(!t)return;const a=e.target.closest(".stx-kebab");if(a){Y(a);return}re(t)},ce=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Y(t)):e.key==="Enter"&&e.target===t&&re(t))};g.addEventListener("click",ie),g.addEventListener("keydown",ce),r(()=>{g.removeEventListener("click",ie),g.removeEventListener("keydown",ce)}),me(l,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),me(l,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>G()});const le=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),G())};document.addEventListener("keydown",le,!0),r(()=>document.removeEventListener("keydown",le,!0));const ue=()=>{if(x.peek())try{S.set(w,{form:$e(X()),base:q.peek(),at:new Date().toISOString()})}catch{}},de=e=>{x.peek()&&(ue(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",de),r(()=>window.removeEventListener("beforeunload",de)),f={dirty:()=>x.peek(),dispose(){k=!0,ue(),u.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(n),$();let h=null;try{h=S.get(w)}catch{}if(h?.form){try{S.remove?S.remove(w):S.set(w,null)}catch{}const e=h.base&&h.base!==q.peek(),t=h.at?` (${ze(h.at)})`:"",a=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(a)&&i()){const c={...b.peek()};for(const[y,W]of Object.entries(h.form))y in c&&y!=="smtpPass"&&(c[y]=W);ne(c),F.peek()&&(V.value=s.adminHome.peek())}}}};
11
+ </div>`,j=["general","branding","favicon","footer","email","pageNavigation","cookies","breadcrumbs","motion","cache","search"];async function Xe(c,d){const[o,k]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),n=new Map([["/","Dashboard (built in)"]]),i=(l,m)=>{for(const r of l||[]){if(!r||r.hidden||r.restricted)continue;const p=typeof r.url=="string"?r.url:"";p.startsWith("#/")&&p.length>2&&!n.has(p.slice(1))&&n.set(p.slice(1),`${m?`${m} \u203A `:""}${r.text||p}`),i(r.items,r.url?m:r.text||m)}};i(Ae(o?.items||[],k||[]),""),n.has(d)||n.set(d,`${d} (not in the sidebar)`),c.replaceChildren();for(const[l,m]of n){const r=document.createElement("option");r.value=l,r.textContent=m,c.appendChild(r)}}let h=null,ke=!1;function Ze(){ke||!window.R?.use||(ke=!0,R.use((c,d,o)=>!(X.test(d?.path||"")&&!X.test(c?.path||""))||!h?.dirty()?o():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(n=>{n?o():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:c}={})=>{if(h&&!X.test(c?.path||"")){const d=h;h=null,d.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(c){if(Ze(),h){const e=h;h=null,e.dispose()}const d=[],o=e=>(d.push(e),e);let k=!1;const n=c.get(0).querySelector(".stx")||c.get(0),i=()=>!k&&n.isConnected;let l=null;const m=E.loader(c.get(0),{type:"dots"});let r;try{r=await D.settings.get()}catch(e){m.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(m.destroy(),!n.isConnected)return;const p=M.observable(r||{}),Z=Ue(r||{}),s={};for(const[e,t]of Object.entries(Z))s[e]=M.observable(t);const q=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),ee=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),b=M.observable(Z),C=M.computed(()=>Q(b.value)),T=M.computed(()=>Q(q())!==C.value),y=M.observable(!1),L=M.observable(""),te=M.observable(!1);D.plugins.policy().then(e=>{i()&&(te.value=e?.signedOnly===!1)}).catch(()=>{});const U=M.observable(!1),ae=M.computed(()=>{const e=new Set(Be(q(),b.value));return Object.fromEntries(Object.keys(ze).map(t=>[t,e.has(t)]))}),F=M.observable([]);o(M.effect(()=>{F.value=Ke(q(),p.value).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const se=M.observable(""),O=M.observable(!1),P=M.observable(""),A=M.observable(""),f=M.observable(null),_=M.observable([]),K=M.observable(""),z=M.observable(!0),N=M.observable(""),ne=M.observable([]);o(M.effect(()=>{const e=K.value.trim().toLowerCase(),t=Date.now();ne.value=[..._.value].reverse().filter(a=>!e||String(a.key).toLowerCase().includes(e)||(a.tags||[]).some(u=>String(u).toLowerCase().includes(e))).map(a=>{const u=J(a.key);return{id:a.key,path:u.path,meta:[u.page?`as ${u.role}`:"not a page",(a.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:He(a.expiresAt,t)}})}));const oe=Ve(s.favicon,s.brandLogo,i),Se={...oe.data,...s,saving:y,dirty:T,tabs:ae,problems:F,testTo:se,testing:O,testResult:P,testTone:A,cacheQuery:K,cacheLoading:z,cacheError:N,cacheRows:ne,errorText:L,problemsText:M.computed(()=>F.value.length?"yes":""),siteUrlError:M.computed(()=>ge(s.siteUrl.value)),noSmtp:M.computed(()=>!String(s.smtpHost.value||"").trim()),mtSizeText:M.computed(()=>`${s.mtIconSize.value} px`),mtRadiusText:M.computed(()=>`${s.mtIconRadius.value} px`),mayAllowInsecureTLS:te,dirtyEmail:M.computed(()=>ae.value.email),cacheOn:M.computed(()=>f.value?.enabled===!0),cacheState:M.computed(()=>{const e=f.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=f.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=f.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>f.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>K.value.trim()?"Nothing matches.":f.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function V(){if(y.peek())return;const e=ee(),t=b.peek();if(l?.dirty()&&Q(e)===C.peek()){y.value=!0;try{await l.save()}finally{y.value=!1}return}const a=ge(e.siteUrl);if(a){E.toast(`Site URL: ${a}`,{type:"error"});return}const u=je(e)[0];if(u){E.toast(u,{type:"error"});return}y.value=!0,L.value="";const Ie=String(e.title).trim()!==String(t.title).trim();try{await D.settings.save(_e(e,p.peek(),{adminHomeReady:U.peek()}));const B=await D.settings.get().catch(()=>null);if(!i())return;B&&(p.value=B);const Pe={...e,smtpPass:""};s.smtpPass.value="",b.value=Pe;try{S.remove?S.remove(w):S.set(w,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),(Ie||["adminBrandEnabled","adminBrandTitle","adminBrandLogo","adminBrandIcon"].some(ve=>e[ve]!==t[ve]))&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),ye(we),E.toast("Settings saved.",{type:"success"}),l?.dirty()&&await l.save()}catch(B){L.value=B.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{y.value=!1}}async function Ee(){await E.confirm("Throw away your unsaved settings?")&&(re(b.peek()),l?.discard(),L.value="")}function re(e){for(const[t,a]of Object.entries(e))s[t]&&(s[t].value=a)}async function Me(){if(!O.peek()){O.value=!0,A.value="",P.value="Sending\u2026";try{const e=await x("/settings/test-email",{method:"POST",body:JSON.stringify({to:se.peek().trim()||void 0})});if(!i())return;P.value=e?.message||"Test email sent.",A.value="is-ok"}catch(e){if(!i())return;P.value=e.message||"Could not send the test email.",A.value="is-bad"}finally{O.value=!1}}}async function $(){z.value=!0;try{const[e,t]=await Promise.all([x("/cache/status").catch(()=>null),x("/cache/keys")]);if(!i())return;f.value=e,_.value=Array.isArray(t?.entries)?t.entries:[],N.value=""}catch(e){if(!i())return;N.value=`Could not read the cache: ${e.message||e}`}finally{z.value=!1}}async function xe(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await x("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}i()&&await $()}}async function Ce(){const e=f.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{f.value=await x("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),ye(we)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}i()&&await $()}}const ie=e=>_.peek().find(t=>t.key===e);function Te(e){const t=ie(e);if(!t)return[];const a=J(t.key);return[{header:a.path},{label:"Open the page",icon:"external-link",disabled:!a.page,action:()=>window.open(a.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...a.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(a.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}n.querySelector(".stx-problems").innerHTML=Je;const g=n.querySelector(".stx-list");g.innerHTML=Qe;const W=E.tabs(n.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),ce=new URLSearchParams(location.hash.split("?")[1]||"").get("tab");j.includes(ce)&&W.activate(j.indexOf(ce));const Re=M.applyBindings(Se,n,{methods:{...oe.methods,save:V,discard:Ee,sendTest:Me,refreshCache:$,clearCache:xe,toggleCache:Ce,pickBrandImage:async e=>{const t=await Ne({title:"Choose a branding image",current:s[e].peek()});t&&i()&&(s[e].value=t)},clearBrandImage:e=>{s[e].value=""},corner:e=>{s.bcPosition.value=e},motionCorner:e=>{s.mtPosition.value=e},motionPlacement:e=>{s.mtPlacement.value=e},goTab:e=>{const t=j.indexOf(e);t>=0&&W.activate(t)}}}),le=n.querySelector("#stx-mt-radius");o(M.effect(()=>{const e=Math.floor((Number.parseInt(s.mtIconSize.value,10)||52)/2);le.max=String(e);const t=Number.parseInt(s.mtIconRadius.peek(),10)||0;t>e?s.mtIconRadius.value=String(e):le.value=String(t)})),o(()=>Re.dispose());for(const e of n.querySelectorAll("[data-brand-icon]")){const t=e.dataset.brandIcon,a=We("Choose an icon",s[t].peek());a.input.id=`stx-${t}`,a.input.classList.add("form-input");const u=()=>{s[t].value=a.input.value};a.input.addEventListener("input",u),o(()=>a.input.removeEventListener("input",u)),o(M.effect(()=>{a.input.value=s[t].value})),e.appendChild(a.el)}o($e(n,{position:"bottom"}));const Le=e=>{const t=e.target.closest("[data-go]");if(!t)return;const a=j.indexOf(t.dataset.go);a>=0&&W.activate(a)};n.querySelector(".stx-problems").addEventListener("click",Le);const Y=n.querySelector("#stx-admin-home");Xe(Y,Fe(s.adminHome.peek())).then(()=>{i()&&(Y.value=s.adminHome.peek(),U.value=!0)}).catch(()=>{});const Oe=E.contextMenu(g,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Te(e.dataset.id)});o(()=>Oe?.destroy?.());const ue=e=>{const t=ie(e.dataset.id),a=t&&J(t.key);a?.page?window.open(a.path,"_blank","noopener"):G(e)},de=e=>{const t=e.target.closest(".stx-row");if(!t)return;const a=e.target.closest(".stx-kebab");if(a){G(a);return}ue(t)},me=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),G(t)):e.key==="Enter"&&e.target===t&&ue(t))};g.addEventListener("click",de),g.addEventListener("keydown",me),o(()=>{g.removeEventListener("click",de),g.removeEventListener("keydown",me)}),be(c,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),be(c,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>V()});const pe=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),V())};document.addEventListener("keydown",pe,!0),o(()=>document.removeEventListener("keydown",pe,!0));const fe=()=>{if(T.peek())try{S.set(w,{form:De(ee()),base:C.peek(),at:new Date().toISOString()})}catch{}},he=e=>{T.peek()&&(fe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",he),o(()=>window.removeEventListener("beforeunload",he)),h={dirty:()=>T.peek()||!!l?.dirty(),dispose(){k=!0,fe(),d.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(n),$();let v=null;try{v=S.get(w)}catch{}if(v?.form){try{S.remove?S.remove(w):S.set(w,null)}catch{}const e=v.base&&v.base!==C.peek(),t=v.at?` (${Ye(v.at)})`:"",a=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;await E.confirm(a)&&i()&&(re(qe(b.peek(),v.form)),U.peek()&&(Y.value=s.adminHome.peek()))}i()&&(l=Ge(n),o(()=>l.dispose()))}};
@@ -396,7 +396,7 @@ headings (Layouts → Page contents settings, and `[contents]` per page).
396
396
  - **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`).
397
397
  - Where: `/robots.txt`; `server/services/sitemap.js`; checked by `tests/routes/llms-robots.test.js`
398
398
  - Since: the Allow lines 1.8.36
399
- - **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.
399
+ - **llms.txt** - an automatic `/llms.txt` (the llmstxt.org format) for AI assistants: `# Site title`, `> description` (SEO settings 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.
400
400
  - Where: `/llms.txt`; **Tools > SEO > Settings** ("Publish /llms.txt for AI assistants"); `config/seo.json` `llms.enabled`; `buildLlmsTxt()` in `server/services/sitemap.js`
401
401
  - Since: 1.8.36
402
402
  - **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]` or `[view]` lists every entry in its no-JS fallback, in client and server mode alike (or its `limit`; at most 1,000 - `FALLBACK_MAX` - with the count left out in `data-cb-more`, which the SEO audit warns about; server mode runs one extra query for it, kept by the page cache), 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, search results and the Calendar month view.
@@ -1099,8 +1099,9 @@ Where: `server/services/presetCollections.js` (`PRESETS`, `seedAll()`), `server/
1099
1099
  - **Site Settings** - One screen with one Save covering nine tabs:
1100
1100
  - General: title, tagline, Site URL, admin home, default spacer size.
1101
1101
  - SEO defaults: default title, separator, meta description.
1102
- - Footer: copyright, a "Reduce motion" switch, social icons for X, Facebook, Instagram, LinkedIn, GitHub and YouTube.
1102
+ - Footer: copyright, layout, blurb, footer blocks, social icons for X, Facebook, Instagram, LinkedIn, GitHub and YouTube.
1103
1103
  - Showcase footer (Footer tab): `footer.look: showcase` (glowing top line, more room), `footer.cta` (a big call to action, some words in a gradient, optional link and sub-line), `footer.watermark` (a huge faint name behind the footer that drifts on scroll), `footer.email` / `footer.address` under the brand, `footer.status` (green dot) and up to four `footer.facts`, `footer.tagline` in the bottom bar, `footer.gradient` `{from, to}`. Columns layout only; each part shows when it has words. Drawn by `public/js/footer-showcase.mjs` + `footer-showcase.css`.
1104
+ - Reduce motion: on/off, label, the default for new visitors; in the footer (switch or checkbox), a floating button (corner, offsets, size, corners) or the navbar.
1104
1105
  - Email: SMTP host, port, user, password and TLS; From address and name; send a test email.
1105
1106
  - Back to top: on/off, label, threshold, position, offsets, smooth scrolling.
1106
1107
  - Cookie consent: message, position, layout, colours, categories, policy links, button text, consent version.