domma-cms 0.90.4 → 0.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -2
- package/admin/js/lib/editor-folding.js +2 -2
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/media-picker.js +24 -0
- package/admin/js/lib/page-editor-reference.js +7 -3
- package/admin/js/lib/seo-settings.js +2 -2
- package/admin/js/lib/shortcode-context-menu.js +1 -1
- package/admin/js/lib/shortcode-registry.js +3 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +53 -2
- package/admin/js/templates/seo.html +2 -0
- package/admin/js/views/seo.js +42 -39
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/server/services/markdown.js +36 -2
package/admin/js/views/seo.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import{apiRequest as
|
|
1
|
+
import{apiRequest as Jt}from"../api.js";import{addBannerAction as Kt}from"../lib/plugin-chrome.js";import{helpPopovers as zt}from"../lib/help-popover.js";import{onceClosed as Ft,openMenuAt as Yt,viewLifetime as Qt}from"../lib/tool-kit.js";import{snippet as Xt}from"../lib/seo-analyse.js";import{checkRule as Qe,matchRedirect as Vt}from"../lib/seo-redirects.js";import{checkSettings as Zt,mergeDefaults as Xe,ORG_TYPES as es}from"../lib/seo-settings.js";import{pickMedia as Ve}from"../lib/media-picker.js";const ts="/seo",Pe=/^#\/seo(\/[a-z0-9-]+)?\/?(\?.*)?$/,Ze=["overview","redirects","sitemap"],ss="#/seo",as={pass:"\u2713",warn:"!",fail:"\u2715",info:"i"},os={301:"301 moved",302:"302 for now",307:"307 for now",308:"308 moved",410:"410 gone"},ns={apiRequest:Jt,addBannerAction:Kt,helpPopovers:zt,onceClosed:Ft,openMenuAt:Yt,viewLifetime:Qt},is=async()=>ns,y=r=>String(r??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Ae(r){const i=document.createElement("template");return i.innerHTML=r.trim(),i.content.firstElementChild}async function ne(r,i){try{await navigator.clipboard.writeText(r),E.toast(`Copied ${i}.`,{type:"success"})}catch{E.toast(`Could not copy - ${r}`,{type:"warning",duration:6e3})}}function et(){try{window.dispatchEvent(new CustomEvent("dm:sidebar-count",{detail:{url:ss}}))}catch{}}const tt=r=>typeof r!="number"?"":r>=85?"good":r>=60?"poor":"bad",st=r=>r?new Date(r).toLocaleDateString("en-GB",{day:"numeric",month:"short",year:"numeric"}):"",at=r=>{if(!r)return"";const i=Math.round((Date.now()-Date.parse(r))/6e4);if(i<1)return"just now";if(i<60)return`${i} min ago`;const $=Math.round(i/60);return $<48?`${$} hour${$===1?"":"s"} ago`:st(r)};function ls(r){const i=d=>{let c=String(d??"");return/^[=+\-@\t\r]/.test(c)&&(c=`'${c}`),/[",\n\r]/.test(c)?`"${c.replace(/"/g,'""')}"`:c},$=Object.keys(r[0]||{});return`\uFEFF${[$.join(","),...r.map(d=>$.map(c=>i(d[c])).join(","))].join(`\r
|
|
2
2
|
`)}\r
|
|
3
|
-
`}function
|
|
3
|
+
`}function rs(r,i,$="text/csv;charset=utf-8"){const d=URL.createObjectURL(new Blob([i],{type:$})),c=Object.assign(document.createElement("a"),{href:d,download:r});document.body.appendChild(c),c.click(),c.remove(),setTimeout(()=>URL.revokeObjectURL(d),1e4)}const cs=`
|
|
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>
|
|
@@ -12,7 +12,7 @@ import{apiRequest as Gt}from"../api.js";import{addBannerAction as Jt}from"../lib
|
|
|
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>`,
|
|
15
|
+
</div>`,us=`
|
|
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,40 +20,40 @@ import{apiRequest as Gt}from"../api.js";import{addBannerAction as Jt}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>`,
|
|
23
|
+
</div>`,ps=`
|
|
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>`,
|
|
29
|
+
</div>`,hs=`
|
|
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>`,
|
|
34
|
+
</div>`,ms=`
|
|
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(
|
|
41
|
+
</div>`;export const seoView={templateUrl:"/admin/js/templates/seo.html",async onMount(r){const i=await is();if(!Pe.test(location.hash))return;const $=seoView._lifetime??(seoView._lifetime=i.viewLifetime(Pe));$.reset();const d=r.get(0).querySelector(".sox")||r.get(0),c=()=>Pe.test(location.hash)&&d.isConnected,v=t=>($.add(t),t),p=(t,e)=>i.apiRequest(`${ts}${t}`,e),ot=location.hash.match(/^#\/seo\/([a-z0-9-]+)/)?.[1]||"overview",q=M.observable(ot),he=M.observable(!0),me=M.observable(""),S=M.observable(!0),h=M.observable(null),be=M.observable({results:[]}),J=M.observable("all"),ve=M.observable(""),fe=M.observable(""),x=M.observable({rules:[],chains:[],onRename:!0}),ge=M.observable(""),ie=M.observable(""),le=M.observable(""),Ie=M.observable("301"),D=M.observable({entries:[],left:[],sources:[],labels:{},robots:""}),V=M.observable("in"),ye=M.observable(""),Ne=M.observable([]),nt=M.computed(()=>!Ze.includes(q.value));let B=null;const K=t=>D.value.labels?.[t]||(t==="pages"?"Pages":t),it=M.computed(()=>(h.value?.score??null)===null?"\u2014":`${h.value.score}%`),lt=M.computed(()=>`${h.value?.score??0}%`),rt=M.computed(()=>tt(h.value?.score)),ct=M.computed(()=>{const t=h.value;return t?{pages:String(t.pages),failing:String(t.failing),duplicates:String(t.duplicates),redirects:String(t.redirects)}:{pages:"\u2014",failing:"\u2014",duplicates:"\u2014",redirects:"\u2014"}}),dt=M.computed(()=>h.value?.failing?String(h.value.failing):""),ut=M.computed(()=>{const t=h.value;return t?.running?`Checking\u2026 ${t.progress?.done||0} of ${t.progress?.total||"?"}`:t?.at?`Checked ${at(t.at)}`:"Not checked yet"}),je=M.computed(()=>[...new Set((be.value.results||[]).map(t=>t.source))]),qe=M.observable([]);let Z=[];v(M.effect(()=>{const t=fe.value.trim().toLowerCase(),e=J.value,s=ve.value;Z=(be.value.results||[]).filter(a=>(e==="all"||(e==="hidden"?a.noindex||a.unlisted:!a.noindex&&(a.error||a.failing>0)))&&(!s||a.source===s)&&(!t||[a.urlPath,a.title,a.label].some(n=>String(n||"").toLowerCase().includes(t)))),Z.sort((a,n)=>(a.error?-1:0)-(n.error?-1:0)||(a.score??101)-(n.score??101)),qe.value=Z.slice(0,1e3).map((a,n)=>({id:`p${n}`,path:a.urlPath,sourceText:[K(a.source),a.noindex?"hidden from search engines":a.unlisted?"not in the sitemap":""].filter(Boolean).join(" \xB7 "),title:a.title||a.label||"",scoreText:a.error?"!":typeof a.score=="number"?String(a.score):"\u2014",tone:a.error?"bad":a.noindex?"info":tt(a.score),problems:a.error||(a.problems?.length?a.problems.join(" \xB7 "):"Nothing to fix")}))}));const pt=M.computed(()=>h.value?.at?fe.value.trim()?"No page matches.":J.value==="problems"?"Nothing needs attention.":"No pages.":h.value?.running?"Checking every page - this takes a moment\u2026":"Not checked yet. Check now with the button top right."),De=M.observable([]);let T=[];v(M.effect(()=>{const t=h.value;if(T=[],t){const e=t.siteSeo||{};for(const s of t.issues?.duplicateTitles||[])T.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 t.issues?.duplicateDescriptions||[]){const a=e.defaultDescription&&s.text===e.defaultDescription;T.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"})}e.defaultImage||T.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"}),(!t.origin||/localhost|127\.0\.0\.1/.test(t.origin))&&T.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"}),t.chains&&T.push({kind:"chains",text:`${t.chains} redirect${t.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"})}De.value=T.map((e,s)=>({id:`s${s}`,text:e.text,detail:e.detail||"",tone:e.tone,badge:e.badge}))}));const xe=M.observable([]);let ee=[];v(M.effect(()=>{const t=ge.value.trim().toLowerCase(),e=new Set(x.value.chains.map(s=>s.id));ee=x.value.rules.filter(s=>!t||`${s.from} ${s.to} ${s.note}`.toLowerCase().includes(t)),xe.value=ee.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,e.has(s.id)?"leads to another redirect":""].filter(Boolean).join(" \xB7 "),code:String(s.code),tone:s.code===410?"bad":e.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 ${at(s.lastHit)}`:"Not used yet"}))}));const ht=M.computed(()=>x.value.rules.length?String(x.value.rules.length):""),mt=M.computed(()=>`${ee.length+0*xe.value.length} of ${x.value.rules.length}`),bt=M.computed(()=>ge.value.trim()?"No redirect matches.":"No redirects yet."),vt=M.computed(()=>{const t=x.value.chains.length;return t?`${t} redirect${t===1?" leads":"s lead"} to another. Right-click one and choose "Go straight there" to make it one hop.`:""}),ft=M.computed(()=>x.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)."),Be=M.observable([]);v(M.effect(()=>{Be.value=D.value.sources.map((t,e)=>({id:`x${e}`,label:K(t.id),detail:`${t.listed} of ${t.count} listed`,state:t.on?"on":"off",tone:t.on?"good":""}))}));const we=M.observable([]);let z=[];v(M.effect(()=>{const t=ye.value.trim().toLowerCase();z=(V.value==="in"?D.value.entries:D.value.left).filter(s=>!t||`${s.urlPath} ${s.title}`.toLowerCase().includes(t)),we.value=z.slice(0,2e3).map((s,a)=>({id:`e${a}`,path:s.urlPath,title:s.title||"",source:K(s.source),note:s.why||[s.lastmod?`Changed ${st(s.lastmod)}`:"",s.changefreq||"",s.priority!==void 0?`priority ${s.priority}`:""].filter(Boolean).join(" \xB7 ")}))}));const gt=M.computed(()=>D.value.robots||""),yt=M.computed(()=>`${z.length+0*we.value.length} address${z.length===1?"":"es"}`),xt=M.computed(()=>D.value.hooks?ye.value.trim()?"No address matches.":V.value==="in"?"The sitemap is empty.":"Nothing is left out.":"This version of Domma CMS lists only its own pages in the sitemap."),re=t=>/403|forbidden/i.test(String(t?.message));async function te(){try{const t=await p("/overview");if(!c()||!t)return;h.value=t,me.value="",t.running&&Ue()}catch(t){c()&&(me.value=re(t)?"You do not have access to SEO.":`Could not load SEO: ${t.message||t}`)}}async function ke(){try{const t=await p("/audit");c()&&t&&(be.value=t)}catch{}}async function se(){try{const t=await p("/redirects");c()&&t&&(x.value=t)}catch{}}async function Me(){try{const t=await p("/sitemap");c()&&t&&(D.value=t)}catch(t){c()&&!re(t)&&E.toast(`Could not load the sitemap: ${t.message||t}`,{type:"error"})}}async function $e(){he.value=!0,await Promise.all([te(),ke(),se(),Me()]),c()&&(he.value=!1)}function Ue(){B||(B=setInterval(async()=>{if(!c()){clearInterval(B),B=null;return}await te(),h.peek()?.running||(clearInterval(B),B=null,await ke(),et())},1500))}v(()=>{B&&clearInterval(B)});async function _e(){try{const t=await p("/audit",{method:"POST",body:JSON.stringify({})});c()&&(h.value={...h.peek(),...t,running:!0}),Ue()}catch(t){E.toast(`Could not start the check: ${t.message||t}`,{type:"error"})}}async function Se(t,e,s=se){try{const a=await t();return e&&E.toast(e,{type:"success"}),await Promise.all([s(),te()]),a}catch(a){return re(a)?(S.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 wt=(t,e=`Redirect from ${t.from} added.`)=>Se(()=>p("/redirects",{method:"POST",body:JSON.stringify(t)}),e),kt=(t,e)=>Se(()=>p(`/redirects/${encodeURIComponent(t.join(","))}`,{method:"DELETE"}),`${e} removed.`);async function Mt(t){try{const s=(await p(`/redirects/test?path=${encodeURIComponent(t.replace(/\/\*$/,"/example"))}`)).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(e){E.toast(`Could not test: ${e.message||e}`,{type:"error"})}}function He(t){const e={from:M.observable(t.from),to:M.observable(t.code===410?"":t.to),code:M.observable(String(t.code)),note:M.observable(t.note||"")};We(`Redirect from ${t.from}`,e,`
|
|
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(
|
|
46
|
+
${Object.entries(os).map(([s,a])=>`<option value="${s}">${a}</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 s={from:e.from.value,to:e.to.value,code:Number(e.code.value),note:e.note.value},a=x.peek().rules.filter(n=>n.id!==t.id);return{body:s,errors:Qe(s,a).errors}},async s=>{await
|
|
48
|
+
</div>`,()=>{const s={from:e.from.value,to:e.to.value,code:Number(e.code.value),note:e.note.value},a=x.peek().rules.filter(n=>n.id!==t.id);return{body:s,errors:Qe(s,a).errors}},async s=>{await p(`/redirects/${encodeURIComponent(t.id)}`,{method:"PUT",body:JSON.stringify(s)}),E.toast("Redirect saved.",{type:"success"}),await se()})}function $t(){const t={csv:M.observable("")};We("Import redirects",t,`
|
|
49
49
|
<p class="sox-note">One redirect per line: <span class="sox-mono">from, to</span>, and optionally the kind (301, 302, 307, 308, 410) and a note. A header line is skipped. Paste from a spreadsheet, or another CMS's export.</p>
|
|
50
|
-
<textarea class="form-input sox-mono" rows="14" data-model="csv.value" placeholder="/old-page, /new-page /news/*, /blog/*, 301 /retired, , 410"></textarea>`,()=>({body:{csv:t.csv.value},errors:t.csv.value.trim()?{}:{csv:"Paste some redirects first."}}),async e=>{const s=await
|
|
50
|
+
<textarea class="form-input sox-mono" rows="14" data-model="csv.value" placeholder="/old-page, /new-page /news/*, /blog/*, 301 /retired, , 410"></textarea>`,()=>({body:{csv:t.csv.value},errors:t.csv.value.trim()?{}:{csv:"Paste some redirects first."}}),async e=>{const s=await p("/redirects/import",{method:"POST",body:JSON.stringify(e)}),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([se(),te()])},"Import")}function We(t,e,s,a,n,m="Save"){const o=M.observable(!1),b=M.computed(()=>a()),O=M.computed(()=>Object.values(b.value.errors).join(" ")),L={...e,saving:o,errText:O,cannot:M.computed(()=>o.value||!!O.value)},f=E.slideover({title:t,size:"md",position:"right"}),g=Ae(`<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 ${
|
|
52
|
+
<div class="sox-actions"><small class="sox-muted">Ctrl+Enter ${m.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">${y(
|
|
54
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">${y(m)}</button></div></div>`);async function R(){if(!L.cannot.peek()){o.value=!0;try{await n(b.peek().body),f.close()}catch(k){E.toast(`Not saved: ${k.message||k}`,{type:"error"})}finally{o.value=!1}}}f.element.appendChild(g);const w=i.helpPopovers(g),P=M.applyBindings(L,g,{methods:{save:R,cancel:()=>f.close()}});g.addEventListener("keydown",k=>{(k.ctrlKey||k.metaKey)&&k.key==="Enter"&&(k.preventDefault(),R())}),i.onceClosed(f,()=>{P.dispose(),w()}),v(()=>{try{f.close()}catch{}}),I.scan(g),f.open(),g.querySelector("input, textarea")?.focus()}let ce=!1;async function G(t){if(ce)return;ce=!0;let e;try{e=await p(`/item?path=${encodeURIComponent(t)}`)}catch(u){ce=!1,E.toast(`Could not open ${t}: ${u.message||u}`,{type:"error"});return}const s={title:M.observable(e.fields.title),description:M.observable(e.fields.description),focus:M.observable(e.fields.focus),image:M.observable(e.fields.image),noindex:M.observable(e.fields.noindex)},a=e.limits||{},n=e.page?.title||e.live?.h1||"",m=e.kind==="page"?`${n}${e.defaults.separator}${e.defaults.siteTitle}`:e.live?.title||"",o=e.kind==="page"?e.defaults.description||"":e.live?.description||"",b=e.kind==="page"?e.defaults.image:e.live?.image||"",O=M.computed(()=>s.title.value.trim()||m),L=M.computed(()=>s.description.value.trim()||o),f=M.computed(()=>s.image.value.trim()||b),g=e.origin||location.origin,R=M.computed(()=>Xt({title:O.value,description:L.value,url:`${g}${t}`,limits:a})),w=(u,oe,X)=>{const W=u.length;return{text:`${W} / ${X}`,width:`${Math.min(100,W/X*100)}%`,tone:W?W<oe||W>X?"poor":"good":"bad"}},P=M.computed(()=>w(O.value,a.titleMin||30,a.titleMax||60)),k=M.computed(()=>w(L.value,a.descMin||70,a.descMax||160)),Oe=(()=>{try{return new URL(g).host}catch{return location.host}})(),pe=M.computed(()=>f.value?`url("${f.value.replace(/["\\\n\r]/g,"")}")`:"none"),l=M.observable([]),_=u=>{const oe=[],X=u?.checks||[],W={fail:0,warn:1,pass:2,info:3};for(const Le of[...new Set(X.map(j=>j.group))]){oe.push({id:`g-${Le}`,head:Le,title:"",detail:"",fix:"",status:"",glyph:""});for(const j of X.filter(Re=>Re.group===Le).sort((Re,Gt)=>W[Re.status]-W[Gt.status]))oe.push({id:`c-${j.id}`,head:"",title:j.title,detail:j.detail,fix:j.status==="pass"?"":j.fix,status:j.status,glyph:as[j.status]||"?"})}l.value=oe};_(e.analysis);const A=M.observable(e.analysis?`Score ${e.analysis.score}% \xB7 ${e.analysis.failing} failing, ${e.analysis.warnings} could be better`:e.status===200?"":`Not live (answers ${e.status||"nothing"}) - the checks run once it is published.`),N=()=>({path:t,title:s.title.value,description:s.description.value,focus:s.focus.value,image:s.image.value,noindex:s.noindex.value});let F=JSON.stringify(N());const Y=M.observable(!1),ze=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.":""),Fe={...s,saving:Y,errText:ze,checks:l,scoreLine:A,serp:R,titleMeter:P,descMeter:k,shareBg:pe,effTitle:O,effDesc:L,host:Oe,hasImage:M.computed(()=>!!f.value),defTitle:m,defDesc:o,canManage:S,cannot:M.computed(()=>Y.value||!!ze.value||!S.value)},Q=E.slideover({title:`SEO \xB7 ${t}`,size:"lg",position:"right"}),Dt=e.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>':e.page?.status!=="published"?`<p class="sox-note is-warn">This page is a ${y(e.page?.status||"draft")} - not live yet.</p>`:"",Bt=e.redirect?`<p class="sox-note is-bad">A redirect sends visitors from here to ${y(e.redirect.to||"(gone)")} - they never see this page.</p>`:"",H=Ae(`
|
|
55
55
|
<div class="sox-form">
|
|
56
|
-
${
|
|
56
|
+
${Dt}${Bt}
|
|
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
58
|
<span class="sox-spacer"></span><a class="btn btn-xs btn-ghost" href="${y(t)}" target="_blank" rel="noopener"><span data-icon="external-link" data-icon-size="12"></span> Open</a></h4>
|
|
59
59
|
<div class="sox-previews">
|
|
@@ -70,15 +70,16 @@ import{apiRequest as Gt}from"../api.js";import{addBannerAction as Jt}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="${y(
|
|
73
|
+
<input id="sox-i-title" class="form-input" data-model="title.value" placeholder="${y(m)}" 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="${y(
|
|
76
|
+
<textarea id="sox-i-desc" rows="3" class="form-input" data-model="description.value" placeholder="${y(o)}" 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
|
-
<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.
|
|
81
|
-
<input id="sox-i-image" class="form-input sox-mono" data-model="image.value" list="sox-media" placeholder="${y(b||"/media/\u2026")}"
|
|
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="${y(b||"/media/\u2026")}">
|
|
82
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="pickImage"><span data-icon="image"></span> Choose\u2026</button></div></div>
|
|
82
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
|
|
83
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>
|
|
84
85
|
</div><datalist id="sox-media"></datalist></section>
|
|
@@ -91,46 +92,48 @@ import{apiRequest as Gt}from"../api.js";import{addBannerAction as Jt}from"../lib
|
|
|
91
92
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
|
|
92
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>
|
|
93
94
|
</div>
|
|
94
|
-
</div>`);
|
|
95
|
+
</div>`);H.querySelector(".sox-checks").innerHTML=ms,St(H.querySelector("#sox-media"));async function Ye(){if(!Fe.cannot.peek()){Y.value=!0;try{await p("/item",{method:"PUT",body:JSON.stringify(N())}),F=JSON.stringify(N()),E.toast("Saved.",{type:"success"});const u=await p(`/item?path=${encodeURIComponent(t)}`).catch(()=>null);u?.analysis&&(_(u.analysis),A.value=`Score ${u.analysis.score}% \xB7 ${u.analysis.failing} failing, ${u.analysis.warnings} could be better`),ke(),te(),et()}catch(u){re(u)&&(S.value=!1),E.toast(`Not saved: ${u.message||u}`,{type:"error"})}finally{Y.value=!1}}}Q.element.appendChild(H);const Ut=i.helpPopovers(H),_t=M.applyBindings(Fe,H,{methods:{save:Ye,cancel:()=>Q.close(),pickImage:async()=>{const u=await Ve({title:"Choose a share image",current:s.image.peek()});u&&(s.image.value=u)}}}),Ht=H.querySelector(".sox-share-img"),Wt=M.effect(()=>{Ht.style.backgroundImage=pe.value});H.addEventListener("keydown",u=>{(u.ctrlKey||u.metaKey)&&u.key==="Enter"&&(u.preventDefault(),Ye())}),i.onceClosed(Q,()=>{_t.dispose(),Ut(),Wt?.(),ce=!1}),Ge(Q,()=>JSON.stringify(N())!==F),v(()=>{try{Q.close(!0)}catch{}}),I.scan(H),Q.open()}let Ee=null;async function St(t){try{Ee??(Ee=await i.apiRequest("/media").then(e=>Array.isArray(e)?e:e?.files||e?.items||[]));for(const e of Ee){const s=e.url||e.path||(e.filename?`/media/${e.filename}`:"");s&&/\.(jpe?g|png|webp|gif|avif|svg)$/i.test(s)&&t.appendChild(Object.assign(document.createElement("option"),{value:s}))}}catch{Ee=[]}}function Ge(t,e){const s=t.close.bind(t);let a=!1,n=!1;t.close=async m=>{if(!n&&m!==!0&&e()){if(a)return;a=!0;const o=await E.confirm("Discard the changes you have not saved?");if(a=!1,!o)return}return n=!0,s()}}let de=!1;async function Et(){if(de)return;de=!0;let t;try{const l=await p("/settings");t=Xe(l.settings)}catch(l){de=!1,E.toast(`Could not load the settings: ${l.message||l}`,{type:"error"});return}const e=t.organisation,s=h.peek()?.siteSeo?.defaultTitle||"The site's name",a={orgOn:M.observable(e.enabled),orgType:M.observable(e.type),orgName:M.observable(e.name),orgUrl:M.observable(e.url),orgLogo:M.observable(e.logo),orgEmail:M.observable(e.email),orgPhone:M.observable(e.phone),orgSame:M.observable(e.sameAs.join(`
|
|
95
96
|
`)),street:M.observable(e.address.street),locality:M.observable(e.address.locality),region:M.observable(e.address.region),postcode:M.observable(e.address.postcode),country:M.observable(e.address.country),articles:M.observable(t.articles),exclude:M.observable(t.sitemap.exclude.join(`
|
|
96
|
-
`)),blockAll:M.observable(t.robots.blockAll),robotsExtra:M.observable(t.robots.extra),onRename:M.observable(t.redirects.onRename),every:M.observable(String(t.audit.everyHours)),tMin:M.observable(String(t.audit.titleMin)),tMax:M.observable(String(t.audit.titleMax)),dMin:M.observable(String(t.audit.descMin)),dMax:M.observable(String(t.audit.descMax)),words:M.observable(String(t.audit.minWords))},
|
|
97
|
-
<input id="sox-s-${
|
|
97
|
+
`)),blockAll:M.observable(t.robots.blockAll),robotsExtra:M.observable(t.robots.extra),onRename:M.observable(t.redirects.onRename),every:M.observable(String(t.audit.everyHours)),tMin:M.observable(String(t.audit.titleMin)),tMax:M.observable(String(t.audit.titleMax)),dMin:M.observable(String(t.audit.descMin)),dMax:M.observable(String(t.audit.descMax)),words:M.observable(String(t.audit.minWords))},n=()=>({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:t.sitemap.sources},robots:{blockAll:a.blockAll.value,extra:a.robotsExtra.value},redirects:{onRename:a.onRename.value},audit:{everyHours:a.every.value,titleMin:a.tMin.value,titleMax:a.tMax.value,descMin:a.dMin.value,descMax:a.dMax.value,minWords:a.words.value}}),m=JSON.stringify(n()),o=M.observable(!1),b=M.computed(()=>Zt(n(),t)),O=M.computed(()=>Object.values(b.value.errors).join(" ")),L={...a,saving:o,errText:O,cannot:M.computed(()=>o.value||!!O.value),isPerson:M.computed(()=>a.orgType.value==="Person")},f=(l,_,A,N="",F="")=>`<div><label class="form-label" for="sox-s-${l}">${A}${N?` <span data-help="${y(N)}"></span>`:""}</label>
|
|
98
|
+
<input id="sox-s-${l}" class="form-input" data-model="${_}.value" ${F}></div>`,g=(l,_,A,N,F,Y="")=>f(l,_,A,Y,`type="number" min="${N}" max="${F}"`),R=(l,_,A="")=>`<label class="sox-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="${l}.value"> ${_}${A?` <span data-help="${y(A)}"></span>`:""}</label>`,w=E.slideover({title:"SEO settings",size:"md",position:"right"}),P=Ae(`
|
|
98
99
|
<div class="sox-form">
|
|
99
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
101
|
<section><h4>Who is behind the site <span data-help="Structured data on the home page, so search engines can show the name, logo and contact details - a knowledge panel, or a local business in maps."></span></h4><div class="sox-form-grid">
|
|
101
|
-
${
|
|
102
|
+
${R("orgOn","Tell search engines who runs the site")}
|
|
102
103
|
<div><label class="form-label" for="sox-s-type">It is</label><select id="sox-s-type" class="form-input" data-model="orgType.value">
|
|
103
|
-
${
|
|
104
|
-
${
|
|
105
|
-
${
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
${es.map(l=>`<option value="${l}">${{Organization:"An organisation",LocalBusiness:"A local business",Person:"A person"}[l]}</option>`).join("")}</select></div>
|
|
105
|
+
${f("name","orgName","Name","Empty uses the site's name.",`placeholder="${y(s)}"`)}
|
|
106
|
+
${f("url","orgUrl","Web address","Empty uses the site address.",'placeholder="https://\u2026"')}
|
|
107
|
+
<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
|
+
<div class="sox-pick"><input id="sox-s-logo" class="form-input sox-mono" data-model="orgLogo.value" placeholder="/media/logo.png">
|
|
109
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="pickLogo"><span data-icon="image"></span> Choose\u2026</button></div></div>
|
|
110
|
+
${f("email","orgEmail","Email","",'type="email"')}
|
|
111
|
+
${f("phone","orgPhone","Phone")}
|
|
109
112
|
<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>
|
|
110
113
|
<div class="sox-form-grid"><input class="form-input sox-wide" placeholder="Street" aria-label="Street" data-model="street.value">
|
|
111
114
|
<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">
|
|
112
115
|
<input class="form-input" placeholder="Postcode" aria-label="Postcode" data-model="postcode.value"><input class="form-input" placeholder="Country (GB)" aria-label="Country" data-model="country.value"></div></div>
|
|
113
116
|
<div class="sox-wide"><label class="form-label" for="sox-s-same">Profiles elsewhere <span data-help="Your pages on Facebook, LinkedIn, Instagram, X and so on - one address per line. Search engines use them to know they are you."></span></label>
|
|
114
117
|
<textarea id="sox-s-same" rows="3" class="form-input sox-mono" data-model="orgSame.value" placeholder="https://www.linkedin.com/company/\u2026"></textarea></div>
|
|
115
|
-
${
|
|
118
|
+
${R("articles","Mark blog posts as articles","Adds BlogPosting structured data to each post - headline, image, publisher - which search engines use for article results.")}
|
|
116
119
|
</div></section>
|
|
117
120
|
<section><h4>Sitemap and robots.txt</h4><div class="sox-form-grid">
|
|
118
121
|
<div class="sox-wide"><label class="form-label" for="sox-s-ex">Leave out of the sitemap <span data-help="One address per line. Ending with / leaves out everything under it: /members/ covers /members/area. To hide one page from search engines altogether, open it on the Pages tab instead."></span></label>
|
|
119
122
|
<textarea id="sox-s-ex" rows="3" class="form-input sox-mono" data-model="exclude.value" placeholder="/thank-you /members/"></textarea></div>
|
|
120
|
-
${
|
|
123
|
+
${R("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.")}
|
|
121
124
|
<div class="sox-wide"><label class="form-label" for="sox-s-rob">More robots.txt rules <span data-help="Lines like Disallow: /private/ join the rules for every crawler. Start with User-agent: to add rules for one crawler, e.g. User-agent: GPTBot then Disallow: / to keep an AI crawler out."></span></label>
|
|
122
125
|
<textarea id="sox-s-rob" rows="3" class="form-input sox-mono" data-model="robotsExtra.value" placeholder="Disallow: /private/"></textarea></div>
|
|
123
126
|
</div></section>
|
|
124
127
|
<section><h4>Redirects</h4><div class="sox-form-grid">
|
|
125
|
-
${
|
|
128
|
+
${R("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.")}
|
|
126
129
|
</div></section>
|
|
127
130
|
<section><h4>The checks</h4><div class="sox-form-grid">
|
|
128
|
-
${
|
|
129
|
-
${
|
|
130
|
-
${
|
|
131
|
-
${
|
|
132
|
-
${
|
|
133
|
-
${
|
|
131
|
+
${g("every","every","Check every page every (hours)",0,168,"0 checks only when you ask.")}
|
|
132
|
+
${g("words","words","Words a page should have",0,5e3)}
|
|
133
|
+
${g("tmin","tMin","Shortest title",10,70)}
|
|
134
|
+
${g("tmax","tMax","Longest title",30,100,"Google cuts titles at about 60 characters.")}
|
|
135
|
+
${g("dmin","dMin","Shortest description",30,160)}
|
|
136
|
+
${g("dmax","dMax","Longest description",80,320,"Google cuts descriptions at about 155-160 characters.")}
|
|
134
137
|
</div></section>
|
|
135
138
|
<p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
|
|
136
139
|
<div class="sox-actions">
|
|
@@ -138,7 +141,7 @@ import{apiRequest as Gt}from"../api.js";import{addBannerAction as Jt}from"../lib
|
|
|
138
141
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
139
142
|
<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>
|
|
140
143
|
</div>
|
|
141
|
-
</div>`);async function
|
|
144
|
+
</div>`);async function k(){if(!L.cannot.peek()){o.value=!0;try{await p("/settings",{method:"PUT",body:JSON.stringify(b.peek().settings)}),E.toast("Settings saved.",{type:"success"}),w.close(!0),$e()}catch(l){E.toast(`Not saved: ${l.message||l}`,{type:"error"})}finally{o.value=!1}}}w.element.appendChild(P);const Oe=i.helpPopovers(P),pe=M.applyBindings(L,P,{methods:{save:k,cancel:()=>w.close(),pickLogo:async()=>{const l=await Ve({title:"Choose a logo",current:a.orgLogo.peek()});l&&(a.orgLogo.value=l)}}});P.addEventListener("keydown",l=>{(l.ctrlKey||l.metaKey)&&l.key==="Enter"&&(l.preventDefault(),k())}),i.onceClosed(w,()=>{pe.dispose(),Oe(),de=!1}),Ge(w,()=>JSON.stringify(n())!==m),v(()=>{try{w.close(!0)}catch{}}),I.scan(P),w.open()}async function Tt(t,e){try{const s=await p("/settings"),a=Xe(s.settings);a.sitemap.sources[t]=e,await p("/settings",{method:"PUT",body:JSON.stringify(a)}),E.toast(`${K(t)} ${e?"back in":"left out of"} the sitemap.`,{type:"success"}),Me()}catch(s){E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}const C=t=>Number(String(t?.dataset?.row||"").slice(1));function Ct(t){const e=Z[C(t)];return e?[{header:e.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>G(e.urlPath)},{label:"Open the page",icon:"external-link",action:()=>window.open(e.urlPath,"_blank","noopener")},{label:"Redirect it elsewhere\u2026",icon:"corner-up-right",disabled:!S.peek(),action:()=>{U("redirects"),ie.value=e.urlPath,le.value=""}},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>ne(e.urlPath,"the address")},{label:"Copy the problems",icon:"clipboard",disabled:!e.problems?.length&&!e.error,action:()=>ne(`${e.urlPath}
|
|
142
145
|
${e.error||e.problems.join(`
|
|
143
|
-
`)}`,"the problems")}]:[]}function
|
|
144
|
-
`),"the addresses")}]:e.kind==="chains"?[{label:"Show the redirects",icon:"corner-up-right",action:()=>
|
|
146
|
+
`)}`,"the problems")}]:[]}function Ot(t){const e=T[C(t)];return e?e.kind==="dupe"?[{header:"Open one of them"},...e.paths.slice(0,12).map(s=>({label:s,icon:"edit-3",action:()=>G(s)})),{type:"divider"},{label:"Copy the addresses",icon:"copy",action:()=>ne(e.paths.join(`
|
|
147
|
+
`),"the addresses")}]:e.kind==="chains"?[{label:"Show the redirects",icon:"corner-up-right",action:()=>U("redirects")}]:[{label:"Go to Settings",icon:"settings",action:()=>{location.hash=e.link}}]:[]}function Lt(t){const e=T[C(t)];e?.kind==="dupe"?G(e.paths[0]):e?.kind==="chains"?U("redirects"):e?.link&&(location.hash=e.link)}function Rt(t){const e=ee[C(t)];if(!e)return[];const s=x.peek().rules,a=x.peek().chains.some(m=>m.id===e.id);let n=null;if(a){n=e.to;for(let m=0;m<10;m++){const o=Vt(s,n);if(!o)break;if(o.code===410){n=null;break}if(n=o.location,/^https?:/i.test(n))break}}return[{header:`${e.from} \u2192 ${e.code===410?"gone":e.to}`},{label:"Edit\u2026",icon:"edit-3",disabled:!S.peek(),action:()=>He(e)},{label:"Test it",icon:"play",action:()=>Mt(e.from)},{label:"Go straight there",icon:"fast-forward",disabled:!n||!S.peek(),action:()=>Se(()=>p(`/redirects/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify({to:n})}),`${e.from} now goes straight to ${n}.`)},{label:"Open the old address",icon:"external-link",disabled:e.from.endsWith("/*"),action:()=>window.open(e.from,"_blank","noopener")},{type:"divider"},{label:"Copy",icon:"copy",action:()=>ne(`${e.from} ${e.to} ${e.code}`,"the redirect")},{label:"Remove",icon:"trash-2",danger:!0,disabled:!S.peek(),action:async()=>{await E.confirm(`Remove the redirect from ${y(e.from)}? Visitors to it will get "not found".`)&&kt([e.id],"Redirect")}}]}function Pt(t){const e=z[C(t)];return e?[{header:e.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>G(e.urlPath)},{label:"Open",icon:"external-link",action:()=>window.open(e.urlPath,"_blank","noopener")},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>ne(e.urlPath,"the address")}]:[]}function At(t){const e=D.peek().sources[C(t)];return e?[{header:K(e.id)},{label:e.on?"Leave all of it out":"List it again",icon:e.on?"eye-off":"eye",disabled:!S.peek(),action:()=>Tt(e.id,!e.on)}]:[]}function ae(t,e,s){const a=E.contextMenu(t,{cursor:"glyph",match:"[data-row]",inherit:!1,density:"compact",items:o=>e(o)});v(()=>a?.destroy?.());const n=o=>{const b=o.target.closest("[data-row]");if(!(!b||o.target.closest("a"))){if(o.target.closest(".sox-kebab")){i.openMenuAt(b);return}s?.(b)}},m=o=>{const b=o.target.closest("[data-row]");b&&(o.key==="F10"&&o.shiftKey?(o.preventDefault(),i.openMenuAt(b)):o.key==="Enter"&&o.target===b&&s?.(b))};t.addEventListener("click",n),t.addEventListener("keydown",m),v(()=>{t.removeEventListener("click",n),t.removeEventListener("keydown",m)})}const Te=d.querySelector(".sox-ext-body"),Ce=d.querySelector(".sox-ext-tabs");let ue=null;async function It(){let t=[];try{t=(await p("/extensions"))?.tabs||[]}catch{}c()&&(Ne.value=t,Ce.replaceChildren(...t.map(e=>{const s=document.createElement("button");return s.type="button",s.setAttribute("role","tab"),s.dataset.ext=e.key,s.innerHTML=`<span data-icon="${y(e.icon||"box")}" data-icon-size="14"></span> ${y(e.label)} <span class="sox-pro">PRO</span>`,e.help&&(s.title=e.help),s.addEventListener("click",()=>U(e.key)),s})),I.scan(Ce),Je())}async function Je(){for(const e of Ce.querySelectorAll("button"))e.classList.toggle("is-on",e.dataset.ext===q.peek());const t=Ne.peek().find(e=>e.key===q.peek());if(!t){Ze.includes(q.peek())||U("overview");return}if(ue?.key!==t.key){try{ue?.dispose?.()}catch{}Te.replaceChildren();try{const e=await import(t.module);if(!c()||q.peek()!==t.key)return;const s=await e.mount(Te,{kit:i,api:i.apiRequest,scope:v,here:c,openItem:G,reload:$e,E,M,I});ue={key:t.key,dispose:s}}catch(e){Te.innerHTML=`<p class="sox-note is-bad">${y(t.label)} could not be shown: ${y(e.message||e)}</p>`}}}v(()=>{try{ue?.dispose?.()}catch{}}),d.querySelector(".sox-pages").innerHTML=cs,d.querySelector(".sox-site").innerHTML=ds,d.querySelector(".sox-rules").innerHTML=us,d.querySelector(".sox-entries").innerHTML=ps,d.querySelector(".sox-sources").innerHTML=hs;const Ke=d.querySelector(".sox-select");v(M.effect(()=>{const t=je.value;Ke.replaceChildren(new Option("Everything",""),...t.map(e=>new Option(K(e),e))),Ke.value=ve.peek()}));function U(t){q.peek()!==t&&(q.value=t,history.replaceState(null,"",`#/seo${t==="overview"?"":`/${t}`}`),t==="redirects"&&se(),t==="sitemap"&&Me()),Je()}const Nt={tab:q,busy:he,error:me,canManage:S,scoreText:it,scoreWidth:lt,scoreTone:rt,ov:ct,attention:dt,asOf:ut,sourceOptions:je,source:ve,show:J,query:fe,pageRows:qe,pagesEmpty:pt,siteRows:De,ruleRows:xe,ruleCount:ht,ruleText:mt,rulesEmpty:bt,chainText:vt,renameText:ft,ruleQuery:ge,newFrom:ie,newTo:le,newCode:Ie,sourceRows:Be,smRows:we,smShow:V,smQuery:ye,smText:yt,smEmpty:xt,robots:gt,extOn:nt},jt={tab_overview:()=>U("overview"),tab_redirects:()=>U("redirects"),tab_sitemap:()=>U("sitemap"),show_all:()=>{J.value="all"},show_problems:()=>{J.value="problems"},show_hidden:()=>{J.value="hidden"},sm_in:()=>{V.value="in"},sm_out:()=>{V.value="out"},importCsv:$t,exportRules:()=>{const t=x.peek().rules;if(!t.length){E.toast("Nothing to export.",{type:"info"});return}rs(`redirects-${location.hostname}-${new Date().toISOString().slice(0,10)}.csv`,ls(t.map(e=>({from:e.from,to:e.to,code:e.code,note:e.note||"",hits:e.hits||0,last_used:e.lastHit||""}))))},addRule:()=>{const t={from:ie.peek(),to:le.peek(),code:Number(Ie.peek())},{errors:e}=Qe(t,x.peek().rules);return Object.keys(e).length?(E.toast(Object.values(e).join(" "),{type:"warning"}),!1):(wt(t).then(s=>{s&&(ie.value="",le.value="")}),!1)}},qt=M.applyBindings(Nt,d,{methods:jt});v(()=>qt.dispose()),v(i.helpPopovers(d,{position:"bottom"})),ae(d.querySelector(".sox-pages"),Ct,t=>{const e=Z[C(t)];e&&G(e.urlPath)}),ae(d.querySelector(".sox-site"),Ot,Lt),ae(d.querySelector(".sox-rules"),Rt,t=>{const e=ee[C(t)];e&&S.peek()&&He(e)}),ae(d.querySelector(".sox-entries"),Pt,t=>{const e=z[C(t)];e&&G(e.urlPath)}),ae(d.querySelector(".sox-sources"),At),i.addBannerAction(r,{id:"seo-check",icon:"refresh-cw",title:"Check every page again",onClick:_e}),i.addBannerAction(r,{id:"seo-settings",icon:"settings",title:"SEO settings",onClick:Et}),I.scan(d),await Promise.all([$e(),It()]),c()&&I.scan(d),c()&&!h.peek()?.at&&!h.peek()?.running&&_e()}};
|