domma-cms 0.89.0 → 0.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/page-editor-arrange.js +1 -1
  5. package/admin/js/lib/page-editor-reference.js +153 -0
  6. package/admin/js/lib/seo-analyse.js +1 -0
  7. package/admin/js/lib/seo-redirects.js +1 -0
  8. package/admin/js/lib/seo-schema.js +2 -0
  9. package/admin/js/lib/seo-settings.js +2 -0
  10. package/admin/js/templates/page-editor.html +119 -80
  11. package/{plugins/seo/admin → admin/js}/templates/seo.html +1 -2
  12. package/admin/js/views/index.js +1 -1
  13. package/admin/js/views/page-editor.js +62 -90
  14. package/admin/js/views/seo.js +144 -0
  15. package/package.json +2 -2
  16. package/plugins/free-tier.lock.json +0 -19
  17. package/server/routes/api/pages.js +10 -13
  18. package/server/routes/api/seo.js +201 -0
  19. package/server/server.js +12 -1
  20. package/server/services/menus.js +43 -0
  21. package/server/services/permissionRegistry.js +11 -0
  22. package/server/services/plugins.js +12 -2
  23. package/{plugins/seo/server → server/services/seo}/audit.js +2 -2
  24. package/server/services/seo/index.js +345 -0
  25. package/{plugins/seo/server → server/services/seo}/registry.js +5 -3
  26. package/server/services/seo/store.js +110 -0
  27. package/server/services/siteGitignore.js +4 -1
  28. package/server/services/tools.js +2 -1
  29. package/admin/js/templates/navigation.html +0 -226
  30. package/admin/js/views/navigation.js +0 -23
  31. package/plugins/seo/CLAUDE.md +0 -48
  32. package/plugins/seo/admin/lib/analyse.js +0 -286
  33. package/plugins/seo/admin/lib/redirects.js +0 -163
  34. package/plugins/seo/admin/lib/schema.js +0 -113
  35. package/plugins/seo/admin/lib/settings.js +0 -141
  36. package/plugins/seo/admin/views/seo.js +0 -912
  37. package/plugins/seo/config.js +0 -7
  38. package/plugins/seo/plugin.js +0 -477
  39. package/plugins/seo/plugin.json +0 -64
  40. package/plugins/seo/server/store.js +0 -76
  41. package/plugins/seo/tests/seo.test.js +0 -192
