@moorage/plugin-site-health 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +3 -3
- 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,13 @@
|
|
|
1
1
|
# @moorage/plugin-site-health
|
|
2
2
|
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **Scheduled tasks not running:** the instructions for your web person now start with one plain sentence before the commands.
|
|
10
|
+
|
|
3
11
|
## 0.1.1
|
|
4
12
|
|
|
5
13
|
### 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.
|
|
@@ -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`:``}.`}const i={scan:`scan`,summary:`summary`,queue:`queue`,options:`options`,alerts:`alerts`,checklist:`checklist`};async function a(e){let t=await e.kv.list(),n=new Map(t.map(e=>[e.key,e.value])),r=n.get(i.options)??{},a=n.get(i.alerts)??{},o=n.get(i.queue)??{},s=n.get(i.checklist)??{};return{scan:n.get(i.scan)??null,summary:n.get(i.summary)??null,queue:{hosts:{},...o},options:{outbound:!0,...r},alerts:{pending:[],sent:[],...a},checklist:{rows:{},...s}}}function o(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 s(e,t){return t.count-e.count}function c(e,t){return e.filter(e=>e.id!==t)}function l(e,t,n,r){let i=n?.state,a=r?.state,l=n?.group;if(i===a&&l===r?.group&&n?.reason===r?.reason)return null;let u={...e,fix:{...e.fix},rows:{site:c(e.rows.site,t),page:c(e.rows.page,t),link:c(e.rows.link,t)},lookRows:c(e.lookRows,t),unknownRows:c(e.unknownRows,t)};return i===`broken`&&l&&(u.fix[l]=Math.max(0,u.fix[l]-1),n?.editor&&(u.editorFix=Math.max(0,u.editorFix-1))),i===`look`&&(u.look=Math.max(0,u.look-1)),i===`unknown`&&(u.unknown=Math.max(0,u.unknown-1)),r&&a===`broken`&&r.group&&(u.fix[r.group]+=1,r.editor&&(u.editorFix+=1),u.rows[r.group]=[...u.rows[r.group],o(t,r)].sort(s).slice(0,10)),r&&a===`look`&&(u.look+=1,u.lookRows=[...u.lookRows,o(t,r)].slice(0,10)),r&&a===`unknown`&&(u.unknown+=1,u.unknownRows=[...u.unknownRows,o(t,r)].slice(0,10)),u}function u(e){return e.fix.site+e.fix.page+e.fix.link}function d(e,t){try{return new URL(e,t)}catch{return null}}const f=/^(mailto|tel|sms|javascript|data|blob|about|ftp):/i;function p(e){return e.startsWith(`/_emdash/`)||e.startsWith(`/_astro/`)||e===`/_emdash`}function m(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 h(e,t){let n=e.trim();if(n===``||n.startsWith(`#`)||n.startsWith(`?`)||f.test(n)||/\s/.test(n)&&!/^https?:\/\//i.test(n))return null;let r=d(t),i=/^https?:\/\//i.test(n)||n.startsWith(`//`);if(!i&&!n.startsWith(`/`))return null;let a=d(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=m(a.pathname);return p(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 g(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 _(e,t=30){let n=e.replace(/^https?:\/\//i,``).replace(/^www\./i,``).replace(/\/$/,``);return n.length>t?`${n.slice(0,t-1)}…`:n}function v(e){let t=d(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 y=864e5;7*y;function b(e){return`${n(e,_(e.url))}${r(e)}`}function x(e){return e.menu||e.reason===`page-error`}function ee(e){let t=v(e.site.url);return t?`${t}/_emdash/admin/plugins/${e.plugin.id}/health`:null}function te(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)}}
|
|
2
|
-
`));a.urgentAt=c,a.pending=[],a.email=
|
|
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`:``}.`}const i={scan:`scan`,summary:`summary`,queue:`queue`,options:`options`,alerts:`alerts`,checklist:`checklist`};async function a(e){let t=await e.kv.list(),n=new Map(t.map(e=>[e.key,e.value])),r=n.get(i.options)??{},a=n.get(i.alerts)??{},o=n.get(i.queue)??{},s=n.get(i.checklist)??{};return{scan:n.get(i.scan)??null,summary:n.get(i.summary)??null,queue:{hosts:{},...o},options:{outbound:!0,...r},alerts:{pending:[],sent:[],...a},checklist:{rows:{},...s}}}function o(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 s(e,t){return t.count-e.count}function c(e,t){return e.filter(e=>e.id!==t)}function l(e,t,n,r){let i=n?.state,a=r?.state,l=n?.group;if(i===a&&l===r?.group&&n?.reason===r?.reason)return null;let u={...e,fix:{...e.fix},rows:{site:c(e.rows.site,t),page:c(e.rows.page,t),link:c(e.rows.link,t)},lookRows:c(e.lookRows,t),unknownRows:c(e.unknownRows,t)};return i===`broken`&&l&&(u.fix[l]=Math.max(0,u.fix[l]-1),n?.editor&&(u.editorFix=Math.max(0,u.editorFix-1))),i===`look`&&(u.look=Math.max(0,u.look-1)),i===`unknown`&&(u.unknown=Math.max(0,u.unknown-1)),r&&a===`broken`&&r.group&&(u.fix[r.group]+=1,r.editor&&(u.editorFix+=1),u.rows[r.group]=[...u.rows[r.group],o(t,r)].sort(s).slice(0,10)),r&&a===`look`&&(u.look+=1,u.lookRows=[...u.lookRows,o(t,r)].slice(0,10)),r&&a===`unknown`&&(u.unknown+=1,u.unknownRows=[...u.unknownRows,o(t,r)].slice(0,10)),u}function u(e){return e.fix.site+e.fix.page+e.fix.link}function d(e,t){try{return new URL(e,t)}catch{return null}}const f=/^(mailto|tel|sms|javascript|data|blob|about|ftp):/i;function p(e){return e.startsWith(`/_emdash/`)||e.startsWith(`/_astro/`)||e===`/_emdash`}function m(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 h(e,t){let n=e.trim();if(n===``||n.startsWith(`#`)||n.startsWith(`?`)||f.test(n)||/\s/.test(n)&&!/^https?:\/\//i.test(n))return null;let r=d(t),i=/^https?:\/\//i.test(n)||n.startsWith(`//`);if(!i&&!n.startsWith(`/`))return null;let a=d(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=m(a.pathname);return p(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 g(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 _(e,t=30){let n=e.replace(/^https?:\/\//i,``).replace(/^www\./i,``).replace(/\/$/,``);return n.length>t?`${n.slice(0,t-1)}…`:n}function v(e){let t=d(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 y=864e5;7*y;function b(e){return`${n(e,_(e.url))}${r(e)}`}function x(e){return e.menu||e.reason===`page-error`}function ee(e){let t=v(e.site.url);return t?`${t}/_emdash/admin/plugins/${e.plugin.id}/health`:null}function te(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 S=/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:S.test(e.error)?`not-set-up`:`failed`,at:n,to:0,list:t,kind:r,error:e.error.slice(0,200)}}const ne=`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 re(e){return e.site.name.trim()||v(e.site.url)?.replace(/^https?:\/\//,``)||`Your site`}async function ie(e,t,n,r){let a=[...n.rows.site,...n.rows.page].filter(x).map(e=>({id:e.id,text:b(e)}));n.live===`updating`&&a.unshift({id:`${ne}${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 o=a.filter(e=>!t.alerts.sent.includes(e.id));if(o.length===0)return;let s={...t.alerts,pending:[...t.alerts.pending,...o.map(e=>({id:e.id,text:e.text}))].slice(-20),sent:[...t.alerts.sent,...o.map(e=>e.id)].slice(-200)};t.alerts=s,await e.kv.set(i.alerts,s)}function ae(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)>=y)||!n.weeklyAt||t-Date.parse(n.weeklyAt)>=6048e5}async function oe(e,t,n){let r=t.options.recipients??[],a={...t.alerts},o=re(e),s=ee(e),c=new Date(n).toISOString();if(a.pending.length>0&&(!a.urgentAt||n-Date.parse(a.urgentAt)>=y)){let t=a.pending.some(e=>e.id.startsWith(ne)),n=a.pending.some(e=>/visitors see an error/.test(e.text)),i=await w(e,r,`${o}: ${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:`,``,...a.pending.map(e=>`- ${e.text}`),``,s?`See them and fix them: ${s}`:`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
|
+
`));a.urgentAt=c,a.pending=[],a.email=C(i,r,c,`alert`)}else if(!a.weeklyAt)a.weeklyAt=c;else if(t.summary){let n=a.weeklyAt,i=[...t.summary.rows.site,...t.summary.rows.page,...t.summary.rows.link].filter(e=>e.since&&e.since>n);if(a.weeklyAt=c,i.length>0){let n=t.summary.fix.site+t.summary.fix.page+t.summary.fix.link;a.email=C(await w(e,r,`${o}: ${i.length} new ${i.length===1?`problem`:`problems`} this week`,[`New since last week:`,``,...i.map(e=>`- ${b(e)}`),``,`${n} ${n===1?`thing`:`things`} to fix in all. ${s?`See them: ${s}`:`See them in Site Health in your site's admin.`}`,``,`Sent once a week, only when something is new.`].join(`
|
|
3
|
+
`)),r,c,`alert`)}}t.alerts=a,await e.kv.set(i.alerts,a)}async function se(e,t,n){let r=t.options.recipients??[],a=await w(e,r,`${re(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:C(a,r,o,`test`)},await e.kv.set(i.alerts,t.alerts),a}const ce=/^(href|url|link|src)$|(_url|Url|_href|Href|_link|Link)$/,le=/iframe|embed|video|map|script|audio|form/i,ue=/image|picture|photo/i;function T(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(!ce.test(e)||t.length>2e3)continue;i(t,e===`src`||e.endsWith(`Src`)||e.endsWith(`_src`)?ue.test(s)?`image`:le.test(s)?`embed`:`link`:`link`)}else t&&typeof t==`object`&&a(t,s,n+1)};return a(e,``,0),[...n.values()]}const E=2e5,de=/<a\b[^>]*?\bhref\s*=\s*("([^"]*)"|'([^']*)')/gi,fe=/<(header|nav|footer)\b[\s\S]*?<\/\1\s*>/gi,pe=/<(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,me=/<img\b[^>]*?\bsrc\s*=\s*("http:[^"]*"|'http:[^']*')/gi;function he(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(fe))for(let n of e[0].matchAll(de)){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(pe)){let t=e[2]??e[3]??e[4];t&&i.add(he(t))}let a=new Set;for(let e of n.matchAll(me))e[1]&&a.add(he(e[1]));return{menu:[...r.values()],active:[...i],passive:[...a]}}function ge(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 O=36e5,k=24*O;function _e(e){return`EmDash-SiteHealth/0.1 (+${e||`unknown site`})`}async function A(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":_e(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>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 ve(e){return/internal host|not allowed to fetch|private|localhost/i.test(e.error??``)}const ye=/ENOTFOUND|EAI_AGAIN|getaddrinfo|DNS|name not resolved|could not resolve/i;function be(e,t){let n=e=>e.replace(/^https?:\/\//i,``).replace(/^www\./i,``).replace(/\/$/,``);return n(e)===n(t)}function j(e,t,n){if(t.timedOut)return{kind:`unknown`,reason:`timeout`};if(t.error!==void 0)return ye.test(t.error)?{kind:`dead`}:{kind:`unknown`,reason:`error`};let r=t.status??0;return r>=300&&r<400&&t.location?be(e,t.location)?{kind:`ok`}:{kind:`moved`,to:t.location}:r>=200&&r<300?t.finalUrl&&!be(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 M(e,t){return new Date(e+t).toISOString()}function N(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 P(e){let{fails:t,firstFailAt:n,since:r,group:i,detail:a,reason:o,...s}=e;return s}function xe(e,t,n){let r=new Date(n).toISOString(),i={...e,checkedAt:r};switch(t.kind){case`ok`:return{...P(i),state:`ok`,due:M(n,7*k)};case`moved`:return{...P(i),state:`look`,reason:`moved`,detail:_(t.to),due:M(n,7*k)};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>=k-O?{...N(i,a,n),fails:(e.fails??0)+1,due:M(n,7*k)}:{...i,state:e.state===`broken`?`broken`:`pending`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:M(n,k)}}case`server`:{let t=e.firstFailAt?Date.parse(e.firstFailAt):NaN;return(e.fails??0)>=2&&n-t>=7*k-O?{...N(i,`server`,n),fails:(e.fails??0)+1,due:M(n,7*k)}:{...i,state:e.state===`broken`?`broken`:`unknown`,reason:e.state===`broken`?e.reason:`trouble`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:M(n,3*k)}}case`unknown`:case`retry`:{let r=t.kind===`unknown`?t.reason:`refuses`;return e.state===`broken`?{...i,due:M(n,k)}:{...i,state:`unknown`,reason:r,due:M(n,r===`slow`||r===`timeout`?k:7*k)}}}}function Se(e,t,n){let r=new Date(n).toISOString(),i={...e,checkedAt:r,due:`9999`};switch(t.kind){case`ok`:return{...P(i),state:`ok`};case`moved`:{let e=d(t.to);return{...P(i),state:`look`,reason:`redirect`,detail:_(e?e.pathname:t.to)}}case`gone`:case`retry`:return N(i,`missing`,n);default:return{...P(i),state:`unknown`,reason:`no-live`}}}function F(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?{...N({...e,checkedAt:r},`page-error`,n),fails:(e.fails??0)+1,due:M(n,k)}:{...e,checkedAt:r,state:e.state===`broken`?`broken`:`pending`,fails:(e.fails??0)+1,firstFailAt:e.firstFailAt??r,due:M(n,6e4)}}return t.status!==void 0&&i<400?{...P({...e,checkedAt:r}),state:`ok`,due:`9999`}:{...P({...e,checkedAt:r}),state:`unknown`,reason:`no-live`,due:`9999`}}async function Ce(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: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(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:`?A(e,`http://${t}/`,{follow:!1}):Promise.resolve(null),A(e,`${s.protocol}//${n}/`,{follow:!1}),A(e,`${c}/site-health-check-a-page-that-does-not-exist`),A(e,`${c}/robots.txt`,{body:!0}),A(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 we=`site-health`,Te=864e5;function I(e){return e.storage.links}function L(e){return e.storage.pages}function R(e){return new Date(e).toISOString()}async function z(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:R(n),lastStepAt:R(n)};return await Promise.all([e.kv.set(i.scan,a),Ee(e)]),a}async function Ee(e){try{await e.cron?.schedule(we,{schedule:`* * * * *`})}catch(t){e.log.warn(`could not schedule the background check`,{error:String(t)})}}function De(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 Oe(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 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`?R(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 ke(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 L(e).putMany(r),n?.hasMore&&n.cursor?t.cursor=n.cursor:(t.cursor=void 0,t.phase=`walk`)}async function Ae(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?ge(r.pattern,r.slug,n):null,a=De(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=Oe(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 L(e).putMany(o),s.size>0){let r=[...s.keys()],i=await I(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=R(n)),{id:e,data:a}});await I(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?_(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:R(i)};return o.group=t(o),o}async function je(e,t,n){let r=await I(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 L(e).getMany([...new Set(i.map(e=>e.data.url))]);await I(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 L(e).count({status:`published`,pass:t.pass}))}async function Me(e,n,r,i,a,o){let s=[];for(let e of a.menu){let t=Oe(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:V(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 I(e).getMany(c),u=s.filter(e=>e.base.kind===`internal`).map(e=>e.base.url),d=u.length>0?await L(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=F(l.get(e.id)??i,{status:t.status},r)),i.pass=n.pass}i.state===`broken`&&!i.since&&(i.since=R(r)),f.set(e.id,i)}await I(e).putMany([...f].map(([e,t])=>({id:e,data:t})))}function Ne(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 Pe(e){return e.status===503?/Database migrations are required|database has not been migrated/i.test((e.body??``).slice(0,2e3)):!1}async function Fe(e,t,n){let r=v(e.site.url);if(!r){t.live=`not-public`,t.phase=`live-paths`;return}let i=await A(e,`${r}/`,{body:!0}),a=i.status??0;if(i.status===void 0){t.live=ve(i)?`not-public`:`failed`,t.phase=`live-paths`;return}if(Pe(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(!Ne(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 Me(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 Ie(e,t,n){let r=v(e.site.url),i=await L(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 A(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 Me(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 Le(t,n,r){let i=v(t.site.url),a=n.live===`ok`&&i!==null,o=await I(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 A(t,`${i}${n.data.url}`);return{id:n.id,data:Se(n.data,j(n.data.url,o,`GET`),r)}}));await I(t).putMany(s)}async function Re(e,t){let[n,r]=await Promise.all([I(e).query({where:{pass:{lt:t.pass}},limit:100}),L(e).query({where:{pass:{lt:t.pass}},limit:100})]);await Promise.all([n.items.length>0?I(e).deleteMany(n.items.map(e=>e.id)):0,r.items.length>0?L(e).deleteMany(r.items.map(e=>e.id)):0]),!n.hasMore&&!r.hasMore&&(t.phase=`count`)}async function ze(e,t){let n=I(e),[r,i,a,o,s,c]=await Promise.all([L(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 Be(e,n,r,i){let a=I(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:R(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=R(i);let h=p.pages===0&&!r.summary;return n.nextAt=R(i+(h?6e5:Te)),n.counts=void 0,n.phase=`redirects`,h?null:m}async function U(e,t,n,r=Date.now()){let a=t.scan;if(!a||a.status!==`running`)return a;let o={...a,lastStepAt:R(r)},s=null;switch(o.phase){case`redirects`:await ke(e,o);break;case`walk`:await Ae(e,o,r);break;case`resolve`:await je(e,o,r);break;case`live-home`:await Fe(e,o,r);break;case`live-pages`:await Ie(e,o,r);break;case`live-paths`:await Le(e,o,r);break;case`checklist`:await Ce(e,o,t,r),o.phase=`sweep`;break;case`sweep`:await Re(e,o);break;case`count`:await ze(e,o);break;case`freeze`:s=await Be(e,o,t,r);break}return s&&(await e.kv.set(i.summary,s),t.summary=s,await ie(e,t,s,r)),o.ops=n.ops+1,await e.kv.set(i.scan,o),t.scan=o,o}const Ve=6e4;async function He(e,t,n,r=Date.now()){let a=await I(e).query({where:{due:{lte:R(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<Ve*(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:F(n,await A(e,`${o}${n.url}`),r)};d+=1;let i=n.get?`GET`:`HEAD`,a=await A(e,n.url,{method:i}),c=j(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=j(t.before.url,await A(e,t.before.url,{method:`GET`}),`GET`)),n.kind===`retry`&&d>=4){t.after={...t.before,due:R(r+Ve),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=xe(t.before,n,r),t.result.kind===`retry`&&(t.after.get=!0)}for(let[e,t]of Object.entries(s))r-t.at>Te&&!t.slow&&delete s[e];f.length>0&&await I(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 ie(e,t,p,r))}return t.queue={hosts:s,cronAt:R(r),ops:n.ops+1},await e.kv.set(i.queue,t.queue),f.length}function Ue(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 We(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?ge(s.pattern,n,r):null,u=De(r,s.titleField),d=r.status===`published`?T(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=Oe(e);t&&!(t.kind===`internal`&&t.url===c)&&p.set(t.id,{base:t,use:e.use})}let m=c?await L(e).get(c):null,h=[...new Set([...p.keys(),...m?.links??[]])].slice(0,400),g=h.length>0?await I(e).getMany(h):new Map,_=[...p.values()].filter(e=>e.base.kind===`internal`).map(e=>e.base.url),v=_.length>0?await L(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=B(c,i.base,f,n?.pass??a.scan.pass,o,i.base.kind===`insecure`?V(i.use):{}),c&&(s={...s,sources:[...e,f].slice(0,10),count:c.count+1,pass:n.pass}),s.kind===`internal`&&(s=H(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=R(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?I(e).putMany(y):void 0,x.length>0?I(e).deleteMany(x):void 0,c?L(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 Ge=10*6e4,Ke=[{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,_(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=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 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 Je(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 ${Ue(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 Ye(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 Xe(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 Ze(e,t,n,r){let{summary:i,scan:a}=t;if(!i)return Je(t);let o=[...n?Ye(i,e.site.url):[],...Xe(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 Ke){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 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.`},Y(`look_table`,`What we noticed`,d.map(e=>({text:q(e,null),action:J(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.`},Y(`unknown_table`,`What happened`,f.map(t=>{let r=t.kind===`internal`?J(t,n):qe(t,e.site.url);return{text:q(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, ${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:p?`refresh`:`check_all`,label:p?`Refresh`:`Check everything again`}]}),o}const Qe={working:`Working`,attention:`Needs attention`,untested:`Not tested yet`,unknown:`Could not tell`};function $e(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<Ge?{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<Ge?{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 et(e){return e.length<=2?`Goes to ${e.join(` and `)}.`:`Goes to ${e.slice(0,2).join(`, `)} and ${e.length-2} more.`}function tt(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=et(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.`}:S.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 nt(e,t,n,r=Date.now()){let i=t?d(t):null,a=i?i.protocol===`https:`?{status:`working`,text:`Set, secure, and the address visitors use.`}:{status:v(i.href)?`attention`:`unknown`,text:v(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`,...$e(e,r)},{name:`Email`,...tt(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 rt(e,t,n,r,i){let a=nt(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:Qe[e.status]}))},...l?[{type:`section`,text:`Email: ${Qe[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:`${_(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=Ze(e,t,n,r);return{blocks:n?[i,{type:`tab`,default_tab:r.tab,panels:[{label:`Problems`,blocks:a},{label:`Checklist`,blocks:rt(t,e.site.url,!!e.email,r,Date.now())}]}]:[i,...a],...r.toast?{toast:r.toast}:{}}}function it(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 ${Ue(Math.max(0,e-s.liveDone),!0)}.`:`First check in progress. Results soon.`},a]}}let c=t?u(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=nt(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 d=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}]},...d?[{type:`context`,text:`Worst: ${_(d.url)}${d.menu||d.url===`/`?` is broken on every page.`:` ${n(d,``).replace(/^[: ]+/,``)}`}`}]:[],a]}}async function at(e){let t=await L(e).query({where:{status:`published`},limit:100}),n=t.hasMore&&t.cursor?await L(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 ot(e){return(await I(e).query({where:{state:`fine`},limit:20})).items.map(({id:e,data:t})=>({id:e,row:t}))}function st(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,a){if(await I(e).put(n,a),t.summary){let o=l(t.summary,n,r,a);o&&(t.summary=o,await e.kv.set(i.summary,o))}}async function ct(n,r,i,a){let o=await I(n).get(i);if(!o)return{type:`info`,message:`That address is no longer on your pages.`};let s=r.summary?.live===`ok`?v(n.site.url):null,c=o,l=o.state===`fine`?{...o,state:`pending`,fine:void 0}:o;if(o.kind===`external`){let e=j(o.url,await A(n,o.url,{method:`HEAD`}),`HEAD`);e.kind===`retry`&&(e=j(o.url,await A(n,o.url),`GET`)),c=xe(l,e,a)}else if(o.kind===`internal`)c=H(l,await L(n).get(o.url)??void 0,a),c.state===`pending`&&(c=s?Se(c,j(o.url,await A(n,`${s}${o.url}`),`GET`),a):{...c,state:`unknown`,reason:`no-live`,due:e});else if(o.kind===`page`)c=s?F({...l,fails:o.state===`broken`?1:0,firstFailAt:new Date(a-6e4).toISOString()},await A(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 A(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=_(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 lt(e,t,n,r,i=!1){try{if(!t.scan||!t.summary&&t.scan.status===`done`){t.scan=await z(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 ut(t,n,r,s){let c=Date.now(),l=await a(r),u=(n?.role??0)>=50,d=W(t)?t:{},f=typeof d.action_id==`string`?d.action_id:``,p={tab:0};if(d.type===`page_load`&&typeof d.page==`string`&&d.page.startsWith(`widget:`))return await lt(r,l,s,c),it(l,u,r.site.url,!!r.email);u&&l.options.recipients===void 0&&n?.email?(l.options={...l.options,recipients:[n.email.toLowerCase()]},await r.kv.set(i.options,l.options)):(d.type===`page_load`||f===`refresh`)&&await lt(r,l,s,c,f===`refresh`);let h=async e=>u?{...e,fine:await ot(r)}:e;if(d.type===`block_action`){if(f===`fix`&&typeof d.value==`string`){let[t=``,i=``]=d.value.split(`|`),a=st(l.summary,i);if(!a)return X(r,l,u,{tab:0,toast:{type:`info`,message:`That problem is no longer listed.`}});if(t===`redirect`&&u)return X(r,l,u,{tab:0,panel:{kind:`redirect`,id:i,url:a.url},pickable:await at(r)});if(t===`menus`)return X(r,l,u,await h({tab:0,panel:{kind:`menus`,url:a.url}}));if(t.startsWith(`edit`)){let e=a.sources.filter(e=>e.c&&e.id)[Number(t.slice(4))];if(e?.c&&e.id)return X(r,l,u,await h({tab:0,panel:{kind:`edit`,url:a.url,title:e.t,collection:e.c,entry:e.id}}))}if(t===`check`)return X(r,l,u,await h({tab:0,toast:await ct(r,l,i,c)}));if(t===`fine`){let t=await I(r).get(i);if(t){let a=n?.name||n?.email||`someone`;await Z(r,l,i,t,{...t,state:`fine`,due:e,fine:{by:a,at:new Date(c).toISOString()}})}return X(r,l,u,await h({tab:0,toast:{type:`success`,message:`Marked as fine. ${_(a.url)} is not checked any more.`}}))}}if(f===`check_fine`&&typeof d.value==`string`&&u)return X(r,l,u,await h({tab:1,toast:await ct(r,l,d.value,c)}));if(f===`check_all`)return l.scan?.status!==`running`&&(l.scan=await z(r,l,c),await U(r,l,s,c)),X(r,l,u,await h({tab:0,toast:{type:`info`,message:`Checking everything again. The last results stay here until it is done.`}}));if(f===`send_test`&&u){if((l.options.recipients??[]).length===0)return X(r,l,u,await h({tab:1,toast:{type:`error`,message:`Add an address under Options first.`}}));let e=await se(r,l,c);return X(r,l,u,await h({tab:1,toast:e.ok?{type:`success`,message:`Test sent to ${K(l.options.recipients?.length??0,`address`,`addresses`)}.`}:{type:`info`,message:S.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=Ke.find(e=>f===`more_${e.group}`)?.group;if(t&&l.summary){let e=W(d.value)?d.value:{},n=typeof e.cursor==`string`&&e.cursor!==`start`?e.cursor:void 0,i=await I(r).query({where:{state:`broken`,group:t},orderBy:{count:`desc`},limit:50,...n?{cursor:n}:{}});return X(r,l,u,await h({tab:0,more:{group:t,rows:i.items.map(({id:e,data:t})=>o(e,t)),...i.hasMore&&i.cursor?{cursor:i.cursor}:{}}}))}return X(r,l,u,await h({tab:f===`close`?0:p.tab}))}if(d.type===`form_submit`&&u){let e=W(d.values)?d.values:{};if(f===`save_options`){let t=te(typeof e.recipients==`string`?e.recipients:(l.options.recipients??[]).join(`
|
|
11
|
+
`));return`wrong`in t?X(r,l,u,await h({tab:1,toast:{type:`error`,message:`"${t.wrong}" is not an email address. Nothing was saved.`}})):(l.options={recipients:t.list,outbound:typeof e.outbound==`boolean`?e.outbound:l.options.outbound},await r.kv.set(i.options,l.options),X(r,l,u,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(f.startsWith(`add_redirect|`)){let t=f.slice(13),n=st(l.summary,t),i=m(typeof e.source==`string`?e.source.trim():n?.url??``),a=typeof e.destination==`string`?e.destination.trim():``,o=a.startsWith(`/`)?m(a):a,s=async e=>X(r,l,u,{tab:0,panel:{kind:`redirect`,id:t,url:i||n?.url||``},pickable:await at(r),problem:e});if(!i.startsWith(`/`))return s(`The old address must be a page on this site, starting with /.`);if(!o.startsWith(`/`)&&!/^https?:\/\//.test(o))return s(`Pick a page to send visitors to.`);if(o===i)return s(`The old address and the new one are the same.`);if((await L(r).get(i))?.status===`published`)return s(`${i} is a live page. Change its address in the page editor instead.`);try{await r.redirects?.create?.({source:i,destination:o,type:301})}catch(e){return s(`EmDash did not add it: ${e instanceof Error?e.message:String(e)}`)}let d={status:`redirect`,path:i,title:i,dest:o,pass:l.scan?.pass??0};await L(r).put(i,d);let p=await I(r).get(t);return p&&await Z(r,l,t,p,H(p,d,c)),X(r,l,u,await h({tab:0,toast:{type:`success`,message:`Done. ${i} now goes to ${o}.`}}))}}return X(r,l,u,await h(p))}const dt=[`kv`,`content`,`schema`,`redirects`,`http`,`email`,`cron`];function ft(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 dt){let i=e[r];i&&(n[r]=ft(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=ft(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 pt(e,t=Date.now()){let n={ops:0},r=Q(e,n),o=await a(r),s=new Date(t).toISOString();if(ae(o,t)){await oe(r,o,t),o.queue={...o.queue,cronAt:s,ops:n.ops+1},await r.kv.set(i.queue,o.queue);return}let c=o.scan;if(!c||c.status===`done`&&c.nextAt!==void 0&&c.nextAt<=s){o.scan={...await z(r,o,t),cronAt:s},await U(r,o,n,t);return}let l=!o.summary||(o.queue.cronAt??``)>=(c?.cronAt??``);if(c?.status===`running`&&l){o.scan={...c,cronAt:s},await U(r,o,n,t);return}await He(r,o,n,t)}async function $(e,t,n,r=!1){if(typeof n==`string`)try{let i=Q(e,{ops:0}),o=await a(i);await We(i,t,(r?null:await i.content?.get(t,n))??{id:n,slug:null,status:`deleted`,data:{}},o)}catch(r){e.log.warn(`could not re-read the page's links`,{collection:t,id:n,error:String(r)})}}const mt=e=>e?.id,ht={hooks:{"plugin:activate":async(e,t)=>{try{await Ee(t);let e={ops:0},n=Q(t,e),r=await a(n);if(r.scan)return;r.scan=await z(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===we&&await pt(t)},"content:afterSave":async(e,t)=>{await $(t,e.collection,mt(e.content))},"content:afterPublish":async(e,t)=>{await $(t,e.collection,mt(e.content))},"content:afterUnpublish":async(e,t)=>{await $(t,e.collection,mt(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 ut(e.input,e.user,Q(t,n),n)}}}};export{pt as cronRun,ht 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.2",
|
|
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",
|