domma-cms 0.90.4 → 0.90.5

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.
@@ -0,0 +1,24 @@
1
+ import{api as q}from"../api.js";import{onceClosed as S}from"./tool-kit.js";const $=/\.(png|jpe?g|gif|webp|avif|svg)$/i;export function pickable(l,{query:s="",accept:p=$}={}){const r=String(s).trim().toLowerCase();return(Array.isArray(l)?l:[]).map(e=>({name:e.name||e.filename||"",url:e.url||(e.name?`/media/${e.name}`:"")})).filter(e=>e.url&&p.test(e.name||e.url)&&(!r||e.name.toLowerCase().includes(r)))}export function pickMedia({title:l="Choose an image",current:s=""}={}){return new Promise(p=>{let r=!1;const e=a=>{r||(r=!0,p(a))},i=E.slideover({title:l,size:"lg",position:"right"}),m=document.createElement("template");m.innerHTML=`<div class="mp-picker">
2
+ <div class="mp-bar">
3
+ <input type="search" class="form-input" placeholder="Search Media\u2026" aria-label="Search Media" data-model="query.value">
4
+ <label class="btn btn-sm btn-ghost mp-upload"><span data-icon="upload"></span> Upload
5
+ <input type="file" accept="image/*" hidden></label>
6
+ </div>
7
+ <p class="mp-note" data-bind-hidden="!note.value" data-bind-text="note.value"></p>
8
+ <div class="media-picker-grid mp-grid" data-each="shown.value key=url">
9
+ <button type="button" class="media-picker-item" data-bind-data-url="url" data-bind-title="name">
10
+ <img data-bind-src="url" data-bind-alt="name" loading="lazy"><span data-bind-text="name"></span>
11
+ </button>
12
+ </div>
13
+ </div>
14
+ <style>
15
+ .mp-picker { padding: 1rem; display: flex; flex-direction: column; gap: .75rem; }
16
+ .mp-bar { display: flex; gap: .5rem; align-items: center; }
17
+ .mp-bar .form-input { flex: 1; }
18
+ .mp-upload { cursor: pointer; white-space: nowrap; }
19
+ .mp-note { margin: 0; font-size: .85rem; opacity: .75; }
20
+ .mp-picker [hidden] { display: none !important; }
21
+ .mp-grid { max-height: none; }
22
+ .mp-grid .media-picker-item { background: none; padding: 0; text-align: left; color: inherit; font: inherit; }
23
+ .mp-grid .media-picker-item.is-current { border-color: var(--dm-primary); }
24
+ </style>`;const o=m.content.firstElementChild,h=m.content.querySelector("style"),d=M.observable([]),u=M.observable(""),t=M.observable("Loading\u2026"),g=M.computed(()=>pickable(d.value,{query:u.value})),x=M.effect(()=>{t.peek()==="Loading\u2026"&&!d.value.length||(t.value=g.value.length?"":u.value.trim()?"No picture matches.":"No pictures in Media yet - upload one.")}),b=async()=>{try{d.value=await q.media.list(),t.value="",pickable(d.peek()).length||(t.value="No pictures in Media yet - upload one.")}catch(a){t.value=`Could not load Media: ${a.message||a}`}},y=o.querySelector(".mp-grid"),k=a=>{const n=a.target.closest("[data-url]");n&&(e(n.dataset.url),i.close())};y.addEventListener("click",k);const v=o.querySelector("input[type=file]");v.addEventListener("change",async()=>{const a=v.files?.[0];if(a){t.value=`Uploading ${a.name}\u2026`;try{const n=new FormData;n.append("file",a);const f=await q.media.upload(n),c=Array.isArray(f)?f[0]:f,w=c?.url||(c?.name?`/media/${c.name}`:"");if(E.toast(`${c?.name||a.name} is in Media.`,{type:"success"}),w){e(w),i.close();return}await b()}catch(n){t.value="",E.toast(`Not uploaded: ${n.message||n}`,{type:"error"})}finally{v.value=""}}}),i.element.appendChild(o),h&&i.element.appendChild(h);const L=M.applyBindings({query:u,note:t,shown:g},o),C=M.effect(()=>{g.value,queueMicrotask(()=>y.querySelectorAll("[data-url]").forEach(a=>a.classList.toggle("is-current",!!s&&a.dataset.url===s)))});S(i,()=>{L.dispose(),x?.(),C?.(),y.removeEventListener("click",k),e(null)}),Domma.icons?.scan?.(o),i.open(),b(),setTimeout(()=>o.querySelector("input[type=search]")?.focus(),50)})}
@@ -1,2 +1,2 @@
1
- import{LIMITS as h}from"./seo-analyse.js";export const ORG_TYPES=["Organization","LocalBusiness","Person"],DEFAULT_SETTINGS={organisation:{enabled:!1,type:"Organization",name:"",logo:"",url:"",email:"",phone:"",sameAs:[],address:{street:"",locality:"",region:"",postcode:"",country:""}},articles:!0,sitemap:{exclude:[],sources:{}},robots:{blockAll:!1,extra:""},redirects:{onRename:!0},audit:{everyHours:24,...h}};const f=(e,i=300)=>String(e??"").trim().slice(0,i),c=(e,i)=>e===void 0?i:e===!0||e==="true",m=e=>(Array.isArray(e)?e:String(e??"").split(/[\n,]+/)).map(i=>String(i).trim()).filter(Boolean),p=(e,i,n)=>{const r=Number(e);return Number.isInteger(r)&&r>=i&&r<=n?r:null},g=e=>{try{return["http:","https:"].includes(new URL(e).protocol)}catch{return!1}};export function checkSettings(e={},i=DEFAULT_SETTINGS){const n={},r=JSON.parse(JSON.stringify(mergeDefaults(i))),o=e.organisation;if(o&&typeof o=="object"){const s=r.organisation;o.enabled!==void 0&&(s.enabled=c(o.enabled,!1)),o.type!==void 0&&(ORG_TYPES.includes(o.type)?s.type=o.type:n["organisation.type"]="Organisation, local business or person.");for(const t of["name","email","phone"])o[t]!==void 0&&(s[t]=f(o[t],200));for(const t of["logo","url"]){if(o[t]===void 0)continue;const a=f(o[t],500);a&&!g(a)&&!a.startsWith("/")?n[`organisation.${t}`]=`${t==="logo"?"The logo":"The address"}: a full web address, or a path like /media/logo.png.`:s[t]=a}if(o.sameAs!==void 0){const t=m(o.sameAs),a=t.filter(l=>!g(l));a.length?n["organisation.sameAs"]=`Not web addresses: ${a.slice(0,2).join(", ")}`:s.sameAs=[...new Set(t)].slice(0,20)}if(o.address&&typeof o.address=="object")for(const t of Object.keys(DEFAULT_SETTINGS.organisation.address))o.address[t]!==void 0&&(s.address[t]=f(o.address[t],200));s.enabled&&!s.name&&(n["organisation.name"]="Give the name search engines should show.")}if(e.articles!==void 0&&(r.articles=c(e.articles,!0)),e.sitemap&&typeof e.sitemap=="object"){if(e.sitemap.exclude!==void 0){const s=m(e.sitemap.exclude),t=s.filter(a=>!a.startsWith("/"));t.length?n["sitemap.exclude"]=`Each starts with "/": ${t.slice(0,3).join(", ")}`:r.sitemap.exclude=[...new Set(s)].slice(0,200)}if(e.sitemap.sources&&typeof e.sitemap.sources=="object")for(const[s,t]of Object.entries(e.sitemap.sources))/^[a-z0-9][a-z0-9:-]*$/.test(s)&&(r.sitemap.sources[s]=c(t,!0))}if(e.robots&&typeof e.robots=="object"&&(e.robots.blockAll!==void 0&&(r.robots.blockAll=c(e.robots.blockAll,!1)),e.robots.extra!==void 0)){const s=String(e.robots.extra??"").replace(/\r/g,"").slice(0,5e3),t=s.split(`
2
- `).map(a=>a.trim()).filter(a=>a&&!a.startsWith("#")&&!/^[A-Za-z-]+\s*:/.test(a));t.length?n["robots.extra"]=`Each line is "Field: value" (or a # comment): ${t[0].slice(0,40)}`:r.robots.extra=s.trim()}e.redirects&&e.redirects.onRename!==void 0&&(r.redirects.onRename=c(e.redirects.onRename,!0));const d=e.audit;if(d&&typeof d=="object"){const s=(t,a,l,b)=>{if(d[t]===void 0)return;const u=p(d[t],a,l);u===null?n[`audit.${t}`]=`${b}: a whole number, ${a} to ${l}.`:r.audit[t]=u};s("everyHours",0,168,"Check every (hours)"),s("titleMin",10,70,"Shortest title"),s("titleMax",30,100,"Longest title"),s("descMin",30,160,"Shortest description"),s("descMax",80,320,"Longest description"),s("minWords",0,5e3,"Words a page should have"),r.audit.titleMax<r.audit.titleMin&&(n["audit.titleMax"]="The longest title cannot be shorter than the shortest."),r.audit.descMax<r.audit.descMin&&(n["audit.descMax"]="The longest description cannot be shorter than the shortest.")}return{errors:n,settings:r}}export function mergeDefaults(e={}){const i=DEFAULT_SETTINGS;return{organisation:{...i.organisation,...e.organisation||{},address:{...i.organisation.address,...e.organisation?.address||{}},sameAs:Array.isArray(e.organisation?.sameAs)?e.organisation.sameAs:[]},articles:e.articles??i.articles,sitemap:{exclude:Array.isArray(e.sitemap?.exclude)?e.sitemap.exclude:[],sources:{...e.sitemap?.sources||{}}},robots:{...i.robots,...e.robots||{}},redirects:{...i.redirects,...e.redirects||{}},audit:{...i.audit,...e.audit||{}}}}export function excluded(e,i=[]){const n=String(e||"").toLowerCase();return i.some(r=>{const o=String(r).toLowerCase().replace(/\*$/,"");return o.endsWith("/")?n===o.slice(0,-1)||n.startsWith(o):n===o})}
1
+ import{LIMITS as h}from"./seo-analyse.js";export const ORG_TYPES=["Organization","LocalBusiness","Person"],DEFAULT_SETTINGS={organisation:{enabled:!1,type:"Organization",name:"",logo:"",url:"",email:"",phone:"",sameAs:[],address:{street:"",locality:"",region:"",postcode:"",country:""}},articles:!0,sitemap:{exclude:[],sources:{}},robots:{blockAll:!1,extra:""},redirects:{onRename:!0},audit:{everyHours:24,...h}};const f=(e,i=300)=>String(e??"").trim().slice(0,i),c=(e,i)=>e===void 0?i:e===!0||e==="true",m=e=>(Array.isArray(e)?e:String(e??"").split(/[\n,]+/)).map(i=>String(i).trim()).filter(Boolean),p=(e,i,n)=>{const r=Number(e);return Number.isInteger(r)&&r>=i&&r<=n?r:null},g=e=>{try{return["http:","https:"].includes(new URL(e).protocol)}catch{return!1}};export function checkSettings(e={},i=DEFAULT_SETTINGS){const n={},r=JSON.parse(JSON.stringify(mergeDefaults(i))),s=e.organisation;if(s&&typeof s=="object"){const o=r.organisation;s.enabled!==void 0&&(o.enabled=c(s.enabled,!1)),s.type!==void 0&&(ORG_TYPES.includes(s.type)?o.type=s.type:n["organisation.type"]="Organisation, local business or person.");for(const t of["name","email","phone"])s[t]!==void 0&&(o[t]=f(s[t],200));for(const t of["logo","url"]){if(s[t]===void 0)continue;const a=f(s[t],500);a&&!g(a)&&!a.startsWith("/")?n[`organisation.${t}`]=`${t==="logo"?"The logo":"The address"}: a full web address, or a path like /media/logo.png.`:o[t]=a}if(s.sameAs!==void 0){const t=m(s.sameAs),a=t.filter(l=>!g(l));a.length?n["organisation.sameAs"]=`Not web addresses: ${a.slice(0,2).join(", ")}`:o.sameAs=[...new Set(t)].slice(0,20)}if(s.address&&typeof s.address=="object")for(const t of Object.keys(DEFAULT_SETTINGS.organisation.address))s.address[t]!==void 0&&(o.address[t]=f(s.address[t],200))}if(e.articles!==void 0&&(r.articles=c(e.articles,!0)),e.sitemap&&typeof e.sitemap=="object"){if(e.sitemap.exclude!==void 0){const o=m(e.sitemap.exclude),t=o.filter(a=>!a.startsWith("/"));t.length?n["sitemap.exclude"]=`Each starts with "/": ${t.slice(0,3).join(", ")}`:r.sitemap.exclude=[...new Set(o)].slice(0,200)}if(e.sitemap.sources&&typeof e.sitemap.sources=="object")for(const[o,t]of Object.entries(e.sitemap.sources))/^[a-z0-9][a-z0-9:-]*$/.test(o)&&(r.sitemap.sources[o]=c(t,!0))}if(e.robots&&typeof e.robots=="object"&&(e.robots.blockAll!==void 0&&(r.robots.blockAll=c(e.robots.blockAll,!1)),e.robots.extra!==void 0)){const o=String(e.robots.extra??"").replace(/\r/g,"").slice(0,5e3),t=o.split(`
2
+ `).map(a=>a.trim()).filter(a=>a&&!a.startsWith("#")&&!/^[A-Za-z-]+\s*:/.test(a));t.length?n["robots.extra"]=`Each line is "Field: value" (or a # comment): ${t[0].slice(0,40)}`:r.robots.extra=o.trim()}e.redirects&&e.redirects.onRename!==void 0&&(r.redirects.onRename=c(e.redirects.onRename,!0));const d=e.audit;if(d&&typeof d=="object"){const o=(t,a,l,b)=>{if(d[t]===void 0)return;const u=p(d[t],a,l);u===null?n[`audit.${t}`]=`${b}: a whole number, ${a} to ${l}.`:r.audit[t]=u};o("everyHours",0,168,"Check every (hours)"),o("titleMin",10,70,"Shortest title"),o("titleMax",30,100,"Longest title"),o("descMin",30,160,"Shortest description"),o("descMax",80,320,"Longest description"),o("minWords",0,5e3,"Words a page should have"),r.audit.titleMax<r.audit.titleMin&&(n["audit.titleMax"]="The longest title cannot be shorter than the shortest."),r.audit.descMax<r.audit.descMin&&(n["audit.descMax"]="The longest description cannot be shorter than the shortest.")}return{errors:n,settings:r}}export function mergeDefaults(e={}){const i=DEFAULT_SETTINGS;return{organisation:{...i.organisation,...e.organisation||{},address:{...i.organisation.address,...e.organisation?.address||{}},sameAs:Array.isArray(e.organisation?.sameAs)?e.organisation.sameAs:[]},articles:e.articles??i.articles,sitemap:{exclude:Array.isArray(e.sitemap?.exclude)?e.sitemap.exclude:[],sources:{...e.sitemap?.sources||{}}},robots:{...i.robots,...e.robots||{}},redirects:{...i.redirects,...e.redirects||{}},audit:{...i.audit,...e.audit||{}}}}export function excluded(e,i=[]){const n=String(e||"").toLowerCase();return i.some(r=>{const s=String(r).toLowerCase().replace(/\*$/,"");return s.endsWith("/")?n===s.slice(0,-1)||n.startsWith(s):n===s})}
@@ -1 +1 @@
1
- export function onceClosed(t,e=()=>{}){const n=t.close.bind(t);let o=!1;t.close=(...c)=>{const r=n(...c);return o||(o=!0,e(),setTimeout(()=>{try{t.destroy?.()}catch{}t.element?.remove()},400)),r}}export function openMenuAt(t){const e=t.getBoundingClientRect();t.dispatchEvent(new MouseEvent("contextmenu",{bubbles:!0,cancelable:!0,clientX:e.left,clientY:e.bottom,view:window}))}export function settingsStore(t,e){return{read(){try{return e(S.get(t))}catch{return e(null)}},write(n){try{S.set(t,n)}catch{}}}}export function viewLifetime(t){let e=[];const n=()=>{const c=e;e=[],c.forEach(r=>{try{r?.()}catch{}})},o=()=>{t.test(location.hash)||n()};return{add(c){e.push(c)},reset(){n(),window.addEventListener("hashchange",o),e.push(()=>window.removeEventListener("hashchange",o))}}}export function sidebarCount(t,e={}){try{window.dispatchEvent(new CustomEvent("dm:sidebar-count",{detail:{url:t,...e}}))}catch{}}
1
+ export function onceClosed(t,e=()=>{}){const n=t.close.bind(t),o=t.close;let c=!1;t.close=(...r)=>{const s=n(...r);return c||(c=!0,e(),setTimeout(()=>{t.close=o;try{t.destroy?.()}catch{}t.element?.remove()},400)),s}}export function openMenuAt(t){const e=t.getBoundingClientRect();t.dispatchEvent(new MouseEvent("contextmenu",{bubbles:!0,cancelable:!0,clientX:e.left,clientY:e.bottom,view:window}))}export function settingsStore(t,e){return{read(){try{return e(S.get(t))}catch{return e(null)}},write(n){try{S.set(t,n)}catch{}}}}export function viewLifetime(t){let e=[];const n=()=>{const c=e;e=[],c.forEach(r=>{try{r?.()}catch{}})},o=()=>{t.test(location.hash)||n()};return{add(c){e.push(c)},reset(){n(),window.addEventListener("hashchange",o),e.push(()=>window.removeEventListener("hashchange",o))}}}export function sidebarCount(t,e={}){try{window.dispatchEvent(new CustomEvent("dm:sidebar-count",{detail:{url:t,...e}}))}catch{}}
@@ -186,6 +186,8 @@
186
186
  .sox-form h4 { margin: 0 0 .5rem; font-size: var(--dm-text-base); display: flex; align-items: center; gap: .5rem; }
