@moorage/plugin-site-health 0.1.4 → 0.1.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.
- package/CHANGELOG.md +8 -0
- package/README.md +5 -1
- package/dist/index.mjs +1 -1
- package/dist/manifest.json +1 -1
- package/dist/plugin.mjs +6 -6
- package/emdash-plugin.jsonc +10 -5
- package/listing/faq.md +4 -0
- package/listing/security.md +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @moorage/plugin-site-health
|
|
2
2
|
|
|
3
|
+
## 0.1.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8370eca: New screenshots of the current admin page, with the help line at its foot.
|
|
8
|
+
- 6516427: Admins see a small help line at the foot of the page, with a link to the guide; it can be hidden under Checklist, Options.
|
|
9
|
+
- ee58b7f: The README and the listing's FAQ now say to restart the site after changing its address.
|
|
10
|
+
|
|
3
11
|
## 0.1.4
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Tells you what is broken on your [EmDash](https://emdashcms.com) site before a customer does: a dead link in the menu, a page that shows an error, a map that will not load. It is a broken link checker for your pages, your menu and the links to other sites, with an email when something breaks and a short setup checklist. It runs on any EmDash site, with no account and no key.
|
|
4
4
|
|
|
5
|
-

|
|
5
|
+

|
|
6
6
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
@@ -104,6 +104,10 @@ Each run stays within the EmDash sandbox's limits of 10 calls (it makes at most
|
|
|
104
104
|
|
|
105
105
|
**Does it send anything anywhere?** Only to the websites your pages link to (to check them) and the email addresses you list.
|
|
106
106
|
|
|
107
|
+
**What is the line at the foot of the page?** Only admins see it: "Help · More free plugins · by Moorage", links to the guide on moorage.dev and to more free plugins. Editors never see it. To hide it, switch on "Hide the help line at the foot of this page" under Checklist, Options and press Save.
|
|
108
|
+
|
|
109
|
+
**Why do the checks and emails use the site's old address?** Plugins keep the site address EmDash had when the server started; Site Health uses it to open your own pages, to tell your links from other sites' and in its emails and instructions. After changing the site's address, restart the site.
|
|
110
|
+
|
|
107
111
|
## Changelog
|
|
108
112
|
|
|
109
113
|
- **0.1.0:** first version.
|
package/dist/index.mjs
CHANGED
package/dist/manifest.json
CHANGED
package/dist/plugin.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
const e=`9999`;function t(e){return e.menu?`site`:e.kind===`page`?e.url===`/`?`site`:`page`:e.kind===`insecure`?`page`:`link`}function n(e,t){switch(e.reason){case`missing`:return`${t} goes to a page that does not exist.`;case`draft`:return`${t} goes to a page that is not published yet.`;case`gone`:return`${t}: that other website has removed this page.`;case`no-site`:return`${t}: that website no longer exists.`;case`server`:return`${t}: that website has shown an error for a week.`;case`page-error`:return`${t}: this page is broken; visitors see an error.`;case`insecure-active`:return`${t} is loaded over an insecure connection. Browsers block it, so visitors see an empty box.`;case`redirect`:case`moved`:return`${t} now goes on to ${e.detail??`another address`}.${e.kind===`internal`&&e.detail?` Linking to ${e.detail} directly saves a step.`:``}`;case`insecure-image`:return`${t} is a picture shown over an insecure connection. Browsers fix this themselves for now.`;case`refuses`:return`${t}: that website turns away automatic checkers.`;case`slow`:return`${t}: that website asked us to slow down. Trying again tomorrow.`;case`timeout`:return`${t} did not answer within 8 seconds.`;case`trouble`:return`${t}: that website had a problem when we checked. Trying again.`;case`no-live`:return`${t}: we could not open your site to check this address.`;default:return`${t}: we could not open it.`}}function r(e){if(e.kind===`page`)return``;let t=[];e.menu&&t.push(`Site menu (every page)`);for(let n of e.sources)if(!n.menu){if(t.length>=4)break;t.push(n.t)}let n=t.length-(e.menu?1:0),r=e.count-(e.menu?e.sources.filter(e=>e.menu).length:0)-n;return t.length===0?``:` Found on: ${t.join(`, `)}${r>0?` and ${r} more`:``}.`}function i(e){return e!=null}async function a(e,t,n){try{await e.settings.set(t.name,n)}catch(n){e.log.warn(`could not copy the settings to their new place`,{key:t.name,error:String(n)})}}async function o(e,t,n,{copy:r=!0}={}){let o=t.get(`settings:${n.name}`);if(i(o))return o;let s=t.get(n.legacy);return i(s)?(r&&await a(e,n,s),s):null}async function s(e,t,n){await e.settings.set(t.name,n);try{await e.kv.set(t.legacy,n)}catch(n){e.log.warn(`could not keep a copy of the settings for the earlier release`,{key:t.legacy,error:String(n)})}}const c={scan:`scan`,summary:`summary`,queue:`queue`,options:`options`,alerts:`alerts`,checklist:`checklist`},l={name:`options`,legacy:c.options};async function u(e,{copy:t=!1}={}){let n=await e.kv.list(),r=new Map(n.map(e=>[e.key,e.value])),i=(t?await o(e,r,l):r.get(`settings:${l.name}`)??r.get(l.legacy)??null)??{},a=r.get(c.alerts)??{},s=r.get(c.queue)??{},u=r.get(c.checklist)??{};return{scan:r.get(c.scan)??null,summary:r.get(c.summary)??null,queue:{hosts:{},...s},options:{outbound:!0,...i},alerts:{pending:[],sent:[],...a},checklist:{rows:{},...u}}}function d(e,t){return{id:e,kind:t.kind,url:t.url,...t.reason?{reason:t.reason}:{},...t.detail?{detail:t.detail}:{},sources:t.sources.slice(0,5),count:t.count,menu:t.menu,editor:t.editor,...t.since?{since:t.since}:{}}}function f(e,t){return t.count-e.count}function p(e,t){return e.filter(e=>e.id!==t)}function m(e,t,n,r){let i=n?.state,a=r?.state,o=n?.group;if(i===a&&o===r?.group&&n?.reason===r?.reason)return null;let s={...e,fix:{...e.fix},rows:{site:p(e.rows.site,t),page:p(e.rows.page,t),link:p(e.rows.link,t)},lookRows:p(e.lookRows,t),unknownRows:p(e.unknownRows,t)};return i===`broken`&&o&&(s.fix[o]=Math.max(0,s.fix[o]-1),n?.editor&&(s.editorFix=Math.max(0,s.editorFix-1))),i===`look`&&(s.look=Math.max(0,s.look-1)),i===`unknown`&&(s.unknown=Math.max(0,s.unknown-1)),r&&a===`broken`&&r.group&&(s.fix[r.group]+=1,r.editor&&(s.editorFix+=1),s.rows[r.group]=[...s.rows[r.group],d(t,r)].sort(f).slice(0,10)),r&&a===`look`&&(s.look+=1,s.lookRows=[...s.lookRows,d(t,r)].slice(0,10)),r&&a===`unknown`&&(s.unknown+=1,s.unknownRows=[...s.unknownRows,d(t,r)].slice(0,10)),s}function h(e){return e.fix.site+e.fix.page+e.fix.link}function g(e,t){try{return new URL(e,t)}catch{return null}}const
|
|
2
|
-
`));i.urgentAt=s,i.pending=[],i.email=
|
|
3
|
-
`)),r,s,`alert`)}}t.alerts=i,await e.kv.set(c.alerts,i)}async function le(e,t,n){let r=t.options.recipients??[],i=await D(e,r,`${ae(e)}: test email from Site Health`,`This is a test from Site Health. If you can read it, alerts about broken links and pages that show errors will reach you.`),a=new Date(n).toISOString();return t.alerts={...t.alerts,email:E(i,r,a,`test`)},await e.kv.set(c.alerts,t.alerts),i}function ue(e,t){return e.ops+t<=9}const de=[`kv`,`settings`,`content`,`schema`,`redirects`,`http`,`email`,`cron`,`log`];function fe(e,t){return new Proxy(e,{get(e,n,r){let i=Reflect.get(e,n,r);return typeof i==`function`?(...n)=>(t.ops+=1,i.apply(e,n)):i}})}function O(e,t){let n={};for(let r of de){let i=e[r];i&&(n[r]=fe(i,t))}let r=new Map,i=new Proxy(e.storage,{get(e,n,i){let a=Reflect.get(e,n,i);if(typeof n!=`string`||!a||typeof a!=`object`)return a;let o=r.get(n);return o||(o=fe(a,t),r.set(n,o)),o}});return new Proxy(e,{get(e,t,r){return t===`storage`?i:typeof t==`string`&&t in n?n[t]:Reflect.get(e,t,r)}})}const pe=/^(href|url|link|src)$|(_url|Url|_href|Href|_link|Link)$/,me=/iframe|embed|video|map|script|audio|form/i,he=/image|picture|photo/i;function k(e,t){let n=new Map,r=0,i=(e,r)=>{let i=b(e,t);i&&n.set(`${r}|${i.url}`,{...i,use:r})},a=(e,t,n)=>{if(r+=1,r>5e3||n>30||typeof e!=`object`||!e)return;if(Array.isArray(e)){for(let r of e)a(r,t,n+1);return}let o=e,s=typeof o._type==`string`?o._type:t;for(let[e,t]of Object.entries(o))if(typeof t==`string`){if(!pe.test(e)||t.length>2e3)continue;i(t,e===`src`||e.endsWith(`Src`)||e.endsWith(`_src`)?he.test(s)?`image`:me.test(s)?`embed`:`link`:`link`)}else t&&typeof t==`object`&&a(t,s,n+1)};return a(e,``,0),[...n.values()]}const A=2e5,ge=/<a\b[^>]*?\bhref\s*=\s*("([^"]*)"|'([^']*)')/gi,_e=/<(header|nav|footer)\b[\s\S]*?<\/\1\s*>/gi,ve=/<(iframe|script|embed|object|video|audio|source)\b[^>]*?\bsrc\s*=\s*("http:[^"]*"|'http:[^']*')|<link\b(?=[^>]*\brel\s*=\s*["']?stylesheet)[^>]*?\bhref\s*=\s*("http:[^"]*"|'http:[^']*')|<form\b[^>]*?\baction\s*=\s*("http:[^"]*"|'http:[^']*')/gi,ye=/<img\b[^>]*?\bsrc\s*=\s*("http:[^"]*"|'http:[^']*')/gi;function be(e){return e.slice(1,-1).replaceAll(`&`,`&`)}function j(e,t){let n=e.length>A?e.slice(0,A):e,r=new Map;for(let e of n.matchAll(_e))for(let n of e[0].matchAll(ge)){let e=b((n[2]??n[3]??``).replaceAll(`&`,`&`),t);e&&r.set(e.url,e)}let i=new Set;for(let e of n.matchAll(ve)){let t=e[2]??e[3]??e[4];t&&i.add(be(t))}let a=new Set;for(let e of n.matchAll(ye))e[1]&&a.add(be(e[1]));return{menu:[...r.values()],active:[...i],passive:[...a]}}function xe(e,t,n){if(!n.slug)return null;let r=e??`/${encodeURIComponent(t)}/{slug}`,i=n.publishedAt?new Date(n.publishedAt):null,a=e=>String(e).padStart(2,`0`),o=i&&!Number.isNaN(i.getTime())?{year:String(i.getUTCFullYear()),month:a(i.getUTCMonth()+1),day:a(i.getUTCDate()),hour:a(i.getUTCHours()),minute:a(i.getUTCMinutes()),second:a(i.getUTCSeconds())}:{},s=r.replaceAll(`{slug}`,encodeURIComponent(n.slug)).replaceAll(`{id}`,encodeURIComponent(n.id)).replace(/\{(year|month|day|hour|minute|second)\}/g,(e,t)=>o[t]??e);if(s.includes(`{`))return null;s=s.replace(/\/{2,}/g,`/`),s.length>1&&s.endsWith(`/`)&&(s=s.slice(0,-1)),s.startsWith(`/`)||(s=`/${s}`);try{return decodeURI(s)}catch{return s}}const M=36e5,N=24*M;function Se(e){return`EmDash-SiteHealth/0.1 (+${e||`unknown site`})`}async function P(e,t,n={}){let r=e.http;if(!r)return{error:`no network`};let i,a=new Promise(e=>{i=setTimeout(()=>e({timedOut:!0}),8e3)}),o=(async()=>{try{let i=await r.fetch(t,{method:n.method??`GET`,redirect:n.follow===!1?`manual`:`follow`,headers:{"User-Agent":Se(e.site.url),Accept:`text/html,*/*;q=0.5`}}),a=i.headers.get(`location`),o={status:i.status,headers:i.headers,...a?{location:g(a,t)?.href??a}:{},...i.redirected&&i.url?{finalUrl:i.url}:{}};if(n.body){let e=await i.text();o.body=e.length>A?e.slice(0,A):e}return o}catch(e){return{error:e instanceof Error?e.message:String(e)}}})();try{return await Promise.race([o,a])}finally{clearTimeout(i)}}function Ce(e){return/internal host|not allowed to fetch|private|localhost/i.test(e.error??``)}const we=/ENOTFOUND|EAI_AGAIN|getaddrinfo|DNS|name not resolved|could not resolve/i;function Te(e,t){let n=e=>e.replace(/^https?:\/\//i,``).replace(/^www\./i,``).replace(/\/$/,``);return n(e)===n(t)}function F(e,t,n){if(t.timedOut)return{kind:`unknown`,reason:`timeout`};if(t.error!==void 0)return we.test(t.error)?{kind:`dead`}:{kind:`unknown`,reason:`error`};let r=t.status??0;return r>=300&&r<400&&t.location?Te(e,t.location)?{kind:`ok`}:{kind:`moved`,to:t.location}:r>=200&&r<300?t.finalUrl&&!Te(e,t.finalUrl)?{kind:`moved`,to:t.finalUrl}:{kind:`ok`}:n===`HEAD`&&[400,403,404,405,501].includes(r)?{kind:`retry`}:r===404||r===410?{kind:`gone`}:r===429?{kind:`unknown`,reason:`slow`}:r>=500&&r<600?{kind:`server`}:[401,403,407,451,999].includes(r)||r>=400?{kind:`unknown`,reason:`refuses`}:{kind:`unknown`,reason:`error`}}function I(e,t){return new Date(e+t).toISOString()}function L(e,n,r){let i={...e,state:`broken`,reason:n,since:e.state===`broken`&&e.since?e.since:new Date(r).toISOString()};return i.group=t(i),i}function R(e){let{fails:t,firstFailAt:n,since:r,group:i,detail:a,reason:o,...s}=e;return s}function Ee(e,t,n){let r=new Date(n).toISOString(),i={...e,checkedAt:r};switch(t.kind){case`ok`:return{...R(i),state:`ok`,due:I(n,7*N)};case`moved`:return{...R(i),state:`look`,reason:`moved`,detail:S(t.to),due:I(n,7*N)};case`gone`:case`dead`:{let a=t.kind===`gone`?`gone`:`no-site`,o=e.firstFailAt?Date.parse(e.firstFailAt):NaN;return(e.fails??0)>=1&&n-o>=N-M?{...L(i,a,n),fails:(e.fails??0)+1,due:I(n,7*N)}:{...i,state:e.state===`broken`?`broken`:`pending`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:I(n,N)}}case`server`:{let t=e.firstFailAt?Date.parse(e.firstFailAt):NaN;return(e.fails??0)>=2&&n-t>=7*N-M?{...L(i,`server`,n),fails:(e.fails??0)+1,due:I(n,7*N)}:{...i,state:e.state===`broken`?`broken`:`unknown`,reason:e.state===`broken`?e.reason:`trouble`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:I(n,3*N)}}case`unknown`:case`retry`:{let r=t.kind===`unknown`?t.reason:`refuses`;return e.state===`broken`?{...i,due:I(n,N)}:{...i,state:`unknown`,reason:r,due:I(n,r===`slow`||r===`timeout`?N:7*N)}}}}function De(e,t,n){let r=new Date(n).toISOString(),i={...e,checkedAt:r,due:`9999`};switch(t.kind){case`ok`:return{...R(i),state:`ok`};case`moved`:{let e=g(t.to);return{...R(i),state:`look`,reason:`redirect`,detail:S(e?e.pathname:t.to)}}case`gone`:case`retry`:return L(i,`missing`,n);default:return{...R(i),state:`unknown`,reason:`no-live`}}}function z(e,t,n){let r=new Date(n).toISOString(),i=t.status??0;if(i>=500){let t=e.firstFailAt?Date.parse(e.firstFailAt):NaN;return(e.fails??0)>=1&&n-t>=5e4?{...L({...e,checkedAt:r},`page-error`,n),fails:(e.fails??0)+1,due:I(n,N)}:{...e,checkedAt:r,state:e.state===`broken`?`broken`:`pending`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:I(n,6e4)}}return t.status!==void 0&&i<400?{...R({...e,checkedAt:r}),state:`ok`,due:`9999`}:{...R({...e,checkedAt:r}),state:`unknown`,reason:`no-live`,due:`9999`}}async function Oe(e,t,n,r){let i=n.checklist.checkedAt?Date.parse(n.checklist.checkedAt):0;if(t.live===`ok`&&r-i<6048e5)return;let a={},o=g(e.site.url),s=C(e.site.url),l={status:`unknown`,text:t.live===`updating`?`Your site was showing visitors an error while an update finished, so we could not find out.`:`We could not open your site to find out.`};if(a.caching=t.caching===`ok`?{status:`working`,text:`Pages can be kept ready for visitors instead of built on every visit.`}:t.caching===`no`?{status:`attention`,text:`Every visit builds the page again, which makes it slower. Not urgent; your web person can let pages be kept ready.`}:l,!s||!o||t.live!==`ok`)Object.assign(a,{secure:l,www:l,missing:l,search:l});else{let t=o.hostname,n=t.startsWith(`www.`)?t.slice(4):`www.${t}`,[r,i,c,u,d]=await Promise.all([o.protocol===`https:`?P(e,`http://${t}/`,{follow:!1}):Promise.resolve(null),P(e,`${o.protocol}//${n}/`,{follow:!1}),P(e,`${s}/site-health-check-a-page-that-does-not-exist`),P(e,`${s}/robots.txt`,{body:!0}),P(e,`${s}/sitemap.xml`,{method:`HEAD`})]);r?(r.status??0)>=300&&(r.status??0)<400&&r.location?.startsWith(`https:`)?a.secure={status:`working`,text:`The insecure address sends visitors to the secure one.`}:r.status===void 0?a.secure=l:a.secure={status:`attention`,text:`The insecure address shows your site without sending visitors to the secure one.`}:a.secure={status:`attention`,text:`Your site address is not secure. Ask your web person to switch it to https.`};let f=i.location?g(i.location)?.hostname:void 0;i.status!==void 0&&i.status>=300&&i.status<400&&f===t?a.www={status:`working`,text:`${n} sends visitors on to your address.`}:i.status!==void 0&&i.status<300?a.www={status:`attention`,text:`${n} shows your site too. Visitors and search engines should be sent to one address; your web person can set this.`}:a.www={status:`attention`,text:`${t.startsWith(`www.`)?`The address without www`:`The address with www`} does not answer. Point it at your site so it sends visitors on.`},c.status===404||c.status===410?a.missing={status:`working`,text:`An address that does not exist shows your "page not found" page and tells search engines it is missing.`}:c.status!==void 0&&c.status<300?a.missing={status:`attention`,text:`An address that does not exist shows a normal page, so search engines may list pages that are not there.`}:a.missing=l;let p=/user-agent:\s*\*\s*(?:\r?\n(?!user-agent)[^\n]*)*?\r?\ndisallow:\s*\/\s*(\r?\n|$)/i.test(u.status===200?u.body??``:``),m=d.status!==void 0&&d.status<300;a.search=p?{status:`attention`,text:`Your site tells search engines to stay away, so it will not appear in search results.`}:{status:`working`,text:m?`They are allowed in, and the list of your pages for them answers.`:`They are allowed in. There is no list of your pages for them yet.`}}let u={...t.live===`ok`?{checkedAt:new Date(r).toISOString()}:{},rows:a};n.checklist=u,await e.kv.set(c.checklist,u)}const ke=`site-health`,Ae=864e5;function B(e){return e.storage.links}function V(e){return e.storage.pages}function H(e){return new Date(e).toISOString()}async function U(e,t,n=Date.now()){let r=await e.schema?.listCollections()??[],i={pass:(t.scan?.pass??0)+1,status:`running`,phase:`redirects`,collections:r.filter(e=>!e.hidden).map(e=>({slug:e.slug,label:e.label,pattern:e.urlPattern,routable:e.routable,titleField:e.titleField})),ci:0,walked:0,liveDone:0,startedAt:H(n),lastStepAt:H(n)};return await Promise.all([e.kv.set(c.scan,i),W(e)]),i}async function W(e){try{await e.cron?.schedule(ke,{schedule:`* * * * *`})}catch(t){e.log.warn(`could not schedule the background check`,{error:String(t)})}}function je(e,t){let n=e.data[t??`title`]??e.data.title??e.data.name;return typeof n==`string`&&n.trim()!==``?n.trim():e.slug??`(no title)`}function G(e){if(e.use===`link`){let t=e.internal?`internal`:`external`;return{id:x(t,e.url),kind:t,url:e.url,host:e.host}}return!e.insecure||e.internal?null:{id:x(`insecure`,e.url),kind:`insecure`,url:e.url,host:e.host}}function K(n,r,i,a,o,s={}){let c=!i.menu,l=!n||n.pass!==a,u=l?[]:[...n.sources],d=u.some(e=>e.p===i.p&&e.menu===i.menu&&e.id===i.id);!d&&u.length<10&&u.push(i);let f=n?{...n,sources:u,count:l?1:n.count+(d?0:1),menu:(l?!1:n.menu)||i.menu===!0,content:(l?!1:n.content)||c,pass:a}:{kind:r.kind,url:r.url,host:r.host,sources:u,count:1,menu:i.menu===!0,content:c,editor:!1,pass:a,state:`pending`,due:r.kind===`external`?H(o):e};return Object.assign(f,s),f.editor=f.kind===`internal`||f.kind===`external`||f.kind===`insecure`&&f.content,f.state===`broken`&&(f.group=t(f)),f}function q(e){return e===`image`?{state:`look`,reason:`insecure-image`}:{state:`broken`,reason:`insecure-active`}}async function Me(e,t){let n=await e.redirects?.list({limit:100,...t.cursor?{cursor:t.cursor}:{}}),r=(n?.items??[]).filter(e=>e.enabled!==!1&&e.source.startsWith(`/`)&&!/[*[(:]/.test(e.source)).map(e=>{let n=y(e.source);return{id:n,data:{status:`redirect`,path:n,title:n,dest:e.destination||``,pass:t.pass}}});r.length>0&&await V(e).putMany(r),n?.hasMore&&n.cursor?t.cursor=n.cursor:(t.cursor=void 0,t.phase=`walk`)}async function Ne(e,t,n){let r=t.collections[t.ci];if(!r){t.phase=`resolve`,t.cursor=void 0;return}let i=await e.content?.list(r.slug,{limit:50,...t.cursor?{cursor:t.cursor}:{}}),a=i?.items??[],o=[],s=new Map;for(let n of a){let i=r.routable?xe(r.pattern,r.slug,n):null,a=je(n,r.titleField),c=[];if(n.status===`published`){let t={c:r.slug,id:n.id,t:a,p:i??``};for(let r of k(n.data,e.site.url)){let e=G(r);if(!e||e.kind===`internal`&&e.url===i)continue;c.push(e.id);let n=s.get(e.id);n?n.sources.push(t):s.set(e.id,{base:e,use:r.use,sources:[t]})}}i&&o.push({id:i,data:{status:n.status,path:i,title:a,collection:r.slug,id:n.id,pass:t.pass,links:c.slice(0,200)}})}if(t.walked+=a.length,o.length>0&&await V(e).putMany(o),s.size>0){let r=[...s.keys()],i=await B(e).getMany(r),a=r.map(e=>{let r=s.get(e),a=i.get(e);for(let e of r.sources)a=K(a,r.base,e,t.pass,n,r.base.kind===`insecure`?q(r.use):{});return a?.kind===`insecure`&&a.state===`broken`&&!a.since&&(a.since=H(n)),{id:e,data:a}});await B(e).putMany(a)}i?.hasMore&&i.cursor?t.cursor=i.cursor:(t.cursor=void 0,t.ci+=1)}function J(n,r,i){let a=e=>{let{reason:t,detail:n,since:r,group:i,fails:a,firstFailAt:o,...s}=e;return s};if(n.state===`fine`)return n;if(n.url===`/`)return{...a(n),state:`ok`};if(!r)return{...a(n),state:`pending`,due:e};if(r.status===`redirect`)return{...a(n),state:`look`,reason:`redirect`,detail:r.dest?S(r.dest):`another page`};if(r.status===`published`)return{...a(n),state:`ok`};let o={...a(n),state:`broken`,reason:`draft`,since:n.state===`broken`&&n.reason===`draft`&&n.since?n.since:H(i)};return o.group=t(o),o}async function Pe(e,t,n){let r=await B(e).query({where:{pass:t.pass},limit:100,...t.cursor?{cursor:t.cursor}:{}}),i=r.items.filter(e=>e.data.kind===`internal`);if(i.length>0){let t=await V(e).getMany([...new Set(i.map(e=>e.data.url))]);await B(e).putMany(i.map(e=>({id:e.id,data:J(e.data,t.get(e.data.url),n)})))}r.hasMore&&r.cursor?t.cursor=r.cursor:(t.cursor=void 0,t.phase=`live-home`,t.liveTotal=await V(e).count({status:`published`,pass:t.pass}))}async function Fe(e,n,r,i,a,o){let s=[];for(let e of a.menu){let t=G(e);t&&s.push({id:t.id,base:t,source:{t:`Site menu`,p:``,menu:!0},extra:{}})}for(let[e,t]of[[a.active,`embed`],[a.passive,`image`]])for(let n of e)s.push({id:x(`insecure`,n),base:{kind:`insecure`,url:n,host:``},source:{t:i.title,p:i.path},extra:q(t)});for(let e of o)s.push({id:x(`page`,e.path),base:{kind:`page`,url:e.path,host:``},source:{t:e.title,p:e.path},extra:{}});if(s.length===0)return;let c=[...new Set(s.map(e=>e.id))],l=await B(e).getMany(c),u=s.filter(e=>e.base.kind===`internal`).map(e=>e.base.url),d=u.length>0?await V(e).getMany([...new Set(u)]):new Map,f=new Map;for(let e of s){let i=K(f.get(e.id)??l.get(e.id),e.base,e.source,n.pass,r,e.extra);if(i.kind===`internal`&&(i=J(i,d.get(i.url),r),i.state===`broken`&&(i.group=t(i))),i.kind===`page`){let t=o.find(e=>e.path===i.url);t&&(i=z(l.get(e.id)??i,{status:t.status},r)),i.pass=n.pass}i.state===`broken`&&!i.since&&(i.since=H(r)),f.set(e.id,i)}await B(e).putMany([...f].map(([e,t])=>({id:e,data:t})))}function Ie(e){let t=e.status??0;if(t<200||t>=300)return!1;let n=e.headers?.get(`content-type`)??``;if(n!==``&&!/html/i.test(n))return!1;let r=(e.body??``).slice(0,2e3);return!/error code: ?\d{3,4}|cf-error-details|Worker threw exception|cloudflare-nginx/i.test(r)}function Le(e){return e.status===503?/Database migrations are required|database has not been migrated/i.test((e.body??``).slice(0,2e3)):!1}async function Re(e,t,n){let r=C(e.site.url);if(!r){t.live=`not-public`,t.phase=`live-paths`;return}let i=await P(e,`${r}/`,{body:!0}),a=i.status??0;if(i.status===void 0){t.live=Ce(i)?`not-public`:`failed`,t.phase=`live-paths`;return}if(Le(i)){t.live=`updating`,t.phase=`live-paths`;return}if(a===403||a===429||a===503||a===401){t.live=`blocked`,t.phase=`live-paths`;return}if(!Ie(i)){t.live=`failed`,t.phase=`live-paths`;return}t.live=`ok`;let o=(i.headers?.get(`cache-control`)??``).toLowerCase();t.caching=/no-store|private/.test(o)||i.headers?.has(`set-cookie`)?`no`:`ok`;let s=j(i.body??``,e.site.url);await Fe(e,t,n,{path:`/`,title:`Home`},{menu:s.menu.map(e=>({...e,use:`link`})),active:s.active,passive:s.passive},a>=500?[{path:`/`,title:`Home`,status:a}]:[]),t.phase=`live-pages`,t.cursor=void 0}async function ze(e,t,n){let r=C(e.site.url),i=await V(e).query({where:{status:`published`},limit:2,...t.cursor?{cursor:t.cursor}:{}});if(r&&i.items.length>0){let a=await Promise.all(i.items.map(async t=>({page:t.data,outcome:await P(e,`${r}${t.data.path}`,{body:!0})}))),o=[],s=[],c=[],l=null;for(let{page:t,outcome:n}of a)if((n.status??0)>=500&&c.push({path:t.path,title:t.title,status:n.status??500}),n.body){let r=j(n.body,e.site.url);o.push(...r.active),s.push(...r.passive),(r.active.length||r.passive.length)&&!l&&(l=t)}await Fe(e,t,n,l??{path:``,title:``},{menu:[],active:o,passive:s},c),t.liveDone+=i.items.length}i.hasMore&&i.cursor?t.cursor=i.cursor:(t.cursor=void 0,t.phase=`live-paths`)}async function Be(t,n,r){let i=C(t.site.url),a=n.live===`ok`&&i!==null,o=await B(t).query({where:{kind:`internal`,state:`pending`},limit:a?2:100});if(o.items.length===0){n.phase=`checklist`;return}let s=await Promise.all(o.items.map(async n=>{if(!a)return{id:n.id,data:{...n.data,state:`unknown`,reason:`no-live`,due:e}};let o=await P(t,`${i}${n.data.url}`);return{id:n.id,data:De(n.data,F(n.data.url,o,`GET`),r)}}));await B(t).putMany(s)}async function Ve(e,t){let[n,r]=await Promise.all([B(e).query({where:{pass:{lt:t.pass}},limit:100}),V(e).query({where:{pass:{lt:t.pass}},limit:100})]);await Promise.all([n.items.length>0?B(e).deleteMany(n.items.map(e=>e.id)):0,r.items.length>0?V(e).deleteMany(r.items.map(e=>e.id)):0]),!n.hasMore&&!r.hasMore&&(t.phase=`count`)}async function He(e,t){let n=B(e),[r,i,a,o,s,c]=await Promise.all([V(e).count({status:`published`}),n.count({kind:{in:[`internal`,`external`]}}),n.count({state:`broken`,group:`site`}),n.count({state:`broken`,group:`page`}),n.count({state:`broken`,group:`link`}),n.count({state:`broken`,editor:!0})]);t.counts={pages:r,links:i,fix:{site:a,page:o,link:s},editorFix:c,look:0,unknown:0},t.phase=`freeze`}async function Ue(e,t){let n=B(e),[r,i,a,o]=await Promise.all([n.query({where:{state:`look`},limit:10}),n.query({where:{state:`unknown`},limit:10}),n.count({state:`look`}),n.count({state:`unknown`})]);t.counts={...t.counts??We,look:a,unknown:o},t.lists={pass:t.pass,lookRows:r.items.map(({id:e,data:t})=>d(e,t)),unknownRows:i.items.map(({id:e,data:t})=>d(e,t))}}const We={pages:0,links:0,fix:{site:0,page:0,link:0},editorFix:0,look:0,unknown:0};async function Ge(e,n,r,i){let a=await B(e).query({where:{state:`broken`},orderBy:{count:`desc`},limit:100}),o={site:[],page:[],link:[]};for(let{id:e,data:n}of a.items){let r=n.group??t(n);o[r].length<10&&o[r].push(d(e,{...n,group:r}))}let s=n.counts??We,c={...s,pass:n.pass,finishedAt:H(i),...r.summary?{prevFinishedAt:r.summary.finishedAt}:{},rows:o,lookRows:n.lists?.lookRows??[],unknownRows:n.lists?.unknownRows??[],live:n.live??`failed`};n.status=`done`,n.finishedAt=H(i);let l=s.pages===0&&!r.summary;return n.nextAt=H(i+(l?6e5:Ae)),n.counts=void 0,n.lists=void 0,n.phase=`redirects`,l?null:c}async function Y(e,t,n,r=Date.now()){let i=t.scan;if(!i||i.status!==`running`)return i;let a={...i,lastStepAt:H(r)},o=null;switch(a.phase){case`redirects`:await Me(e,a);break;case`walk`:await Ne(e,a,r);break;case`resolve`:await Pe(e,a,r);break;case`live-home`:await Re(e,a,r);break;case`live-pages`:await ze(e,a,r);break;case`live-paths`:await Be(e,a,r);break;case`checklist`:await Oe(e,a,t,r),a.phase=`sweep`;break;case`sweep`:await Ve(e,a);break;case`count`:await He(e,a);break;case`freeze`:a.lists?.pass===a.pass?o=await Ge(e,a,t,r):await Ue(e,a);break}return o&&(await e.kv.set(c.summary,o),t.summary=o,await oe(e,t,o,r)),a.ops=n.ops+1,await e.kv.set(c.scan,a),t.scan=a,a}const Ke=6e4;async function qe(e,t,n,r=Date.now()){let i=await B(e).query({where:{due:{lte:H(r)}},orderBy:{due:`asc`},limit:20}),a=C(e.site.url),o={...t.queue.hosts},s=[],l=new Set;for(let e of i.items){if(s.length>=3)break;let n=e.data;if(n.state!==`fine`){if(n.kind===`external`){if(!t.options.outbound)continue;let e=o[n.host];if(l.has(n.host)||e&&r-e.at<Ke*(e.slow&&e.slow>=2?1440:1))continue;l.add(n.host)}else if(n.kind!==`page`||!a||t.summary?.live!==`ok`)continue;s.push({id:e.id,row:n})}}let u=await Promise.all(s.map(async({id:t,row:n})=>{if(n.kind===`page`)return{id:t,before:n,after:z(n,await P(e,`${a}${n.url}`),r)};let i=n.get?`GET`:`HEAD`,s=await P(e,n.url,{method:i}),c=F(n.url,s,i);return o[n.host]={at:r,...o[n.host]?.slow?{slow:o[n.host].slow}:{}},{id:t,before:n,after:null,result:c,outcome:s}}));for(let t of u){if(t.after||!(`result`in t)||!t.result)continue;let i=t.result;if(i.kind===`retry`&&ue(n,5)&&(i=F(t.before.url,await P(e,t.before.url,{method:`GET`}),`GET`)),i.kind===`retry`){t.after={...t.before,due:H(r+Ke),get:!0};continue}if(i.kind===`unknown`&&i.reason===`slow`){let e=o[t.before.host]??{at:r};o[t.before.host]={...e,slow:(e.slow??0)+1}}t.after=Ee(t.before,i,r),t.result.kind===`retry`&&(t.after.get=!0)}for(let[e,t]of Object.entries(o))r-t.at>Ae&&!t.slow&&delete o[e];u.length>0&&await B(e).putMany(u.map(e=>({id:e.id,data:e.after})));let d=t.summary;if(d){for(let e of u){let t=m(d,e.id,e.before,e.after);t&&(d=t)}d!==t.summary&&(await e.kv.set(c.summary,d),t.summary=d,await oe(e,t,d,r))}return t.queue={hosts:o,cronAt:H(r),ops:n.ops+1},await e.kv.set(c.queue,t.queue),u.length}function Je(e,t){let n=Math.ceil(e/50)+(t?Math.ceil(e/2):0)+3;if(n<60)return`about ${Math.max(5,Math.round(n/5)*5)} minutes`;let r=Math.round(n/60);return r===1?`about an hour`:`about ${r} hours`}async function Ye(e,n,r,i,a=Date.now()){if(!i.scan)return;let o=i.scan.collections.find(e=>e.slug===n);if(!o)return;let s=o.routable?xe(o.pattern,n,r):null,l=je(r,o.titleField),u=r.status===`published`?k(r.data,e.site.url):[],d={c:n,id:r.id,t:l,p:s??``},f=new Map;for(let e of u){let t=G(e);t&&!(t.kind===`internal`&&t.url===s)&&f.set(t.id,{base:t,use:e.use})}let p=[...f.values()].filter(e=>e.base.kind===`internal`).map(e=>e.base.url),h=[...new Set([...s?[s]:[],...p])],g=h.length>0?await V(e).getMany(h):new Map,_=s?g.get(s)??null:null,v=[...new Set([...f.keys(),..._?.links??[]])].slice(0,400),y=v.length>0?await B(e).getMany(v):new Map,b=[],x=i.summary;for(let e of v){let n=y.get(e),o=f.get(e),s=null;if(o){let e=n?n.sources.filter(e=>e.id!==r.id):[],c=n?{...n,sources:e,count:Math.max(e.length,n.count-(n.sources.length-e.length))}:void 0;s=K(c,o.base,d,n?.pass??i.scan.pass,a,o.base.kind===`insecure`?q(o.use):{}),c&&(s={...s,sources:[...e,d].slice(0,10),count:c.count+1,pass:n.pass}),s.kind===`internal`&&(s=J(s,g.get(s.url),a)),s.kind===`internal`&&s.state===`pending`&&n?.state!==`pending`&&(s={...s,state:n?.state===`broken`&&n.reason===`missing`?`broken`:`unknown`,reason:n?.reason??`no-live`},s.state===`broken`&&(s.group=t(s)))}else if(n){let e=n.sources.filter(e=>e.id!==r.id);s=e.length===0&&!n.menu?null:{...n,sources:e,count:Math.max(e.length,n.count-1)}}s&&s.state===`broken`&&!s.since&&(s.since=H(a)),s&&b.push({id:e,data:s}),x&&=m(x,e,n,s)??x}let S=v.filter(e=>!f.has(e)&&!b.some(t=>t.id===e));await Promise.all([b.length>0?B(e).putMany(b):void 0,S.length>0?B(e).deleteMany(S):void 0,s?V(e).put(s,{status:r.status,path:s,title:l,collection:n,id:r.id,pass:_?.pass??i.scan.pass,links:[...f.keys()].slice(0,200)}):void 0]),x&&x!==i.summary&&await e.kv.set(c.summary,x)}const Xe=10*6e4,Ze=[{group:`site`,title:`Every page`,text:`In the site menu or footer, so every visitor can hit it. Fix these first.`},{group:`page`,title:`One page`,text:`Visitors who open these pages see an error or an empty box.`},{group:`link`,title:`One link`,text:`A link inside a page that goes nowhere.`}];function Qe(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function X(e,t=Date.now()){if(!e)return``;let n=Math.round((t-Date.parse(e))/6e4);if(Number.isNaN(n))return``;if(n<2)return`just now`;if(n<60)return`${n} minutes ago`;let r=Math.round(n/60);if(r<24)return r===1?`an hour ago`:`${r} hours ago`;let i=Math.round(r/24);return i===1?`yesterday`:`${i} days ago`}function Z(e,t,n=`${t}s`){return`${e} ${e===1?t:n}`}function $e(e,t){let i=t?.prevFinishedAt!==void 0&&e.since!==void 0&&e.since>t.prevFinishedAt,a=(e.reason===`gone`||e.reason===`no-site`)&&e.since?` Checked twice since ${new Date(e.since).toLocaleDateString(`en-GB`,{day:`numeric`,month:`short`,timeZone:`UTC`})}.`:``;return`${i?`New since the last check. `:``}${n(e,S(e.url))}${a}${r(e)}`}function et(e,t){let n=[],r=e.kind===`internal`||e.kind===`page`;return t&&r&&e.url!==`/`&&e.reason!==`draft`&&e.reason!==`page-error`&&n.push({label:`Send visitors to another page`,value:`redirect|${e.id}`}),e.menu&&n.push({label:`Change the link in Menus`,value:`menus|${e.id}`}),e.sources.filter(e=>e.c&&e.id).slice(0,3).forEach((t,r)=>n.push({label:`Edit ${t.t}`,value:`edit${r}|${e.id}`})),n.push({label:`Check it again now`,value:`check|${e.id}`}),n.push({label:`This is fine, stop checking it`,value:`fine|${e.id}`}),{type:`menu`,action_id:`fix`,label:`Fix`,items:n}}function tt(e,t){let n=C(t)??g(t)?.origin,r=e.kind===`internal`?n?`${n}${e.url}`:null:e.url;if(!(!r||!/^https?:\/\//.test(r)))return{type:`link`,label:`Open`,target:{kind:`external`,url:r}}}function nt(e,t,n,r){return{type:`table`,page_action_id:e,columns:[{key:`text`,label:t},{key:`fix`,label:``,format:`element`}],rows:n.map(e=>({text:e.text,...e.action?{fix:e.action}:{}})),...r?{next_cursor:r}:{}}}function rt(e){let t=e.scan,n=!t||[`redirects`,`walk`,`resolve`].includes(t.phase),r=t?.liveTotal??t?.walked??0,i=[{type:`banner`,title:`Checking your site for the first time`,description:`${n?`This usually takes 5 to 30 minutes, and a few hours for a very large site.`:`This takes ${Je(r,t?.live===`ok`)} for a site with ${Z(r,`page`)}.`} Links to other sites follow a day later: each one is checked twice, a day apart, before we call it broken. You can leave this page.`}];return t&&!n&&t.live===`ok`&&(t.liveTotal??0)>0?i.push({type:`meter`,label:`Pages checked`,value:Math.min(t.liveDone,t.liveTotal??0),max:t.liveTotal??0,custom_value:`${Math.min(t.liveDone,t.liveTotal??0)} of ${Z(t.liveTotal??0,`page`)}`}):t&&!n&&i.push({type:`context`,text:`${Z(t.walked,`page`)} read. Finishing off.`}),i.push({type:`context`,text:`Started ${X(t?.startedAt)||`just now`}. Each page is also checked again when it is published.`},{type:`actions`,elements:[{type:`button`,action_id:`refresh`,label:`Refresh`}]}),i}function it(e,t){return e.live===`not-public`?[{type:`banner`,title:`This site is not public yet`,description:`${t?`Its address is ${t}, which only this computer or network can open.`:`It has no address set yet.`} Links inside your pages are checked now; the rest starts once the site has a public address.`}]:e.live===`updating`?[{type:`banner`,variant:`alert`,title:`Your site is in the middle of an update`,description:`During the check that finished ${X(e.finishedAt)}, your site showed visitors an error page instead of your pages: an update to its database had not finished. Whoever looks after your site should finish the update. Links inside your pages are still checked; menus and page errors are checked again once your site is back.`}]:e.live===`blocked`||e.live===`failed`?[{type:`banner`,variant:`alert`,title:`We cannot open your pages to check them`,description:`Links inside your pages are still checked. Menus, page errors and insecure items are not. If your site has bot protection, ask whoever set it up to let "EmDash Site Health" in.`}]:[]}function at(e){let t=e.panel;if(!t)return[];if(t.kind===`redirect`){let n=e.pickable??[];return[{type:`header`,text:`Send visitors to another page`},{type:`context`,text:`Anyone who opens ${t.url}, from your menu, a bookmark or Google, lands on the page you pick. This fixes it everywhere at once.`},...e.problem?[{type:`banner`,variant:`alert`,title:`Not added`,description:e.problem}]:[],{type:`form`,block_id:`redirect`,fields:[{type:`text_input`,action_id:`source`,label:`Old address`,initial_value:t.url},{type:`combobox`,action_id:`destination`,label:`Send visitors to`,placeholder:`Pick a page, or type an address`,options:n}],submit:{label:`Send visitors there`,action_id:`add_redirect|${t.id}`}},{type:`actions`,elements:[{type:`button`,action_id:`close`,label:`Close`}]}]}return t.kind===`edit`?[{type:`banner`,title:`Change the link to ${S(t.url)} on ${t.title}`,description:`Open the page, find the words that link to ${S(t.url)} and point them at the right page: select the words, then use the link button.`},{type:`actions`,elements:[{type:`link`,label:`Open ${t.title}`,appearance:`primary`,target:{kind:`content`,collection:t.collection,id:t.entry}},{type:`button`,action_id:`close`,label:`Close`}]}]:[{type:`banner`,title:`Change the link to ${S(t.url)} in your menus`,description:`Open Menus in the sidebar, under Manage. Find the item that goes to this address and point it at the right page. If the address is on this site, sending visitors to another page also fixes it.`},{type:`actions`,elements:[{type:`button`,action_id:`close`,label:`Close`}]}]}function ot(e,t,n,r){let{summary:i,scan:a}=t;if(!i)return rt(t);let o=[...n?it(i,e.site.url):[],...at(r)],s=e=>n||e.editor,c=n?h(i):i.editorFix;o.push({type:`stats`,items:[{label:`Pages checked`,value:i.pages},{label:`Links checked`,value:i.links},{label:`To fix`,value:c}]});let l=!1;for(let{group:e,title:t,text:a}of Ze){let c=r.more?.group===e?r.more:void 0,u=(c?c.rows:i.rows[e]).filter(s);if(u.length===0)continue;l=!0;let d=n?i.fix[e]:u.length,f=c?c.cursor:d>u.length?`start`:void 0;o.push({type:`header`,text:t},{type:`context`,text:a},nt(`more_${e}`,`Problem`,u.map(e=>({text:$e(e,i),action:et(e,n)})),f))}l||o.push({type:`empty`,size:`sm`,title:`Nothing broken. Nice work.`,description:`We checked ${Z(i.pages,`page`)} and ${Z(i.links,`link`)} ${X(i.finishedAt)}. Next check: tomorrow.`});let u=i.lookRows.filter(s);u.length>0&&o.push({type:`accordion`,label:`Worth a look (${n?i.look:u.length})`,blocks:[{type:`context`,text:`These work, but take a detour. Tidier to fix, not urgent.`},nt(`look_table`,`What we noticed`,u.map(e=>({text:$e(e,null),action:et(e,n)})))]});let d=i.unknownRows.filter(s);d.length>0&&o.push({type:`accordion`,label:`Links we could not check (${n?i.unknown:d.length})`,blocks:[{type:`context`,text:`Some sites, such as LinkedIn and Instagram, turn away automatic checkers. These links are probably fine. Open one to see for yourself; we try again next week.`},nt(`unknown_table`,`What happened`,d.map(t=>{let r=t.kind===`internal`?et(t,n):tt(t,e.site.url);return{text:$e(t,null),...r?{action:r}:{}}}))]});let f=a?.status===`running`;return o.push({type:`context`,text:f?`Checking again now. These are the results of the last full check, ${X(i.finishedAt)}.`:`Last full check: ${X(i.finishedAt)}. Pages on this site are checked every day, links to other sites every week.`}),n||o.push({type:`context`,text:`Pages that show errors and the site's setup are looked after by your site admin.`}),o.push({type:`actions`,elements:[{type:`button`,action_id:f?`refresh`:`check_all`,label:f?`Refresh`:`Check everything again`}]}),o}const st={working:`Working`,attention:`Needs attention`,untested:`Not tested yet`,unknown:`Could not tell`};function ct(e,t){let n=[e.queue.cronAt,e.scan?.cronAt].filter(e=>!!e).map(Date.parse),r=n.length>0?Math.max(...n):0,i=e.scan?Date.parse(e.scan.startedAt):t;return r>0&&t-r<Xe?{status:`working`,text:`Last ran ${Z(Math.max(1,Math.round((t-r)/6e4)),`minute`)} ago. Scheduled posts and these checks depend on it.`}:r===0&&t-i<Xe?{status:`untested`,text:`Waiting for the first run. Scheduled posts and these checks depend on it.`}:{status:`attention`,text:`Not running. Scheduled posts will not publish, and these checks only move while this page is open.`}}function lt(e){return e.length<=2?`Goes to ${e.join(` and `)}.`:`Goes to ${e.slice(0,2).join(`, `)} and ${e.length-2} more.`}function ut(e,t,n=Date.now()){let r=e.options.recipients??[],i=e.alerts.email;if(!t)return{status:`attention`,text:`Not set up. Your site has no email service chosen, so alerts, form messages and password resets are not sent. Whoever set up your site can choose one in Settings, Email.`};if(r.length===0)return{status:`attention`,text:`Nobody is on the list, so no alerts are sent. Add an address under Options below.`};let a=lt(r);if(!i)return{status:`untested`,text:`Not checked yet. ${a} To find out whether email works, press Send a test email to check now.`};let o=X(i.at,n),s=i.kind===`alert`?`an alert`:`a test email`;return i.state===`working`?(i.list?i.list.length===r.length&&i.list.every(e=>r.includes(e)):i.to===r.length)?{status:`working`,text:`Last checked ${o}: ${s} was sent. ${a} To check again, press Send a test email.`}:{status:`untested`,text:`The list has changed since the last email, ${o}. ${a} To find out whether it reaches them, press Send a test email to check now.`}:T.test(i.error??``)?{status:`attention`,text:`Last checked ${o}: your site had no email service chosen, so nothing was sent. If one has been chosen in Settings, Email since, press Send a test email to check now.`}:{status:`attention`,text:`Last checked ${o}: ${s} could not be sent; the email service turned it down. ${a} Press Send a test email to try again; if it fails, whoever set up your site can look at Settings, Email.`}}function dt(e,t,n,r=Date.now()){let i=t?g(t):null,a=i?i.protocol===`https:`?{status:`working`,text:`Set, secure, and the address visitors use.`}:{status:C(i.href)?`attention`:`unknown`,text:C(i.href)?`Set, but not secure. Ask your web person to switch it to https.`:`Set to an address only this computer or network can open.`}:{status:`attention`,text:`Not set. Set it in Settings so links and emails point to your site.`},o=e.checklist.rows;return[...e.summary?.live===`updating`?[{name:`Site up`,status:`attention`,text:`During the check that finished ${X(e.summary.finishedAt,r)}, your site showed visitors an error page: an update to its database had not finished. Whoever looks after your site should finish the update.`}]:[],{name:`Site address`,...a},{name:`Scheduled tasks`,...ct(e,r)},{name:`Email`,...ut(e,n,r)},{name:`Secure connection`,...o.secure??{status:`untested`,text:`Checked with the next full check.`}},{name:`With and without www`,...o.www??{status:`untested`,text:`Checked with the next full check.`}},{name:`Missing pages`,...o.missing??{status:`untested`,text:`Checked with the next full check.`}},{name:`Search engines`,...o.search??{status:`untested`,text:`Checked with the next full check.`}},{name:`Quick repeat visits`,...o.caching??{status:`untested`,text:`Checked with the next full check.`}}]}function ft(e,t,n,r,i){let a=dt(e,t,n,i),o=a.filter(e=>e.status===`attention`&&e.name!==`Quick repeat visits`),s=[...a].sort((e,t)=>Number(t.status===`attention`)-Number(e.status===`attention`)),c=[];a.find(e=>e.name===`Scheduled tasks`)?.status===`attention`?c.push({type:`banner`,variant:`alert`,title:`Scheduled tasks are not running`,description:`Scheduled posts will not publish, and these checks only move while this page is open. Whoever set up your site can switch them on in a few minutes.`},{type:`accordion`,label:`Copy the instructions for your web person`,blocks:[{type:`context`,text:`Send the steps below to whoever set up your site: they switch on the scheduled tasks your site needs.`},{type:`code`,code:`This EmDash site's scheduled tasks are not running (the Site Health task has not run for 10 minutes).
|
|
1
|
+
const e=`9999`;function t(e){return e.menu?`site`:e.kind===`page`?e.url===`/`?`site`:`page`:e.kind===`insecure`?`page`:`link`}function n(e,t){switch(e.reason){case`missing`:return`${t} goes to a page that does not exist.`;case`draft`:return`${t} goes to a page that is not published yet.`;case`gone`:return`${t}: that other website has removed this page.`;case`no-site`:return`${t}: that website no longer exists.`;case`server`:return`${t}: that website has shown an error for a week.`;case`page-error`:return`${t}: this page is broken; visitors see an error.`;case`insecure-active`:return`${t} is loaded over an insecure connection. Browsers block it, so visitors see an empty box.`;case`redirect`:case`moved`:return`${t} now goes on to ${e.detail??`another address`}.${e.kind===`internal`&&e.detail?` Linking to ${e.detail} directly saves a step.`:``}`;case`insecure-image`:return`${t} is a picture shown over an insecure connection. Browsers fix this themselves for now.`;case`refuses`:return`${t}: that website turns away automatic checkers.`;case`slow`:return`${t}: that website asked us to slow down. Trying again tomorrow.`;case`timeout`:return`${t} did not answer within 8 seconds.`;case`trouble`:return`${t}: that website had a problem when we checked. Trying again.`;case`no-live`:return`${t}: we could not open your site to check this address.`;default:return`${t}: we could not open it.`}}function r(e){if(e.kind===`page`)return``;let t=[];e.menu&&t.push(`Site menu (every page)`);for(let n of e.sources)if(!n.menu){if(t.length>=4)break;t.push(n.t)}let n=t.length-(e.menu?1:0),r=e.count-(e.menu?e.sources.filter(e=>e.menu).length:0)-n;return t.length===0?``:` Found on: ${t.join(`, `)}${r>0?` and ${r} more`:``}.`}function i(e){return e!=null}async function a(e,t,n){try{await e.settings.set(t.name,n)}catch(n){e.log.warn(`could not copy the settings to their new place`,{key:t.name,error:String(n)})}}async function o(e,t,n,{copy:r=!0}={}){let o=t.get(`settings:${n.name}`);if(i(o))return o;let s=t.get(n.legacy);return i(s)?(r&&await a(e,n,s),s):null}async function s(e,t,n){await e.settings.set(t.name,n);try{await e.kv.set(t.legacy,n)}catch(n){e.log.warn(`could not keep a copy of the settings for the earlier release`,{key:t.legacy,error:String(n)})}}const c={scan:`scan`,summary:`summary`,queue:`queue`,options:`options`,alerts:`alerts`,checklist:`checklist`},l={name:`options`,legacy:c.options};async function u(e,{copy:t=!1}={}){let n=await e.kv.list(),r=new Map(n.map(e=>[e.key,e.value])),i=(t?await o(e,r,l):r.get(`settings:${l.name}`)??r.get(l.legacy)??null)??{},a=r.get(c.alerts)??{},s=r.get(c.queue)??{},u=r.get(c.checklist)??{};return{scan:r.get(c.scan)??null,summary:r.get(c.summary)??null,queue:{hosts:{},...s},options:{outbound:!0,...i},alerts:{pending:[],sent:[],...a},checklist:{rows:{},...u}}}function d(e,t){return{id:e,kind:t.kind,url:t.url,...t.reason?{reason:t.reason}:{},...t.detail?{detail:t.detail}:{},sources:t.sources.slice(0,5),count:t.count,menu:t.menu,editor:t.editor,...t.since?{since:t.since}:{}}}function f(e,t){return t.count-e.count}function p(e,t){return e.filter(e=>e.id!==t)}function m(e,t,n,r){let i=n?.state,a=r?.state,o=n?.group;if(i===a&&o===r?.group&&n?.reason===r?.reason)return null;let s={...e,fix:{...e.fix},rows:{site:p(e.rows.site,t),page:p(e.rows.page,t),link:p(e.rows.link,t)},lookRows:p(e.lookRows,t),unknownRows:p(e.unknownRows,t)};return i===`broken`&&o&&(s.fix[o]=Math.max(0,s.fix[o]-1),n?.editor&&(s.editorFix=Math.max(0,s.editorFix-1))),i===`look`&&(s.look=Math.max(0,s.look-1)),i===`unknown`&&(s.unknown=Math.max(0,s.unknown-1)),r&&a===`broken`&&r.group&&(s.fix[r.group]+=1,r.editor&&(s.editorFix+=1),s.rows[r.group]=[...s.rows[r.group],d(t,r)].sort(f).slice(0,10)),r&&a===`look`&&(s.look+=1,s.lookRows=[...s.lookRows,d(t,r)].slice(0,10)),r&&a===`unknown`&&(s.unknown+=1,s.unknownRows=[...s.unknownRows,d(t,r)].slice(0,10)),s}function h(e){return e.fix.site+e.fix.page+e.fix.link}function g(e,t){try{return new URL(e,t)}catch{return null}}const ee=/^(mailto|tel|sms|javascript|data|blob|about|ftp):/i;function _(e){return e.startsWith(`/_emdash/`)||e.startsWith(`/_astro/`)||e===`/_emdash`}function v(e){let t=e;try{t=decodeURI(e)}catch{}let n=t.replace(/\/{2,}/g,`/`);return n.length>1&&n.endsWith(`/`)?n.slice(0,-1):n||`/`}function y(e,t){let n=e.trim();if(n===``||n.startsWith(`#`)||n.startsWith(`?`)||ee.test(n)||/\s/.test(n)&&!/^https?:\/\//i.test(n))return null;let r=g(t),i=/^https?:\/\//i.test(n)||n.startsWith(`//`);if(!i&&!n.startsWith(`/`))return null;let a=g(n.startsWith(`//`)?`https:${n}`:n,`https://site.invalid`);if(!a||a.protocol!==`https:`&&a.protocol!==`http:`)return null;let o=a.hostname.toLowerCase();if(!i||r!==null&&o.replace(/^www\./,``)===r.hostname.replace(/^www\./,``)){let e=v(a.pathname);return _(e)?null:{url:e,internal:!0,host:``,insecure:i&&a.protocol===`http:`}}return a.hash=``,{url:a.href,internal:!1,host:o,insecure:a.protocol===`http:`}}function b(e,t){let n=2166136261,r=16777619,i=`${e}|${t}`;for(let e=0;e<i.length;e+=1){let t=i.charCodeAt(e);n=Math.imul(n^t,16777619)>>>0,r=Math.imul(r^t,1540483477)>>>0}return`${e[0]}${n.toString(36)}${(r&1048575).toString(36)}`}function x(e,t=30){let n=e.replace(/^https?:\/\//i,``).replace(/^www\./i,``).replace(/\/$/,``);return n.length>t?`${n.slice(0,t-1)}…`:n}function S(e){let t=g(e);if(!t||t.protocol!==`https:`&&t.protocol!==`http:`)return null;let n=t.hostname.toLowerCase().replace(/\.$/,``);return n===`localhost`||n.endsWith(`.localhost`)||n.endsWith(`.local`)||/^(127|10|0)\.|^192\.168\.|^169\.254\.|^172\.(1[6-9]|2\d|3[01])\./.test(n)||n===`[::1]`||n.startsWith(`[fc`)||n.startsWith(`[fd`)?null:t.origin}const C=864e5;7*C;function te(e){return`${n(e,x(e.url))}${r(e)}`}function ne(e){return e.menu||e.reason===`page-error`}function re(e){let t=S(e.site.url);return t?`${t}/_emdash/admin/plugins/${e.plugin.id}/health`:null}function ie(e){let t=[];for(let n of e.split(/[\s,;]+/)){let e=n.trim();if(e!==``){if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))return{wrong:e};t.includes(e.toLowerCase())||t.push(e.toLowerCase())}}return{list:t.slice(0,20)}}const w=/no email provider|not configured/i;function T(e,t,n,r){return e.ok?{state:`working`,at:n,to:t.length,list:t,kind:r}:{state:w.test(e.error)?`not-set-up`:`failed`,at:n,to:0,list:t,kind:r,error:e.error.slice(0,200)}}const ae=`updating|`;async function E(e,t,n,r){let[i,...a]=t;if(!i)return{ok:!1,error:`no addresses`};if(!e.email)return{ok:!1,error:`Email is not configured`};try{return await e.email.send({to:i,...a.length>0?{cc:a}:{},subject:n,text:r}),{ok:!0}}catch(e){return{ok:!1,error:e instanceof Error?e.message:String(e)}}}function oe(e){return e.site.name.trim()||S(e.site.url)?.replace(/^https?:\/\//,``)||`Your site`}async function se(e,t,n,r){let i=[...n.rows.site,...n.rows.page].filter(ne).map(e=>({id:e.id,text:te(e)}));n.live===`updating`&&i.unshift({id:`${ae}${n.pass}`,text:`Your site showed visitors an error page instead of your pages: an update to its database has not finished. Whoever looks after your site should finish the update.`});let a=i.filter(e=>!t.alerts.sent.includes(e.id));if(a.length===0)return;let o={...t.alerts,pending:[...t.alerts.pending,...a.map(e=>({id:e.id,text:e.text}))].slice(-20),sent:[...t.alerts.sent,...a.map(e=>e.id)].slice(-200)};t.alerts=o,await e.kv.set(c.alerts,o)}function ce(e,t){if((e.options.recipients??[]).length===0||!e.summary)return!1;let{alerts:n}=e;return n.pending.length>0&&(!n.urgentAt||t-Date.parse(n.urgentAt)>=C)||!n.weeklyAt||t-Date.parse(n.weeklyAt)>=6048e5}async function le(e,t,n){let r=t.options.recipients??[],i={...t.alerts},a=oe(e),o=re(e),s=new Date(n).toISOString();if(i.pending.length>0&&(!i.urgentAt||n-Date.parse(i.urgentAt)>=C)){let t=i.pending.some(e=>e.id.startsWith(ae)),n=i.pending.some(e=>/visitors see an error/.test(e.text)),c=await E(e,r,`${a}: ${t?`your site is showing visitors an error`:n?`a page on your site shows an error`:`a link in your site menu is broken`}`,[`Site Health found something visitors will hit:`,``,...i.pending.map(e=>`- ${e.text}`),``,o?`See them and fix them: ${o}`:`See them in Site Health in your site's admin.`,``,`You get this because your address is on Site Health's list. At most one of these a day. Change the list in Site Health, Checklist, Options.`].join(`
|
|
2
|
+
`));i.urgentAt=s,i.pending=[],i.email=T(c,r,s,`alert`)}else if(!i.weeklyAt)i.weeklyAt=s;else if(t.summary){let n=i.weeklyAt,c=[...t.summary.rows.site,...t.summary.rows.page,...t.summary.rows.link].filter(e=>e.since&&e.since>n);if(i.weeklyAt=s,c.length>0){let n=t.summary.fix.site+t.summary.fix.page+t.summary.fix.link;i.email=T(await E(e,r,`${a}: ${c.length} new ${c.length===1?`problem`:`problems`} this week`,[`New since last week:`,``,...c.map(e=>`- ${te(e)}`),``,`${n} ${n===1?`thing`:`things`} to fix in all. ${o?`See them: ${o}`:`See them in Site Health in your site's admin.`}`,``,`Sent once a week, only when something is new.`].join(`
|
|
3
|
+
`)),r,s,`alert`)}}t.alerts=i,await e.kv.set(c.alerts,i)}async function ue(e,t,n){let r=t.options.recipients??[],i=await E(e,r,`${oe(e)}: test email from Site Health`,`This is a test from Site Health. If you can read it, alerts about broken links and pages that show errors will reach you.`),a=new Date(n).toISOString();return t.alerts={...t.alerts,email:T(i,r,a,`test`)},await e.kv.set(c.alerts,t.alerts),i}function de(e,t){return e.ops+t<=9}const fe=[`kv`,`settings`,`content`,`schema`,`redirects`,`http`,`email`,`cron`,`log`];function pe(e,t){return new Proxy(e,{get(e,n,r){let i=Reflect.get(e,n,r);return typeof i==`function`?(...n)=>(t.ops+=1,i.apply(e,n)):i}})}function D(e,t){let n={};for(let r of fe){let i=e[r];i&&(n[r]=pe(i,t))}let r=new Map,i=new Proxy(e.storage,{get(e,n,i){let a=Reflect.get(e,n,i);if(typeof n!=`string`||!a||typeof a!=`object`)return a;let o=r.get(n);return o||(o=pe(a,t),r.set(n,o)),o}});return new Proxy(e,{get(e,t,r){return t===`storage`?i:typeof t==`string`&&t in n?n[t]:Reflect.get(e,t,r)}})}const me=/^(href|url|link|src)$|(_url|Url|_href|Href|_link|Link)$/,he=/iframe|embed|video|map|script|audio|form/i,ge=/image|picture|photo/i;function O(e,t){let n=new Map,r=0,i=(e,r)=>{let i=y(e,t);i&&n.set(`${r}|${i.url}`,{...i,use:r})},a=(e,t,n)=>{if(r+=1,r>5e3||n>30||typeof e!=`object`||!e)return;if(Array.isArray(e)){for(let r of e)a(r,t,n+1);return}let o=e,s=typeof o._type==`string`?o._type:t;for(let[e,t]of Object.entries(o))if(typeof t==`string`){if(!me.test(e)||t.length>2e3)continue;i(t,e===`src`||e.endsWith(`Src`)||e.endsWith(`_src`)?ge.test(s)?`image`:he.test(s)?`embed`:`link`:`link`)}else t&&typeof t==`object`&&a(t,s,n+1)};return a(e,``,0),[...n.values()]}const k=2e5,_e=/<a\b[^>]*?\bhref\s*=\s*("([^"]*)"|'([^']*)')/gi,ve=/<(header|nav|footer)\b[\s\S]*?<\/\1\s*>/gi,ye=/<(iframe|script|embed|object|video|audio|source)\b[^>]*?\bsrc\s*=\s*("http:[^"]*"|'http:[^']*')|<link\b(?=[^>]*\brel\s*=\s*["']?stylesheet)[^>]*?\bhref\s*=\s*("http:[^"]*"|'http:[^']*')|<form\b[^>]*?\baction\s*=\s*("http:[^"]*"|'http:[^']*')/gi,be=/<img\b[^>]*?\bsrc\s*=\s*("http:[^"]*"|'http:[^']*')/gi;function xe(e){return e.slice(1,-1).replaceAll(`&`,`&`)}function A(e,t){let n=e.length>k?e.slice(0,k):e,r=new Map;for(let e of n.matchAll(ve))for(let n of e[0].matchAll(_e)){let e=y((n[2]??n[3]??``).replaceAll(`&`,`&`),t);e&&r.set(e.url,e)}let i=new Set;for(let e of n.matchAll(ye)){let t=e[2]??e[3]??e[4];t&&i.add(xe(t))}let a=new Set;for(let e of n.matchAll(be))e[1]&&a.add(xe(e[1]));return{menu:[...r.values()],active:[...i],passive:[...a]}}function Se(e,t,n){if(!n.slug)return null;let r=e??`/${encodeURIComponent(t)}/{slug}`,i=n.publishedAt?new Date(n.publishedAt):null,a=e=>String(e).padStart(2,`0`),o=i&&!Number.isNaN(i.getTime())?{year:String(i.getUTCFullYear()),month:a(i.getUTCMonth()+1),day:a(i.getUTCDate()),hour:a(i.getUTCHours()),minute:a(i.getUTCMinutes()),second:a(i.getUTCSeconds())}:{},s=r.replaceAll(`{slug}`,encodeURIComponent(n.slug)).replaceAll(`{id}`,encodeURIComponent(n.id)).replace(/\{(year|month|day|hour|minute|second)\}/g,(e,t)=>o[t]??e);if(s.includes(`{`))return null;s=s.replace(/\/{2,}/g,`/`),s.length>1&&s.endsWith(`/`)&&(s=s.slice(0,-1)),s.startsWith(`/`)||(s=`/${s}`);try{return decodeURI(s)}catch{return s}}const j=36e5,M=24*j;function Ce(e){return`EmDash-SiteHealth/0.1 (+${e||`unknown site`})`}async function N(e,t,n={}){let r=e.http;if(!r)return{error:`no network`};let i,a=new Promise(e=>{i=setTimeout(()=>e({timedOut:!0}),8e3)}),o=(async()=>{try{let i=await r.fetch(t,{method:n.method??`GET`,redirect:n.follow===!1?`manual`:`follow`,headers:{"User-Agent":Ce(e.site.url),Accept:`text/html,*/*;q=0.5`}}),a=i.headers.get(`location`),o={status:i.status,headers:i.headers,...a?{location:g(a,t)?.href??a}:{},...i.redirected&&i.url?{finalUrl:i.url}:{}};if(n.body){let e=await i.text();o.body=e.length>k?e.slice(0,k):e}return o}catch(e){return{error:e instanceof Error?e.message:String(e)}}})();try{return await Promise.race([o,a])}finally{clearTimeout(i)}}function we(e){return/internal host|not allowed to fetch|private|localhost/i.test(e.error??``)}const Te=/ENOTFOUND|EAI_AGAIN|getaddrinfo|DNS|name not resolved|could not resolve/i;function Ee(e,t){let n=e=>e.replace(/^https?:\/\//i,``).replace(/^www\./i,``).replace(/\/$/,``);return n(e)===n(t)}function P(e,t,n){if(t.timedOut)return{kind:`unknown`,reason:`timeout`};if(t.error!==void 0)return Te.test(t.error)?{kind:`dead`}:{kind:`unknown`,reason:`error`};let r=t.status??0;return r>=300&&r<400&&t.location?Ee(e,t.location)?{kind:`ok`}:{kind:`moved`,to:t.location}:r>=200&&r<300?t.finalUrl&&!Ee(e,t.finalUrl)?{kind:`moved`,to:t.finalUrl}:{kind:`ok`}:n===`HEAD`&&[400,403,404,405,501].includes(r)?{kind:`retry`}:r===404||r===410?{kind:`gone`}:r===429?{kind:`unknown`,reason:`slow`}:r>=500&&r<600?{kind:`server`}:[401,403,407,451,999].includes(r)||r>=400?{kind:`unknown`,reason:`refuses`}:{kind:`unknown`,reason:`error`}}function F(e,t){return new Date(e+t).toISOString()}function I(e,n,r){let i={...e,state:`broken`,reason:n,since:e.state===`broken`&&e.since?e.since:new Date(r).toISOString()};return i.group=t(i),i}function L(e){let{fails:t,firstFailAt:n,since:r,group:i,detail:a,reason:o,...s}=e;return s}function De(e,t,n){let r=new Date(n).toISOString(),i={...e,checkedAt:r};switch(t.kind){case`ok`:return{...L(i),state:`ok`,due:F(n,7*M)};case`moved`:return{...L(i),state:`look`,reason:`moved`,detail:x(t.to),due:F(n,7*M)};case`gone`:case`dead`:{let a=t.kind===`gone`?`gone`:`no-site`,o=e.firstFailAt?Date.parse(e.firstFailAt):NaN;return(e.fails??0)>=1&&n-o>=M-j?{...I(i,a,n),fails:(e.fails??0)+1,due:F(n,7*M)}:{...i,state:e.state===`broken`?`broken`:`pending`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:F(n,M)}}case`server`:{let t=e.firstFailAt?Date.parse(e.firstFailAt):NaN;return(e.fails??0)>=2&&n-t>=7*M-j?{...I(i,`server`,n),fails:(e.fails??0)+1,due:F(n,7*M)}:{...i,state:e.state===`broken`?`broken`:`unknown`,reason:e.state===`broken`?e.reason:`trouble`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:F(n,3*M)}}case`unknown`:case`retry`:{let r=t.kind===`unknown`?t.reason:`refuses`;return e.state===`broken`?{...i,due:F(n,M)}:{...i,state:`unknown`,reason:r,due:F(n,r===`slow`||r===`timeout`?M:7*M)}}}}function Oe(e,t,n){let r=new Date(n).toISOString(),i={...e,checkedAt:r,due:`9999`};switch(t.kind){case`ok`:return{...L(i),state:`ok`};case`moved`:{let e=g(t.to);return{...L(i),state:`look`,reason:`redirect`,detail:x(e?e.pathname:t.to)}}case`gone`:case`retry`:return I(i,`missing`,n);default:return{...L(i),state:`unknown`,reason:`no-live`}}}function R(e,t,n){let r=new Date(n).toISOString(),i=t.status??0;if(i>=500){let t=e.firstFailAt?Date.parse(e.firstFailAt):NaN;return(e.fails??0)>=1&&n-t>=5e4?{...I({...e,checkedAt:r},`page-error`,n),fails:(e.fails??0)+1,due:F(n,M)}:{...e,checkedAt:r,state:e.state===`broken`?`broken`:`pending`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:F(n,6e4)}}return t.status!==void 0&&i<400?{...L({...e,checkedAt:r}),state:`ok`,due:`9999`}:{...L({...e,checkedAt:r}),state:`unknown`,reason:`no-live`,due:`9999`}}const z=`help_line`,ke=`hide_help_line`,Ae=`save_help_line`;function je(e){return{type:`toggle`,action_id:ke,label:`Hide the help line at the foot of this page`,description:`Only admins see it: links to the guide and to more free plugins.`,initial_value:e}}function Me(e){return{type:`form`,block_id:`${z}_switch_${e?`on`:`off`}`,fields:[je(e)],submit:{label:`Save`,action_id:Ae}}}function Ne(e,t){let n=e[ke];return typeof n==`boolean`?n:t}function Pe(e){if(!e)return[];let t=(e,t)=>({type:`link`,label:e,target:{kind:`external`,url:t}});return[{type:`divider`,block_id:`${z}_rule`},{type:`actions`,block_id:z,elements:[t(`Help`,`https://moorage.dev/guides/find-and-fix-broken-links-on-emdash`),t(`More free plugins`,`https://moorage.dev/plugins`),t(`by Moorage`,`https://moorage.dev`)]}]}async function Fe(e,t,n,r){let i=n.checklist.checkedAt?Date.parse(n.checklist.checkedAt):0;if(t.live===`ok`&&r-i<6048e5)return;let a={},o=g(e.site.url),s=S(e.site.url),l={status:`unknown`,text:t.live===`updating`?`Your site was showing visitors an error while an update finished, so we could not find out.`:`We could not open your site to find out.`};if(a.caching=t.caching===`ok`?{status:`working`,text:`Pages can be kept ready for visitors instead of built on every visit.`}:t.caching===`no`?{status:`attention`,text:`Every visit builds the page again, which makes it slower. Not urgent; your web person can let pages be kept ready.`}:l,!s||!o||t.live!==`ok`)Object.assign(a,{secure:l,www:l,missing:l,search:l});else{let t=o.hostname,n=t.startsWith(`www.`)?t.slice(4):`www.${t}`,[r,i,c,u,d]=await Promise.all([o.protocol===`https:`?N(e,`http://${t}/`,{follow:!1}):Promise.resolve(null),N(e,`${o.protocol}//${n}/`,{follow:!1}),N(e,`${s}/site-health-check-a-page-that-does-not-exist`),N(e,`${s}/robots.txt`,{body:!0}),N(e,`${s}/sitemap.xml`,{method:`HEAD`})]);r?(r.status??0)>=300&&(r.status??0)<400&&r.location?.startsWith(`https:`)?a.secure={status:`working`,text:`The insecure address sends visitors to the secure one.`}:r.status===void 0?a.secure=l:a.secure={status:`attention`,text:`The insecure address shows your site without sending visitors to the secure one.`}:a.secure={status:`attention`,text:`Your site address is not secure. Ask your web person to switch it to https.`};let f=i.location?g(i.location)?.hostname:void 0;i.status!==void 0&&i.status>=300&&i.status<400&&f===t?a.www={status:`working`,text:`${n} sends visitors on to your address.`}:i.status!==void 0&&i.status<300?a.www={status:`attention`,text:`${n} shows your site too. Visitors and search engines should be sent to one address; your web person can set this.`}:a.www={status:`attention`,text:`${t.startsWith(`www.`)?`The address without www`:`The address with www`} does not answer. Point it at your site so it sends visitors on.`},c.status===404||c.status===410?a.missing={status:`working`,text:`An address that does not exist shows your "page not found" page and tells search engines it is missing.`}:c.status!==void 0&&c.status<300?a.missing={status:`attention`,text:`An address that does not exist shows a normal page, so search engines may list pages that are not there.`}:a.missing=l;let p=/user-agent:\s*\*\s*(?:\r?\n(?!user-agent)[^\n]*)*?\r?\ndisallow:\s*\/\s*(\r?\n|$)/i.test(u.status===200?u.body??``:``),m=d.status!==void 0&&d.status<300;a.search=p?{status:`attention`,text:`Your site tells search engines to stay away, so it will not appear in search results.`}:{status:`working`,text:m?`They are allowed in, and the list of your pages for them answers.`:`They are allowed in. There is no list of your pages for them yet.`}}let u={...t.live===`ok`?{checkedAt:new Date(r).toISOString()}:{},rows:a};n.checklist=u,await e.kv.set(c.checklist,u)}const Ie=`site-health`,Le=864e5;function B(e){return e.storage.links}function V(e){return e.storage.pages}function H(e){return new Date(e).toISOString()}async function U(e,t,n=Date.now()){let r=await e.schema?.listCollections()??[],i={pass:(t.scan?.pass??0)+1,status:`running`,phase:`redirects`,collections:r.filter(e=>!e.hidden).map(e=>({slug:e.slug,label:e.label,pattern:e.urlPattern,routable:e.routable,titleField:e.titleField})),ci:0,walked:0,liveDone:0,startedAt:H(n),lastStepAt:H(n)};return await Promise.all([e.kv.set(c.scan,i),W(e)]),i}async function W(e){try{await e.cron?.schedule(Ie,{schedule:`* * * * *`})}catch(t){e.log.warn(`could not schedule the background check`,{error:String(t)})}}function Re(e,t){let n=e.data[t??`title`]??e.data.title??e.data.name;return typeof n==`string`&&n.trim()!==``?n.trim():e.slug??`(no title)`}function G(e){if(e.use===`link`){let t=e.internal?`internal`:`external`;return{id:b(t,e.url),kind:t,url:e.url,host:e.host}}return!e.insecure||e.internal?null:{id:b(`insecure`,e.url),kind:`insecure`,url:e.url,host:e.host}}function K(n,r,i,a,o,s={}){let c=!i.menu,l=!n||n.pass!==a,u=l?[]:[...n.sources],d=u.some(e=>e.p===i.p&&e.menu===i.menu&&e.id===i.id);!d&&u.length<10&&u.push(i);let f=n?{...n,sources:u,count:l?1:n.count+(d?0:1),menu:(l?!1:n.menu)||i.menu===!0,content:(l?!1:n.content)||c,pass:a}:{kind:r.kind,url:r.url,host:r.host,sources:u,count:1,menu:i.menu===!0,content:c,editor:!1,pass:a,state:`pending`,due:r.kind===`external`?H(o):e};return Object.assign(f,s),f.editor=f.kind===`internal`||f.kind===`external`||f.kind===`insecure`&&f.content,f.state===`broken`&&(f.group=t(f)),f}function q(e){return e===`image`?{state:`look`,reason:`insecure-image`}:{state:`broken`,reason:`insecure-active`}}async function ze(e,t){let n=await e.redirects?.list({limit:100,...t.cursor?{cursor:t.cursor}:{}}),r=(n?.items??[]).filter(e=>e.enabled!==!1&&e.source.startsWith(`/`)&&!/[*[(:]/.test(e.source)).map(e=>{let n=v(e.source);return{id:n,data:{status:`redirect`,path:n,title:n,dest:e.destination||``,pass:t.pass}}});r.length>0&&await V(e).putMany(r),n?.hasMore&&n.cursor?t.cursor=n.cursor:(t.cursor=void 0,t.phase=`walk`)}async function Be(e,t,n){let r=t.collections[t.ci];if(!r){t.phase=`resolve`,t.cursor=void 0;return}let i=await e.content?.list(r.slug,{limit:50,...t.cursor?{cursor:t.cursor}:{}}),a=i?.items??[],o=[],s=new Map;for(let n of a){let i=r.routable?Se(r.pattern,r.slug,n):null,a=Re(n,r.titleField),c=[];if(n.status===`published`){let t={c:r.slug,id:n.id,t:a,p:i??``};for(let r of O(n.data,e.site.url)){let e=G(r);if(!e||e.kind===`internal`&&e.url===i)continue;c.push(e.id);let n=s.get(e.id);n?n.sources.push(t):s.set(e.id,{base:e,use:r.use,sources:[t]})}}i&&o.push({id:i,data:{status:n.status,path:i,title:a,collection:r.slug,id:n.id,pass:t.pass,links:c.slice(0,200)}})}if(t.walked+=a.length,o.length>0&&await V(e).putMany(o),s.size>0){let r=[...s.keys()],i=await B(e).getMany(r),a=r.map(e=>{let r=s.get(e),a=i.get(e);for(let e of r.sources)a=K(a,r.base,e,t.pass,n,r.base.kind===`insecure`?q(r.use):{});return a?.kind===`insecure`&&a.state===`broken`&&!a.since&&(a.since=H(n)),{id:e,data:a}});await B(e).putMany(a)}i?.hasMore&&i.cursor?t.cursor=i.cursor:(t.cursor=void 0,t.ci+=1)}function J(n,r,i){let a=e=>{let{reason:t,detail:n,since:r,group:i,fails:a,firstFailAt:o,...s}=e;return s};if(n.state===`fine`)return n;if(n.url===`/`)return{...a(n),state:`ok`};if(!r)return{...a(n),state:`pending`,due:e};if(r.status===`redirect`)return{...a(n),state:`look`,reason:`redirect`,detail:r.dest?x(r.dest):`another page`};if(r.status===`published`)return{...a(n),state:`ok`};let o={...a(n),state:`broken`,reason:`draft`,since:n.state===`broken`&&n.reason===`draft`&&n.since?n.since:H(i)};return o.group=t(o),o}async function Ve(e,t,n){let r=await B(e).query({where:{pass:t.pass},limit:100,...t.cursor?{cursor:t.cursor}:{}}),i=r.items.filter(e=>e.data.kind===`internal`);if(i.length>0){let t=await V(e).getMany([...new Set(i.map(e=>e.data.url))]);await B(e).putMany(i.map(e=>({id:e.id,data:J(e.data,t.get(e.data.url),n)})))}r.hasMore&&r.cursor?t.cursor=r.cursor:(t.cursor=void 0,t.phase=`live-home`,t.liveTotal=await V(e).count({status:`published`,pass:t.pass}))}async function He(e,n,r,i,a,o){let s=[];for(let e of a.menu){let t=G(e);t&&s.push({id:t.id,base:t,source:{t:`Site menu`,p:``,menu:!0},extra:{}})}for(let[e,t]of[[a.active,`embed`],[a.passive,`image`]])for(let n of e)s.push({id:b(`insecure`,n),base:{kind:`insecure`,url:n,host:``},source:{t:i.title,p:i.path},extra:q(t)});for(let e of o)s.push({id:b(`page`,e.path),base:{kind:`page`,url:e.path,host:``},source:{t:e.title,p:e.path},extra:{}});if(s.length===0)return;let c=[...new Set(s.map(e=>e.id))],l=await B(e).getMany(c),u=s.filter(e=>e.base.kind===`internal`).map(e=>e.base.url),d=u.length>0?await V(e).getMany([...new Set(u)]):new Map,f=new Map;for(let e of s){let i=K(f.get(e.id)??l.get(e.id),e.base,e.source,n.pass,r,e.extra);if(i.kind===`internal`&&(i=J(i,d.get(i.url),r),i.state===`broken`&&(i.group=t(i))),i.kind===`page`){let t=o.find(e=>e.path===i.url);t&&(i=R(l.get(e.id)??i,{status:t.status},r)),i.pass=n.pass}i.state===`broken`&&!i.since&&(i.since=H(r)),f.set(e.id,i)}await B(e).putMany([...f].map(([e,t])=>({id:e,data:t})))}function Ue(e){let t=e.status??0;if(t<200||t>=300)return!1;let n=e.headers?.get(`content-type`)??``;if(n!==``&&!/html/i.test(n))return!1;let r=(e.body??``).slice(0,2e3);return!/error code: ?\d{3,4}|cf-error-details|Worker threw exception|cloudflare-nginx/i.test(r)}function We(e){return e.status===503?/Database migrations are required|database has not been migrated/i.test((e.body??``).slice(0,2e3)):!1}async function Ge(e,t,n){let r=S(e.site.url);if(!r){t.live=`not-public`,t.phase=`live-paths`;return}let i=await N(e,`${r}/`,{body:!0}),a=i.status??0;if(i.status===void 0){t.live=we(i)?`not-public`:`failed`,t.phase=`live-paths`;return}if(We(i)){t.live=`updating`,t.phase=`live-paths`;return}if(a===403||a===429||a===503||a===401){t.live=`blocked`,t.phase=`live-paths`;return}if(!Ue(i)){t.live=`failed`,t.phase=`live-paths`;return}t.live=`ok`;let o=(i.headers?.get(`cache-control`)??``).toLowerCase();t.caching=/no-store|private/.test(o)||i.headers?.has(`set-cookie`)?`no`:`ok`;let s=A(i.body??``,e.site.url);await He(e,t,n,{path:`/`,title:`Home`},{menu:s.menu.map(e=>({...e,use:`link`})),active:s.active,passive:s.passive},a>=500?[{path:`/`,title:`Home`,status:a}]:[]),t.phase=`live-pages`,t.cursor=void 0}async function Ke(e,t,n){let r=S(e.site.url),i=await V(e).query({where:{status:`published`},limit:2,...t.cursor?{cursor:t.cursor}:{}});if(r&&i.items.length>0){let a=await Promise.all(i.items.map(async t=>({page:t.data,outcome:await N(e,`${r}${t.data.path}`,{body:!0})}))),o=[],s=[],c=[],l=null;for(let{page:t,outcome:n}of a)if((n.status??0)>=500&&c.push({path:t.path,title:t.title,status:n.status??500}),n.body){let r=A(n.body,e.site.url);o.push(...r.active),s.push(...r.passive),(r.active.length||r.passive.length)&&!l&&(l=t)}await He(e,t,n,l??{path:``,title:``},{menu:[],active:o,passive:s},c),t.liveDone+=i.items.length}i.hasMore&&i.cursor?t.cursor=i.cursor:(t.cursor=void 0,t.phase=`live-paths`)}async function qe(t,n,r){let i=S(t.site.url),a=n.live===`ok`&&i!==null,o=await B(t).query({where:{kind:`internal`,state:`pending`},limit:a?2:100});if(o.items.length===0){n.phase=`checklist`;return}let s=await Promise.all(o.items.map(async n=>{if(!a)return{id:n.id,data:{...n.data,state:`unknown`,reason:`no-live`,due:e}};let o=await N(t,`${i}${n.data.url}`);return{id:n.id,data:Oe(n.data,P(n.data.url,o,`GET`),r)}}));await B(t).putMany(s)}async function Je(e,t){let[n,r]=await Promise.all([B(e).query({where:{pass:{lt:t.pass}},limit:100}),V(e).query({where:{pass:{lt:t.pass}},limit:100})]);await Promise.all([n.items.length>0?B(e).deleteMany(n.items.map(e=>e.id)):0,r.items.length>0?V(e).deleteMany(r.items.map(e=>e.id)):0]),!n.hasMore&&!r.hasMore&&(t.phase=`count`)}async function Ye(e,t){let n=B(e),[r,i,a,o,s,c]=await Promise.all([V(e).count({status:`published`}),n.count({kind:{in:[`internal`,`external`]}}),n.count({state:`broken`,group:`site`}),n.count({state:`broken`,group:`page`}),n.count({state:`broken`,group:`link`}),n.count({state:`broken`,editor:!0})]);t.counts={pages:r,links:i,fix:{site:a,page:o,link:s},editorFix:c,look:0,unknown:0},t.phase=`freeze`}async function Xe(e,t){let n=B(e),[r,i,a,o]=await Promise.all([n.query({where:{state:`look`},limit:10}),n.query({where:{state:`unknown`},limit:10}),n.count({state:`look`}),n.count({state:`unknown`})]);t.counts={...t.counts??Ze,look:a,unknown:o},t.lists={pass:t.pass,lookRows:r.items.map(({id:e,data:t})=>d(e,t)),unknownRows:i.items.map(({id:e,data:t})=>d(e,t))}}const Ze={pages:0,links:0,fix:{site:0,page:0,link:0},editorFix:0,look:0,unknown:0};async function Qe(e,n,r,i){let a=await B(e).query({where:{state:`broken`},orderBy:{count:`desc`},limit:100}),o={site:[],page:[],link:[]};for(let{id:e,data:n}of a.items){let r=n.group??t(n);o[r].length<10&&o[r].push(d(e,{...n,group:r}))}let s=n.counts??Ze,c={...s,pass:n.pass,finishedAt:H(i),...r.summary?{prevFinishedAt:r.summary.finishedAt}:{},rows:o,lookRows:n.lists?.lookRows??[],unknownRows:n.lists?.unknownRows??[],live:n.live??`failed`};n.status=`done`,n.finishedAt=H(i);let l=s.pages===0&&!r.summary;return n.nextAt=H(i+(l?6e5:Le)),n.counts=void 0,n.lists=void 0,n.phase=`redirects`,l?null:c}async function Y(e,t,n,r=Date.now()){let i=t.scan;if(!i||i.status!==`running`)return i;let a={...i,lastStepAt:H(r)},o=null;switch(a.phase){case`redirects`:await ze(e,a);break;case`walk`:await Be(e,a,r);break;case`resolve`:await Ve(e,a,r);break;case`live-home`:await Ge(e,a,r);break;case`live-pages`:await Ke(e,a,r);break;case`live-paths`:await qe(e,a,r);break;case`checklist`:await Fe(e,a,t,r),a.phase=`sweep`;break;case`sweep`:await Je(e,a);break;case`count`:await Ye(e,a);break;case`freeze`:a.lists?.pass===a.pass?o=await Qe(e,a,t,r):await Xe(e,a);break}return o&&(await e.kv.set(c.summary,o),t.summary=o,await se(e,t,o,r)),a.ops=n.ops+1,await e.kv.set(c.scan,a),t.scan=a,a}const $e=6e4;async function et(e,t,n,r=Date.now()){let i=await B(e).query({where:{due:{lte:H(r)}},orderBy:{due:`asc`},limit:20}),a=S(e.site.url),o={...t.queue.hosts},s=[],l=new Set;for(let e of i.items){if(s.length>=3)break;let n=e.data;if(n.state!==`fine`){if(n.kind===`external`){if(!t.options.outbound)continue;let e=o[n.host];if(l.has(n.host)||e&&r-e.at<$e*(e.slow&&e.slow>=2?1440:1))continue;l.add(n.host)}else if(n.kind!==`page`||!a||t.summary?.live!==`ok`)continue;s.push({id:e.id,row:n})}}let u=await Promise.all(s.map(async({id:t,row:n})=>{if(n.kind===`page`)return{id:t,before:n,after:R(n,await N(e,`${a}${n.url}`),r)};let i=n.get?`GET`:`HEAD`,s=await N(e,n.url,{method:i}),c=P(n.url,s,i);return o[n.host]={at:r,...o[n.host]?.slow?{slow:o[n.host].slow}:{}},{id:t,before:n,after:null,result:c,outcome:s}}));for(let t of u){if(t.after||!(`result`in t)||!t.result)continue;let i=t.result;if(i.kind===`retry`&&de(n,5)&&(i=P(t.before.url,await N(e,t.before.url,{method:`GET`}),`GET`)),i.kind===`retry`){t.after={...t.before,due:H(r+$e),get:!0};continue}if(i.kind===`unknown`&&i.reason===`slow`){let e=o[t.before.host]??{at:r};o[t.before.host]={...e,slow:(e.slow??0)+1}}t.after=De(t.before,i,r),t.result.kind===`retry`&&(t.after.get=!0)}for(let[e,t]of Object.entries(o))r-t.at>Le&&!t.slow&&delete o[e];u.length>0&&await B(e).putMany(u.map(e=>({id:e.id,data:e.after})));let d=t.summary;if(d){for(let e of u){let t=m(d,e.id,e.before,e.after);t&&(d=t)}d!==t.summary&&(await e.kv.set(c.summary,d),t.summary=d,await se(e,t,d,r))}return t.queue={hosts:o,cronAt:H(r),ops:n.ops+1},await e.kv.set(c.queue,t.queue),u.length}function tt(e,t){let n=Math.ceil(e/50)+(t?Math.ceil(e/2):0)+3;if(n<60)return`about ${Math.max(5,Math.round(n/5)*5)} minutes`;let r=Math.round(n/60);return r===1?`about an hour`:`about ${r} hours`}async function nt(e,n,r,i,a=Date.now()){if(!i.scan)return;let o=i.scan.collections.find(e=>e.slug===n);if(!o)return;let s=o.routable?Se(o.pattern,n,r):null,l=Re(r,o.titleField),u=r.status===`published`?O(r.data,e.site.url):[],d={c:n,id:r.id,t:l,p:s??``},f=new Map;for(let e of u){let t=G(e);t&&!(t.kind===`internal`&&t.url===s)&&f.set(t.id,{base:t,use:e.use})}let p=[...f.values()].filter(e=>e.base.kind===`internal`).map(e=>e.base.url),h=[...new Set([...s?[s]:[],...p])],g=h.length>0?await V(e).getMany(h):new Map,ee=s?g.get(s)??null:null,_=[...new Set([...f.keys(),...ee?.links??[]])].slice(0,400),v=_.length>0?await B(e).getMany(_):new Map,y=[],b=i.summary;for(let e of _){let n=v.get(e),o=f.get(e),s=null;if(o){let e=n?n.sources.filter(e=>e.id!==r.id):[],c=n?{...n,sources:e,count:Math.max(e.length,n.count-(n.sources.length-e.length))}:void 0;s=K(c,o.base,d,n?.pass??i.scan.pass,a,o.base.kind===`insecure`?q(o.use):{}),c&&(s={...s,sources:[...e,d].slice(0,10),count:c.count+1,pass:n.pass}),s.kind===`internal`&&(s=J(s,g.get(s.url),a)),s.kind===`internal`&&s.state===`pending`&&n?.state!==`pending`&&(s={...s,state:n?.state===`broken`&&n.reason===`missing`?`broken`:`unknown`,reason:n?.reason??`no-live`},s.state===`broken`&&(s.group=t(s)))}else if(n){let e=n.sources.filter(e=>e.id!==r.id);s=e.length===0&&!n.menu?null:{...n,sources:e,count:Math.max(e.length,n.count-1)}}s&&s.state===`broken`&&!s.since&&(s.since=H(a)),s&&y.push({id:e,data:s}),b&&=m(b,e,n,s)??b}let x=_.filter(e=>!f.has(e)&&!y.some(t=>t.id===e));await Promise.all([y.length>0?B(e).putMany(y):void 0,x.length>0?B(e).deleteMany(x):void 0,s?V(e).put(s,{status:r.status,path:s,title:l,collection:n,id:r.id,pass:ee?.pass??i.scan.pass,links:[...f.keys()].slice(0,200)}):void 0]),b&&b!==i.summary&&await e.kv.set(c.summary,b)}const rt=10*6e4,it=[{group:`site`,title:`Every page`,text:`In the site menu or footer, so every visitor can hit it. Fix these first.`},{group:`page`,title:`One page`,text:`Visitors who open these pages see an error or an empty box.`},{group:`link`,title:`One link`,text:`A link inside a page that goes nowhere.`}];function at(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function X(e,t=Date.now()){if(!e)return``;let n=Math.round((t-Date.parse(e))/6e4);if(Number.isNaN(n))return``;if(n<2)return`just now`;if(n<60)return`${n} minutes ago`;let r=Math.round(n/60);if(r<24)return r===1?`an hour ago`:`${r} hours ago`;let i=Math.round(r/24);return i===1?`yesterday`:`${i} days ago`}function Z(e,t,n=`${t}s`){return`${e} ${e===1?t:n}`}function ot(e,t){let i=t?.prevFinishedAt!==void 0&&e.since!==void 0&&e.since>t.prevFinishedAt,a=(e.reason===`gone`||e.reason===`no-site`)&&e.since?` Checked twice since ${new Date(e.since).toLocaleDateString(`en-GB`,{day:`numeric`,month:`short`,timeZone:`UTC`})}.`:``;return`${i?`New since the last check. `:``}${n(e,x(e.url))}${a}${r(e)}`}function st(e,t){let n=[],r=e.kind===`internal`||e.kind===`page`;return t&&r&&e.url!==`/`&&e.reason!==`draft`&&e.reason!==`page-error`&&n.push({label:`Send visitors to another page`,value:`redirect|${e.id}`}),e.menu&&n.push({label:`Change the link in Menus`,value:`menus|${e.id}`}),e.sources.filter(e=>e.c&&e.id).slice(0,3).forEach((t,r)=>n.push({label:`Edit ${t.t}`,value:`edit${r}|${e.id}`})),n.push({label:`Check it again now`,value:`check|${e.id}`}),n.push({label:`This is fine, stop checking it`,value:`fine|${e.id}`}),{type:`menu`,action_id:`fix`,label:`Fix`,items:n}}function ct(e,t){let n=S(t)??g(t)?.origin,r=e.kind===`internal`?n?`${n}${e.url}`:null:e.url;if(!(!r||!/^https?:\/\//.test(r)))return{type:`link`,label:`Open`,target:{kind:`external`,url:r}}}function lt(e,t,n,r){return{type:`table`,page_action_id:e,columns:[{key:`text`,label:t},{key:`fix`,label:``,format:`element`}],rows:n.map(e=>({text:e.text,...e.action?{fix:e.action}:{}})),...r?{next_cursor:r}:{}}}function ut(e){let t=e.scan,n=!t||[`redirects`,`walk`,`resolve`].includes(t.phase),r=t?.liveTotal??t?.walked??0,i=[{type:`banner`,title:`Checking your site for the first time`,description:`${n?`This usually takes 5 to 30 minutes, and a few hours for a very large site.`:`This takes ${tt(r,t?.live===`ok`)} for a site with ${Z(r,`page`)}.`} Links to other sites follow a day later: each one is checked twice, a day apart, before we call it broken. You can leave this page.`}];return t&&!n&&t.live===`ok`&&(t.liveTotal??0)>0?i.push({type:`meter`,label:`Pages checked`,value:Math.min(t.liveDone,t.liveTotal??0),max:t.liveTotal??0,custom_value:`${Math.min(t.liveDone,t.liveTotal??0)} of ${Z(t.liveTotal??0,`page`)}`}):t&&!n&&i.push({type:`context`,text:`${Z(t.walked,`page`)} read. Finishing off.`}),i.push({type:`context`,text:`Started ${X(t?.startedAt)||`just now`}. Each page is also checked again when it is published.`},{type:`actions`,elements:[{type:`button`,action_id:`refresh`,label:`Refresh`}]}),i}function dt(e,t){return e.live===`not-public`?[{type:`banner`,title:`This site is not public yet`,description:`${t?`Its address is ${t}, which only this computer or network can open.`:`It has no address set yet.`} Links inside your pages are checked now; the rest starts once the site has a public address.`}]:e.live===`updating`?[{type:`banner`,variant:`alert`,title:`Your site is in the middle of an update`,description:`During the check that finished ${X(e.finishedAt)}, your site showed visitors an error page instead of your pages: an update to its database had not finished. Whoever looks after your site should finish the update. Links inside your pages are still checked; menus and page errors are checked again once your site is back.`}]:e.live===`blocked`||e.live===`failed`?[{type:`banner`,variant:`alert`,title:`We cannot open your pages to check them`,description:`Links inside your pages are still checked. Menus, page errors and insecure items are not. If your site has bot protection, ask whoever set it up to let "EmDash Site Health" in.`}]:[]}function ft(e){let t=e.panel;if(!t)return[];if(t.kind===`redirect`){let n=e.pickable??[];return[{type:`header`,text:`Send visitors to another page`},{type:`context`,text:`Anyone who opens ${t.url}, from your menu, a bookmark or Google, lands on the page you pick. This fixes it everywhere at once.`},...e.problem?[{type:`banner`,variant:`alert`,title:`Not added`,description:e.problem}]:[],{type:`form`,block_id:`redirect`,fields:[{type:`text_input`,action_id:`source`,label:`Old address`,initial_value:t.url},{type:`combobox`,action_id:`destination`,label:`Send visitors to`,placeholder:`Pick a page, or type an address`,options:n}],submit:{label:`Send visitors there`,action_id:`add_redirect|${t.id}`}},{type:`actions`,elements:[{type:`button`,action_id:`close`,label:`Close`}]}]}return t.kind===`edit`?[{type:`banner`,title:`Change the link to ${x(t.url)} on ${t.title}`,description:`Open the page, find the words that link to ${x(t.url)} and point them at the right page: select the words, then use the link button.`},{type:`actions`,elements:[{type:`link`,label:`Open ${t.title}`,appearance:`primary`,target:{kind:`content`,collection:t.collection,id:t.entry}},{type:`button`,action_id:`close`,label:`Close`}]}]:[{type:`banner`,title:`Change the link to ${x(t.url)} in your menus`,description:`Open Menus in the sidebar, under Manage. Find the item that goes to this address and point it at the right page. If the address is on this site, sending visitors to another page also fixes it.`},{type:`actions`,elements:[{type:`button`,action_id:`close`,label:`Close`}]}]}function pt(e,t,n,r){let{summary:i,scan:a}=t;if(!i)return ut(t);let o=[...n?dt(i,e.site.url):[],...ft(r)],s=e=>n||e.editor,c=n?h(i):i.editorFix;o.push({type:`stats`,items:[{label:`Pages checked`,value:i.pages},{label:`Links checked`,value:i.links},{label:`To fix`,value:c}]});let l=!1;for(let{group:e,title:t,text:a}of it){let c=r.more?.group===e?r.more:void 0,u=(c?c.rows:i.rows[e]).filter(s);if(u.length===0)continue;l=!0;let d=n?i.fix[e]:u.length,f=c?c.cursor:d>u.length?`start`:void 0;o.push({type:`header`,text:t},{type:`context`,text:a},lt(`more_${e}`,`Problem`,u.map(e=>({text:ot(e,i),action:st(e,n)})),f))}l||o.push({type:`empty`,size:`sm`,title:`Nothing broken. Nice work.`,description:`We checked ${Z(i.pages,`page`)} and ${Z(i.links,`link`)} ${X(i.finishedAt)}. Next check: tomorrow.`});let u=i.lookRows.filter(s);u.length>0&&o.push({type:`accordion`,label:`Worth a look (${n?i.look:u.length})`,blocks:[{type:`context`,text:`These work, but take a detour. Tidier to fix, not urgent.`},lt(`look_table`,`What we noticed`,u.map(e=>({text:ot(e,null),action:st(e,n)})))]});let d=i.unknownRows.filter(s);d.length>0&&o.push({type:`accordion`,label:`Links we could not check (${n?i.unknown:d.length})`,blocks:[{type:`context`,text:`Some sites, such as LinkedIn and Instagram, turn away automatic checkers. These links are probably fine. Open one to see for yourself; we try again next week.`},lt(`unknown_table`,`What happened`,d.map(t=>{let r=t.kind===`internal`?st(t,n):ct(t,e.site.url);return{text:ot(t,null),...r?{action:r}:{}}}))]});let f=a?.status===`running`;return o.push({type:`context`,text:f?`Checking again now. These are the results of the last full check, ${X(i.finishedAt)}.`:`Last full check: ${X(i.finishedAt)}. Pages on this site are checked every day, links to other sites every week.`}),n||o.push({type:`context`,text:`Pages that show errors and the site's setup are looked after by your site admin.`}),o.push({type:`actions`,elements:[{type:`button`,action_id:f?`refresh`:`check_all`,label:f?`Refresh`:`Check everything again`}]}),o}const mt={working:`Working`,attention:`Needs attention`,untested:`Not tested yet`,unknown:`Could not tell`};function ht(e,t){let n=[e.queue.cronAt,e.scan?.cronAt].filter(e=>!!e).map(Date.parse),r=n.length>0?Math.max(...n):0,i=e.scan?Date.parse(e.scan.startedAt):t;return r>0&&t-r<rt?{status:`working`,text:`Last ran ${Z(Math.max(1,Math.round((t-r)/6e4)),`minute`)} ago. Scheduled posts and these checks depend on it.`}:r===0&&t-i<rt?{status:`untested`,text:`Waiting for the first run. Scheduled posts and these checks depend on it.`}:{status:`attention`,text:`Not running. Scheduled posts will not publish, and these checks only move while this page is open.`}}function gt(e){return e.length<=2?`Goes to ${e.join(` and `)}.`:`Goes to ${e.slice(0,2).join(`, `)} and ${e.length-2} more.`}function _t(e,t,n=Date.now()){let r=e.options.recipients??[],i=e.alerts.email;if(!t)return{status:`attention`,text:`Not set up. Your site has no email service chosen, so alerts, form messages and password resets are not sent. Whoever set up your site can choose one in Settings, Email.`};if(r.length===0)return{status:`attention`,text:`Nobody is on the list, so no alerts are sent. Add an address under Options below.`};let a=gt(r);if(!i)return{status:`untested`,text:`Not checked yet. ${a} To find out whether email works, press Send a test email to check now.`};let o=X(i.at,n),s=i.kind===`alert`?`an alert`:`a test email`;return i.state===`working`?(i.list?i.list.length===r.length&&i.list.every(e=>r.includes(e)):i.to===r.length)?{status:`working`,text:`Last checked ${o}: ${s} was sent. ${a} To check again, press Send a test email.`}:{status:`untested`,text:`The list has changed since the last email, ${o}. ${a} To find out whether it reaches them, press Send a test email to check now.`}:w.test(i.error??``)?{status:`attention`,text:`Last checked ${o}: your site had no email service chosen, so nothing was sent. If one has been chosen in Settings, Email since, press Send a test email to check now.`}:{status:`attention`,text:`Last checked ${o}: ${s} could not be sent; the email service turned it down. ${a} Press Send a test email to try again; if it fails, whoever set up your site can look at Settings, Email.`}}function vt(e,t,n,r=Date.now()){let i=t?g(t):null,a=i?i.protocol===`https:`?{status:`working`,text:`Set, secure, and the address visitors use.`}:{status:S(i.href)?`attention`:`unknown`,text:S(i.href)?`Set, but not secure. Ask your web person to switch it to https.`:`Set to an address only this computer or network can open.`}:{status:`attention`,text:`Not set. Set it in Settings so links and emails point to your site.`},o=e.checklist.rows;return[...e.summary?.live===`updating`?[{name:`Site up`,status:`attention`,text:`During the check that finished ${X(e.summary.finishedAt,r)}, your site showed visitors an error page: an update to its database had not finished. Whoever looks after your site should finish the update.`}]:[],{name:`Site address`,...a},{name:`Scheduled tasks`,...ht(e,r)},{name:`Email`,..._t(e,n,r)},{name:`Secure connection`,...o.secure??{status:`untested`,text:`Checked with the next full check.`}},{name:`With and without www`,...o.www??{status:`untested`,text:`Checked with the next full check.`}},{name:`Missing pages`,...o.missing??{status:`untested`,text:`Checked with the next full check.`}},{name:`Search engines`,...o.search??{status:`untested`,text:`Checked with the next full check.`}},{name:`Quick repeat visits`,...o.caching??{status:`untested`,text:`Checked with the next full check.`}}]}function yt(e,t,n,r,i){let a=vt(e,t,n,i),o=a.filter(e=>e.status===`attention`&&e.name!==`Quick repeat visits`),s=[...a].sort((e,t)=>Number(t.status===`attention`)-Number(e.status===`attention`)),c=[];a.find(e=>e.name===`Scheduled tasks`)?.status===`attention`?c.push({type:`banner`,variant:`alert`,title:`Scheduled tasks are not running`,description:`Scheduled posts will not publish, and these checks only move while this page is open. Whoever set up your site can switch them on in a few minutes.`},{type:`accordion`,label:`Copy the instructions for your web person`,blocks:[{type:`context`,text:`Send the steps below to whoever set up your site: they switch on the scheduled tasks your site needs.`},{type:`code`,code:`This EmDash site's scheduled tasks are not running (the Site Health task has not run for 10 minutes).
|
|
4
4
|
On Cloudflare: add a Cron Trigger to the Worker, for example in wrangler.jsonc:
|
|
5
5
|
"triggers": { "crons": ["* * * * *"] }
|
|
6
6
|
and make sure the Worker's entry exports EmDash's scheduled() handler.
|
|
7
7
|
On Node: EmDash runs scheduled tasks inside the server process; check the server stays running.
|
|
8
8
|
Then run: npx emdash doctor
|
|
9
|
-
See EmDash's deployment guide, "Scheduled tasks".`,language:`bash`}]}):o.length>0&&c.push({type:`banner`,variant:`alert`,title:`${Z(o.length,`thing needs`,`things need`)} attention`,description:o[0]?.text??``});let l=a.find(e=>e.name===`Email`);c.push({type:`table`,page_action_id:`checklist_table`,columns:[{key:`check`,label:`Check`},{key:`status`,label:`Status`,format:`badge`}],rows:s.filter(e=>e!==l).map(e=>({check:`${e.name}. ${e.text}`,status:
|
|
10
|
-
`)},{type:`toggle`,action_id:`outbound`,label:`Check links to other sites (recommended; a few each minute, never one site more than once a minute)`,initial_value:e.options.outbound}],submit:{label:`Save`,action_id:`save_options`}},{type:`context`,text:`Emails go out at once when a page shows an error or a menu link breaks, and once a week with anything new. Never more than one email a day of each kind.`},{type:`header`,text:`Links you marked as fine (${d.length})`},d.length===0?{type:`context`,text:`None yet. Pick "This is fine, stop checking it" in a Fix menu to add one.`}:{type:`table`,page_action_id:`fine_table`,columns:[{key:`text`,label:`Address`},{key:`again`,label:``,format:`element`}],rows:d.map(({id:e,row:t})=>({text:`${
|
|
11
|
-
`));return`wrong`in t?Q(r,h,o,await g({tab:1,toast:{type:`error`,message:`"${t.wrong}" is not an email address. Nothing was saved.`}})):(h.options={recipients:t.list,outbound:typeof e.outbound==`boolean`?e.outbound:h.options.outbound},await s(r,l,h.options),Q(r,h,o,await g({tab:1,toast:{type:`success`,message:t.list.length===0?`Saved. Nobody gets emails now.`:`Saved. Emails go to ${Z(t.list.length,`address`,`addresses`)}.`}})))}if(f.startsWith(`add_redirect|`)){let t=f.slice(13),n=
|
|
9
|
+
See EmDash's deployment guide, "Scheduled tasks".`,language:`bash`}]}):o.length>0&&c.push({type:`banner`,variant:`alert`,title:`${Z(o.length,`thing needs`,`things need`)} attention`,description:o[0]?.text??``});let l=a.find(e=>e.name===`Email`);c.push({type:`table`,page_action_id:`checklist_table`,columns:[{key:`check`,label:`Check`},{key:`status`,label:`Status`,format:`badge`}],rows:s.filter(e=>e!==l).map(e=>({check:`${e.name}. ${e.text}`,status:mt[e.status]}))},...l?[{type:`section`,text:`Email: ${mt[l.status]}. ${l.text}`,accessory:{type:`button`,action_id:`send_test`,label:`Send a test email`}}]:[],{type:`context`,text:e.checklist.checkedAt?`Checked ${X(e.checklist.checkedAt)}. Changes you make elsewhere show here after the next check.`:`The rest is checked with the next full check.`});let u=e.options.recipients??[],d=r.fine??[];return c.push({type:`accordion`,label:`Options`,default_open:u.length===0||(r.toast?.message?.startsWith(`Saved`)??!1),blocks:[{type:`form`,block_id:`options`,fields:[{type:`text_input`,action_id:`recipients`,label:`Who gets emails (one address per line)`,multiline:!0,initial_value:u.join(`
|
|
10
|
+
`)},{type:`toggle`,action_id:`outbound`,label:`Check links to other sites (recommended; a few each minute, never one site more than once a minute)`,initial_value:e.options.outbound}],submit:{label:`Save`,action_id:`save_options`}},{type:`context`,text:`Emails go out at once when a page shows an error or a menu link breaks, and once a week with anything new. Never more than one email a day of each kind.`},{type:`header`,text:`Links you marked as fine (${d.length})`},d.length===0?{type:`context`,text:`None yet. Pick "This is fine, stop checking it" in a Fix menu to add one.`}:{type:`table`,page_action_id:`fine_table`,columns:[{key:`text`,label:`Address`},{key:`again`,label:``,format:`element`}],rows:d.map(({id:e,row:t})=>({text:`${x(t.url,24)} Marked by ${t.fine?.by??`someone`}, ${new Date(t.fine?.at??Date.now()).toLocaleDateString(`en-GB`,{day:`numeric`,month:`short`,timeZone:`UTC`})}.`,again:{type:`button`,action_id:`check_fine`,label:`Check it again`,value:e}}))},Me(e.options.hideHelpLine===!0)]}),c}function Q(e,t,n,r){let i={type:`context`,text:`Finds broken links and setup problems before your visitors do.`},a=pt(e,t,n,r),o=n?[i,{type:`tab`,default_tab:r.tab,panels:[{label:`Problems`,blocks:a},{label:`Checklist`,blocks:yt(t,e.site.url,!!e.email,r,Date.now())}]}]:[i,...a];return n&&o.push(...Pe(t.options.hideHelpLine!==!0)),{blocks:o,...r.toast?{toast:r.toast}:{}}}function bt(e,t,r,i){let a={type:`actions`,elements:[{type:`link`,label:`Open Site Health`,target:{kind:`plugin-page`,path:`/health`}}]},{summary:o,scan:s}=e;if(!o){let e=s?.liveTotal;return{blocks:[{type:`section`,text:s&&e&&s.live===`ok`?`First check in progress: ${Math.min(s.liveDone,e)} of ${Z(e,`page`)}. Results in ${tt(Math.max(0,e-s.liveDone),!0)}.`:`First check in progress. Results soon.`},a]}}let c=t?h(o):o.editorFix;if(!t)return{blocks:[{type:`section`,text:c===0?`Nothing broken. Checked ${X(o.finishedAt)}.`:`${Z(c,`broken link`)} on your pages.`},a]};let l=vt(e,r,i).filter(e=>e.status===`attention`&&e.name!==`Quick repeat visits`).length;if(c===0&&l===0)return{blocks:[{type:`section`,text:`Nothing broken. Checked ${X(o.finishedAt)}.`},a]};let u=o.rows.site[0]??o.rows.page[0]??o.rows.link[0];return{blocks:[{type:`stats`,items:[{label:`To fix`,value:c},{label:`Setup`,value:l}]},...u?[{type:`context`,text:`Worst: ${x(u.url)}${u.menu||u.url===`/`?` is broken on every page.`:` ${n(u,``).replace(/^[: ]+/,``)}`}`}]:[],a]}}async function xt(e){let t=await V(e).query({where:{status:`published`},limit:100}),n=t.hasMore&&t.cursor?await V(e).query({where:{status:`published`},limit:100,cursor:t.cursor}):{items:[]};return[...t.items,...n.items].map(({data:e})=>({label:`${e.title} (${e.path})`,value:e.path})).sort((e,t)=>e.value===`/`?-1:t.value===`/`?1:e.label.localeCompare(t.label))}async function St(e){return(await B(e).query({where:{state:`fine`},limit:20})).items.map(({id:e,data:t})=>({id:e,row:t}))}function Ct(e,t){if(e)return[...e.rows.site,...e.rows.page,...e.rows.link,...e.lookRows,...e.unknownRows].find(e=>e.id===t)}async function wt(e,t,n,r,i){if(await B(e).put(n,i),t.summary){let a=m(t.summary,n,r,i);a&&(t.summary=a,await e.kv.set(c.summary,a))}}async function Tt(n,r,i,a){let o=await B(n).get(i);if(!o)return{type:`info`,message:`That address is no longer on your pages.`};let s=r.summary?.live===`ok`?S(n.site.url):null,c=o,l=o.state===`fine`?{...o,state:`pending`,fine:void 0}:o;if(o.kind===`external`){let e=P(o.url,await N(n,o.url,{method:`HEAD`}),`HEAD`);e.kind===`retry`&&(e=P(o.url,await N(n,o.url),`GET`)),c=De(l,e,a)}else if(o.kind===`internal`)c=J(l,await V(n).get(o.url)??void 0,a),c.state===`pending`&&(c=s?Oe(c,P(o.url,await N(n,`${s}${o.url}`),`GET`),a):{...c,state:`unknown`,reason:`no-live`,due:e});else if(o.kind===`page`)c=s?R({...l,fails:o.state===`broken`?1:0,firstFailAt:new Date(a-6e4).toISOString()},await N(n,`${s}${o.url}`),a):o;else{let e=o.sources[0],t=!0;if(e?.c&&e.id){let r=await n.content?.get(e.c,e.id);t=!!r&&O(r.data,n.site.url).some(e=>e.url===o.url)}else if(s&&e){let r=await N(n,`${s}${e.p||`/`}`,{body:!0}),i=r.body?A(r.body,n.site.url):null;t=!i||[...i.active,...i.passive].includes(o.url)}c=t?l:{...l,state:`ok`,reason:void 0,group:void 0,since:void 0}}c.state===`broken`&&(c.group=t(c)),await wt(n,r,i,o,c);let u=x(o.url);return c.state===`ok`||c.state===`look`?{type:`success`,message:`Works now. ${u} is off the list.`}:c.state===`broken`?{type:`info`,message:`Still broken. ${u} was checked just now.`}:{type:`info`,message:`We could not check ${u} just now. It is not counted as broken.`}}async function Et(e,t,n,r,i=!1){if(de(n,8))try{if(!t.scan||!t.summary&&t.scan.status===`done`){t.scan=await U(e,t,r),await Y(e,t,n,r);return}let a=Date.parse(t.scan.lastStepAt??t.scan.startedAt);t.scan.status===`running`&&(i||r-a>12e4)&&await Y(e,t,n,r)}catch(t){e.log.warn(`could not move the check along`,{error:String(t)})}}async function Dt(t,n,r,i){let a=Date.now(),o=(n?.role??0)>=50,c=at(t)?t:{},f=typeof c.action_id==`string`?c.action_id:``,p={tab:0},m=c.type===`page_load`&&typeof c.page==`string`&&c.page.startsWith(`widget:`),h=await u(r,{copy:o&&!m});if(m)return await Et(r,h,i,a),bt(h,o,r.site.url,!!r.email);o&&h.options.recipients===void 0&&n?.email?(h.options={...h.options,recipients:[n.email.toLowerCase()]},await s(r,l,h.options)):(c.type===`page_load`||f===`refresh`)&&await Et(r,h,i,a,f===`refresh`);let g=async e=>o?{...e,fine:await St(r)}:e;if(c.type===`block_action`){if(f===`fix`&&typeof c.value==`string`){let[t=``,i=``]=c.value.split(`|`),s=Ct(h.summary,i);if(!s)return Q(r,h,o,{tab:0,toast:{type:`info`,message:`That problem is no longer listed.`}});if(t===`redirect`&&o)return Q(r,h,o,{tab:0,panel:{kind:`redirect`,id:i,url:s.url},pickable:await xt(r)});if(t===`menus`)return Q(r,h,o,await g({tab:0,panel:{kind:`menus`,url:s.url}}));if(t.startsWith(`edit`)){let e=s.sources.filter(e=>e.c&&e.id)[Number(t.slice(4))];if(e?.c&&e.id)return Q(r,h,o,await g({tab:0,panel:{kind:`edit`,url:s.url,title:e.t,collection:e.c,entry:e.id}}))}if(t===`check`)return Q(r,h,o,await g({tab:0,toast:await Tt(r,h,i,a)}));if(t===`fine`){let t=await B(r).get(i);if(t){let o=n?.name||n?.email||`someone`;await wt(r,h,i,t,{...t,state:`fine`,due:e,fine:{by:o,at:new Date(a).toISOString()}})}return Q(r,h,o,await g({tab:0,toast:{type:`success`,message:`Marked as fine. ${x(s.url)} is not checked any more.`}}))}}if(f===`check_fine`&&typeof c.value==`string`&&o)return Q(r,h,o,await g({tab:1,toast:await Tt(r,h,c.value,a)}));if(f===`check_all`)return h.scan?.status!==`running`&&(h.scan=await U(r,h,a),await Y(r,h,i,a)),Q(r,h,o,await g({tab:0,toast:{type:`info`,message:`Checking everything again. The last results stay here until it is done.`}}));if(f===`send_test`&&o){if((h.options.recipients??[]).length===0)return Q(r,h,o,await g({tab:1,toast:{type:`error`,message:`Add an address under Options first.`}}));let e=await ue(r,h,a);return Q(r,h,o,await g({tab:1,toast:e.ok?{type:`success`,message:`Test sent to ${Z(h.options.recipients?.length??0,`address`,`addresses`)}.`}:{type:`info`,message:w.test(e.error)?`Your site has no email service chosen. Whoever set it up can choose one in Settings, Email.`:`The test email could not be sent. The Email row says what to do.`}}))}let t=it.find(e=>f===`more_${e.group}`)?.group;if(t&&h.summary){let e=at(c.value)?c.value:{},n=typeof e.cursor==`string`&&e.cursor!==`start`?e.cursor:void 0,i=await B(r).query({where:{state:`broken`,group:t},orderBy:{count:`desc`},limit:50,...n?{cursor:n}:{}});return Q(r,h,o,await g({tab:0,more:{group:t,rows:i.items.map(({id:e,data:t})=>d(e,t)),...i.hasMore&&i.cursor?{cursor:i.cursor}:{}}}))}return Q(r,h,o,await g({tab:f===`close`?0:p.tab}))}if(c.type===`form_submit`&&o){let e=at(c.values)?c.values:{};if(f===Ae){let t=Ne(e,h.options.hideHelpLine===!0);return h.options={...h.options,hideHelpLine:t},await s(r,l,h.options),Q(r,h,o,await g({tab:1,toast:{type:`success`,message:t?`Saved. The help line is hidden.`:`Saved. The help line shows again.`}}))}if(f===`save_options`){let t=ie(typeof e.recipients==`string`?e.recipients:(h.options.recipients??[]).join(`
|
|
11
|
+
`));return`wrong`in t?Q(r,h,o,await g({tab:1,toast:{type:`error`,message:`"${t.wrong}" is not an email address. Nothing was saved.`}})):(h.options={...h.options,recipients:t.list,outbound:typeof e.outbound==`boolean`?e.outbound:h.options.outbound},await s(r,l,h.options),Q(r,h,o,await g({tab:1,toast:{type:`success`,message:t.list.length===0?`Saved. Nobody gets emails now.`:`Saved. Emails go to ${Z(t.list.length,`address`,`addresses`)}.`}})))}if(f.startsWith(`add_redirect|`)){let t=f.slice(13),n=Ct(h.summary,t),i=v(typeof e.source==`string`?e.source.trim():n?.url??``),s=typeof e.destination==`string`?e.destination.trim():``,c=s.startsWith(`/`)?v(s):s,l=async e=>Q(r,h,o,{tab:0,panel:{kind:`redirect`,id:t,url:i||n?.url||``},pickable:await xt(r),problem:e});if(!i.startsWith(`/`))return l(`The old address must be a page on this site, starting with /.`);if(!c.startsWith(`/`)&&!/^https?:\/\//.test(c))return l(`Pick a page to send visitors to.`);if(c===i)return l(`The old address and the new one are the same.`);if((await V(r).get(i))?.status===`published`)return l(`${i} is a live page. Change its address in the page editor instead.`);try{await r.redirects?.create?.({source:i,destination:c,type:301})}catch(e){return l(`EmDash did not add it: ${e instanceof Error?e.message:String(e)}`)}let u={status:`redirect`,path:i,title:i,dest:c,pass:h.scan?.pass??0};await V(r).put(i,u);let d=await B(r).get(t);return d&&await wt(r,h,t,d,J(d,u,a)),Q(r,h,o,await g({tab:0,toast:{type:`success`,message:`Done. ${i} now goes to ${c}.`}}))}}return Q(r,h,o,await g(p))}async function Ot(e,t=Date.now()){let n={ops:0},r=D(e,n),i=await u(r),a=new Date(t).toISOString();if(ce(i,t)){await le(r,i,t),i.queue={...i.queue,cronAt:a,ops:n.ops+1},await r.kv.set(c.queue,i.queue);return}let o=i.scan;if(!o||o.status===`done`&&o.nextAt!==void 0&&o.nextAt<=a){i.scan={...await U(r,i,t),cronAt:a},await Y(r,i,n,t);return}let s=!i.summary||(i.queue.cronAt??``)>=(o?.cronAt??``);if(o?.status===`running`&&s){i.scan={...o,cronAt:a},await Y(r,i,n,t);return}await et(r,i,n,t)}async function $(e,t,n,r=!1){if(typeof n!=`string`)return;let i=D(e,{ops:0});try{let e=await u(i);await nt(i,t,(r?null:await i.content?.get(t,n))??{id:n,slug:null,status:`deleted`,data:{}},e)}catch(e){i.log.warn(`could not re-read the page's links`,{collection:t,id:n,error:String(e)})}}const kt=e=>e?.id,At={hooks:{"plugin:activate":async(e,t)=>{let n={ops:0},r=D(t,n);try{let e=await u(r);if(e.scan){await W(r);return}e.scan=await U(r,e),await Y(r,e,n)}catch(e){r.log.warn(`could not start the first check`,{error:String(e)}),await W(r)}},cron:async(e,t)=>{e.name===Ie&&await Ot(t)},"content:afterSave":async(e,t)=>{await $(t,e.collection,kt(e.content))},"content:afterPublish":async(e,t)=>{await $(t,e.collection,kt(e.content))},"content:afterUnpublish":async(e,t)=>{await $(t,e.collection,kt(e.content))},"content:afterDelete":async(e,t)=>{await $(t,e.collection,e.id,!0)}},routes:{admin:{permission:`plugins:read`,handler:async(e,t)=>{let n={ops:0};return Dt(e.input,e.user,D(t,n),n)}}}};export{Ot as cronRun,At as default};
|
package/emdash-plugin.jsonc
CHANGED
|
@@ -18,22 +18,27 @@
|
|
|
18
18
|
"description": { "file": "listing/description.md" },
|
|
19
19
|
"installation": { "file": "listing/installation.md" },
|
|
20
20
|
"faq": { "file": "listing/faq.md" },
|
|
21
|
+
"security": { "file": "listing/security.md" },
|
|
21
22
|
},
|
|
22
23
|
"release": {
|
|
23
24
|
"requires": { "env:emdash": ">=1.3.0" },
|
|
24
25
|
"artifacts": {
|
|
26
|
+
// This plugin's own glyph on the Moorage land tile, from icon.svg (scripts/render-banners.mjs in apps/demo-site).
|
|
27
|
+
"icon": { "file": "./images/icon.png" },
|
|
28
|
+
// Cover at the top of the listing and its share image: 3:1, from banner.svg (scripts/render-banners.mjs in apps/demo-site).
|
|
29
|
+
"banner": { "file": "./images/banner.png" },
|
|
25
30
|
"screenshots": [
|
|
26
|
-
{ "file": "./images/problems.png", "lang": "en" },
|
|
27
|
-
{ "file": "./images/fix.png", "lang": "en" },
|
|
28
|
-
{ "file": "./images/checklist.png", "lang": "en" },
|
|
29
|
-
{ "file": "./images/problems-phone.png", "lang": "en" },
|
|
31
|
+
{ "file": "./images/problems-0-1-5.png", "lang": "en" },
|
|
32
|
+
{ "file": "./images/fix-0-1-5.png", "lang": "en" },
|
|
33
|
+
{ "file": "./images/checklist-0-1-5.png", "lang": "en" },
|
|
34
|
+
{ "file": "./images/problems-0-1-5-phone.png", "lang": "en" },
|
|
30
35
|
],
|
|
31
36
|
},
|
|
32
37
|
},
|
|
33
38
|
"publisher": "did:plc:cus3srdqglppyowqrw3hvjfj",
|
|
34
39
|
|
|
35
40
|
"license": "MIT",
|
|
36
|
-
"author": { "name": "Moorage" },
|
|
41
|
+
"author": { "name": "Moorage", "url": "https://moorage.dev" },
|
|
37
42
|
"security": { "email": "security@moorage.dev" },
|
|
38
43
|
|
|
39
44
|
// Trust contract. network:request:unrestricted: links to other sites and the
|
package/listing/faq.md
CHANGED
|
@@ -21,3 +21,7 @@ Partly. Links inside your pages that point to one of your pages or posts are che
|
|
|
21
21
|
### Who gets the emails?
|
|
22
22
|
|
|
23
23
|
The first Admin who opens Site Health, plus anyone you add under Checklist, Options. Your site needs an email service for EmDash; the Checklist's "Send a test email" tells you whether it has one.
|
|
24
|
+
|
|
25
|
+
## Why do the checks and emails use the site's old address?
|
|
26
|
+
|
|
27
|
+
Plugins keep the site address EmDash had when the server started. After changing the site's address, restart the site.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
## Reporting a problem
|
|
2
|
+
|
|
3
|
+
Email **security@moorage.dev**, not a public issue. Say which version you use, what you found and how to reproduce it. Fixes go into the latest release; update to it to receive them. The source is public on GitHub.
|
|
4
|
+
|
|
5
|
+
## What it keeps and sends
|
|
6
|
+
|
|
7
|
+
It keeps the problems it found and your choices (marked as fine, alert addresses) in the site's own database. It requests the site's own pages and the addresses your pages link to, and nothing else; it sends no page content to anyone. Alerts go by the site's own email to the addresses you enter. Adding a redirect happens only when you press Fix.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moorage/plugin-site-health",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Site Health for EmDash: a broken link checker for your pages, menus and links to other sites, with email alerts and a setup checklist.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"emdash",
|