@moorage/plugin-site-health 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +121 -2
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +85 -0
- package/dist/manifest.json +94 -0
- package/dist/plugin.d.mts +10 -0
- package/dist/plugin.mjs +11 -0
- package/emdash-plugin.jsonc +71 -0
- package/listing/description.md +10 -0
- package/listing/faq.md +23 -0
- package/listing/installation.md +16 -0
- package/package.json +66 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @moorage/plugin-site-health
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 93ae7d5: First version of Site Health & Broken Links:
|
|
8
|
+
|
|
9
|
+
- **Broken links,** grouped by how bad (every page, one page, one link), with a Fix menu on every row: send visitors to a page picked from a list, edit the page, check again, or mark as fine.
|
|
10
|
+
- **Links to other sites,** checked politely and confirmed a day apart before they count.
|
|
11
|
+
- **Email** at once for page errors and broken menu links, plus a weekly list of what is new.
|
|
12
|
+
- **A setup checklist** and a dashboard widget.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GoSi Tech
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,122 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Site Health & Broken Links for EmDash
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tells you what is broken on your [EmDash](https://emdashcms.com) site before a customer does: a dead link in the menu, a page that shows an error, a map that will not load. It is a broken link checker for your pages, your menu and the links to other sites, with an email when something breaks and a short setup checklist. It runs on any EmDash site, with no account and no key.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- **Problems, grouped by how bad.**
|
|
10
|
+
- **Every page:** a broken link in the site menu or footer.
|
|
11
|
+
- **One page:** a page that shows visitors an error, or loads a map, video, script or form over an insecure connection (browsers block those, so visitors see an empty box).
|
|
12
|
+
- **One link:** a link inside a page that goes to a page that does not exist, a page not published yet, or a page another website has removed.
|
|
13
|
+
|
|
14
|
+
There is one row per broken address, with the pages it was found on. A row is marked "New since the last check" when it first appeared in the latest check.
|
|
15
|
+
|
|
16
|
+
- **Fix menu on every row.**
|
|
17
|
+
- **Send visitors to another page:** adds a redirect from the dead address to a page you pick from a list, which fixes it everywhere at once. Admins only, as in EmDash itself.
|
|
18
|
+
- **Edit:** opens the page, with the link named.
|
|
19
|
+
- **Check it again now.**
|
|
20
|
+
- **This is fine, stop checking it.**
|
|
21
|
+
- **Two folded lists that never count.**
|
|
22
|
+
- **Worth a look:** links that work but take a detour, and pictures over an insecure connection.
|
|
23
|
+
- **Links we could not check:** sites that turn away automatic checkers, such as LinkedIn and Instagram. These are never called broken.
|
|
24
|
+
- **Email.**
|
|
25
|
+
- At once when a page shows an error or a menu link breaks.
|
|
26
|
+
- A weekly list of only what is new.
|
|
27
|
+
- Sent to several addresses, with the site's name first in the subject.
|
|
28
|
+
- Never more than one email a day of each kind.
|
|
29
|
+
- **Checklist** (Admins):
|
|
30
|
+
- the site address is set and secure;
|
|
31
|
+
- the insecure address sends visitors to the secure one;
|
|
32
|
+
- the address with and without www;
|
|
33
|
+
- a made-up address shows the "page not found" page;
|
|
34
|
+
- search engines are allowed in;
|
|
35
|
+
- pages can be kept ready for visitors;
|
|
36
|
+
- scheduled tasks are running;
|
|
37
|
+
- email works (with a test button).
|
|
38
|
+
- **Dashboard widget** with the count.
|
|
39
|
+
|
|
40
|
+
Editors see the links they can fix. Page errors, the Checklist and Options are for Admins.
|
|
41
|
+
|
|
42
|
+
## What it does not do
|
|
43
|
+
|
|
44
|
+
- It does not change the links inside your pages. That would need permission to rewrite content; the redirect fixes an address on your own site everywhere instead.
|
|
45
|
+
- No score, grade, history, charts or CSV export.
|
|
46
|
+
- No speed tests, security headers, certificate expiry or version checks.
|
|
47
|
+
- No missing alt text (the SEO plugin does that). It does not check canonical addresses (the SEO plugin does that too).
|
|
48
|
+
- It cannot read menus, widget areas or sections directly. Menu and footer links are found on the rendered home page, so they need the site to be public.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
Install from **Plugins** in your EmDash admin, or pin it in code:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
npm install @moorage/plugin-site-health
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
// astro.config.mjs
|
|
60
|
+
import siteHealth from "@moorage/plugin-site-health";
|
|
61
|
+
|
|
62
|
+
emdash({ sandboxed: [siteHealth] });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
On a site without a sandbox runner (the Workers free plan), list it under `plugins: [siteHealth]` instead. It works the same.
|
|
66
|
+
|
|
67
|
+
Then open **Tools > Site Health** in the admin sidebar.
|
|
68
|
+
|
|
69
|
+
**Permissions it asks for:**
|
|
70
|
+
|
|
71
|
+
| Permission | Why |
|
|
72
|
+
| ------------------------------ | ------------------------------------------------------------------- |
|
|
73
|
+
| Read content and content types | Finds the links in your pages. |
|
|
74
|
+
| Change redirects | For "Send visitors to another page". |
|
|
75
|
+
| Fetch any address | Links to other sites and your own address are not known in advance. |
|
|
76
|
+
| Send email | Alerts and the test email. |
|
|
77
|
+
|
|
78
|
+
**It needs EmDash's scheduled tasks.** On Cloudflare that means a Cron Trigger on the Worker. If they are not running, the Checklist says so and gives your web person the steps to copy; the check then moves one step each time the page is opened.
|
|
79
|
+
|
|
80
|
+
## How it checks
|
|
81
|
+
|
|
82
|
+
| What | How | How often |
|
|
83
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
84
|
+
| Your pages | Reads every entry, a batch each minute. Links to your own site are checked against your pages and redirects, with no fetching. | Every day, and a page again when it is saved or published. |
|
|
85
|
+
| Your rendered pages | Opens the home page (for the menu and footer) and then each page, two a minute. | Every day. |
|
|
86
|
+
| Links to other sites | Three a minute, on different sites, never one site more than once a minute. Each one says who it is: `EmDash-SiteHealth/0.1 (+your address)`. A link is broken only when the other website says the page is gone on two checks at least a day apart. | Every week, and again a day after a failure. |
|
|
87
|
+
| Checklist | Up to five requests. | Once a week. |
|
|
88
|
+
|
|
89
|
+
The numbers on the page are always a finished check. A check that is still running never changes them.
|
|
90
|
+
|
|
91
|
+
Each run stays within the EmDash sandbox's limits of about 10 calls and 50 ms of CPU. The scheduled task runs every minute.
|
|
92
|
+
|
|
93
|
+
**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.
|
|
94
|
+
|
|
95
|
+
## FAQ
|
|
96
|
+
|
|
97
|
+
**Why does a link show under "could not check"?** Some sites refuse automatic checkers, ask them to slow down, or did not answer within 8 seconds. Open it to see for yourself; it is tried again next week.
|
|
98
|
+
|
|
99
|
+
**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.
|
|
100
|
+
|
|
101
|
+
**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 configured in EmDash; "Send a test email" tells you whether it has one.
|
|
102
|
+
|
|
103
|
+
**Does it send anything anywhere?** Only to the websites your pages link to (to check them) and the email addresses you list.
|
|
104
|
+
|
|
105
|
+
## Changelog
|
|
106
|
+
|
|
107
|
+
- **0.1.0:** first version.
|
|
108
|
+
|
|
109
|
+
## Develop
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
pnpm --filter @moorage/plugin-site-health build
|
|
113
|
+
pnpm --filter @moorage/plugin-site-health test # sandboxed, own pages and other sites simulated
|
|
114
|
+
DEMO_SITE_PORT=4402 pnpm --filter demo-site exec playwright test tests/site-health.spec.ts
|
|
115
|
+
DEMO_SITE_PORT=4402 DEMO_SITE_SITE_HEALTH=in-process pnpm --filter demo-site exec playwright test tests/site-health.spec.ts
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The tests never contact a real site.
|
|
119
|
+
|
|
120
|
+
## Licence
|
|
121
|
+
|
|
122
|
+
MIT. Copyright 2026 GoSi Tech.
|
package/dist/index.d.mts
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Auto-generated by emdash-plugin build. Do not edit.
|
|
2
|
+
// Source: emdash-plugin.jsonc + package.json
|
|
3
|
+
//
|
|
4
|
+
// Default-exports a sandboxed plugin descriptor. Pass it directly into
|
|
5
|
+
// emdash's `plugins:` or `sandboxed:` array — no factory call needed.
|
|
6
|
+
|
|
7
|
+
/** @type {import("emdash").PluginDescriptor} */
|
|
8
|
+
const descriptor = Object.freeze({
|
|
9
|
+
"id": "site-health",
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"format": "standard",
|
|
12
|
+
"entrypoint": "@moorage/plugin-site-health/sandbox",
|
|
13
|
+
"capabilities": [
|
|
14
|
+
"content:read",
|
|
15
|
+
"schema:read",
|
|
16
|
+
"redirects:read",
|
|
17
|
+
"redirects:write",
|
|
18
|
+
"network:request:unrestricted",
|
|
19
|
+
"network:request",
|
|
20
|
+
"email:send"
|
|
21
|
+
],
|
|
22
|
+
"allowedHosts": [],
|
|
23
|
+
"storage": {
|
|
24
|
+
"links": {
|
|
25
|
+
"indexes": [
|
|
26
|
+
"state",
|
|
27
|
+
"due",
|
|
28
|
+
"pass",
|
|
29
|
+
[
|
|
30
|
+
"kind",
|
|
31
|
+
"state"
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"state",
|
|
35
|
+
"group"
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
"state",
|
|
39
|
+
"editor"
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
"state",
|
|
43
|
+
"count"
|
|
44
|
+
]
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"pages": {
|
|
48
|
+
"indexes": [
|
|
49
|
+
"status",
|
|
50
|
+
"pass"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"hooks": [
|
|
55
|
+
"plugin:activate",
|
|
56
|
+
"cron",
|
|
57
|
+
"content:afterSave",
|
|
58
|
+
"content:afterPublish",
|
|
59
|
+
"content:afterUnpublish",
|
|
60
|
+
"content:afterDelete"
|
|
61
|
+
],
|
|
62
|
+
"routes": [
|
|
63
|
+
{
|
|
64
|
+
"name": "admin",
|
|
65
|
+
"permission": "plugins:read"
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
"adminPages": [
|
|
69
|
+
{
|
|
70
|
+
"path": "/health",
|
|
71
|
+
"label": "Site Health",
|
|
72
|
+
"icon": "heartbeat",
|
|
73
|
+
"group": "Tools"
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
"adminWidgets": [
|
|
77
|
+
{
|
|
78
|
+
"id": "site-health",
|
|
79
|
+
"title": "Site health",
|
|
80
|
+
"size": "half"
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export default descriptor;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "site-health",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"declaredAccess": {
|
|
5
|
+
"content": {
|
|
6
|
+
"read": {}
|
|
7
|
+
},
|
|
8
|
+
"schema": {
|
|
9
|
+
"read": {}
|
|
10
|
+
},
|
|
11
|
+
"redirects": {
|
|
12
|
+
"read": {},
|
|
13
|
+
"write": {}
|
|
14
|
+
},
|
|
15
|
+
"network": {
|
|
16
|
+
"request": {}
|
|
17
|
+
},
|
|
18
|
+
"email": {
|
|
19
|
+
"send": {}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"capabilities": [
|
|
23
|
+
"content:read",
|
|
24
|
+
"schema:read",
|
|
25
|
+
"redirects:read",
|
|
26
|
+
"redirects:write",
|
|
27
|
+
"network:request:unrestricted",
|
|
28
|
+
"network:request",
|
|
29
|
+
"email:send"
|
|
30
|
+
],
|
|
31
|
+
"allowedHosts": [],
|
|
32
|
+
"storage": {
|
|
33
|
+
"links": {
|
|
34
|
+
"indexes": [
|
|
35
|
+
"state",
|
|
36
|
+
"due",
|
|
37
|
+
"pass",
|
|
38
|
+
[
|
|
39
|
+
"kind",
|
|
40
|
+
"state"
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
"state",
|
|
44
|
+
"group"
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
"state",
|
|
48
|
+
"editor"
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
"state",
|
|
52
|
+
"count"
|
|
53
|
+
]
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"pages": {
|
|
57
|
+
"indexes": [
|
|
58
|
+
"status",
|
|
59
|
+
"pass"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"hooks": [
|
|
64
|
+
"plugin:activate",
|
|
65
|
+
"cron",
|
|
66
|
+
"content:afterSave",
|
|
67
|
+
"content:afterPublish",
|
|
68
|
+
"content:afterUnpublish",
|
|
69
|
+
"content:afterDelete"
|
|
70
|
+
],
|
|
71
|
+
"routes": [
|
|
72
|
+
{
|
|
73
|
+
"name": "admin",
|
|
74
|
+
"permission": "plugins:read"
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
"admin": {
|
|
78
|
+
"pages": [
|
|
79
|
+
{
|
|
80
|
+
"path": "/health",
|
|
81
|
+
"label": "Site Health",
|
|
82
|
+
"icon": "heartbeat",
|
|
83
|
+
"group": "Tools"
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"widgets": [
|
|
87
|
+
{
|
|
88
|
+
"id": "site-health",
|
|
89
|
+
"title": "Site health",
|
|
90
|
+
"size": "half"
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { PluginContext } from "emdash";
|
|
2
|
+
import { SandboxedPlugin } from "emdash/plugin";
|
|
3
|
+
|
|
4
|
+
//#region src/plugin.d.ts
|
|
5
|
+
/** One scheduled run: send a due email, or move the full check one step, or check what is due. */
|
|
6
|
+
declare function cronRun(raw: PluginContext, now?: number): Promise<void>;
|
|
7
|
+
declare const plugin: SandboxedPlugin;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { cronRun, plugin as default };
|
|
10
|
+
//# sourceMappingURL=plugin.d.mts.map
|
package/dist/plugin.mjs
ADDED
|
@@ -0,0 +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)}}async function S(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 ne(e){return e.site.name.trim()||v(e.site.url)?.replace(/^https?:\/\//,``)||`Your site`}async function re(e,t,n,r){let a=[...n.rows.site,...n.rows.page].filter(x).filter(e=>!t.alerts.sent.includes(e.id));if(a.length===0)return;let o={...t.alerts,pending:[...t.alerts.pending,...a.map(e=>({id:e.id,text:b(e)}))].slice(-20),sent:[...t.alerts.sent,...a.map(e=>e.id)].slice(-200)};t.alerts=o,await e.kv.set(i.alerts,o)}function ie(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 ae(e,t,n){let r=t.options.recipients??[],a={...t.alerts},o=ne(e),s=ee(e),c=new Date(n).toISOString();if(a.pending.length>0&&(!a.urgentAt||n-Date.parse(a.urgentAt)>=y)){let t=await S(e,r,`${o}: ${a.pending.some(e=>/visitors see an error/.test(e.text))?`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=t.ok?{state:`working`,at:c,to:r.length}:{state:`not-set-up`,at:c,to:0,error:t.error}}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,l=await S(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
|
+
`));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).
|
|
4
|
+
On Cloudflare: add a Cron Trigger to the Worker, for example in wrangler.jsonc:
|
|
5
|
+
"triggers": { "crons": ["* * * * *"] }
|
|
6
|
+
and make sure the Worker's entry exports EmDash's scheduled() handler.
|
|
7
|
+
On Node: EmDash runs scheduled tasks inside the server process; check the server stays running.
|
|
8
|
+
Then run: npx emdash doctor
|
|
9
|
+
See EmDash's deployment guide, "Scheduled tasks".`,language:`bash`}]}):a.length>0&&s.push({type:`banner`,variant:`alert`,title:`${G(a.length,`thing needs`,`things need`)} attention`,description:a[0]?.text??``}),s.push({type:`table`,page_action_id:`checklist_table`,columns:[{key:`check`,label:`Check`},{key:`status`,label:`Status`,format:`badge`}],rows:o.map(e=>({check:`${e.name}. ${e.text}`,status:Ye[e.status]}))},{type:`section`,text:`Send a test email to the people on your alert list.`,accessory:{type:`button`,action_id:`send_test`,label:`Send a test email`}},{type:`context`,text:e.checklist.checkedAt?`Checked ${W(e.checklist.checkedAt)}. Changes you make elsewhere show here after the next check.`:`The rest is checked with the next full check.`});let c=e.options.recipients??[],l=n.fine??[];return s.push({type:`accordion`,label:`Options`,default_open:n.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:c.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 (${l.length})`},l.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:l.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}}))}]}),s}function Y(e,t,n,r){let i={type:`context`,text:`Finds broken links and setup problems before your visitors do.`},a=Je(e,t,n,r);return{blocks:n?[i,{type:`tab`,default_tab:r.tab,panels:[{label:`Problems`,blocks:a},{label:`Checklist`,blocks:$e(t,e.site.url,r,Date.now())}]}]:[i,...a],...r.toast?{toast:r.toast}:{}}}function et(e,t,r){let i={type:`actions`,elements:[{type:`link`,label:`Open Site Health`,target:{kind:`plugin-page`,path:`/health`}}]},{summary:a,scan:o}=e;if(!a){let e=o?.liveTotal;return{blocks:[{type:`section`,text:o&&e&&o.live===`ok`?`First check in progress: ${Math.min(o.liveDone,e)} of ${G(e,`page`)}. Results in ${Be(Math.max(0,e-o.liveDone),!0)}.`:`First check in progress. Results soon.`},i]}}let s=t?u(a):a.editorFix;if(!t)return{blocks:[{type:`section`,text:s===0?`Nothing broken. Checked ${W(a.finishedAt)}.`:`${G(s,`broken link`)} on your pages.`},i]};let c=Qe(e,r).filter(e=>e.status===`attention`&&e.name!==`Quick repeat visits`).length;if(s===0&&c===0)return{blocks:[{type:`section`,text:`Nothing broken. Checked ${W(a.finishedAt)}.`},i]};let l=a.rows.site[0]??a.rows.page[0]??a.rows.link[0];return{blocks:[{type:`stats`,items:[{label:`To fix`,value:s},{label:`Setup`,value:c}]},...l?[{type:`context`,text:`Worst: ${_(l.url)}${l.menu||l.url===`/`?` is broken on every page.`:` ${n(l,``).replace(/^[: ]+/,``)}`}`}]:[],i]}}async function tt(e){let t=await F(e).query({where:{status:`published`},limit:100}),n=t.hasMore&&t.cursor?await F(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 nt(e){return(await P(e).query({where:{state:`fine`},limit:20})).items.map(({id:e,data:t})=>({id:e,row:t}))}function rt(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 X(e,t,n,r,a){if(await P(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 it(n,r,i,a){let o=await P(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=k(o.url,await O(n,o.url,{method:`HEAD`}),`HEAD`);e.kind===`retry`&&(e=k(o.url,await O(n,o.url),`GET`)),c=be(l,e,a)}else if(o.kind===`internal`)c=V(l,await F(n).get(o.url)??void 0,a),c.state===`pending`&&(c=s?xe(c,k(o.url,await O(n,`${s}${o.url}`),`GET`),a):{...c,state:`unknown`,reason:`no-live`,due:e});else if(o.kind===`page`)c=s?N({...l,fails:o.state===`broken`?1:0,firstFailAt:new Date(a-6e4).toISOString()},await O(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&&C(r.data,n.site.url).some(e=>e.url===o.url)}else if(s&&e){let r=await O(n,`${s}${e.p||`/`}`,{body:!0}),i=r.body?T(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 X(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 at(e,t,n,r,i=!1){try{if(!t.scan||!t.summary&&t.scan.status===`done`){t.scan=await L(e,t,r),await H(e,t,n,r);return}let a=Date.parse(t.scan.lastStepAt??t.scan.startedAt);t.scan.status===`running`&&(i||r-a>12e4)&&await H(e,t,n,r)}catch(t){e.log.warn(`could not move the check along`,{error:String(t)})}}async function ot(t,n,r,s){let c=Date.now(),l=await a(r),u=(n?.role??0)>=50,d=U(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 at(r,l,s,c),et(l,u,r.site.url);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 at(r,l,s,c,f===`refresh`);let h=async e=>u?{...e,fine:await nt(r)}:e;if(d.type===`block_action`){if(f===`fix`&&typeof d.value==`string`){let[t=``,i=``]=d.value.split(`|`),a=rt(l.summary,i);if(!a)return Y(r,l,u,{tab:0,toast:{type:`info`,message:`That problem is no longer listed.`}});if(t===`redirect`&&u)return Y(r,l,u,{tab:0,panel:{kind:`redirect`,id:i,url:a.url},pickable:await tt(r)});if(t===`menus`)return Y(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 Y(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 Y(r,l,u,await h({tab:0,toast:await it(r,l,i,c)}));if(t===`fine`){let t=await P(r).get(i);if(t){let a=n?.name||n?.email||`someone`;await X(r,l,i,t,{...t,state:`fine`,due:e,fine:{by:a,at:new Date(c).toISOString()}})}return Y(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 Y(r,l,u,await h({tab:1,toast:await it(r,l,d.value,c)}));if(f===`check_all`)return l.scan?.status!==`running`&&(l.scan=await L(r,l,c),await H(r,l,s,c)),Y(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)return(l.options.recipients??[]).length===0?Y(r,l,u,await h({tab:1,toast:{type:`error`,message:`Add an address under Options first.`}})):Y(r,l,u,await h({tab:1,toast:(await oe(r,l,c)).ok?{type:`success`,message:`Test sent to ${G(l.options.recipients?.length??0,`address`,`addresses`)}.`}:{type:`info`,message:`Your site cannot send email yet. Whoever set it up can add an email service.`}}));let t=Ue.find(e=>f===`more_${e.group}`)?.group;if(t&&l.summary){let e=U(d.value)?d.value:{},n=typeof e.cursor==`string`&&e.cursor!==`start`?e.cursor:void 0,i=await P(r).query({where:{state:`broken`,group:t},orderBy:{count:`desc`},limit:50,...n?{cursor:n}:{}});return Y(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 Y(r,l,u,await h({tab:f===`close`?0:p.tab}))}if(d.type===`form_submit`&&u){let e=U(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?Y(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),Y(r,l,u,await h({tab:1,toast:{type:`success`,message:t.list.length===0?`Saved. Nobody gets emails now.`:`Saved. Emails go to ${G(t.list.length,`address`,`addresses`)}.`}})))}if(f.startsWith(`add_redirect|`)){let t=f.slice(13),n=rt(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=>Y(r,l,u,{tab:0,panel:{kind:`redirect`,id:t,url:i||n?.url||``},pickable:await tt(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 F(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 F(r).put(i,d);let p=await P(r).get(t);return p&&await X(r,l,t,p,V(p,d,c)),Y(r,l,u,await h({tab:0,toast:{type:`success`,message:`Done. ${i} now goes to ${o}.`}}))}}return Y(r,l,u,await h(p))}const st=[`kv`,`content`,`schema`,`redirects`,`http`,`email`,`cron`];function ct(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 Z(e,t){let n={};for(let r of st){let i=e[r];i&&(n[r]=ct(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=ct(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 lt(e,t=Date.now()){let n={ops:0},r=Z(e,n),o=await a(r),s=new Date(t).toISOString();if(ie(o,t)){await ae(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 L(r,o,t),cronAt:s},await H(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 H(r,o,n,t);return}await ze(r,o,n,t)}async function Q(e,t,n,r=!1){if(typeof n==`string`)try{let i=Z(e,{ops:0}),o=await a(i);await Ve(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 $=e=>e?.id,ut={hooks:{"plugin:activate":async(e,t)=>{try{await Te(t);let e={ops:0},n=Z(t,e),r=await a(n);if(r.scan)return;r.scan=await L(n,r),await H(n,r,e)}catch(e){t.log.warn(`could not start the first check`,{error:String(e)})}},cron:async(e,t)=>{e.name===Ce&&await lt(t)},"content:afterSave":async(e,t)=>{await Q(t,e.collection,$(e.content))},"content:afterPublish":async(e,t)=>{await Q(t,e.collection,$(e.content))},"content:afterUnpublish":async(e,t)=>{await Q(t,e.collection,$(e.content))},"content:afterDelete":async(e,t)=>{await Q(t,e.collection,e.id,!0)}},routes:{admin:{permission:`plugins:read`,handler:async(e,t)=>{let n={ops:0};return ot(e.input,e.user,Z(t,n),n)}}}};export{lt as cronRun,ut as default};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
|
|
3
|
+
|
|
4
|
+
"slug": "site-health",
|
|
5
|
+
// Listing text. Registry search matches exact phrases in name, description,
|
|
6
|
+
// keywords and sections, so the phrases people type appear verbatim.
|
|
7
|
+
"name": "Site Health & Broken Links",
|
|
8
|
+
"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.",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"broken links",
|
|
11
|
+
"broken link checker",
|
|
12
|
+
"link checker",
|
|
13
|
+
"site health",
|
|
14
|
+
"email alerts",
|
|
15
|
+
],
|
|
16
|
+
"repo": "https://github.com/moorage-dev/moorage",
|
|
17
|
+
"sections": {
|
|
18
|
+
"description": { "file": "listing/description.md" },
|
|
19
|
+
"installation": { "file": "listing/installation.md" },
|
|
20
|
+
"faq": { "file": "listing/faq.md" },
|
|
21
|
+
},
|
|
22
|
+
"release": {
|
|
23
|
+
"requires": { "env:emdash": ">=1.3.0" },
|
|
24
|
+
"artifacts": {
|
|
25
|
+
"screenshots": [
|
|
26
|
+
{ "file": "./images/problems.png", "lang": "en" },
|
|
27
|
+
{ "file": "./images/fix.png", "lang": "en" },
|
|
28
|
+
{ "file": "./images/checklist.png", "lang": "en" },
|
|
29
|
+
{ "file": "./images/problems-phone.png", "lang": "en" },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
"publisher": "did:plc:cus3srdqglppyowqrw3hvjfj",
|
|
34
|
+
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"author": { "name": "Moorage" },
|
|
37
|
+
"security": { "email": "security@moorage.dev" },
|
|
38
|
+
|
|
39
|
+
// Trust contract. network:request:unrestricted: links to other sites and the
|
|
40
|
+
// site's own address are not known when the plugin is built. email:send: alerts
|
|
41
|
+
// and the test email. redirects:write: the "send visitors to another page" fix,
|
|
42
|
+
// offered to Admins only, as in EmDash itself.
|
|
43
|
+
"capabilities": [
|
|
44
|
+
"content:read",
|
|
45
|
+
"schema:read",
|
|
46
|
+
"redirects:write",
|
|
47
|
+
"network:request:unrestricted",
|
|
48
|
+
"email:send",
|
|
49
|
+
],
|
|
50
|
+
"allowedHosts": [],
|
|
51
|
+
"admin": {
|
|
52
|
+
"pages": [{ "path": "/health", "label": "Site Health", "icon": "heartbeat", "group": "Tools" }],
|
|
53
|
+
"widgets": [{ "id": "site-health", "title": "Site health", "size": "half" }],
|
|
54
|
+
},
|
|
55
|
+
"storage": {
|
|
56
|
+
// One row per address found: a link, an embedded item, or a page of this site.
|
|
57
|
+
"links": {
|
|
58
|
+
"indexes": [
|
|
59
|
+
"state",
|
|
60
|
+
"due",
|
|
61
|
+
"pass",
|
|
62
|
+
["kind", "state"],
|
|
63
|
+
["state", "group"],
|
|
64
|
+
["state", "editor"],
|
|
65
|
+
["state", "count"],
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
// One row per entry (by its address) and per redirect source.
|
|
69
|
+
"pages": { "indexes": ["status", "pass"] },
|
|
70
|
+
},
|
|
71
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Site Health is a broken link checker for EmDash that tells you what is broken before a customer does: a dead link in the menu, a page that shows an error, a map that will not load.
|
|
2
|
+
|
|
3
|
+
- **Broken links, grouped by how bad.** Every page (the site menu and footer), one page (a page that shows an error, or loads a map or form over an insecure connection), one link (a link inside a page that goes nowhere). One row per broken address, with every page it was found on.
|
|
4
|
+
- **Fix in one step.** Send visitors from a dead address on your site to a page you pick from a list, and the link is fixed everywhere it appears. Or open the page with the link named. Check any row again at once, or mark it as fine.
|
|
5
|
+
- **Links to other sites, checked politely.** A few at a time, never one site more than once a minute. A link is called broken only when the other website says the page is gone, twice, a day apart. Sites that turn away automatic checkers (LinkedIn, Instagram) are listed as "could not check", never as broken.
|
|
6
|
+
- **Email when it matters.** At once when a page shows an error or a menu link breaks, and a weekly list of only what is new. Several addresses, the site's name first in the subject, never more than one email a day of each kind.
|
|
7
|
+
- **A setup checklist** for whoever looks after the site: secure address, with and without www, the "page not found" page, search engines allowed in, scheduled tasks running, email working.
|
|
8
|
+
- **A dashboard widget** with the count.
|
|
9
|
+
|
|
10
|
+
No account, no key, nothing sent to anyone but the websites your pages link to. Editors see the links they can fix; page errors and setup are for Admins.
|
package/listing/faq.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
### Why does it say "could not check" instead of broken?
|
|
2
|
+
|
|
3
|
+
Some websites, such as LinkedIn and Instagram, turn away every automatic checker. Calling those links broken is the most common complaint about link checkers, so they are listed apart and never counted. Open one to check it yourself.
|
|
4
|
+
|
|
5
|
+
### Why do links to other sites take a day?
|
|
6
|
+
|
|
7
|
+
A link is called broken only when the other website says the page is gone on two checks at least a day apart. A site that is down for an hour should not send you an alert.
|
|
8
|
+
|
|
9
|
+
### Will it slow my site down or annoy other websites?
|
|
10
|
+
|
|
11
|
+
No. It reads your pages a few at a time in the background, asks other websites for a few pages a minute, never the same website more than once a minute, and says who it is.
|
|
12
|
+
|
|
13
|
+
### Can it change the links inside my pages for me?
|
|
14
|
+
|
|
15
|
+
Not yet. For an address on your own site, "Send visitors to another page" fixes the link everywhere at once. For other links, it opens the page with the link named.
|
|
16
|
+
|
|
17
|
+
### My site is not public yet. Does it work?
|
|
18
|
+
|
|
19
|
+
Links inside your pages are checked against your own pages straight away. Menus, page errors and the Checklist start once the site has a public address.
|
|
20
|
+
|
|
21
|
+
### Who gets the emails?
|
|
22
|
+
|
|
23
|
+
The first Admin who opens Site Health, plus anyone you add under Checklist, Options. Your site needs an email service for EmDash; the Checklist's "Send a test email" tells you whether it has one.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Install from **Plugins** in your EmDash admin, or pin it in code:
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
npm install @moorage/plugin-site-health
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
// astro.config.mjs
|
|
9
|
+
import siteHealth from "@moorage/plugin-site-health";
|
|
10
|
+
|
|
11
|
+
emdash({ sandboxed: [siteHealth] });
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then open **Tools > Site Health** in the admin sidebar. The first check starts by itself and takes about 20 minutes for a site with 40 pages; links to other sites follow a day later.
|
|
15
|
+
|
|
16
|
+
Site Health needs EmDash's scheduled tasks. On Cloudflare that means a Cron Trigger on the Worker; the Checklist tab says so if they are not running.
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moorage/plugin-site-health",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"emdash",
|
|
7
|
+
"emdash-plugin",
|
|
8
|
+
"emdash-cms",
|
|
9
|
+
"cms",
|
|
10
|
+
"broken-links",
|
|
11
|
+
"broken-link-checker",
|
|
12
|
+
"link-checker",
|
|
13
|
+
"site-health",
|
|
14
|
+
"wordpress"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Moorage (https://moorage.dev)",
|
|
18
|
+
"homepage": "https://moorage.dev/plugins/site-health",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/moorage-dev/moorage.git",
|
|
22
|
+
"directory": "packages/plugin-site-health"
|
|
23
|
+
},
|
|
24
|
+
"bugs": "https://github.com/moorage-dev/moorage/issues",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "dist/index.mjs",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"import": "./dist/index.mjs",
|
|
33
|
+
"types": "./dist/index.d.mts"
|
|
34
|
+
},
|
|
35
|
+
"./sandbox": "./dist/plugin.mjs"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"!dist/*.tar.gz",
|
|
40
|
+
"emdash-plugin.jsonc",
|
|
41
|
+
"listing",
|
|
42
|
+
"CHANGELOG.md",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"emdash": ">=1.3.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@emdash-cms/blocks": "1.3.0",
|
|
51
|
+
"@emdash-cms/plugin-cli": "0.13.4",
|
|
52
|
+
"@emdash-cms/plugin-test": "0.2.9",
|
|
53
|
+
"@moorage/typescript-config": "0.0.0",
|
|
54
|
+
"emdash": "1.3.0",
|
|
55
|
+
"typescript": "~6.0.3",
|
|
56
|
+
"vitest": "4.1.10"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"validate": "emdash-plugin validate",
|
|
60
|
+
"build": "emdash-plugin build",
|
|
61
|
+
"dev": "emdash-plugin dev",
|
|
62
|
+
"typecheck": "tsc --noEmit",
|
|
63
|
+
"test": "emdash-plugin validate && vitest run",
|
|
64
|
+
"bundle": "emdash-plugin bundle",
|
|
65
|
+
"login": "emdash-plugin login",
|
|
66
|
+
"registry:publish": "emdash-plugin publish",
|
|
67
|
+
"release:setup": "emdash-plugin release setup"
|
|
68
|
+
}
|
|
6
69
|
}
|