@@ -0,0 +1,144 @@
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
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=`
4
+ <div class="sox-row" data-bind-data-row="id" tabindex="0" data-bind-title="path">
5
+ <span class="sox-pill" data-bind-data-tone="tone" data-bind-text="scoreText"></span>
6
+ <span class="sox-cell"><span data-bind-text="path"></span><small data-bind-text="sourceText"></small></span>
7
+ <span class="sox-cell sox-hide-sm" data-bind-text="title" data-bind-title="title"></span>
8
+ <span class="sox-cell sox-muted sox-hide-sm" data-bind-text="problems" data-bind-title="problems"></span>
9
+ <button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
10
+ </div>`,ds=`
11
+ <div class="sox-row" data-bind-data-row="id" tabindex="0">
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
+ <span class="sox-pill" data-bind-data-tone="tone" data-bind-text="badge"></span>
14
+ <button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
15
+ </div>`,cs=`
16
+ <div class="sox-row" data-bind-data-row="id" tabindex="0" data-bind-title="note">
17
+ <span class="sox-cell sox-mono" data-bind-text="from" data-bind-title="from"></span>
18
+ <span class="sox-arrow" aria-hidden="true">\u2192</span>
19
+ <span class="sox-cell sox-mono"><span data-bind-text="to" data-bind-title="to"></span><small data-bind-text="note"></small></span>
20
+ <span class="sox-pill sox-hide-sm" data-bind-data-tone="tone" data-bind-text="code"></span>
21
+ <span class="sox-cell sox-muted sox-hide-sm" data-bind-text="hits" data-bind-title="lastHit"></span>
22
+ <button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
23
+ </div>`,us=`
24
+ <div class="sox-row" data-bind-data-row="id" tabindex="0">
25
+ <span class="sox-cell"><span data-bind-text="path" data-bind-title="path"></span><small data-bind-text="title"></small></span>
26
+ <span class="sox-cell sox-muted sox-hide-sm" data-bind-text="source"></span>
27
+ <span class="sox-cell sox-muted sox-hide-sm" data-bind-text="note" data-bind-title="note"></span>
28
+ <button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
29
+ </div>`,ps=`
30
+ <div class="sox-row" data-bind-data-row="id" tabindex="0">
31
+ <span class="sox-cell"><span data-bind-text="label"></span><small data-bind-text="detail"></small></span>
32
+ <span class="sox-pill" data-bind-data-tone="tone" data-bind-text="state"></span>
33
+ <button type="button" class="sox-kebab" aria-label="Actions" title="Actions">\u22EE</button>
34
+ </div>`,hs=`
35
+ <div>
36
+ <div class="sox-group" data-bind-hidden="!head" data-bind-text="head"></div>
37
+ <div class="sox-check" data-bind-hidden="!title">
38
+ <span class="sox-status" data-bind-data-status="status" data-bind-text="glyph"></span>
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
+ </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,`
42
+ <div class="sox-form-grid">
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
+ <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
+ <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>
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,`
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}
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>
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(`
55
+ <div class="sox-form">
56
+ ${qt}${Dt}
57
+ <section><h4>As it will look <span data-help="The search result is drawn the way Google draws it, cut where it cuts (roughly - it measures pixels). The share card is what Facebook, LinkedIn, WhatsApp and others show when someone shares the link."></span>
58
+ <span class="sox-spacer"></span><a class="btn btn-xs btn-ghost" href="${y(t)}" target="_blank" rel="noopener"><span data-icon="external-link" data-icon-size="12"></span> Open</a></h4>
59
+ <div class="sox-previews">
60
+ <div class="sox-serp" aria-label="Search result preview">
61
+ <div class="sox-serp-crumbs" data-bind-text="serp.value.crumbs"></div>
62
+ <div class="sox-serp-title" data-bind-text="serp.value.title"></div>
63
+ <div class="sox-serp-desc" data-bind-text="serp.value.description"></div>
64
+ </div>
65
+ <div class="sox-share" aria-label="Share card preview">
66
+ <div class="sox-share-img"><span data-bind-hidden="hasImage.value">No image</span></div>
67
+ <div class="sox-share-body"><small data-bind-text="host"></small><strong data-bind-text="effTitle.value"></strong><span data-bind-text="effDesc.value"></span></div>
68
+ </div>
69
+ </div>
70
+ </section>
71
+ <section><h4>Search and sharing</h4><div class="sox-form-grid">
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">
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
+ <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>
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
+ <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
+ <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>
82
+ <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
+ <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
+ </div><datalist id="sox-media"></datalist></section>
85
+ <section><h4>Checks <small class="sox-muted" data-bind-text="scoreLine.value"></small></h4>
86
+ <div class="sox-checks" data-each="checks.value key=id"></div>
87
+ </section>
88
+ <p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
89
+ <div class="sox-actions">
90
+ <small class="sox-muted">Ctrl+Enter saves.</small><span class="sox-spacer"></span>
91
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
92
+ <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
+ </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
+ `)),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(`
98
+ <div class="sox-form">
99
+ <p class="sox-note">The site's default title, description and share image are in <a href="#/settings">Settings \u203A SEO</a>.</p>
100
+ <section><h4>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
+ <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")}
109
+ <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
+ <div class="sox-form-grid"><input class="form-input sox-wide" placeholder="Street" aria-label="Street" data-model="street.value">
111
+ <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
+ <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
+ <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
+ <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.")}
116
+ </div></section>
117
+ <section><h4>Sitemap and robots.txt</h4><div class="sox-form-grid">
118
+ <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
+ <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.")}
121
+ <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
+ <textarea id="sox-s-rob" rows="3" class="form-input sox-mono" data-model="robotsExtra.value" placeholder="Disallow: /private/"></textarea></div>
123
+ </div></section>
124
+ <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.")}
126
+ </div></section>
127
+ <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.")}
134
+ </div></section>
135
+ <p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
136
+ <div class="sox-actions">
137
+ <small class="sox-muted">Ctrl+Enter saves.</small><span class="sox-spacer"></span>
138
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
139
+ <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
+ </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}
142
+ ${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()}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.89.0",
3
+ "version": "0.90.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -83,7 +83,7 @@
83
83
  "@fastify/rate-limit": "^10.3.0",
84
84
  "@fastify/static": "9.1.1",
85
85
  "bcryptjs": "^3.0.3",
