@moorage/plugin-site-health 0.1.1 → 0.1.3
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 +16 -0
- package/README.md +4 -4
- package/dist/index.mjs +1 -1
- package/dist/manifest.json +1 -1
- package/dist/plugin.mjs +6 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @moorage/plugin-site-health
|
|
2
2
|
|
|
3
|
+
## 0.1.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 961b4b9: Who gets emails, and whether links to other websites are checked, now stay put when your site moves the plugin between running in-process (`plugins: []`) and sandboxed (`sandboxed: []`). EmDash keeps most plugin data in a different place in each mode, so before this a switch emptied the email list (the first Admin to open the page then became the only person on it) and turned the outside-link check back on. These options are now kept where EmDash finds them in both modes.
|
|
8
|
+
|
|
9
|
+
Nothing to do on your side: the next check or visit to the Site Health page after updating copies them to the new place. Update first, open the page once, and only then switch modes. Each save also keeps a copy where earlier versions look, so going back to 0.1.2 still uses your latest options. The check results themselves are rebuilt by the next full check after a switch.
|
|
10
|
+
|
|
11
|
+
## 0.1.2
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 1bba02f: - **Checklist, Email row:** worked out fresh every time the Checklist opens, instead of repeating the last email sent. It now says "Not set up" only when it knows (no email service chosen, on a site where Site Health runs in the site's own process), needs attention when nobody is on the list, and otherwise says when it last checked and how that went, with "press Send a test email to check now". The Email line, directly under the Checklist table, shows its status, who gets the emails and the "Send a test email" button together. Options opens by itself when nobody is on the list.
|
|
16
|
+
- **A site in the middle of an update:** when the home page answers with EmDash's "database update not finished" page, Site Health no longer says the site turns checkers away. It shows "Your site is in the middle of an update" on the Problems tab, a "Site up" line on the Checklist, and emails at once (still at most one urgent email a day). Bot protection is reported as before.
|
|
17
|
+
- **Scheduled tasks not running:** the instructions for your web person now start with one plain sentence before the commands.
|
|
18
|
+
|
|
3
19
|
## 0.1.1
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Tells you what is broken on your [EmDash](https://emdashcms.com) site before a c
|
|
|
34
34
|
- search engines are allowed in;
|
|
35
35
|
- pages can be kept ready for visitors;
|
|
36
36
|
- scheduled tasks are running;
|
|
37
|
-
- email works
|
|
37
|
+
- email works: who gets it, whether an email service is chosen, and when it last worked, with the test button beside it.
|
|
38
38
|
- **Dashboard widget** with the count.
|
|
39
39
|
|
|
40
40
|
Editors see the links they can fix. Page errors, the Checklist and Options are for Admins.
|
|
@@ -62,7 +62,7 @@ import siteHealth from "@moorage/plugin-site-health";
|
|
|
62
62
|
emdash({ sandboxed: [siteHealth] });
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
On a site without a sandbox runner (the Workers free plan), list it under `plugins: [siteHealth]` instead. It works the same.
|
|
65
|
+
On a site without a sandbox runner (the Workers free plan), list it under `plugins: [siteHealth]` instead. It works the same. Since 0.1.3 the email list and the outside-link choice survive moving the plugin from one list to the other (the check results are rebuilt by the next check); when updating from an earlier version, update first, open the Site Health page once, then move it.
|
|
66
66
|
|
|
67
67
|
**On Cloudflare's Workers free plan** each request gets about 10 ms of CPU. EmDash's own saving and publishing already comes close to that, and every plugin run in the site's process adds a little, so a save or publish can now and then fail with "Worker exceeded resource limits"; trying again works. Workers Paid (30 seconds per request) does not have this problem.
|
|
68
68
|
|
|
@@ -92,7 +92,7 @@ The numbers on the page are always a finished check. A check that is still runni
|
|
|
92
92
|
|
|
93
93
|
Each run stays within the EmDash sandbox's limits of about 10 calls and 50 ms of CPU. The scheduled task runs every minute.
|
|
94
94
|
|
|
95
|
-
**If the site cannot open its own pages** (not public yet, bot protection, or a host that blocks a site from fetching itself), it says so in one banner. Links inside your content are still checked, and anything that needed the live page is listed as "could not check", never as broken.
|
|
95
|
+
**If the site cannot open its own pages** (not public yet, bot protection, or a host that blocks a site from fetching itself), it says so in one banner. If your site is in the middle of an update and shows every visitor an error page, it says that instead, and emails at once. Links inside your content are still checked, and anything that needed the live page is listed as "could not check", never as broken.
|
|
96
96
|
|
|
97
97
|
## FAQ
|
|
98
98
|
|
|
@@ -100,7 +100,7 @@ Each run stays within the EmDash sandbox's limits of about 10 calls and 50 ms of
|
|
|
100
100
|
|
|
101
101
|
**Why do links to other sites take a day?** A site that is down for an hour should not send you an alert, so a broken answer is confirmed a day later.
|
|
102
102
|
|
|
103
|
-
**Who gets the emails?** The first Admin who opens Site Health, plus anyone added under Checklist, Options. An empty list means no email. Your site needs an email service
|
|
103
|
+
**Who gets the emails?** The first Admin who opens Site Health, plus anyone added under Checklist, Options. An empty list means no email. Your site needs an email service chosen in EmDash (Settings, Email); the Email row on the Checklist says whether it has one, and "Send a test email" checks it now.
|
|
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
|
|
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`:``}.`}
|
|
2
|
-
`));
|
|
3
|
-
`));a.email=l.ok?{state:`working`,at:c,to:r.length}:{state:`not-set-up`,at:c,to:0,error:l.error}}}t.alerts=a,await e.kv.set(i.alerts,a)}async function oe(e,t,n){let r=t.options.recipients??[],a=await S(e,r,`${ne(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.`),o=new Date(n).toISOString();return t.alerts={...t.alerts,email:a.ok?{state:`working`,at:o,to:r.length}:{state:`not-set-up`,at:o,to:0,error:a.error}},await e.kv.set(i.alerts,t.alerts),a}const se=/^(href|url|link|src)$|(_url|Url|_href|Href|_link|Link)$/,ce=/iframe|embed|video|map|script|audio|form/i,le=/image|picture|photo/i;function C(e,t){let n=new Map,r=0,i=(e,r)=>{let i=h(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(!se.test(e)||t.length>2e3)continue;i(t,e===`src`||e.endsWith(`Src`)||e.endsWith(`_src`)?le.test(s)?`image`:ce.test(s)?`embed`:`link`:`link`)}else t&&typeof t==`object`&&a(t,s,n+1)};return a(e,``,0),[...n.values()]}const w=2e5,ue=/<a\b[^>]*?\bhref\s*=\s*("([^"]*)"|'([^']*)')/gi,de=/<(header|nav|footer)\b[\s\S]*?<\/\1\s*>/gi,fe=/<(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,pe=/<img\b[^>]*?\bsrc\s*=\s*("http:[^"]*"|'http:[^']*')/gi;function me(e){return e.slice(1,-1).replaceAll(`&`,`&`)}function T(e,t){let n=e.length>w?e.slice(0,w):e,r=new Map;for(let e of n.matchAll(de))for(let n of e[0].matchAll(ue)){let e=h((n[2]??n[3]??``).replaceAll(`&`,`&`),t);e&&r.set(e.url,e)}let i=new Set;for(let e of n.matchAll(fe)){let t=e[2]??e[3]??e[4];t&&i.add(me(t))}let a=new Set;for(let e of n.matchAll(pe))e[1]&&a.add(me(e[1]));return{menu:[...r.values()],active:[...i],passive:[...a]}}function he(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 E=36e5,D=24*E;function ge(e){return`EmDash-SiteHealth/0.1 (+${e||`unknown site`})`}async function O(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":ge(e.site.url),Accept:`text/html,*/*;q=0.5`}}),a=i.headers.get(`location`),o={status:i.status,headers:i.headers,...a?{location:d(a,t)?.href??a}:{},...i.redirected&&i.url?{finalUrl:i.url}:{}};if(n.body){let e=await i.text();o.body=e.length>w?e.slice(0,w):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 _e(e){return/internal host|not allowed to fetch|private|localhost/i.test(e.error??``)}const ve=/ENOTFOUND|EAI_AGAIN|getaddrinfo|DNS|name not resolved|could not resolve/i;function ye(e,t){let n=e=>e.replace(/^https?:\/\//i,``).replace(/^www\./i,``).replace(/\/$/,``);return n(e)===n(t)}function k(e,t,n){if(t.timedOut)return{kind:`unknown`,reason:`timeout`};if(t.error!==void 0)return ve.test(t.error)?{kind:`dead`}:{kind:`unknown`,reason:`error`};let r=t.status??0;return r>=300&&r<400&&t.location?ye(e,t.location)?{kind:`ok`}:{kind:`moved`,to:t.location}:r>=200&&r<300?t.finalUrl&&!ye(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 A(e,t){return new Date(e+t).toISOString()}function j(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 M(e){let{fails:t,firstFailAt:n,since:r,group:i,detail:a,reason:o,...s}=e;return s}function be(e,t,n){let r=new Date(n).toISOString(),i={...e,checkedAt:r};switch(t.kind){case`ok`:return{...M(i),state:`ok`,due:A(n,7*D)};case`moved`:return{...M(i),state:`look`,reason:`moved`,detail:_(t.to),due:A(n,7*D)};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>=D-E?{...j(i,a,n),fails:(e.fails??0)+1,due:A(n,7*D)}:{...i,state:e.state===`broken`?`broken`:`pending`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:A(n,D)}}case`server`:{let t=e.firstFailAt?Date.parse(e.firstFailAt):NaN;return(e.fails??0)>=2&&n-t>=7*D-E?{...j(i,`server`,n),fails:(e.fails??0)+1,due:A(n,7*D)}:{...i,state:e.state===`broken`?`broken`:`unknown`,reason:e.state===`broken`?e.reason:`trouble`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:A(n,3*D)}}case`unknown`:case`retry`:{let r=t.kind===`unknown`?t.reason:`refuses`;return e.state===`broken`?{...i,due:A(n,D)}:{...i,state:`unknown`,reason:r,due:A(n,r===`slow`||r===`timeout`?D:7*D)}}}}function xe(e,t,n){let r=new Date(n).toISOString(),i={...e,checkedAt:r,due:`9999`};switch(t.kind){case`ok`:return{...M(i),state:`ok`};case`moved`:{let e=d(t.to);return{...M(i),state:`look`,reason:`redirect`,detail:_(e?e.pathname:t.to)}}case`gone`:case`retry`:return j(i,`missing`,n);default:return{...M(i),state:`unknown`,reason:`no-live`}}}function N(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?{...j({...e,checkedAt:r},`page-error`,n),fails:(e.fails??0)+1,due:A(n,D)}:{...e,checkedAt:r,state:e.state===`broken`?`broken`:`pending`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:A(n,6e4)}}return t.status!==void 0&&i<400?{...M({...e,checkedAt:r}),state:`ok`,due:`9999`}:{...M({...e,checkedAt:r}),state:`unknown`,reason:`no-live`,due:`9999`}}async function Se(e,t,n,r){let a=n.checklist.checkedAt?Date.parse(n.checklist.checkedAt):0;if(t.live===`ok`&&r-a<6048e5)return;let o={},s=d(e.site.url),c=v(e.site.url),l={status:`unknown`,text:`We could not open your site to find out.`};if(o.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,!c||!s||t.live!==`ok`)Object.assign(o,{secure:l,www:l,missing:l,search:l});else{let t=s.hostname,n=t.startsWith(`www.`)?t.slice(4):`www.${t}`,[r,i,a,u,f]=await Promise.all([s.protocol===`https:`?O(e,`http://${t}/`,{follow:!1}):Promise.resolve(null),O(e,`${s.protocol}//${n}/`,{follow:!1}),O(e,`${c}/site-health-check-a-page-that-does-not-exist`),O(e,`${c}/robots.txt`,{body:!0}),O(e,`${c}/sitemap.xml`,{method:`HEAD`})]);r?(r.status??0)>=300&&(r.status??0)<400&&r.location?.startsWith(`https:`)?o.secure={status:`working`,text:`The insecure address sends visitors to the secure one.`}:r.status===void 0?o.secure=l:o.secure={status:`attention`,text:`The insecure address shows your site without sending visitors to the secure one.`}:o.secure={status:`attention`,text:`Your site address is not secure. Ask your web person to switch it to https.`};let p=i.location?d(i.location)?.hostname:void 0;i.status!==void 0&&i.status>=300&&i.status<400&&p===t?o.www={status:`working`,text:`${n} sends visitors on to your address.`}:i.status!==void 0&&i.status<300?o.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.`}:o.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.`},a.status===404||a.status===410?o.missing={status:`working`,text:`An address that does not exist shows your "page not found" page and tells search engines it is missing.`}:a.status!==void 0&&a.status<300?o.missing={status:`attention`,text:`An address that does not exist shows a normal page, so search engines may list pages that are not there.`}:o.missing=l;let m=/user-agent:\s*\*\s*(?:\r?\n(?!user-agent)[^\n]*)*?\r?\ndisallow:\s*\/\s*(\r?\n|$)/i.test(u.status===200?u.body??``:``),h=f.status!==void 0&&f.status<300;o.search=m?{status:`attention`,text:`Your site tells search engines to stay away, so it will not appear in search results.`}:{status:`working`,text:h?`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:o};n.checklist=u,await e.kv.set(i.checklist,u)}const Ce=`site-health`,we=864e5;function P(e){return e.storage.links}function F(e){return e.storage.pages}function I(e){return new Date(e).toISOString()}async function L(e,t,n=Date.now()){let r=await e.schema?.listCollections()??[],a={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:I(n),lastStepAt:I(n)};return await Promise.all([e.kv.set(i.scan,a),Te(e)]),a}async function Te(e){try{await e.cron?.schedule(Ce,{schedule:`* * * * *`})}catch(t){e.log.warn(`could not schedule the background check`,{error:String(t)})}}function Ee(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 R(e){if(e.use===`link`){let t=e.internal?`internal`:`external`;return{id:g(t,e.url),kind:t,url:e.url,host:e.host}}return!e.insecure||e.internal?null:{id:g(`insecure`,e.url),kind:`insecure`,url:e.url,host:e.host}}function z(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`?I(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 B(e){return e===`image`?{state:`look`,reason:`insecure-image`}:{state:`broken`,reason:`insecure-active`}}async function De(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=m(e.source);return{id:n,data:{status:`redirect`,path:n,title:n,dest:e.destination||``,pass:t.pass}}});r.length>0&&await F(e).putMany(r),n?.hasMore&&n.cursor?t.cursor=n.cursor:(t.cursor=void 0,t.phase=`walk`)}async function Oe(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?he(r.pattern,r.slug,n):null,a=Ee(n,r.titleField),c=[];if(n.status===`published`){let t={c:r.slug,id:n.id,t:a,p:i??``};for(let r of C(n.data,e.site.url)){let e=R(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 F(e).putMany(o),s.size>0){let r=[...s.keys()],i=await P(e).getMany(r),a=r.map(e=>{let r=s.get(e),a=i.get(e);for(let e of r.sources)a=z(a,r.base,e,t.pass,n,r.base.kind===`insecure`?B(r.use):{});return a?.kind===`insecure`&&a.state===`broken`&&!a.since&&(a.since=I(n)),{id:e,data:a}});await P(e).putMany(a)}i?.hasMore&&i.cursor?t.cursor=i.cursor:(t.cursor=void 0,t.ci+=1)}function V(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?_(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:I(i)};return o.group=t(o),o}async function ke(e,t,n){let r=await P(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 F(e).getMany([...new Set(i.map(e=>e.data.url))]);await P(e).putMany(i.map(e=>({id:e.id,data:V(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 F(e).count({status:`published`,pass:t.pass}))}async function Ae(e,n,r,i,a,o){let s=[];for(let e of a.menu){let t=R(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:g(`insecure`,n),base:{kind:`insecure`,url:n,host:``},source:{t:i.title,p:i.path},extra:B(t)});for(let e of o)s.push({id:g(`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 P(e).getMany(c),u=s.filter(e=>e.base.kind===`internal`).map(e=>e.base.url),d=u.length>0?await F(e).getMany([...new Set(u)]):new Map,f=new Map;for(let e of s){let i=z(f.get(e.id)??l.get(e.id),e.base,e.source,n.pass,r,e.extra);if(i.kind===`internal`&&(i=V(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=N(l.get(e.id)??i,{status:t.status},r)),i.pass=n.pass}i.state===`broken`&&!i.since&&(i.since=I(r)),f.set(e.id,i)}await P(e).putMany([...f].map(([e,t])=>({id:e,data:t})))}function je(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)}async function Me(e,t,n){let r=v(e.site.url);if(!r){t.live=`not-public`,t.phase=`live-paths`;return}let i=await O(e,`${r}/`,{body:!0}),a=i.status??0;if(i.status===void 0){t.live=_e(i)?`not-public`:`failed`,t.phase=`live-paths`;return}if(a===403||a===429||a===503||a===401){t.live=`blocked`,t.phase=`live-paths`;return}if(!je(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=T(i.body??``,e.site.url);await Ae(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 Ne(e,t,n){let r=v(e.site.url),i=await F(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 O(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=T(n.body,e.site.url);o.push(...r.active),s.push(...r.passive),(r.active.length||r.passive.length)&&!l&&(l=t)}await Ae(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 Pe(t,n,r){let i=v(t.site.url),a=n.live===`ok`&&i!==null,o=await P(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 O(t,`${i}${n.data.url}`);return{id:n.id,data:xe(n.data,k(n.data.url,o,`GET`),r)}}));await P(t).putMany(s)}async function Fe(e,t){let[n,r]=await Promise.all([P(e).query({where:{pass:{lt:t.pass}},limit:100}),F(e).query({where:{pass:{lt:t.pass}},limit:100})]);await Promise.all([n.items.length>0?P(e).deleteMany(n.items.map(e=>e.id)):0,r.items.length>0?F(e).deleteMany(r.items.map(e=>e.id)):0]),!n.hasMore&&!r.hasMore&&(t.phase=`count`)}async function Ie(e,t){let n=P(e),[r,i,a,o,s,c]=await Promise.all([F(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 Le(e,n,r,i){let a=P(e),[s,c,l,u,d]=await Promise.all([a.query({where:{state:`broken`},orderBy:{count:`desc`},limit:100}),a.query({where:{state:`look`},limit:10}),a.query({where:{state:`unknown`},limit:10}),a.count({state:`look`}),a.count({state:`unknown`})]),f={site:[],page:[],link:[]};for(let{id:e,data:n}of s.items){let r=n.group??t(n);f[r].length<10&&f[r].push(o(e,{...n,group:r}))}let p=n.counts??{pages:0,links:0,fix:{site:0,page:0,link:0},editorFix:0,look:0,unknown:0},m={...p,look:u,unknown:d,pass:n.pass,finishedAt:I(i),...r.summary?{prevFinishedAt:r.summary.finishedAt}:{},rows:f,lookRows:c.items.map(({id:e,data:t})=>o(e,t)),unknownRows:l.items.map(({id:e,data:t})=>o(e,t)),live:n.live??`failed`};n.status=`done`,n.finishedAt=I(i);let h=p.pages===0&&!r.summary;return n.nextAt=I(i+(h?6e5:we)),n.counts=void 0,n.phase=`redirects`,h?null:m}async function H(e,t,n,r=Date.now()){let a=t.scan;if(!a||a.status!==`running`)return a;let o={...a,lastStepAt:I(r)},s=null;switch(o.phase){case`redirects`:await De(e,o);break;case`walk`:await Oe(e,o,r);break;case`resolve`:await ke(e,o,r);break;case`live-home`:await Me(e,o,r);break;case`live-pages`:await Ne(e,o,r);break;case`live-paths`:await Pe(e,o,r);break;case`checklist`:await Se(e,o,t,r),o.phase=`sweep`;break;case`sweep`:await Fe(e,o);break;case`count`:await Ie(e,o);break;case`freeze`:s=await Le(e,o,t,r);break}return s&&(await e.kv.set(i.summary,s),t.summary=s,await re(e,t,s,r)),o.ops=n.ops+1,await e.kv.set(i.scan,o),t.scan=o,o}const Re=6e4;async function ze(e,t,n,r=Date.now()){let a=await P(e).query({where:{due:{lte:I(r)}},orderBy:{due:`asc`},limit:20}),o=v(e.site.url),s={...t.queue.hosts},c=[],u=new Set;for(let e of a.items){if(c.length>=3)break;let n=e.data;if(n.state!==`fine`){if(n.kind===`external`){if(!t.options.outbound)continue;let e=s[n.host];if(u.has(n.host)||e&&r-e.at<Re*(e.slow&&e.slow>=2?1440:1))continue;u.add(n.host)}else if(n.kind!==`page`||!o||t.summary?.live!==`ok`)continue;c.push({id:e.id,row:n})}}let d=0,f=await Promise.all(c.map(async({id:t,row:n})=>{if(n.kind===`page`)return d+=1,{id:t,before:n,after:N(n,await O(e,`${o}${n.url}`),r)};d+=1;let i=n.get?`GET`:`HEAD`,a=await O(e,n.url,{method:i}),c=k(n.url,a,i);return s[n.host]={at:r,...s[n.host]?.slow?{slow:s[n.host].slow}:{}},{id:t,before:n,after:null,result:c,outcome:a}}));for(let t of f){if(t.after||!(`result`in t)||!t.result)continue;let n=t.result;if(n.kind===`retry`&&d<4&&(d+=1,n=k(t.before.url,await O(e,t.before.url,{method:`GET`}),`GET`)),n.kind===`retry`&&d>=4){t.after={...t.before,due:I(r+Re),get:!0};continue}if(n.kind===`unknown`&&n.reason===`slow`){let e=s[t.before.host]??{at:r};s[t.before.host]={...e,slow:(e.slow??0)+1}}t.after=be(t.before,n,r),t.result.kind===`retry`&&(t.after.get=!0)}for(let[e,t]of Object.entries(s))r-t.at>we&&!t.slow&&delete s[e];f.length>0&&await P(e).putMany(f.map(e=>({id:e.id,data:e.after})));let p=t.summary;if(p){for(let e of f){let t=l(p,e.id,e.before,e.after);t&&(p=t)}p!==t.summary&&(await e.kv.set(i.summary,p),t.summary=p,await re(e,t,p,r))}return t.queue={hosts:s,cronAt:I(r),ops:n.ops+1},await e.kv.set(i.queue,t.queue),f.length}function Be(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 Ve(e,n,r,a,o=Date.now()){if(!a.scan)return;let s=a.scan.collections.find(e=>e.slug===n);if(!s)return;let c=s.routable?he(s.pattern,n,r):null,u=Ee(r,s.titleField),d=r.status===`published`?C(r.data,e.site.url):[],f={c:n,id:r.id,t:u,p:c??``},p=new Map;for(let e of d){let t=R(e);t&&!(t.kind===`internal`&&t.url===c)&&p.set(t.id,{base:t,use:e.use})}let m=c?await F(e).get(c):null,h=[...new Set([...p.keys(),...m?.links??[]])].slice(0,400),g=h.length>0?await P(e).getMany(h):new Map,_=[...p.values()].filter(e=>e.base.kind===`internal`).map(e=>e.base.url),v=_.length>0?await F(e).getMany([...new Set(_)]):new Map,y=[],b=a.summary;for(let e of h){let n=g.get(e),i=p.get(e),s=null;if(i){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=z(c,i.base,f,n?.pass??a.scan.pass,o,i.base.kind===`insecure`?B(i.use):{}),c&&(s={...s,sources:[...e,f].slice(0,10),count:c.count+1,pass:n.pass}),s.kind===`internal`&&(s=V(s,v.get(s.url),o)),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=I(o)),s&&y.push({id:e,data:s}),b&&=l(b,e,n,s)??b}let x=h.filter(e=>!p.has(e)&&!y.some(t=>t.id===e));await Promise.all([y.length>0?P(e).putMany(y):void 0,x.length>0?P(e).deleteMany(x):void 0,c?F(e).put(c,{status:r.status,path:c,title:u,collection:n,id:r.id,pass:m?.pass??a.scan.pass,links:[...p.keys()].slice(0,200)}):void 0]),b&&b!==a.summary&&await e.kv.set(i.summary,b)}const He=10*6e4,Ue=[{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 U(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function W(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 G(e,t,n=`${t}s`){return`${e} ${e===1?t:n}`}function K(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,_(e.url))}${a}${r(e)}`}function q(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 We(e,t){let n=v(t)??d(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 J(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 Ge(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 ${Be(r,t?.live===`ok`)} for a site with ${G(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 ${G(t.liveTotal??0,`page`)}`}):t&&!n&&i.push({type:`context`,text:`${G(t.walked,`page`)} read. Finishing off.`}),i.push({type:`context`,text:`Started ${W(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 Ke(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===`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 qe(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 ${_(t.url)} on ${t.title}`,description:`Open the page, find the words that link to ${_(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 ${_(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 Je(e,t,n,r){let{summary:i,scan:a}=t;if(!i)return Ge(t);let o=[...n?Ke(i,e.site.url):[],...qe(r)],s=e=>n||e.editor,c=n?u(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 Ue){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},J(`more_${e}`,`Problem`,u.map(e=>({text:K(e,i),action:q(e,n)})),f))}l||o.push({type:`empty`,size:`sm`,title:`Nothing broken. Nice work.`,description:`We checked ${G(i.pages,`page`)} and ${G(i.links,`link`)} ${W(i.finishedAt)}. Next check: tomorrow.`});let d=i.lookRows.filter(s);d.length>0&&o.push({type:`accordion`,label:`Worth a look (${n?i.look:d.length})`,blocks:[{type:`context`,text:`These work, but take a detour. Tidier to fix, not urgent.`},J(`look_table`,`What we noticed`,d.map(e=>({text:K(e,null),action:q(e,n)})))]});let f=i.unknownRows.filter(s);f.length>0&&o.push({type:`accordion`,label:`Links we could not check (${n?i.unknown:f.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.`},J(`unknown_table`,`What happened`,f.map(t=>{let r=t.kind===`internal`?q(t,n):We(t,e.site.url);return{text:K(t,null),...r?{action:r}:{}}}))]});let p=a?.status===`running`;return o.push({type:`context`,text:p?`Checking again now. These are the results of the last full check, ${W(i.finishedAt)}.`:`Last full check: ${W(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:p?`refresh`:`check_all`,label:p?`Refresh`:`Check everything again`}]}),o}const Ye={working:`Working`,attention:`Needs attention`,untested:`Not tested yet`,unknown:`Could not tell`};function Xe(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<He?{status:`working`,text:`Last ran ${G(Math.max(1,Math.round((t-r)/6e4)),`minute`)} ago. Scheduled posts and these checks depend on it.`}:r===0&&t-i<He?{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 Ze(e){let t=e.alerts.email,n=e.options.recipients?.length??0;return t?t.state===`working`?{status:`working`,text:`Test sent to ${G(t.to,`address`,`addresses`)} ${W(t.at)}.`}:{status:`attention`,text:`Not set up. Your site has no way to send email yet, so alerts, form messages and password resets are not sent.`}:{status:`untested`,text:`Alerts, form messages and password resets need it. Send a test to find out.${n===0?` Add an address under Options first.`:``}`}}function Qe(e,t,n=Date.now()){let r=t?d(t):null,i=r?r.protocol===`https:`?{status:`working`,text:`Set, secure, and the address visitors use.`}:{status:v(r.href)?`attention`:`unknown`,text:v(r.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.`},a=e.checklist.rows;return[{name:`Site address`,...i},{name:`Scheduled tasks`,...Xe(e,n)},{name:`Email`,...Ze(e)},{name:`Secure connection`,...a.secure??{status:`untested`,text:`Checked with the next full check.`}},{name:`With and without www`,...a.www??{status:`untested`,text:`Checked with the next full check.`}},{name:`Missing pages`,...a.missing??{status:`untested`,text:`Checked with the next full check.`}},{name:`Search engines`,...a.search??{status:`untested`,text:`Checked with the next full check.`}},{name:`Quick repeat visits`,...a.caching??{status:`untested`,text:`Checked with the next full check.`}}]}function $e(e,t,n,r){let i=Qe(e,t,r),a=i.filter(e=>e.status===`attention`&&e.name!==`Quick repeat visits`),o=[...i].sort((e,t)=>Number(t.status===`attention`)-Number(e.status===`attention`)),s=[];i.find(e=>e.name===`Scheduled tasks`)?.status===`attention`?s.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:`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){let r=t.get(`settings:${n.name}`);if(i(r))return r;let o=t.get(n.legacy);return i(o)?(await a(e,n,o),o):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){let t=await e.kv.list(),n=new Map(t.map(e=>[e.key,e.value])),r=await o(e,n,l)??{},i=n.get(c.alerts)??{},a=n.get(c.queue)??{},s=n.get(c.checklist)??{};return{scan:n.get(c.scan)??null,summary:n.get(c.summary)??null,queue:{hosts:{},...a},options:{outbound:!0,...r},alerts:{pending:[],sent:[],...i},checklist:{rows:{},...s}}}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 _=/^(mailto|tel|sms|javascript|data|blob|about|ftp):/i;function ee(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(`?`)||_.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 ee(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 te=864e5;7*te;function ne(e){return`${n(e,x(e.url))}${r(e)}`}function re(e){return e.menu||e.reason===`page-error`}function ie(e){let t=S(e.site.url);return t?`${t}/_emdash/admin/plugins/${e.plugin.id}/health`:null}function ae(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 oe=/no email provider|not configured/i;function C(e,t,n,r){return e.ok?{state:`working`,at:n,to:t.length,list:t,kind:r}:{state:oe.test(e.error)?`not-set-up`:`failed`,at:n,to:0,list:t,kind:r,error:e.error.slice(0,200)}}const se=`updating|`;async function w(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 ce(e){return e.site.name.trim()||S(e.site.url)?.replace(/^https?:\/\//,``)||`Your site`}async function le(e,t,n,r){let i=[...n.rows.site,...n.rows.page].filter(re).map(e=>({id:e.id,text:ne(e)}));n.live===`updating`&&i.unshift({id:`${se}${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 ue(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)>=te)||!n.weeklyAt||t-Date.parse(n.weeklyAt)>=6048e5}async function de(e,t,n){let r=t.options.recipients??[],i={...t.alerts},a=ce(e),o=ie(e),s=new Date(n).toISOString();if(i.pending.length>0&&(!i.urgentAt||n-Date.parse(i.urgentAt)>=te)){let t=i.pending.some(e=>e.id.startsWith(se)),n=i.pending.some(e=>/visitors see an error/.test(e.text)),c=await w(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=C(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=C(await w(e,r,`${a}: ${c.length} new ${c.length===1?`problem`:`problems`} this week`,[`New since last week:`,``,...c.map(e=>`- ${ne(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 fe(e,t,n){let r=t.options.recipients??[],i=await w(e,r,`${ce(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:C(i,r,a,`test`)},await e.kv.set(c.alerts,t.alerts),i}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 T(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(!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 E=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 D(e,t){let n=e.length>E?e.slice(0,E):e,r=new Map;for(let e of n.matchAll(_e))for(let n of e[0].matchAll(ge)){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(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 Se=36e5,O=24*Se;function Ce(e){return`EmDash-SiteHealth/0.1 (+${e||`unknown site`})`}async function k(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>E?e.slice(0,E):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 A(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 j(e,t){return new Date(e+t).toISOString()}function M(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 N(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{...N(i),state:`ok`,due:j(n,7*O)};case`moved`:return{...N(i),state:`look`,reason:`moved`,detail:x(t.to),due:j(n,7*O)};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>=O-Se?{...M(i,a,n),fails:(e.fails??0)+1,due:j(n,7*O)}:{...i,state:e.state===`broken`?`broken`:`pending`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:j(n,O)}}case`server`:{let t=e.firstFailAt?Date.parse(e.firstFailAt):NaN;return(e.fails??0)>=2&&n-t>=7*O-Se?{...M(i,`server`,n),fails:(e.fails??0)+1,due:j(n,7*O)}:{...i,state:e.state===`broken`?`broken`:`unknown`,reason:e.state===`broken`?e.reason:`trouble`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:j(n,3*O)}}case`unknown`:case`retry`:{let r=t.kind===`unknown`?t.reason:`refuses`;return e.state===`broken`?{...i,due:j(n,O)}:{...i,state:`unknown`,reason:r,due:j(n,r===`slow`||r===`timeout`?O:7*O)}}}}function Oe(e,t,n){let r=new Date(n).toISOString(),i={...e,checkedAt:r,due:`9999`};switch(t.kind){case`ok`:return{...N(i),state:`ok`};case`moved`:{let e=g(t.to);return{...N(i),state:`look`,reason:`redirect`,detail:x(e?e.pathname:t.to)}}case`gone`:case`retry`:return M(i,`missing`,n);default:return{...N(i),state:`unknown`,reason:`no-live`}}}function P(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?{...M({...e,checkedAt:r},`page-error`,n),fails:(e.fails??0)+1,due:j(n,O)}:{...e,checkedAt:r,state:e.state===`broken`?`broken`:`pending`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:j(n,6e4)}}return t.status!==void 0&&i<400?{...N({...e,checkedAt:r}),state:`ok`,due:`9999`}:{...N({...e,checkedAt:r}),state:`unknown`,reason:`no-live`,due:`9999`}}async function ke(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:`?k(e,`http://${t}/`,{follow:!1}):Promise.resolve(null),k(e,`${o.protocol}//${n}/`,{follow:!1}),k(e,`${s}/site-health-check-a-page-that-does-not-exist`),k(e,`${s}/robots.txt`,{body:!0}),k(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 Ae=`site-health`,je=864e5;function F(e){return e.storage.links}function I(e){return e.storage.pages}function L(e){return new Date(e).toISOString()}async function R(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:L(n),lastStepAt:L(n)};return await Promise.all([e.kv.set(c.scan,i),Me(e)]),i}async function Me(e){try{await e.cron?.schedule(Ae,{schedule:`* * * * *`})}catch(t){e.log.warn(`could not schedule the background check`,{error:String(t)})}}function Ne(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 z(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 B(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`?L(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 V(e){return e===`image`?{state:`look`,reason:`insecure-image`}:{state:`broken`,reason:`insecure-active`}}async function Pe(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 I(e).putMany(r),n?.hasMore&&n.cursor?t.cursor=n.cursor:(t.cursor=void 0,t.phase=`walk`)}async function Fe(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=Ne(n,r.titleField),c=[];if(n.status===`published`){let t={c:r.slug,id:n.id,t:a,p:i??``};for(let r of T(n.data,e.site.url)){let e=z(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 I(e).putMany(o),s.size>0){let r=[...s.keys()],i=await F(e).getMany(r),a=r.map(e=>{let r=s.get(e),a=i.get(e);for(let e of r.sources)a=B(a,r.base,e,t.pass,n,r.base.kind===`insecure`?V(r.use):{});return a?.kind===`insecure`&&a.state===`broken`&&!a.since&&(a.since=L(n)),{id:e,data:a}});await F(e).putMany(a)}i?.hasMore&&i.cursor?t.cursor=i.cursor:(t.cursor=void 0,t.ci+=1)}function H(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:L(i)};return o.group=t(o),o}async function Ie(e,t,n){let r=await F(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 I(e).getMany([...new Set(i.map(e=>e.data.url))]);await F(e).putMany(i.map(e=>({id:e.id,data:H(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 I(e).count({status:`published`,pass:t.pass}))}async function Le(e,n,r,i,a,o){let s=[];for(let e of a.menu){let t=z(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:V(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 F(e).getMany(c),u=s.filter(e=>e.base.kind===`internal`).map(e=>e.base.url),d=u.length>0?await I(e).getMany([...new Set(u)]):new Map,f=new Map;for(let e of s){let i=B(f.get(e.id)??l.get(e.id),e.base,e.source,n.pass,r,e.extra);if(i.kind===`internal`&&(i=H(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=P(l.get(e.id)??i,{status:t.status},r)),i.pass=n.pass}i.state===`broken`&&!i.since&&(i.since=L(r)),f.set(e.id,i)}await F(e).putMany([...f].map(([e,t])=>({id:e,data:t})))}function Re(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 ze(e){return e.status===503?/Database migrations are required|database has not been migrated/i.test((e.body??``).slice(0,2e3)):!1}async function Be(e,t,n){let r=S(e.site.url);if(!r){t.live=`not-public`,t.phase=`live-paths`;return}let i=await k(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(ze(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(!Re(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=D(i.body??``,e.site.url);await Le(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 Ve(e,t,n){let r=S(e.site.url),i=await I(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 k(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=D(n.body,e.site.url);o.push(...r.active),s.push(...r.passive),(r.active.length||r.passive.length)&&!l&&(l=t)}await Le(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 He(t,n,r){let i=S(t.site.url),a=n.live===`ok`&&i!==null,o=await F(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 k(t,`${i}${n.data.url}`);return{id:n.id,data:Oe(n.data,A(n.data.url,o,`GET`),r)}}));await F(t).putMany(s)}async function Ue(e,t){let[n,r]=await Promise.all([F(e).query({where:{pass:{lt:t.pass}},limit:100}),I(e).query({where:{pass:{lt:t.pass}},limit:100})]);await Promise.all([n.items.length>0?F(e).deleteMany(n.items.map(e=>e.id)):0,r.items.length>0?I(e).deleteMany(r.items.map(e=>e.id)):0]),!n.hasMore&&!r.hasMore&&(t.phase=`count`)}async function We(e,t){let n=F(e),[r,i,a,o,s,c]=await Promise.all([I(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 Ge(e,n,r,i){let a=F(e),[o,s,c,l,u]=await Promise.all([a.query({where:{state:`broken`},orderBy:{count:`desc`},limit:100}),a.query({where:{state:`look`},limit:10}),a.query({where:{state:`unknown`},limit:10}),a.count({state:`look`}),a.count({state:`unknown`})]),f={site:[],page:[],link:[]};for(let{id:e,data:n}of o.items){let r=n.group??t(n);f[r].length<10&&f[r].push(d(e,{...n,group:r}))}let p=n.counts??{pages:0,links:0,fix:{site:0,page:0,link:0},editorFix:0,look:0,unknown:0},m={...p,look:l,unknown:u,pass:n.pass,finishedAt:L(i),...r.summary?{prevFinishedAt:r.summary.finishedAt}:{},rows:f,lookRows:s.items.map(({id:e,data:t})=>d(e,t)),unknownRows:c.items.map(({id:e,data:t})=>d(e,t)),live:n.live??`failed`};n.status=`done`,n.finishedAt=L(i);let h=p.pages===0&&!r.summary;return n.nextAt=L(i+(h?6e5:je)),n.counts=void 0,n.phase=`redirects`,h?null:m}async function U(e,t,n,r=Date.now()){let i=t.scan;if(!i||i.status!==`running`)return i;let a={...i,lastStepAt:L(r)},o=null;switch(a.phase){case`redirects`:await Pe(e,a);break;case`walk`:await Fe(e,a,r);break;case`resolve`:await Ie(e,a,r);break;case`live-home`:await Be(e,a,r);break;case`live-pages`:await Ve(e,a,r);break;case`live-paths`:await He(e,a,r);break;case`checklist`:await ke(e,a,t,r),a.phase=`sweep`;break;case`sweep`:await Ue(e,a);break;case`count`:await We(e,a);break;case`freeze`:o=await Ge(e,a,t,r);break}return o&&(await e.kv.set(c.summary,o),t.summary=o,await le(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 F(e).query({where:{due:{lte:L(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<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=0,d=await Promise.all(s.map(async({id:t,row:n})=>{if(n.kind===`page`)return u+=1,{id:t,before:n,after:P(n,await k(e,`${a}${n.url}`),r)};u+=1;let i=n.get?`GET`:`HEAD`,s=await k(e,n.url,{method:i}),c=A(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 d){if(t.after||!(`result`in t)||!t.result)continue;let n=t.result;if(n.kind===`retry`&&u<4&&(u+=1,n=A(t.before.url,await k(e,t.before.url,{method:`GET`}),`GET`)),n.kind===`retry`&&u>=4){t.after={...t.before,due:L(r+Ke),get:!0};continue}if(n.kind===`unknown`&&n.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,n,r),t.result.kind===`retry`&&(t.after.get=!0)}for(let[e,t]of Object.entries(o))r-t.at>je&&!t.slow&&delete o[e];d.length>0&&await F(e).putMany(d.map(e=>({id:e.id,data:e.after})));let f=t.summary;if(f){for(let e of d){let t=m(f,e.id,e.before,e.after);t&&(f=t)}f!==t.summary&&(await e.kv.set(c.summary,f),t.summary=f,await le(e,t,f,r))}return t.queue={hosts:o,cronAt:L(r),ops:n.ops+1},await e.kv.set(c.queue,t.queue),d.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=Ne(r,o.titleField),u=r.status===`published`?T(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=z(e);t&&!(t.kind===`internal`&&t.url===s)&&f.set(t.id,{base:t,use:e.use})}let p=s?await I(e).get(s):null,h=[...new Set([...f.keys(),...p?.links??[]])].slice(0,400),g=h.length>0?await F(e).getMany(h):new Map,_=[...f.values()].filter(e=>e.base.kind===`internal`).map(e=>e.base.url),ee=_.length>0?await I(e).getMany([...new Set(_)]):new Map,v=[],y=i.summary;for(let e of h){let n=g.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=B(c,o.base,d,n?.pass??i.scan.pass,a,o.base.kind===`insecure`?V(o.use):{}),c&&(s={...s,sources:[...e,d].slice(0,10),count:c.count+1,pass:n.pass}),s.kind===`internal`&&(s=H(s,ee.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=L(a)),s&&v.push({id:e,data:s}),y&&=m(y,e,n,s)??y}let b=h.filter(e=>!f.has(e)&&!v.some(t=>t.id===e));await Promise.all([v.length>0?F(e).putMany(v):void 0,b.length>0?F(e).deleteMany(b):void 0,s?I(e).put(s,{status:r.status,path:s,title:l,collection:n,id:r.id,pass:p?.pass??i.scan.pass,links:[...f.keys()].slice(0,200)}):void 0]),y&&y!==i.summary&&await e.kv.set(c.summary,y)}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 W(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function G(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 K(e,t,n=`${t}s`){return`${e} ${e===1?t:n}`}function q(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 J(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 Qe(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 Y(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 $e(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 ${K(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 ${K(t.liveTotal??0,`page`)}`}):t&&!n&&i.push({type:`context`,text:`${K(t.walked,`page`)} read. Finishing off.`}),i.push({type:`context`,text:`Started ${G(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 et(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 ${G(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 tt(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 nt(e,t,n,r){let{summary:i,scan:a}=t;if(!i)return $e(t);let o=[...n?et(i,e.site.url):[],...tt(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},Y(`more_${e}`,`Problem`,u.map(e=>({text:q(e,i),action:J(e,n)})),f))}l||o.push({type:`empty`,size:`sm`,title:`Nothing broken. Nice work.`,description:`We checked ${K(i.pages,`page`)} and ${K(i.links,`link`)} ${G(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.`},Y(`look_table`,`What we noticed`,u.map(e=>({text:q(e,null),action:J(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.`},Y(`unknown_table`,`What happened`,d.map(t=>{let r=t.kind===`internal`?J(t,n):Qe(t,e.site.url);return{text:q(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, ${G(i.finishedAt)}.`:`Last full check: ${G(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 rt={working:`Working`,attention:`Needs attention`,untested:`Not tested yet`,unknown:`Could not tell`};function it(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 ${K(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 at(e){return e.length<=2?`Goes to ${e.join(` and `)}.`:`Goes to ${e.slice(0,2).join(`, `)} and ${e.length-2} more.`}function ot(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=at(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=G(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.`}:oe.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 st(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 ${G(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`,...it(e,r)},{name:`Email`,...ot(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 ct(e,t,n,r,i){let a=st(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`}]}):
|
|
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 (${
|
|
11
|
-
`));return`wrong`in t?
|
|
9
|
+
See EmDash's deployment guide, "Scheduled tasks".`,language:`bash`}]}):o.length>0&&c.push({type:`banner`,variant:`alert`,title:`${K(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:rt[e.status]}))},...l?[{type:`section`,text:`Email: ${rt[l.status]}. ${l.text}`,accessory:{type:`button`,action_id:`send_test`,label:`Send a test email`}}]:[],{type:`context`,text:e.checklist.checkedAt?`Checked ${G(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}}))}]}),c}function X(e,t,n,r){let i={type:`context`,text:`Finds broken links and setup problems before your visitors do.`},a=nt(e,t,n,r);return{blocks:n?[i,{type:`tab`,default_tab:r.tab,panels:[{label:`Problems`,blocks:a},{label:`Checklist`,blocks:ct(t,e.site.url,!!e.email,r,Date.now())}]}]:[i,...a],...r.toast?{toast:r.toast}:{}}}function lt(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 ${K(e,`page`)}. Results in ${Je(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 ${G(o.finishedAt)}.`:`${K(c,`broken link`)} on your pages.`},a]};let l=st(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 ${G(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 ut(e){let t=await I(e).query({where:{status:`published`},limit:100}),n=t.hasMore&&t.cursor?await I(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 dt(e){return(await F(e).query({where:{state:`fine`},limit:20})).items.map(({id:e,data:t})=>({id:e,row:t}))}function ft(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 Z(e,t,n,r,i){if(await F(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 pt(n,r,i,a){let o=await F(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=A(o.url,await k(n,o.url,{method:`HEAD`}),`HEAD`);e.kind===`retry`&&(e=A(o.url,await k(n,o.url),`GET`)),c=De(l,e,a)}else if(o.kind===`internal`)c=H(l,await I(n).get(o.url)??void 0,a),c.state===`pending`&&(c=s?Oe(c,A(o.url,await k(n,`${s}${o.url}`),`GET`),a):{...c,state:`unknown`,reason:`no-live`,due:e});else if(o.kind===`page`)c=s?P({...l,fails:o.state===`broken`?1:0,firstFailAt:new Date(a-6e4).toISOString()},await k(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&&T(r.data,n.site.url).some(e=>e.url===o.url)}else if(s&&e){let r=await k(n,`${s}${e.p||`/`}`,{body:!0}),i=r.body?D(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 Z(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 mt(e,t,n,r,i=!1){try{if(!t.scan||!t.summary&&t.scan.status===`done`){t.scan=await R(e,t,r),await U(e,t,n,r);return}let a=Date.parse(t.scan.lastStepAt??t.scan.startedAt);t.scan.status===`running`&&(i||r-a>12e4)&&await U(e,t,n,r)}catch(t){e.log.warn(`could not move the check along`,{error:String(t)})}}async function ht(t,n,r,i){let a=Date.now(),o=await u(r),c=(n?.role??0)>=50,f=W(t)?t:{},p=typeof f.action_id==`string`?f.action_id:``,m={tab:0};if(f.type===`page_load`&&typeof f.page==`string`&&f.page.startsWith(`widget:`))return await mt(r,o,i,a),lt(o,c,r.site.url,!!r.email);c&&o.options.recipients===void 0&&n?.email?(o.options={...o.options,recipients:[n.email.toLowerCase()]},await s(r,l,o.options)):(f.type===`page_load`||p===`refresh`)&&await mt(r,o,i,a,p===`refresh`);let h=async e=>c?{...e,fine:await dt(r)}:e;if(f.type===`block_action`){if(p===`fix`&&typeof f.value==`string`){let[t=``,i=``]=f.value.split(`|`),s=ft(o.summary,i);if(!s)return X(r,o,c,{tab:0,toast:{type:`info`,message:`That problem is no longer listed.`}});if(t===`redirect`&&c)return X(r,o,c,{tab:0,panel:{kind:`redirect`,id:i,url:s.url},pickable:await ut(r)});if(t===`menus`)return X(r,o,c,await h({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 X(r,o,c,await h({tab:0,panel:{kind:`edit`,url:s.url,title:e.t,collection:e.c,entry:e.id}}))}if(t===`check`)return X(r,o,c,await h({tab:0,toast:await pt(r,o,i,a)}));if(t===`fine`){let t=await F(r).get(i);if(t){let s=n?.name||n?.email||`someone`;await Z(r,o,i,t,{...t,state:`fine`,due:e,fine:{by:s,at:new Date(a).toISOString()}})}return X(r,o,c,await h({tab:0,toast:{type:`success`,message:`Marked as fine. ${x(s.url)} is not checked any more.`}}))}}if(p===`check_fine`&&typeof f.value==`string`&&c)return X(r,o,c,await h({tab:1,toast:await pt(r,o,f.value,a)}));if(p===`check_all`)return o.scan?.status!==`running`&&(o.scan=await R(r,o,a),await U(r,o,i,a)),X(r,o,c,await h({tab:0,toast:{type:`info`,message:`Checking everything again. The last results stay here until it is done.`}}));if(p===`send_test`&&c){if((o.options.recipients??[]).length===0)return X(r,o,c,await h({tab:1,toast:{type:`error`,message:`Add an address under Options first.`}}));let e=await fe(r,o,a);return X(r,o,c,await h({tab:1,toast:e.ok?{type:`success`,message:`Test sent to ${K(o.options.recipients?.length??0,`address`,`addresses`)}.`}:{type:`info`,message:oe.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=Ze.find(e=>p===`more_${e.group}`)?.group;if(t&&o.summary){let e=W(f.value)?f.value:{},n=typeof e.cursor==`string`&&e.cursor!==`start`?e.cursor:void 0,i=await F(r).query({where:{state:`broken`,group:t},orderBy:{count:`desc`},limit:50,...n?{cursor:n}:{}});return X(r,o,c,await h({tab:0,more:{group:t,rows:i.items.map(({id:e,data:t})=>d(e,t)),...i.hasMore&&i.cursor?{cursor:i.cursor}:{}}}))}return X(r,o,c,await h({tab:p===`close`?0:m.tab}))}if(f.type===`form_submit`&&c){let e=W(f.values)?f.values:{};if(p===`save_options`){let t=ae(typeof e.recipients==`string`?e.recipients:(o.options.recipients??[]).join(`
|
|
11
|
+
`));return`wrong`in t?X(r,o,c,await h({tab:1,toast:{type:`error`,message:`"${t.wrong}" is not an email address. Nothing was saved.`}})):(o.options={recipients:t.list,outbound:typeof e.outbound==`boolean`?e.outbound:o.options.outbound},await s(r,l,o.options),X(r,o,c,await h({tab:1,toast:{type:`success`,message:t.list.length===0?`Saved. Nobody gets emails now.`:`Saved. Emails go to ${K(t.list.length,`address`,`addresses`)}.`}})))}if(p.startsWith(`add_redirect|`)){let t=p.slice(13),n=ft(o.summary,t),i=v(typeof e.source==`string`?e.source.trim():n?.url??``),s=typeof e.destination==`string`?e.destination.trim():``,l=s.startsWith(`/`)?v(s):s,u=async e=>X(r,o,c,{tab:0,panel:{kind:`redirect`,id:t,url:i||n?.url||``},pickable:await ut(r),problem:e});if(!i.startsWith(`/`))return u(`The old address must be a page on this site, starting with /.`);if(!l.startsWith(`/`)&&!/^https?:\/\//.test(l))return u(`Pick a page to send visitors to.`);if(l===i)return u(`The old address and the new one are the same.`);if((await I(r).get(i))?.status===`published`)return u(`${i} is a live page. Change its address in the page editor instead.`);try{await r.redirects?.create?.({source:i,destination:l,type:301})}catch(e){return u(`EmDash did not add it: ${e instanceof Error?e.message:String(e)}`)}let d={status:`redirect`,path:i,title:i,dest:l,pass:o.scan?.pass??0};await I(r).put(i,d);let f=await F(r).get(t);return f&&await Z(r,o,t,f,H(f,d,a)),X(r,o,c,await h({tab:0,toast:{type:`success`,message:`Done. ${i} now goes to ${l}.`}}))}}return X(r,o,c,await h(m))}const gt=[`kv`,`settings`,`content`,`schema`,`redirects`,`http`,`email`,`cron`];function _t(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 Q(e,t){let n={};for(let r of gt){let i=e[r];i&&(n[r]=_t(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=_t(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)}})}async function vt(e,t=Date.now()){let n={ops:0},r=Q(e,n),i=await u(r),a=new Date(t).toISOString();if(ue(i,t)){await de(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 R(r,i,t),cronAt:a},await U(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 U(r,i,n,t);return}await qe(r,i,n,t)}async function $(e,t,n,r=!1){if(typeof n==`string`)try{let i=Q(e,{ops:0}),a=await u(i);await Ye(i,t,(r?null:await i.content?.get(t,n))??{id:n,slug:null,status:`deleted`,data:{}},a)}catch(r){e.log.warn(`could not re-read the page's links`,{collection:t,id:n,error:String(r)})}}const yt=e=>e?.id,bt={hooks:{"plugin:activate":async(e,t)=>{try{await Me(t);let e={ops:0},n=Q(t,e),r=await u(n);if(r.scan)return;r.scan=await R(n,r),await U(n,r,e)}catch(e){t.log.warn(`could not start the first check`,{error:String(e)})}},cron:async(e,t)=>{e.name===Ae&&await vt(t)},"content:afterSave":async(e,t)=>{await $(t,e.collection,yt(e.content))},"content:afterPublish":async(e,t)=>{await $(t,e.collection,yt(e.content))},"content:afterUnpublish":async(e,t)=>{await $(t,e.collection,yt(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 ht(e.input,e.user,Q(t,n),n)}}}};export{vt as cronRun,bt as default};
|
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.3",
|
|
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",
|