187
187
  .sox-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); gap: .7rem 1rem; }
188
188
  .sox-wide { grid-column: 1 / -1; }
189
+ .sox-pick { display: flex; gap: .4rem; align-items: center; }
190
+ .sox-pick .form-input { flex: 1; min-width: 0; }
189
191
  .sox-err { color: var(--dm-danger); font-size: var(--dm-text-xs); }
190
192
  .sox-actions { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--dm-border); padding-top: .8rem; }
191
193
  .sox-meter { display: flex; align-items: center; gap: .5rem; font-size: var(--dm-text-xs); color: var(--dm-text-muted); margin-top: .25rem; }
@@ -1,6 +1,6 @@
1
- import{apiRequest as Gt}from"../api.js";import{addBannerAction as Jt}from"../lib/plugin-chrome.js";import{helpPopovers as Kt}from"../lib/help-popover.js";import{onceClosed as zt,openMenuAt as Ft,viewLifetime as Yt}from"../lib/tool-kit.js";import{snippet as Qt}from"../lib/seo-analyse.js";import{checkRule as Qe,matchRedirect as Xt}from"../lib/seo-redirects.js";import{checkSettings as Vt,mergeDefaults as Xe,ORG_TYPES as Zt}from"../lib/seo-settings.js";const es="/seo",Ae=/^#\/seo(\/[a-z0-9-]+)?\/?(\?.*)?$/,Ve=["overview","redirects","sitemap"],ts="#/seo",ss={pass:"\u2713",warn:"!",fail:"\u2715",info:"i"},as={301:"301 moved",302:"302 for now",307:"307 for now",308:"308 moved",410:"410 gone"},os={apiRequest:Gt,addBannerAction:Jt,helpPopovers:Kt,onceClosed:zt,openMenuAt:Ft,viewLifetime:Yt},ns=async()=>os,y=l=>String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Pe(l){const o=document.createElement("template");return o.innerHTML=l.trim(),o.content.firstElementChild}async function oe(l,o){try{await navigator.clipboard.writeText(l),E.toast(`Copied ${o}.`,{type:"success"})}catch{E.toast(`Could not copy - ${l}`,{type:"warning",duration:6e3})}}function Ze(){try{window.dispatchEvent(new CustomEvent("dm:sidebar-count",{detail:{url:ts}}))}catch{}}const et=l=>typeof l!="number"?"":l>=85?"good":l>=60?"poor":"bad",tt=l=>l?new Date(l).toLocaleDateString("en-GB",{day:"numeric",month:"short",year:"numeric"}):"",st=l=>{if(!l)return"";const o=Math.round((Date.now()-Date.parse(l))/6e4);if(o<1)return"just now";if(o<60)return`${o} min ago`;const $=Math.round(o/60);return $<48?`${$} hour${$===1?"":"s"} ago`:tt(l)};function is(l){const o=d=>{let r=String(d??"");return/^[=+\-@\t\r]/.test(r)&&(r=`'${r}`),/[",\n\r]/.test(r)?`"${r.replace(/"/g,'""')}"`:r},$=Object.keys(l[0]||{});return`\uFEFF${[$.join(","),...l.map(d=>$.map(r=>o(d[r])).join(","))].join(`\r
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");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 ls(l,o,$="text/csv;charset=utf-8"){const d=URL.createObjectURL(new Blob([o],{type:$})),r=Object.assign(document.createElement("a"),{href:d,download:l});document.body.appendChild(r),r.click(),r.remove(),setTimeout(()=>URL.revokeObjectURL(d),1e4)}const rs=`
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>`,cs=`
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>`,us=`
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>`,ps=`
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>`,hs=`
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(l){const o=await ns();if(!Ae.test(location.hash))return;const $=seoView._lifetime??(seoView._lifetime=o.viewLifetime(Ae));$.reset();const d=l.get(0).querySelector(".sox")||l.get(0),r=()=>Ae.test(location.hash)&&d.isConnected,f=t=>($.add(t),t),h=(t,e)=>o.apiRequest(`${es}${t}`,e),at=location.hash.match(/^#\/seo\/([a-z0-9-]+)/)?.[1]||"overview",N=M.observable(at),he=M.observable(!0),me=M.observable(""),S=M.observable(!0),m=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(""),ne=M.observable(""),ie=M.observable(""),Ie=M.observable("301"),j=M.observable({entries:[],left:[],sources:[],labels:{},robots:""}),X=M.observable("in"),ye=M.observable(""),Ne=M.observable([]),ot=M.computed(()=>!Ve.includes(N.value));let q=null;const K=t=>j.value.labels?.[t]||(t==="pages"?"Pages":t),nt=M.computed(()=>(m.value?.score??null)===null?"\u2014":`${m.value.score}%`),it=M.computed(()=>`${m.value?.score??0}%`),lt=M.computed(()=>et(m.value?.score)),rt=M.computed(()=>{const t=m.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(()=>m.value?.failing?String(m.value.failing):""),ct=M.computed(()=>{const t=m.value;return t?.running?`Checking\u2026 ${t.progress?.done||0} of ${t.progress?.total||"?"}`:t?.at?`Checked ${st(t.at)}`:"Not checked yet"}),je=M.computed(()=>[...new Set((be.value.results||[]).map(t=>t.source))]),qe=M.observable([]);let V=[];f(M.effect(()=>{const t=fe.value.trim().toLowerCase(),e=J.value,s=ve.value;V=(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)))),V.sort((a,n)=>(a.error?-1:0)-(n.error?-1:0)||(a.score??101)-(n.score??101)),qe.value=V.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":et(a.score),problems:a.error||(a.problems?.length?a.problems.join(" \xB7 "):"Nothing to fix")}))}));const ut=M.computed(()=>m.value?.at?fe.value.trim()?"No page matches.":J.value==="problems"?"Nothing needs attention.":"No pages.":m.value?.running?"Checking every page - this takes a moment\u2026":"Not checked yet. Check now with the button top right."),De=M.observable([]);let O=[];f(M.effect(()=>{const t=m.value;if(O=[],t){const e=t.siteSeo||{};for(const s of t.issues?.duplicateTitles||[])O.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;O.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||O.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))&&O.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&&O.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=O.map((e,s)=>({id:`s${s}`,text:e.text,detail:e.detail||"",tone:e.tone,badge:e.badge}))}));const xe=M.observable([]);let Z=[];f(M.effect(()=>{const t=ge.value.trim().toLowerCase(),e=new Set(x.value.chains.map(s=>s.id));Z=x.value.rules.filter(s=>!t||`${s.from} ${s.to} ${s.note}`.toLowerCase().includes(t)),xe.value=Z.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 ${st(s.lastHit)}`:"Not used yet"}))}));const pt=M.computed(()=>x.value.rules.length?String(x.value.rules.length):""),ht=M.computed(()=>`${Z.length+0*xe.value.length} of ${x.value.rules.length}`),mt=M.computed(()=>ge.value.trim()?"No redirect matches.":"No redirects yet."),bt=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.`:""}),vt=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([]);f(M.effect(()=>{Be.value=j.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=[];f(M.effect(()=>{const t=ye.value.trim().toLowerCase();z=(X.value==="in"?j.value.entries:j.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 ${tt(s.lastmod)}`:"",s.changefreq||"",s.priority!==void 0?`priority ${s.priority}`:""].filter(Boolean).join(" \xB7 ")}))}));const ft=M.computed(()=>j.value.robots||""),gt=M.computed(()=>`${z.length+0*we.value.length} address${z.length===1?"":"es"}`),yt=M.computed(()=>j.value.hooks?ye.value.trim()?"No address matches.":X.value==="in"?"The sitemap is empty.":"Nothing is left out.":"This version of Domma CMS lists only its own pages in the sitemap."),le=t=>/403|forbidden/i.test(String(t?.message));async function ee(){try{const t=await h("/overview");if(!r()||!t)return;m.value=t,me.value="",t.running&&Ue()}catch(t){r()&&(me.value=le(t)?"You do not have access to SEO.":`Could not load SEO: ${t.message||t}`)}}async function ke(){try{const t=await h("/audit");r()&&t&&(be.value=t)}catch{}}async function te(){try{const t=await h("/redirects");r()&&t&&(x.value=t)}catch{}}async function Me(){try{const t=await h("/sitemap");r()&&t&&(j.value=t)}catch(t){r()&&!le(t)&&E.toast(`Could not load the sitemap: ${t.message||t}`,{type:"error"})}}async function $e(){he.value=!0,await Promise.all([ee(),ke(),te(),Me()]),r()&&(he.value=!1)}function Ue(){q||(q=setInterval(async()=>{if(!r()){clearInterval(q),q=null;return}await ee(),m.peek()?.running||(clearInterval(q),q=null,await ke(),Ze())},1500))}f(()=>{q&&clearInterval(q)});async function _e(){try{const t=await h("/audit",{method:"POST",body:JSON.stringify({})});r()&&(m.value={...m.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=te){try{const a=await t();return e&&E.toast(e,{type:"success"}),await Promise.all([s(),ee()]),a}catch(a){return le(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 xt=(t,e=`Redirect from ${t.from} added.`)=>Se(()=>h("/redirects",{method:"POST",body:JSON.stringify(t)}),e),wt=(t,e)=>Se(()=>h(`/redirects/${encodeURIComponent(t.join(","))}`,{method:"DELETE"}),`${e} removed.`);async function kt(t){try{const s=(await h(`/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,`
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(as).map(([s,a])=>`<option value="${s}">${a}</option>`).join("")}</select></div>
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 h(`/redirects/${encodeURIComponent(t.id)}`,{method:"PUT",body:JSON.stringify(s)}),E.toast("Redirect saved.",{type:"success"}),await te()})}function Mt(){const t={csv:M.observable("")};We("Import redirects",t,`
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&#10;/news/*, /blog/*, 301&#10;/retired, , 410"></textarea>`,()=>({body:{csv:t.csv.value},errors:t.csv.value.trim()?{}:{csv:"Paste some redirects first."}}),async e=>{const s=await h("/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([te(),ee()])},"Import")}function We(t,e,s,a,n,u="Save"){const i=M.observable(!1),b=M.computed(()=>a()),R=M.computed(()=>Object.values(b.value.errors).join(" ")),w={...e,saving:i,errText:R,cannot:M.computed(()=>i.value||!!R.value)},v=E.slideover({title:t,size:"md",position:"right"}),g=Pe(`<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: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 ${u.toLowerCase()}s.</small><span class="sox-spacer"></span>
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(u)}</button></div></div>`);async function k(){if(!w.cannot.peek()){i.value=!0;try{await n(b.peek().body),v.close()}catch(T){E.toast(`Not saved: ${T.message||T}`,{type:"error"})}finally{i.value=!1}}}v.element.appendChild(g);const C=o.helpPopovers(g),F=M.applyBindings(w,g,{methods:{save:k,cancel:()=>v.close()}});g.addEventListener("keydown",T=>{(T.ctrlKey||T.metaKey)&&T.key==="Enter"&&(T.preventDefault(),k())}),o.onceClosed(v,()=>{F.dispose(),C()}),f(()=>{try{v.close()}catch{}}),I.scan(g),v.open(),g.querySelector("input, textarea")?.focus()}let re=!1;async function W(t){if(re)return;re=!0;let e;try{e=await h(`/item?path=${encodeURIComponent(t)}`)}catch(p){re=!1,E.toast(`Could not open ${t}: ${p.message||p}`,{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||"",u=e.kind==="page"?`${n}${e.defaults.separator}${e.defaults.siteTitle}`:e.live?.title||"",i=e.kind==="page"?e.defaults.description||"":e.live?.description||"",b=e.kind==="page"?e.defaults.image:e.live?.image||"",R=M.computed(()=>s.title.value.trim()||u),w=M.computed(()=>s.description.value.trim()||i),v=M.computed(()=>s.image.value.trim()||b),g=e.origin||location.origin,k=M.computed(()=>Qt({title:R.value,description:w.value,url:`${g}${t}`,limits:a})),C=(p,ae,Q)=>{const H=p.length;return{text:`${H} / ${Q}`,width:`${Math.min(100,H/Q*100)}%`,tone:H?H<ae||H>Q?"poor":"good":"bad"}},F=M.computed(()=>C(R.value,a.titleMin||30,a.titleMax||60)),T=M.computed(()=>C(w.value,a.descMin||70,a.descMax||160)),Oe=(()=>{try{return new URL(g).host}catch{return location.host}})(),c=M.computed(()=>v.value?`url("${v.value.replace(/["\\\n\r]/g,"")}")`:"none"),B=M.observable([]),A=p=>{const ae=[],Q=p?.checks||[],H={fail:0,warn:1,pass:2,info:3};for(const Le of[...new Set(Q.map(P=>P.group))]){ae.push({id:`g-${Le}`,head:Le,title:"",detail:"",fix:"",status:"",glyph:""});for(const P of Q.filter(Re=>Re.group===Le).sort((Re,Wt)=>H[Re.status]-H[Wt.status]))ae.push({id:`c-${P.id}`,head:"",title:P.title,detail:P.detail,fix:P.status==="pass"?"":P.fix,status:P.status,glyph:ss[P.status]||"?"})}B.value=ae};A(e.analysis);const G=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.`),U=()=>({path:t,title:s.title.value,description:s.description.value,focus:s.focus.value,image:s.image.value,noindex:s.noindex.value});let ue=JSON.stringify(U());const pe=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:pe,errText:ze,checks:B,scoreLine:G,serp:k,titleMeter:F,descMeter:T,shareBg:c,effTitle:R,effDesc:w,host:Oe,hasImage:M.computed(()=>!!v.value),defTitle:u,defDesc:i,canManage:S,cannot:M.computed(()=>pe.value||!!ze.value||!S.value)},Y=E.slideover({title:`SEO \xB7 ${t}`,size:"lg",position:"right"}),qt=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>`:"",Dt=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>`:"",_=Pe(`
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
- ${qt}${Dt}
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(u)}" maxlength="200">
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(i)}" maxlength="400"></textarea>
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. A path from Media (/media/photo.jpg) or a full address. Empty uses the site's default image."></span></label>
81
- <input id="sox-i-image" class="form-input sox-mono" data-model="image.value" list="sox-media" placeholder="${y(b||"/media/\u2026")}"></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. 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>`);_.querySelector(".sox-checks").innerHTML=hs,$t(_.querySelector("#sox-media"));async function Ye(){if(!Fe.cannot.peek()){pe.value=!0;try{await h("/item",{method:"PUT",body:JSON.stringify(U())}),ue=JSON.stringify(U()),E.toast("Saved.",{type:"success"});const p=await h(`/item?path=${encodeURIComponent(t)}`).catch(()=>null);p?.analysis&&(A(p.analysis),G.value=`Score ${p.analysis.score}% \xB7 ${p.analysis.failing} failing, ${p.analysis.warnings} could be better`),ke(),ee(),Ze()}catch(p){le(p)&&(S.value=!1),E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{pe.value=!1}}}Y.element.appendChild(_);const Bt=o.helpPopovers(_),Ut=M.applyBindings(Fe,_,{methods:{save:Ye,cancel:()=>Y.close()}}),_t=_.querySelector(".sox-share-img"),Ht=M.effect(()=>{_t.style.backgroundImage=c.value});_.addEventListener("keydown",p=>{(p.ctrlKey||p.metaKey)&&p.key==="Enter"&&(p.preventDefault(),Ye())}),o.onceClosed(Y,()=>{Ut.dispose(),Bt(),Ht?.(),re=!1}),Ge(Y,()=>JSON.stringify(U())!==ue),f(()=>{try{Y.close(!0)}catch{}}),I.scan(_),Y.open()}let Ee=null;async function $t(t){try{Ee??(Ee=await o.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;t.close=async n=>{if(n!==!0&&e()){if(a)return;a=!0;const u=await E.confirm("Discard the changes you have not saved?");if(a=!1,!u)return}return s()}}let de=!1;async function St(){if(de)return;de=!0;let t;try{const c=await h("/settings");t=Xe(c.settings)}catch(c){de=!1,E.toast(`Could not load the settings: ${c.message||c}`,{type:"error"});return}const e=t.organisation,s={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
+ </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))},a=()=>({organisation:{enabled:s.orgOn.value,type:s.orgType.value,name:s.orgName.value,url:s.orgUrl.value,logo:s.orgLogo.value,email:s.orgEmail.value,phone:s.orgPhone.value,sameAs:s.orgSame.value,address:{street:s.street.value,locality:s.locality.value,region:s.region.value,postcode:s.postcode.value,country:s.country.value}},articles:s.articles.value,sitemap:{exclude:s.exclude.value,sources:t.sitemap.sources},robots:{blockAll:s.blockAll.value,extra:s.robotsExtra.value},redirects:{onRename:s.onRename.value},audit:{everyHours:s.every.value,titleMin:s.tMin.value,titleMax:s.tMax.value,descMin:s.dMin.value,descMax:s.dMax.value,minWords:s.words.value}}),n=JSON.stringify(a()),u=M.observable(!1),i=M.computed(()=>Vt(a(),t)),b=M.computed(()=>Object.values(i.value.errors).join(" ")),R={...s,saving:u,errText:b,cannot:M.computed(()=>u.value||!!b.value),isPerson:M.computed(()=>s.orgType.value==="Person")},w=(c,B,A,G="",U="")=>`<div><label class="form-label" for="sox-s-${c}">${A}${G?` <span data-help="${y(G)}"></span>`:""}</label>
97
- <input id="sox-s-${c}" class="form-input" data-model="${B}.value" ${U}></div>`,v=(c,B,A,G,U,ue="")=>w(c,B,A,ue,`type="number" min="${G}" max="${U}"`),g=(c,B,A="")=>`<label class="sox-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="${c}.value"> ${B}${A?` <span data-help="${y(A)}"></span>`:""}</label>`,k=E.slideover({title:"SEO settings",size:"md",position:"right"}),C=Pe(`
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
- ${g("orgOn","Tell search engines who runs the site")}
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
- ${Zt.map(c=>`<option value="${c}">${{Organization:"An organisation",LocalBusiness:"A local business",Person:"A person"}[c]}</option>`).join("")}</select></div>
104
- ${w("name","orgName","Name")}
105
- ${w("url","orgUrl","Web address","Empty uses the site address.",'placeholder="https://\u2026"')}
106
- ${w("logo","orgLogo","Logo (or photo)","A square image works best - a path from Media or a full address.",'placeholder="/media/logo.png"')}
107
- ${w("email","orgEmail","Email","",'type="email"')}
108
- ${w("phone","orgPhone","Phone")}
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
- ${g("articles","Mark blog posts as articles","Adds BlogPosting structured data to each post - headline, image, publisher - which search engines use for article results.")}
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&#10;/members/"></textarea></div>
120
- ${g("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.")}
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
- ${g("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.")}
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
- ${v("every","every","Check every page every (hours)",0,168,"0 checks only when you ask.")}
129
- ${v("words","words","Words a page should have",0,5e3)}
130
- ${v("tmin","tMin","Shortest title",10,70)}
131
- ${v("tmax","tMax","Longest title",30,100,"Google cuts titles at about 60 characters.")}
132
- ${v("dmin","dMin","Shortest description",30,160)}
133
- ${v("dmax","dMax","Longest description",80,320,"Google cuts descriptions at about 155-160 characters.")}
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 F(){if(!R.cannot.peek()){u.value=!0;try{await h("/settings",{method:"PUT",body:JSON.stringify(i.peek().settings)}),E.toast("Settings saved.",{type:"success"}),k.close(!0),$e()}catch(c){E.toast(`Not saved: ${c.message||c}`,{type:"error"})}finally{u.value=!1}}}k.element.appendChild(C);const T=o.helpPopovers(C),Oe=M.applyBindings(R,C,{methods:{save:F,cancel:()=>k.close()}});C.addEventListener("keydown",c=>{(c.ctrlKey||c.metaKey)&&c.key==="Enter"&&(c.preventDefault(),F())}),o.onceClosed(k,()=>{Oe.dispose(),T(),de=!1}),Ge(k,()=>JSON.stringify(a())!==n),f(()=>{try{k.close(!0)}catch{}}),I.scan(C),k.open()}async function Et(t,e){try{const s=await h("/settings"),a=Xe(s.settings);a.sitemap.sources[t]=e,await h("/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 L=t=>Number(String(t?.dataset?.row||"").slice(1));function Tt(t){const e=V[L(t)];return e?[{header:e.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>W(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:()=>{D("redirects"),ne.value=e.urlPath,ie.value=""}},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>oe(e.urlPath,"the address")},{label:"Copy the problems",icon:"clipboard",disabled:!e.problems?.length&&!e.error,action:()=>oe(`${e.urlPath}
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 Ct(t){const e=O[L(t)];return e?e.kind==="dupe"?[{header:"Open one of them"},...e.paths.slice(0,12).map(s=>({label:s,icon:"edit-3",action:()=>W(s)})),{type:"divider"},{label:"Copy the addresses",icon:"copy",action:()=>oe(e.paths.join(`
144
- `),"the addresses")}]:e.kind==="chains"?[{label:"Show the redirects",icon:"corner-up-right",action:()=>D("redirects")}]:[{label:"Go to Settings",icon:"settings",action:()=>{location.hash=e.link}}]:[]}function Ot(t){const e=O[L(t)];e?.kind==="dupe"?W(e.paths[0]):e?.kind==="chains"?D("redirects"):e?.link&&(location.hash=e.link)}function Lt(t){const e=Z[L(t)];if(!e)return[];const s=x.peek().rules,a=x.peek().chains.some(u=>u.id===e.id);let n=null;if(a){n=e.to;for(let u=0;u<10;u++){const i=Xt(s,n);if(!i)break;if(i.code===410){n=null;break}if(n=i.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:()=>kt(e.from)},{label:"Go straight there",icon:"fast-forward",disabled:!n||!S.peek(),action:()=>Se(()=>h(`/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:()=>oe(`${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".`)&&wt([e.id],"Redirect")}}]}function Rt(t){const e=z[L(t)];return e?[{header:e.urlPath},{label:"Title, description and checks\u2026",icon:"edit-3",action:()=>W(e.urlPath)},{label:"Open",icon:"external-link",action:()=>window.open(e.urlPath,"_blank","noopener")},{type:"divider"},{label:"Copy the address",icon:"copy",action:()=>oe(e.urlPath,"the address")}]:[]}function At(t){const e=j.peek().sources[L(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:()=>Et(e.id,!e.on)}]:[]}function se(t,e,s){const a=E.contextMenu(t,{cursor:"glyph",match:"[data-row]",inherit:!1,density:"compact",items:i=>e(i)});f(()=>a?.destroy?.());const n=i=>{const b=i.target.closest("[data-row]");if(!(!b||i.target.closest("a"))){if(i.target.closest(".sox-kebab")){o.openMenuAt(b);return}s?.(b)}},u=i=>{const b=i.target.closest("[data-row]");b&&(i.key==="F10"&&i.shiftKey?(i.preventDefault(),o.openMenuAt(b)):i.key==="Enter"&&i.target===b&&s?.(b))};t.addEventListener("click",n),t.addEventListener("keydown",u),f(()=>{t.removeEventListener("click",n),t.removeEventListener("keydown",u)})}const Te=d.querySelector(".sox-ext-body"),Ce=d.querySelector(".sox-ext-tabs");let ce=null;async function Pt(){let t=[];try{t=(await h("/extensions"))?.tabs||[]}catch{}r()&&(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",()=>D(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===N.peek());const t=Ne.peek().find(e=>e.key===N.peek());if(!t){Ve.includes(N.peek())||D("overview");return}if(ce?.key!==t.key){try{ce?.dispose?.()}catch{}Te.replaceChildren();try{const e=await import(t.module);if(!r()||N.peek()!==t.key)return;const s=await e.mount(Te,{kit:o,api:o.apiRequest,scope:f,here:r,openItem:W,reload:$e,E,M,I});ce={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>`}}}f(()=>{try{ce?.dispose?.()}catch{}}),d.querySelector(".sox-pages").innerHTML=rs,d.querySelector(".sox-site").innerHTML=ds,d.querySelector(".sox-rules").innerHTML=cs,d.querySelector(".sox-entries").innerHTML=us,d.querySelector(".sox-sources").innerHTML=ps;const Ke=d.querySelector(".sox-select");f(M.effect(()=>{const t=je.value;Ke.replaceChildren(new Option("Everything",""),...t.map(e=>new Option(K(e),e))),Ke.value=ve.peek()}));function D(t){N.peek()!==t&&(N.value=t,history.replaceState(null,"",`#/seo${t==="overview"?"":`/${t}`}`),t==="redirects"&&te(),t==="sitemap"&&Me()),Je()}const It={tab:N,busy:he,error:me,canManage:S,scoreText:nt,scoreWidth:it,scoreTone:lt,ov:rt,attention:dt,asOf:ct,sourceOptions:je,source:ve,show:J,query:fe,pageRows:qe,pagesEmpty:ut,siteRows:De,ruleRows:xe,ruleCount:pt,ruleText:ht,rulesEmpty:mt,chainText:bt,renameText:vt,ruleQuery:ge,newFrom:ne,newTo:ie,newCode:Ie,sourceRows:Be,smRows:we,smShow:X,smQuery:ye,smText:gt,smEmpty:yt,robots:ft,extOn:ot},Nt={tab_overview:()=>D("overview"),tab_redirects:()=>D("redirects"),tab_sitemap:()=>D("sitemap"),show_all:()=>{J.value="all"},show_problems:()=>{J.value="problems"},show_hidden:()=>{J.value="hidden"},sm_in:()=>{X.value="in"},sm_out:()=>{X.value="out"},importCsv:Mt,exportRules:()=>{const t=x.peek().rules;if(!t.length){E.toast("Nothing to export.",{type:"info"});return}ls(`redirects-${location.hostname}-${new Date().toISOString().slice(0,10)}.csv`,is(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:ne.peek(),to:ie.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):(xt(t).then(s=>{s&&(ne.value="",ie.value="")}),!1)}},jt=M.applyBindings(It,d,{methods:Nt});f(()=>jt.dispose()),f(o.helpPopovers(d,{position:"bottom"})),se(d.querySelector(".sox-pages"),Tt,t=>{const e=V[L(t)];e&&W(e.urlPath)}),se(d.querySelector(".sox-site"),Ct,Ot),se(d.querySelector(".sox-rules"),Lt,t=>{const e=Z[L(t)];e&&S.peek()&&He(e)}),se(d.querySelector(".sox-entries"),Rt,t=>{const e=z[L(t)];e&&W(e.urlPath)}),se(d.querySelector(".sox-sources"),At),o.addBannerAction(l,{id:"seo-check",icon:"refresh-cw",title:"Check every page again",onClick:_e}),o.addBannerAction(l,{id:"seo-settings",icon:"settings",title:"SEO settings",onClick:St}),I.scan(d),await Promise.all([$e(),Pt()]),r()&&I.scan(d),r()&&!m.peek()?.at&&!m.peek()?.running&&_e()}};
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()}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.90.4",
3
+ "version": "0.90.5",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",