86
- "domma-js": "0.44.6",
86
+ "domma-js": "0.44.7",
87
87
  "dotenv": "^17.2.3",
88
88
  "fastify": "5.8.5",
89
89
  "gray-matter": "^4.0.3",
@@ -106,25 +106,6 @@
106
106
  "tests/security.test.js": "f0cb70dcb28bc8b3de8d3b39064616296382c5b0f97bfd615ee8e24fe3b031bc"
107
107
  }
108
108
  },
109
- "seo": {
110
- "version": "1.0.0",
111
- "files": {
112
- "CLAUDE.md": "c4b326afeacded36d076f96ec3554b6cd875087ea5c84b29dffb144059292ff7",
113
- "admin/lib/analyse.js": "368b9e20bc1eac957870272ee6909b5f7c09ecf0a2fc2c344fb5cd85473f18cd",
114
- "admin/lib/redirects.js": "a506ca40b712c3d8087d6698397b900fe5005ae6094f80dbc3f3091d5d9b6819",
115
- "admin/lib/schema.js": "4313e0ed528562ee8b85c18479663c8216101f892848b44426baefc8d7c1c7e8",
116
- "admin/lib/settings.js": "94e76c2960376f0cc0570128a7226af177f5de7ee115357e37baba45b1e05e19",
117
- "admin/templates/seo.html": "e8684d98bd0649c001d7395b75b2bc3c74234f947d3dbda508ff4ecb91e20d1c",
118
- "admin/views/seo.js": "b37774a5df642bdbdb6f7fb89b12dbc6d45d06d2b70bd2de7faf4c3098c26189",
119
- "config.js": "d90f4cfb10b2f44b528de37522d31fd01351a6ebbab95556b5dfa2820d69e1a8",
120
- "plugin.js": "3dd88bec75535349e6a748bae90fc49e54e2781c4765b9c8ff02898795ef476d",
121
- "plugin.json": "6fe2fb5d50d37cc9749cf84db7ed59c5557a52c1b38f63c8e7bc1744065cd7d6",
122
- "server/audit.js": "f51fcf9403ad40fde9e9ba5f31e58ed95fc2a5c8514c921146f23ee1ca2e0bd3",
123
- "server/registry.js": "9336614bff3ab8b4fe9d50de074a9633307ab41c46fa62113ca82379cfad90a6",
124
- "server/store.js": "3584ec3efd8a2d0ba8fd5be7e56d97cca76b5b2358a7dda99e379c42be67253c",
125
- "tests/seo.test.js": "bdc52f2a9f8f02aaa0cb907f76f1acf8ff887034172e18d9ae108d3675b68f1a"
126
- }
127
- },
128
109
  "shopping-cart": {
129
110
  "version": "1.0.0",
130
111
  "files": {
@@ -24,6 +24,7 @@ import {
24
24
  import {getVersionCount} from '../../services/versions.js';
25
25
  import {authenticate, requirePermission} from '../../middleware/auth.js';
26
26
  import {getConfig, saveConfig} from '../../config.js';
27
+ import {relinkItems, relinkMenus} from '../../services/menus.js';
27
28
  import {ago, clip, plural} from '../../services/sidebarBadges.js';
28
29
 
29
30
  /**
@@ -257,24 +258,20 @@ export async function pagesRoutes(fastify) {
257
258
  }
258
259
 
259
260
  /**
260
- * Rewrite any navigation item URLs that match oldUrlPath to newUrlPath,
261
- * then persist the updated navigation config.
261
+ * A page moved: re-aim the menu links that pointed at it. Menus replaced
262
+ * config/navigation.json; a site that still has the legacy file gets it rewritten
263
+ * too, and one that does not is not an error - reading it unguarded made every
264
+ * rename answer 500 AFTER the page had already moved.
262
265
  *
263
266
  * @param {string} oldUrlPath
264
267
  * @param {string} newUrlPath
265
268
  * @returns {Promise<void>}
266
269
  */
267
270
  async function rewriteNavLinks(oldUrlPath, newUrlPath) {
268
- const nav = getConfig('navigation');
269
- if (!nav?.items?.length) return;
270
-
271
- let changed = false;
272
- for (const item of nav.items) {
273
- if (item.url === oldUrlPath) { item.url = newUrlPath; changed = true; }
274
- for (const child of item.children || []) {
275
- if (child.url === oldUrlPath) { child.url = newUrlPath; changed = true; }
276
- }
277
- }
271
+ await relinkMenus(oldUrlPath, newUrlPath);
278
272
 
279
- if (changed) await saveConfig('navigation', nav);
273
+ let nav = null;
274
+ try { nav = getConfig('navigation'); } catch { return; }
275
+ if (!nav?.items?.length) return;
276
+ if (relinkItems(nav.items, oldUrlPath, newUrlPath)) await saveConfig('navigation', nav);
280
277
  }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * SEO - the API (mounted at /api/seo behind toolGate('seo'); the `seo`
3
+ * permission: read / manage). The engine is services/seo/index.js.
4
+ *
5
+ * GET /overview · GET /count · GET /audit · POST /audit · GET|PUT /item
6
+ * GET|POST /redirects · PUT|DELETE /redirects/:id · POST /redirects/import
7
+ * GET /redirects/test · GET /sitemap · GET|PUT /settings · GET /extensions
8
+ *
9
+ * Was /api/plugins/seo while SEO was a plugin (0.89.0).
10
+ */
11
+ import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
12
+ import {getConfig} from '../../config.js';
13
+ import {getPage, updatePage} from '../../services/content.js';
14
+ import {getSitemapSources} from '../../services/hooks.js';
15
+ import {seoEngine} from '../../services/seo/index.js';
16
+ import {excluded} from '../../../admin/js/lib/seo-settings.js';
17
+
18
+ /**
19
+ * @param {import('fastify').FastifyInstance} fastify
20
+ * @param {object} [opts] - authenticate / requirePermission overrides (tests)
21
+ */
22
+ export async function seoRoutes(fastify, opts = {}) {
23
+ const authenticate = opts.authenticate || defaultAuthenticate;
24
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
25
+ const may = (action) => ({preHandler: [authenticate, requirePermission('seo', action)]});
26
+ const seo = () => seoEngine();
27
+ const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
28
+ const off = (reply) => reply.code(503).send({error: 'SEO is not running on this site.'});
29
+
30
+ fastify.addHook('preHandler', async (request, reply) => {
31
+ if (!seo()) return off(reply);
32
+ });
33
+
34
+ fastify.get('/overview', may('read'), async (request) => {
35
+ const s = seo().summary();
36
+ const rules = seo().store.rules();
37
+ return {...s, redirects: rules.length, chains: seo().chains(rules).length, hooks: true,
38
+ origin: s.origin || seo().originOf(request), siteSeo: site().seo || {}};
39
+ });
40
+
41
+ fastify.get('/count', may('read'), async () => {
42
+ const s = seo().summary();
43
+ if (!s.at) return {count: 0, label: 'Not checked yet'};
44
+ const bad = (seo().store.audit().results || []).filter(r => r.error || r.failing > 0);
45
+ return {
46
+ count: bad.length,
47
+ label: bad.length ? `${bad.length} ${bad.length === 1 ? 'page has' : 'pages have'} SEO problems` : 'No SEO problems found',
48
+ ...(s.broken && {tone: 'danger'}),
49
+ details: {title: 'SEO', rows: [{label: 'Score', value: s.score === null ? '—' : `${s.score}%`}, {label: 'Pages', value: String(s.pages)}],
50
+ items: bad.slice(0, 5).map(r => ({text: r.urlPath, meta: r.error ? 'broken' : `${r.failing} failing`})),
51
+ empty: 'Every page passes.'}
52
+ };
53
+ });
54
+
55
+ fastify.get('/audit', may('read'), async () => {
56
+ const a = seo().store.audit();
57
+ return {...seo().summary(a), results: (a.results || []).map(r => ({...r, checks: undefined,
58
+ problems: (r.checks || []).filter(c => c.status === 'fail' || c.status === 'warn').map(c => c.title)}))};
59
+ });
60
+
61
+ fastify.post('/audit', may('read'), async (request) => {
62
+ const p = seo().runAudit(seo().originOf(request));
63
+ if (request.body?.wait === true) await p;
64
+ return seo().summary();
65
+ });
66
+
67
+ fastify.get('/item', may('read'), async (request, reply) => {
68
+ const e = seo();
69
+ const urlPath = e.normalisePath(request.query?.path || '') || '/';
70
+ const origin = e.originOf(request);
71
+ const page = await getPage(urlPath).catch(() => null);
72
+ const override = e.store.override(urlPath) || {};
73
+ const fields = page
74
+ ? {title: page.seo?.title || '', description: page.seo?.description || '', image: page.seo?.image || '', noindex: page.seo?.noindex === true, focus: page.seo?.focus || ''}
75
+ : {title: override.title || '', description: override.description || '', image: override.image || '', noindex: override.noindex === true, focus: override.focus || ''};
76
+ const fetched = await e.fetchLocal(urlPath, origin).catch(err => ({status: 0, error: err.message}));
77
+ if (!page && fetched.status === 404) return reply.code(404).send({error: `Nothing is served at ${urlPath}.`});
78
+ // Only a page that is really served is judged - a draft answers with the 404 page.
79
+ const facts = fetched.status === 200 && fetched.html ? e.scanHtml(fetched.html) : null;
80
+ const limits = e.settings().audit;
81
+ const judged = facts ? e.analyse(facts, {urlPath, focus: fields.focus, limits, origin}) : null;
82
+ const s = site();
83
+ return {
84
+ urlPath, origin, kind: page ? 'page' : 'url',
85
+ page: page ? {title: page.title, status: page.status, description: page.description || ''} : null,
86
+ fields,
87
+ live: facts ? {title: facts.title, description: facts.description || '', image: facts.og.image, h1: facts.headings.find(h => h.level === 1)?.text || '', words: facts.words, canonical: facts.canonical} : null,
88
+ status: fetched.status || 0,
89
+ defaults: {separator: s.seo?.titleSeparator || ' | ', siteTitle: s.seo?.defaultTitle || s.title || '', description: s.seo?.defaultDescription || '', image: s.seo?.defaultImage || ''},
90
+ analysis: judged,
91
+ limits,
92
+ redirect: e.matchRedirect(e.store.rules(), urlPath)?.rule || null
93
+ };
94
+ });
95
+
96
+ fastify.put('/item', may('manage'), async (request, reply) => {
97
+ const e = seo();
98
+ const b = request.body ?? {};
99
+ const urlPath = e.normalisePath(b.path || '') || '/';
100
+ const clip = (v, n) => String(v ?? '').trim().slice(0, n);
101
+ const fields = {title: clip(b.title, 200), description: clip(b.description, 400), image: clip(b.image, 500), noindex: b.noindex === true, focus: clip(b.focus, 100)};
102
+ if (fields.image && !/^(https?:\/\/|\/)/i.test(fields.image)) return reply.code(400).send({error: 'The image: a path like /media/photo.jpg, or a full web address.'});
103
+ const page = await getPage(urlPath).catch(() => null);
104
+ if (page) {
105
+ // Empty means "use the default": sent as null, updatePage's merge removes the key.
106
+ const seoFields = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, v === '' || v === false ? null : v]));
107
+ await updatePage(urlPath, {seo: seoFields}, undefined, {author: request.user?.username || request.user?.email});
108
+ } else {
109
+ e.store.setOverride(urlPath, fields);
110
+ }
111
+ e.sitemapChanged();
112
+ await e.reaudit(urlPath, {origin: e.originOf(request), focus: fields.focus}).catch(() => {});
113
+ return {ok: true, kind: page ? 'page' : 'url'};
114
+ });
115
+
116
+ // Redirects
117
+ fastify.get('/redirects', may('read'), async () => {
118
+ const rules = seo().store.rulesWithHits();
119
+ return {rules, chains: seo().chains(rules).map(c => ({id: c.rule.id, via: c.via.id})), max: seo().maxRules, onRename: seo().settings().redirects.onRename};
120
+ });
121
+ fastify.post('/redirects', may('manage'), async (request, reply) => {
122
+ const r = seo().addRule(request.body ?? {});
123
+ if (r.errors) return reply.code(400).send({error: Object.values(r.errors).join(' '), errors: r.errors});
124
+ return r;
125
+ });
126
+ fastify.put('/redirects/:id', may('manage'), async (request, reply) => {
127
+ const r = seo().updateRule(request.params.id, request.body ?? {});
128
+ if (r.missing) return reply.code(404).send({error: 'No such redirect.'});
129
+ if (r.errors) return reply.code(400).send({error: Object.values(r.errors).join(' '), errors: r.errors});
130
+ return r;
131
+ });
132
+ fastify.delete('/redirects/:id', may('manage'), async (request) => {
133
+ seo().removeRules(String(request.params.id).split(','));
134
+ return {ok: true};
135
+ });
136
+ fastify.post('/redirects/import', may('manage'), async (request) => {
137
+ const text = String(request.body?.csv ?? '').slice(0, 1_000_000);
138
+ let added = 0;
139
+ const skipped = [];
140
+ for (const [i, line] of text.split(/\r?\n/).entries()) {
141
+ const cells = line.split(/[,\t]/).map(c => c.trim().replace(/^"|"$/g, ''));
142
+ if (!cells[0] || cells[0].startsWith('#') || /^(from|source|old)/i.test(cells[0])) continue;
143
+ const r = seo().addRule({from: cells[0], to: cells[1], code: cells[2] || 301, note: cells[3] || 'Imported'});
144
+ if (r.errors) skipped.push({line: i + 1, from: cells[0], why: Object.values(r.errors).join(' ')});
145
+ else added++;
146
+ if (added + skipped.length >= 2000) break;
147
+ }
148
+ return {added, skipped};
149
+ });
150
+ fastify.get('/redirects/test', may('read'), async (request) => {
151
+ const hops = [];
152
+ let at = String(request.query?.path || '');
153
+ const rules = seo().store.rules();
154
+ for (let i = 0; i < 10 && at; i++) {
155
+ const hit = seo().matchRedirect(rules, at);
156
+ if (!hit) break;
157
+ hops.push({from: at, to: hit.location, code: hit.code, id: hit.rule.id});
158
+ if (hit.code === 410 || /^https?:/i.test(hit.location)) break;
159
+ at = hit.location;
160
+ }
161
+ return {hops};
162
+ });
163
+
164
+ // Sitemap and robots
165
+ fastify.get('/sitemap', may('read'), async (request) => {
166
+ const e = seo();
167
+ const cfg = e.settings();
168
+ const origin = e.originOf(request);
169
+ const {listSitemapEntries, buildRobotsTxt} = await import('../../services/sitemap.js');
170
+ const entries = await listSitemapEntries(origin, {log: fastify.log});
171
+ const raw = await e.rawEntries(origin);
172
+ const kept = new Set(entries.map(x => x.urlPath));
173
+ const sources = {};
174
+ for (const x of raw) {
175
+ sources[x.source] ??= {id: x.source, count: 0, listed: 0, on: cfg.sitemap.sources[x.source] !== false};
176
+ sources[x.source].count++;
177
+ if (kept.has(x.urlPath)) sources[x.source].listed++;
178
+ }
179
+ const rules = e.store.rules();
180
+ const over = e.store.overrides();
181
+ const left = raw.filter(x => !kept.has(x.urlPath)).map(x => ({...x,
182
+ why: cfg.sitemap.sources[x.source] === false ? 'Its source is switched off'
183
+ : excluded(x.urlPath, cfg.sitemap.exclude) ? 'Excluded in the settings'
184
+ : over[x.urlPath]?.noindex ? 'Hidden from search engines'
185
+ : e.matchRedirect(rules, x.urlPath) ? 'Redirected' : 'Left out'}));
186
+ return {hooks: true, origin, entries, left, sources: Object.values(sources),
187
+ labels: {pages: 'Pages', ...Object.fromEntries(getSitemapSources().map(s => [s.id, s.label]))},
188
+ robots: buildRobotsTxt(origin)};
189
+ });
190
+
191
+ fastify.get('/settings', may('read'), async () => ({settings: seo().settings(), hooks: true}));
192
+ fastify.put('/settings', may('manage'), async (request, reply) => {
193
+ const r = seo().saveSettings(request.body ?? {});
194
+ if (r.errors) return reply.code(400).send({error: Object.values(r.errors).join(' '), errors: r.errors});
195
+ return {settings: r.settings, hooks: true};
196
+ });
197
+
198
+ fastify.get('/extensions', may('read'), async () => ({
199
+ tabs: seo().registry.list().flatMap(x => x.tabs.map(t => ({...t, owner: x.owner, ownerLabel: x.label})))
200
+ }));
201
+ }
package/server/server.js CHANGED
@@ -491,6 +491,7 @@ const { notesRoutes } = await import('./routes/api/notes.js');
491
491
  const { todoRoutes } = await import('./routes/api/todo.js');
492
492
  const { analyticsRoutes } = await import('./routes/api/analytics.js');
493
493
  const { contactsRoutes } = await import('./routes/api/contacts.js');
494
+ const { seoRoutes } = await import('./routes/api/seo.js');
494
495
  const { relatedRoutes } = await import('./routes/api/related.js');
495
496
  const { pluginMarketplaceRoutes } = await import('./routes/api/plugin-marketplace.js');
496
497
  const { collectionsRoutes } = await import('./routes/api/collections.js');
@@ -548,6 +549,15 @@ await app.register(toolGate('notes', notesRoutes), {prefix: '/api/notes'});
548
549
  await app.register(toolGate('todo', todoRoutes), {prefix: '/api/todo'});
549
550
  await app.register(toolGate('analytics', analyticsRoutes), {prefix: '/api/analytics'});
550
551
  await app.register(toolGate('contacts', contactsRoutes), {prefix: '/api/contacts'});
552
+ // SEO (0.90.0; the `seo` plugin in 0.89.0): a global plugin - its redirect guard
553
+ // sees every request - plus its API. Off, the engine does nothing at all.
554
+ {
555
+ const {seoPlugin} = await import('./services/seo/index.js');
556
+ const {getBaseUrl} = await import('./routes/public.js');
557
+ const rolesService = await import('./services/roles.js');
558
+ await app.register(seoPlugin, {enabled: isToolEnabled('seo'), getBaseUrl, rolesService});
559
+ }
560
+ await app.register(toolGate('seo', seoRoutes), {prefix: '/api/seo'});
551
561
  await app.register(toolsRoutes, {prefix: '/api'});
552
562
  // What plugins show beside a contact or a note (Calendar's Events) - 0.79.
553
563
  await app.register(relatedRoutes, {prefix: '/api/related'});
@@ -576,7 +586,8 @@ const coreToolItems = [
576
586
  {text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'},
577
587
  {text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'},
578
588
  {text: 'Analytics', url: '#/analytics', icon: 'chart-bar', permission: 'analytics', countUrl: '/api/analytics/active-count'},
579
- {text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'}
589
+ {text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'},
590
+ {text: 'SEO', url: '#/seo', icon: 'search', permission: 'seo', countUrl: '/api/seo/count'}
580
591
  ];
581
592
  for (const item of coreToolItems) {
582
593
  if (isToolEnabled(item.permission)) registerSidebarItem({item: {source: 'Built in', ...item}});
@@ -391,6 +391,49 @@ export async function listMenus() {
391
391
  return out.sort((a, b) => a.slug.localeCompare(b.slug));
392
392
  }
393
393
 
394
+ /**
395
+ * Point every item that links to `from` at `to` instead - the whole tree, sub-items
396
+ * (`items`) and the legacy `children` key alike. Pure: mutates `items`, returns how
397
+ * many it changed. Only an exact match moves; `/about#team` or `/about?x` is left.
398
+ *
399
+ * @param {object[]} items
400
+ * @param {string} from
401
+ * @param {string} to
402
+ * @returns {number}
403
+ */
404
+ export function relinkItems(items, from, to) {
405
+ let n = 0;
406
+ for (const item of Array.isArray(items) ? items : []) {
407
+ if (!item || typeof item !== 'object') continue;
408
+ if (item.url === from) { item.url = to; n++; }
409
+ n += relinkItems(item.items, from, to) + relinkItems(item.children, from, to);
410
+ }
411
+ return n;
412
+ }
413
+
414
+ /**
415
+ * A page moved from `from` to `to`: re-aim every menu's links to it. Menus replaced
416
+ * config/navigation.json (menus-migration.js), so this is what "menu links to the
417
+ * old address are updated" means. The file is rewritten as it was, only the URLs
418
+ * and meta.updatedAt changed - no re-validation of a menu nobody edited.
419
+ *
420
+ * @param {string} from
421
+ * @param {string} to
422
+ * @returns {Promise<string[]>} slugs of the menus that changed
423
+ */
424
+ export async function relinkMenus(from, to) {
425
+ const changed = [];
426
+ if (!from || !to || from === to) return changed;
427
+ for (const {slug} of await listMenus()) {
428
+ const menu = await getMenu(slug);
429
+ if (!menu || !relinkItems(menu.items, from, to)) continue;
430
+ menu.meta = {...(menu.meta || {}), updatedAt: new Date().toISOString()};
431
+ await writeMenuFile(menu);
432
+ changed.push(slug);
433
+ }
434
+ return changed;
435
+ }
436
+
394
437
  /**
395
438
  * Read a full menu by slug. Returns null if missing or slug is invalid.
396
439
  *