@moorage/plugin-seo 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 +16 -0
- package/LICENSE +110 -0
- package/README.md +81 -2
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +74 -0
- package/dist/manifest.json +80 -0
- package/dist/plugin.d.mts +12 -0
- package/dist/plugin.mjs +6 -0
- package/emdash-plugin.jsonc +38 -0
- package/listing/description.md +25 -0
- package/listing/faq.md +27 -0
- package/listing/installation.md +14 -0
- package/package.json +64 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @moorage/plugin-seo
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ad5eea3: First release.
|
|
8
|
+
|
|
9
|
+
- **Schema markup.** One linked structured-data graph per page in place of EmDash's single JSON-LD object: Organization, Person or a local business with address and opening hours; WebSite; WebPage; blog posts and articles with their byline authors; services; team members; breadcrumbs; the primary image. Extra schema per entry through a `json_ld` field.
|
|
10
|
+
- **Robots tag.** Hide a whole content type from search, with snippet and image-preview defaults on indexable pages. A page hidden in EmDash's SEO panel always stays hidden.
|
|
11
|
+
- **SEO page** in the admin, with three tabs. Overview: pages checked, pages to fix with a link to each, the details Google still needs for your kind of site, and warnings when content is hidden. Redirects: add, change and remove redirects, and import a list exported from Redirection, Yoast SEO Premium or Rank Math. Settings: who the site is for, address and phone, opening hours, logo, profiles elsewhere and content types, each marked needed, worth adding or optional.
|
|
12
|
+
- **Page checks**, run on every save and across the whole site: no title, a title that says nothing, no search description, a title shared with another page, a canonical pointing at a different page; and advice on long titles, missing social images, articles without an author, pictures without alt text and nearly empty pages.
|
|
13
|
+
- **Google preview** panel in the entry editor: the address, title and description as Google may show them, the page's type, and what to fix.
|
|
14
|
+
- **Dashboard widget** with the number of pages to fix.
|
|
15
|
+
|
|
16
|
+
Requires EmDash 1.2.0 or newer. Asks for four permissions: read content, read bylines, read the content schema, manage redirects.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Functional Source License, Version 1.1, MIT Future License
|
|
2
|
+
|
|
3
|
+
## Abbreviation
|
|
4
|
+
|
|
5
|
+
FSL-1.1-MIT
|
|
6
|
+
|
|
7
|
+
## Notice
|
|
8
|
+
|
|
9
|
+
Copyright 2026 Moorage
|
|
10
|
+
|
|
11
|
+
## Terms and Conditions
|
|
12
|
+
|
|
13
|
+
### Licensor ("We")
|
|
14
|
+
|
|
15
|
+
The party offering the Software under these Terms and Conditions.
|
|
16
|
+
|
|
17
|
+
### The Software
|
|
18
|
+
|
|
19
|
+
The "Software" is each version of the software that we make available under
|
|
20
|
+
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
|
21
|
+
Conditions with the Software.
|
|
22
|
+
|
|
23
|
+
### License Grant
|
|
24
|
+
|
|
25
|
+
Subject to your compliance with this License Grant and the Patents,
|
|
26
|
+
Redistribution and Trademark clauses below, we hereby grant you the right to
|
|
27
|
+
use, copy, modify, create derivative works, publicly perform, publicly display
|
|
28
|
+
and redistribute the Software for any Permitted Purpose identified below.
|
|
29
|
+
|
|
30
|
+
### Permitted Purpose
|
|
31
|
+
|
|
32
|
+
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
|
33
|
+
means making the Software available to others in a commercial product or
|
|
34
|
+
service that:
|
|
35
|
+
|
|
36
|
+
1. substitutes for the Software;
|
|
37
|
+
|
|
38
|
+
2. substitutes for any other product or service we offer using the Software
|
|
39
|
+
that exists as of the date we make the Software available; or
|
|
40
|
+
|
|
41
|
+
3. offers the same or substantially similar functionality as the Software.
|
|
42
|
+
|
|
43
|
+
Permitted Purposes specifically include using the Software:
|
|
44
|
+
|
|
45
|
+
1. for your internal use and access;
|
|
46
|
+
|
|
47
|
+
2. for non-commercial education;
|
|
48
|
+
|
|
49
|
+
3. for non-commercial research; and
|
|
50
|
+
|
|
51
|
+
4. in connection with professional services that you provide to a licensee
|
|
52
|
+
using the Software in accordance with these Terms and Conditions.
|
|
53
|
+
|
|
54
|
+
### Patents
|
|
55
|
+
|
|
56
|
+
To the extent your use for a Permitted Purpose would necessarily infringe our
|
|
57
|
+
patents, the license grant above includes a license under our patents. If you
|
|
58
|
+
make a claim against any party that the Software infringes or contributes to
|
|
59
|
+
the infringement of any patent, then your patent license to the Software ends
|
|
60
|
+
immediately.
|
|
61
|
+
|
|
62
|
+
### Redistribution
|
|
63
|
+
|
|
64
|
+
The Terms and Conditions apply to all copies, modifications and derivatives of
|
|
65
|
+
the Software.
|
|
66
|
+
|
|
67
|
+
If you redistribute any copies, modifications or derivatives of the Software,
|
|
68
|
+
you must include a copy of or a link to these Terms and Conditions and not
|
|
69
|
+
remove any copyright notices provided in or with the Software.
|
|
70
|
+
|
|
71
|
+
### Disclaimer
|
|
72
|
+
|
|
73
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
74
|
+
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
75
|
+
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
76
|
+
|
|
77
|
+
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
|
78
|
+
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
|
79
|
+
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
|
80
|
+
|
|
81
|
+
### Trademarks
|
|
82
|
+
|
|
83
|
+
Except for displaying the License Details and identifying us as the origin of
|
|
84
|
+
the Software, you have no right under these Terms and Conditions to use our
|
|
85
|
+
trademarks, trade names, service marks or product names.
|
|
86
|
+
|
|
87
|
+
## Grant of Future License
|
|
88
|
+
|
|
89
|
+
We hereby irrevocably grant you an additional license to use the Software under
|
|
90
|
+
the MIT license that is effective on the second anniversary of the date we make
|
|
91
|
+
the Software available. On or after that date, you may use the Software under
|
|
92
|
+
the MIT license, in which case the following will apply:
|
|
93
|
+
|
|
94
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
95
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
96
|
+
the Software without restriction, including without limitation the rights to
|
|
97
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
98
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
99
|
+
so, subject to the following conditions:
|
|
100
|
+
|
|
101
|
+
The above copyright notice and this permission notice shall be included in all
|
|
102
|
+
copies or substantial portions of the Software.
|
|
103
|
+
|
|
104
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
105
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
106
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
107
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
108
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
109
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
110
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @moorage/plugin-seo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SEO & Schema Markup for [EmDash](https://emdashcms.com): one linked structured data graph, noindex and snippet rules, a Google preview, on-page SEO checks and redirects import from WordPress. Runs on any EmDash site, self-hosted or on Moorage, with no account and no key.
|
|
4
|
+
|
|
5
|
+
## What it does today
|
|
6
|
+
|
|
7
|
+
- Replaces EmDash's single JSON-LD object with one linked graph per page: Organization (or Person, or a LocalBusiness type), WebSite, WebPage, BreadcrumbList, the primary image, and BlogPosting with byline authors and dates on article pages.
|
|
8
|
+
- Writes the robots tag: `noindex` per collection, and `max-snippet`, `max-image-preview` and `max-video-preview` limits on indexable pages. A noindex set in an entry's SEO panel is always kept.
|
|
9
|
+
- Uses the theme's breadcrumb trail when it passes one, otherwise derives Home and one crumb per URL segment.
|
|
10
|
+
- Emits extra schema from an entry: add a JSON field named `json_ld` to a collection and paste any schema.org object (for example a Service or an Event).
|
|
11
|
+
|
|
12
|
+
It does not write the page title, the sitemap or robots.txt; EmDash core and your theme do.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
From npm, registered in your site's code:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pnpm add @moorage/plugin-seo
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
// astro.config.mjs
|
|
24
|
+
import seo from "@moorage/plugin-seo";
|
|
25
|
+
import emdash from "emdash/astro";
|
|
26
|
+
|
|
27
|
+
export default defineConfig({
|
|
28
|
+
integrations: [emdash({ sandboxed: [seo] })],
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`sandboxed` needs a sandbox runner: on Cloudflare it is the Worker Loader binding from `@emdash-cms/cloudflare`; on Node add `@emdash-cms/sandbox-workerd` and set `sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox"`. Without a runner, list the plugin under `plugins: [seo]` instead, which runs it in-process. Requires EmDash 1.2.0 or newer.
|
|
33
|
+
|
|
34
|
+
Installing from the Plugins screen in the EmDash admin (the registry) is not available yet.
|
|
35
|
+
|
|
36
|
+
## The SEO page
|
|
37
|
+
|
|
38
|
+
One page, **SEO**, in the admin sidebar, with three tabs. It works before you touch it: the site is described as an organisation named after the site, each content type is guessed from its name (posts and blog become blog posts, services become services, team becomes people), and your pages are checked by themselves.
|
|
39
|
+
|
|
40
|
+
- **Overview**: how many pages were checked, how many have nothing to fix and how many do; a warning whenever a content type is hidden from Google; one question if you have not said who the site is for; then **Your details**, the few things Google still needs for your kind of site (an address, opening hours and a phone number for a local business; a logo and profiles for a company); then the pages to fix, each with what is missing and a link to the page; and, folded, pages that are fine but could be better.
|
|
41
|
+
- **Redirects**: your redirects, with Change and Remove on each; add one by typing the old address and where to send visitors; import a list exported from WordPress (Redirection, Yoast SEO Premium or Rank Math), which is checked before anything is added and can be removed again as a batch; export as a copyable list. EmDash already redirects a page's old address when you change it.
|
|
42
|
+
- **Settings**: who the site is for (a local business, a company or a person) and what you do, then folded sections ordered by how much they matter for that kind of site. Each title says whether the section is needed, worth adding or optional, and what is already filled in: address and phone, opening hours (two ranges a day for split shifts), logo, profiles elsewhere, more about you, and content types (what each kind of content is, and whether it is hidden from Google, which asks before it saves). One location for now. A local business is described as an organisation until it has an address, because Google requires one.
|
|
43
|
+
|
|
44
|
+
Admins can change settings. Editors see a read-only page with the pages to fix.
|
|
45
|
+
|
|
46
|
+
In the entry editor, the **Google preview** panel shows how the published page may look on Google (address, title, description), what kind of page it is, whether it is in search results, and up to three things to fix. Admins can also change one page's type, open Google's Rich Results Test for the page, and view the markup. A dashboard widget shows the number of pages to fix.
|
|
47
|
+
|
|
48
|
+
Not yet: Product and Event types (they need price and date fields on the collection), and FAQ (Google stopped showing FAQ results in May 2026). Use the `json_ld` field for those today.
|
|
49
|
+
|
|
50
|
+
### The checks
|
|
51
|
+
|
|
52
|
+
To fix: no title; a title that says nothing, such as "Home"; no search description; the same title as another page; a canonical address that points at a different page. Worth a look: a title that may be cut off; no image for shared links; an article with no author; pictures without alt text; a page with almost no text. Hidden pages are counted separately, and content without a page of its own (testimonials, for example) is left out. There is no score, and no keyword, readability or word-count rule: Google documents none of them as ranking factors.
|
|
53
|
+
|
|
54
|
+
Not yet: Product and Event types (they need price and date fields on the collection), and FAQ (Google stopped showing FAQ results in May 2026). Use the `json_ld` field for those today.
|
|
55
|
+
|
|
56
|
+
## FAQ
|
|
57
|
+
|
|
58
|
+
- How do I add schema markup to EmDash? Install this plugin; it adds structured data (JSON-LD) to every page.
|
|
59
|
+
- How do I add my own structured data to one page? Add a JSON field named `json_ld` to the collection and paste the schema.org object into the entry.
|
|
60
|
+
- Is there a Yoast alternative for EmDash? EmDash core covers titles, descriptions, sitemaps and canonicals; this plugin adds the schema graph, robots rules, previews and checks.
|
|
61
|
+
- Does it check my page while I type? No. EmDash does not let a general-purpose plugin read unsaved text. The panel and the checks describe the published page, as Google sees it: changes to the page show up once you publish, and changes in the SEO panel show up as soon as they are saved.
|
|
62
|
+
- Is there an SEO score or a traffic light? No, on purpose. You get a list of things to fix that can reach zero.
|
|
63
|
+
- Does it add FAQ rich results? No. Google stopped showing FAQ rich results in May 2026.
|
|
64
|
+
- How do I import redirects from WordPress? Export them from Redirection, Yoast SEO Premium or Rank Math, open SEO > Redirects > Import a list from WordPress, paste the list and press Check list. Nothing is added until you confirm.
|
|
65
|
+
- Why were some redirects skipped? EmDash has no regular-expression redirects. Exact addresses and "everything under a folder" are converted; other patterns are listed for you to rewrite by hand. Redirects to another website and addresses with a `?` are not supported by EmDash.
|
|
66
|
+
- Why does a large import take a while on Cloudflare? Sandboxed plugins may only do a few operations per request, so a long list is added a few rows at a time in the background. You can leave the page.
|
|
67
|
+
|
|
68
|
+
## Changelog
|
|
69
|
+
|
|
70
|
+
See [CHANGELOG.md](CHANGELOG.md) once the first version is released; unreleased changes are in the repository's `.changeset` folder.
|
|
71
|
+
|
|
72
|
+
## Develop
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
pnpm --filter @moorage/plugin-seo build # emdash-plugin build
|
|
76
|
+
pnpm --filter @moorage/plugin-seo test # validate + vitest through the sandbox harness
|
|
77
|
+
pnpm --filter @moorage/plugin-seo validate
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Licence
|
|
81
|
+
|
|
82
|
+
FSL-1.1-MIT; see LICENSE.
|
package/dist/index.d.mts
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
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": "seo",
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"format": "standard",
|
|
12
|
+
"entrypoint": "@moorage/plugin-seo/sandbox",
|
|
13
|
+
"capabilities": [
|
|
14
|
+
"content:read",
|
|
15
|
+
"schema:read",
|
|
16
|
+
"bylines:read",
|
|
17
|
+
"redirects:read",
|
|
18
|
+
"redirects:write"
|
|
19
|
+
],
|
|
20
|
+
"allowedHosts": [],
|
|
21
|
+
"storage": {
|
|
22
|
+
"entries": {
|
|
23
|
+
"indexes": [
|
|
24
|
+
"status",
|
|
25
|
+
"titleKey"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"hooks": [
|
|
30
|
+
"page:metadata",
|
|
31
|
+
"content:afterSave",
|
|
32
|
+
"content:afterPublish",
|
|
33
|
+
"content:afterUnpublish",
|
|
34
|
+
"content:afterRestore",
|
|
35
|
+
"plugin:activate",
|
|
36
|
+
"cron",
|
|
37
|
+
"content:afterDelete"
|
|
38
|
+
],
|
|
39
|
+
"routes": [
|
|
40
|
+
{
|
|
41
|
+
"name": "admin",
|
|
42
|
+
"permission": "plugins:read"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "editor/preview",
|
|
46
|
+
"permission": "content:read"
|
|
47
|
+
},
|
|
48
|
+
"hello"
|
|
49
|
+
],
|
|
50
|
+
"adminPages": [
|
|
51
|
+
{
|
|
52
|
+
"path": "/overview",
|
|
53
|
+
"label": "SEO",
|
|
54
|
+
"icon": "search",
|
|
55
|
+
"group": "Marketing"
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"adminWidgets": [
|
|
59
|
+
{
|
|
60
|
+
"id": "seo",
|
|
61
|
+
"title": "SEO",
|
|
62
|
+
"size": "third"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"editorPanels": [
|
|
66
|
+
{
|
|
67
|
+
"id": "preview",
|
|
68
|
+
"title": "Google preview",
|
|
69
|
+
"route": "editor/preview"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export default descriptor;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "seo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"declaredAccess": {
|
|
5
|
+
"content": {
|
|
6
|
+
"read": {}
|
|
7
|
+
},
|
|
8
|
+
"schema": {
|
|
9
|
+
"read": {}
|
|
10
|
+
},
|
|
11
|
+
"bylines": {
|
|
12
|
+
"read": {}
|
|
13
|
+
},
|
|
14
|
+
"redirects": {
|
|
15
|
+
"read": {},
|
|
16
|
+
"write": {}
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"capabilities": [
|
|
20
|
+
"content:read",
|
|
21
|
+
"schema:read",
|
|
22
|
+
"bylines:read",
|
|
23
|
+
"redirects:read",
|
|
24
|
+
"redirects:write"
|
|
25
|
+
],
|
|
26
|
+
"allowedHosts": [],
|
|
27
|
+
"storage": {
|
|
28
|
+
"entries": {
|
|
29
|
+
"indexes": [
|
|
30
|
+
"status",
|
|
31
|
+
"titleKey"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"hooks": [
|
|
36
|
+
"page:metadata",
|
|
37
|
+
"content:afterSave",
|
|
38
|
+
"content:afterPublish",
|
|
39
|
+
"content:afterUnpublish",
|
|
40
|
+
"content:afterRestore",
|
|
41
|
+
"plugin:activate",
|
|
42
|
+
"cron",
|
|
43
|
+
"content:afterDelete"
|
|
44
|
+
],
|
|
45
|
+
"routes": [
|
|
46
|
+
{
|
|
47
|
+
"name": "admin",
|
|
48
|
+
"permission": "plugins:read"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "editor/preview",
|
|
52
|
+
"permission": "content:read"
|
|
53
|
+
},
|
|
54
|
+
"hello"
|
|
55
|
+
],
|
|
56
|
+
"admin": {
|
|
57
|
+
"pages": [
|
|
58
|
+
{
|
|
59
|
+
"path": "/overview",
|
|
60
|
+
"label": "SEO",
|
|
61
|
+
"icon": "search",
|
|
62
|
+
"group": "Marketing"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"widgets": [
|
|
66
|
+
{
|
|
67
|
+
"id": "seo",
|
|
68
|
+
"title": "SEO",
|
|
69
|
+
"size": "third"
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"editorPanels": [
|
|
73
|
+
{
|
|
74
|
+
"id": "preview",
|
|
75
|
+
"title": "Google preview",
|
|
76
|
+
"route": "editor/preview"
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { SandboxedPlugin } from "emdash/plugin";
|
|
2
|
+
|
|
3
|
+
//#region src/plugin.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Sandboxed plugin entry. The explicit `SandboxedPlugin` annotation gives TypeScript per-hook /
|
|
6
|
+
* per-route inference (`ctx` is `PluginContext` automatically; hook
|
|
7
|
+
* `event` parameters are typed by hook name).
|
|
8
|
+
*/
|
|
9
|
+
declare const plugin: SandboxedPlugin;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { plugin as default };
|
|
12
|
+
//# sourceMappingURL=plugin.d.mts.map
|
package/dist/plugin.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
function e(e,t){try{return new URL(e,t)}catch{return null}}const t=`config`,n=[`WebPage`,`AboutPage`,`ContactPage`,`CollectionPage`,`ProfilePage`],r=[`Article`,`BlogPosting`,`NewsArticle`],i=[`Service`];function a(e){let t=e.toLowerCase();return[`posts`,`post`,`blog`].includes(t)?`BlogPosting`:[`news`,`articles`,`article`].includes(t)?`Article`:[`services`,`service`].includes(t)?`Service`:[`team`,`people`,`staff`,`authors`].includes(t)?`ProfilePage`:`WebPage`}const o={site:{type:`Organization`,sameAs:[],openingHours:[],areaServed:[]},collections:{},robots:{maxSnippet:-1,maxImagePreview:`large`,maxVideoPreview:-1,nosnippet:!1},jsonLdField:`json_ld`,breadcrumbs:!0,steps:{identity:!1,types:!1}};function s(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function c(e){if(typeof e!=`string`)return;let t=e.trim();return t===``?void 0:t}function l(t){let n=c(t),r=n?e(n)?.protocol:void 0;return r===`https:`||r===`http:`?n:void 0}function u(e,t){return typeof e==`number`&&Number.isInteger(e)&&e>=-1?e:t}function d(e){let t=[...n,...r,...i];return typeof e==`string`&&t.includes(e)?e:void 0}function f(e){if(!s(e))return;let t={};for(let n of[`streetAddress`,`addressLocality`,`addressRegion`,`postalCode`,`addressCountry`]){let r=c(e[n]);r&&(t[n]=r)}return Object.keys(t).length>0?t:void 0}function p(e){if(!Array.isArray(e))return[];let t=[];for(let n of e){if(!s(n)||!Array.isArray(n.days))continue;let e=n.days.filter(e=>typeof e==`string`&&e!==``),r=c(n.opens),i=c(n.closes);e.length>0&&r&&i&&t.push({days:e,opens:r,closes:i})}return t}function m(e){if(!s(e))return{...o.site};let t=s(e.geo)?e.geo:void 0,n=t?.latitude,r=t?.longitude;return{type:c(e.type)??`Organization`,name:c(e.name),alternateName:c(e.alternateName),legalName:c(e.legalName),description:c(e.description),logo:l(e.logo),sameAs:Array.isArray(e.sameAs)?e.sameAs.map(l).filter(e=>e!==void 0):[],email:c(e.email),telephone:c(e.telephone),address:f(e.address),geo:typeof n==`number`&&typeof r==`number`?{latitude:n,longitude:r}:void 0,openingHours:p(e.openingHours),priceRange:c(e.priceRange),areaServed:Array.isArray(e.areaServed)?e.areaServed.map(c).filter(e=>e!==void 0):[],foundingDate:c(e.foundingDate),vatID:c(e.vatID),taxID:c(e.taxID),numberOfEmployees:typeof e.numberOfEmployees==`number`&&e.numberOfEmployees>0?Math.round(e.numberOfEmployees):void 0}}function h(e){if(!s(e))return o;let t={};if(s(e.collections))for(let[n,r]of Object.entries(e.collections))s(r)&&(t[n]={type:d(r.type),noindex:r.noindex===!0,...r.hiddenOk===!0?{hiddenOk:!0}:{}});let n=s(e.robots)?e.robots:{},r=n.maxImagePreview;return{site:m(e.site),collections:t,robots:{maxSnippet:u(n.maxSnippet,o.robots.maxSnippet),maxImagePreview:r===`none`||r===`standard`||r===`large`?r:o.robots.maxImagePreview,maxVideoPreview:u(n.maxVideoPreview,o.robots.maxVideoPreview),nosnippet:n.nosnippet===!0},jsonLdField:c(e.jsonLdField)??o.jsonLdField,breadcrumbs:e.breadcrumbs!==!1,steps:{identity:s(e.steps)&&e.steps.identity===!0,types:s(e.steps)&&e.steps.types===!0}}}const g=[`Monday`,`Tuesday`,`Wednesday`,`Thursday`,`Friday`,`Saturday`,`Sunday`],_=/^(\d{1,2})(?:[:.](\d{2}))?\s*[-–—]\s*(\d{1,2})(?:[:.](\d{2}))?$/;function v(e,t){let n=Number(e),r=Number(t??`0`);return n>24||r>59||n===24&&r!==0?null:`${String(n).padStart(2,`0`)}:${String(r).padStart(2,`0`)}`}function y(e,t){let n=t.trim();if(n===``||n.toLowerCase()===`closed`)return[];let r=n.split(/\s*[,;]\s*|\s+and\s+/i).filter(e=>e!==``);if(r.length>2)return null;let i=[];for(let t of r){let n=_.exec(t);if(!n)return null;let r=v(n[1]??``,n[2]),a=v(n[3]??``,n[4]);if(!r||!a)return null;i.push({days:[e],opens:r,closes:a})}return i}function b(e,t){return t.filter(t=>t.days.includes(e)).map(e=>`${e.opens}-${e.closes}`).join(`, `)}const x=[{value:`WebPage`,label:`Web page`,short:`Web page`},{value:`AboutPage`,label:`About page`,short:`About page`},{value:`ContactPage`,label:`Contact page`,short:`Contact page`},{value:`BlogPosting`,label:`Blog post`,short:`Blog post`},{value:`Article`,label:`Article`,short:`Article`},{value:`Service`,label:`Service`,short:`Service`},{value:`ProfilePage`,label:`Person or team member`,short:`Person`}];function S(e){return x.find(t=>t.value===e)?.short??e}const C=[301,302,307,308,410,451],w=2e3,T={empty:`No old address`,query:`Addresses with a ? are not supported`,external:`Points to another website`,noTarget:`No new address`,same:`Old and new address are the same`,code:`Unknown redirect type`,pattern:`Uses a pattern. Add it by hand under Manage > Redirects`,inactive:`Switched off in the export`,repeated:`Listed twice`,exists:`Already exists`,conflict:`Conflicts with an existing redirect`,loop:`Would send visitors in a circle`,tooMany:`Only the first ${w} rows are imported at once`};function E(e,t){let n=[],r=``,i=!1;for(let a=0;a<e.length;a+=1){let o=e[a];i?o===`"`&&e[a+1]===`"`?(r+=`"`,a+=1):o===`"`?i=!1:r+=o:o===`"`?i=!0:o===t?(n.push(r.trim()),r=``):r+=o}n.push(r.trim());let a=n[0]??``;return n.length===1&&/\S\s+\S/.test(a)?a.split(/\s+/):n}const D={source:`source`,origin:`source`,from:`source`,"old url":`source`,old:`source`,sources:`source`,target:`target`,destination:`target`,to:`target`,"new url":`target`,new:`target`,url_to:`target`,regex:`regex`,code:`code`,"http code":`code`,type:`code`,header_code:`code`,format:`format`,matching:`matching`,status:`status`,ignore:`ignore`,id:`id`};function ee(e,t){let n=e.map(e=>D[e.toLowerCase()]),r=n.includes(`source`)&&n.includes(`target`);if(r){let e={};return n.forEach((t,n)=>{t&&e[t]===void 0&&(e[t]=n)}),{format:e.matching===void 0?e.format===void 0?e.regex===void 0?`plain`:`redirection`:`yoast`:`rankmath`,hasHeader:r,index:e}}let i=t.slice(0,20),a=i.map(e=>e[2]??``),o=i.map(e=>(e[3]??``).toLowerCase());return o.length>0&&o.every(e=>e===`plain`||e===`regex`)?{format:`yoast`,hasHeader:r,index:{source:0,target:1,code:2,format:3}}:a.length>0&&a.every(e=>e===`0`||e===`1`)?{format:`redirection`,hasHeader:r,index:{source:0,target:1,regex:2,code:3}}:{format:`plain`,hasHeader:r,index:{source:0,target:1,code:2}}}function O(t){let n=t.trim();if(n===``)return{reason:T.empty};if(/^https?:\/\//i.test(n)){let t=e(n);if(!t)return{reason:T.empty};n=`${t.pathname}${t.search}`}return n.includes(`?`)?{reason:T.query}:(n.startsWith(`/`)||(n=`/${n}`),n.length>1&&(n=n.replace(/\/+$/,``)),{path:n===``?`/`:n})}const k=/[\\^$.*+?()[\]{}|]/;function A(e,t){let n=e.trim();if(n.startsWith(`^`)&&(n=n.slice(1)),n.endsWith(`$`)&&(n=n.slice(0,-1)),n.endsWith(`/?`)&&(n=n.slice(0,-2)),!k.test(n))return{source:n,target:t};let r=/^(\/[^\\^$.*+?()[\]{}|]*\/)\(\.\*\)$/.exec(n);return r&&!/\$[2-9]/.test(t)?{source:`${r[1]}[...rest]`,target:t.replace(/\$1/g,`[...rest]`)}:null}function te(e){if(e===void 0||e.trim()===``)return 301;let t=Number(e.trim());return C.includes(t)?t:null}function ne(t,n){let r=t.split(/\r?\n/),i=r.some(e=>e.includes(` `))?` `:`,`,a=r.map((e,t)=>({line:t+1,cells:E(e,i)})).filter(e=>e.cells.some(e=>e!==``)),o=a[0];if(!o)return{format:`plain`,ready:[],skipped:[]};let s=ee(o.cells,a.map(e=>e.cells)),c=s.hasHeader?a.slice(1):a,l=(e,t)=>{let n=s.index[t];return n===void 0?void 0:e[n]},u=[],d=[],f=new Map(n.map(e=>[e.source,e.destination])),p=new Set;for(let{line:t,cells:n}of c){let r=l(n,`source`)??``,i=e=>d.push({line:t,source:r,reason:e});if(u.length>=w){i(T.tooMany);continue}if([`inactive`,`disabled`].includes((l(n,`status`)??`active`).toLowerCase())){i(T.inactive);continue}let a=te(l(n,`code`));if(a===null){i(T.code);continue}let o=r,s=l(n,`target`)??``,c=(l(n,`matching`)??`exact`).toLowerCase();if(l(n,`regex`)===`1`||(l(n,`format`)??``).toLowerCase()===`regex`||c===`regex`){let e=A(o,s);if(!e){i(T.pattern);continue}o=e.source,s=e.target}else if(c===`start`&&o.trim().endsWith(`/`))o=`${o.trim()}[...rest]`;else if(c!==`exact`){i(T.pattern);continue}let m=O(o);if(`reason`in m){i(m.reason);continue}let h=a===410||a===451,g=``;if(!h){if(s.trim()===``){i(T.noTarget);continue}let t=/^https?:\/\//i.test(s.trim()),n=/^https?:\/\//i.test(r.trim())?e(r.trim())?.host:void 0;if(t&&n!==e(s.trim())?.host){i(T.external);continue}let a=O(s);if(`reason`in a){i(a.reason===T.empty?T.noTarget:a.reason);continue}if(g=a.path,g===m.path){i(T.same);continue}}if(p.has(m.path)){i(T.repeated);continue}let _=f.get(m.path);if(_!==void 0){i(_===g?T.exists:T.conflict);continue}p.add(m.path),u.push({line:t,source:m.path,destination:g,type:a})}let m=new Map(f);for(let e of u)m.set(e.source,e.destination);let h=[];for(let e of u){let t=e.destination,n=!1;for(let r=0;r<20&&t!==``&&m.has(t);r+=1)if(t=m.get(t)??``,t===e.source){n=!0;break}n?d.push({line:e.line,source:e.source,reason:T.loop}):h.push({source:e.source,destination:e.destination,type:e.type})}return{format:s.format,ready:h,skipped:d}}function re(e){let t=e=>/[",\n]/.test(e)?`"${e.replace(/"/g,`""`)}"`:e;return[`source,target,code`,...e.map(e=>`${t(e.source)},${t(e.destination)},${e.type}`)].join(`
|
|
2
|
+
`)}const j=`redirects`,M=`redirects-import`,ie={redirection:`Redirection`,yoast:`Yoast SEO Premium`,rankmath:`Rank Math`,plain:`a plain list`};async function ae(e){let t=await e.kv.get(j);return t&&Array.isArray(t.rows)?t:null}async function oe(e){let t=[],n;for(let r=0;r<5;r+=1){let r=await e.redirects?.list({limit:100,cursor:n});if(!r||(t.push(...r.items),n=r.hasMore?r.cursor:void 0,!n))break}return t}async function se(e,t){let n=ne(t,await oe(e)),r={status:`checked`,format:n.format,rows:n.ready,skipped:n.skipped,cursor:0,added:0,group:`Imported ${new Date().toISOString().slice(0,16).replace(`T`,` `)}`};return await e.kv.set(j,r),r}function ce(e){let t=e instanceof Error?e.message:String(e);return/already exists/i.test(t)?{kind:`exists`,message:t}:/invalid source pattern|must be different|destination|path traversal|loop/i.test(t)?{kind:`invalid`,message:t}:{kind:`stop`,message:t}}async function le(e,t){let n={...t,status:`adding`,skipped:[...t.skipped]},r=async()=>{try{return await e.kv.set(j,n),!0}catch{return!1}};if(t.status===`checked`)try{await e.cron?.schedule(M,{schedule:`* * * * *`})}catch(t){e.log.warn(`could not schedule background import`,{error:String(t)})}let i=0,a=n.cursor;for(;n.cursor<n.rows.length;){let t=n.rows[n.cursor];if(!t)break;try{await e.redirects?.create?.({source:t.source,destination:t.destination,type:t.type,groupName:n.group}),n.added+=1}catch(e){let{kind:r,message:i}=ce(e);if(r===`stop`){if(n.cursor!==a||(n.stalls=(n.stalls??0)+1,n.stalls<3))break;n.skipped.push({line:0,source:t.source,reason:`EmDash could not add it: ${i}`})}else r===`exists`?n.added+=1:n.skipped.push({line:0,source:t.source,reason:`EmDash refused it: ${i}`})}if(n.cursor+=1,n.stalls=0,i+=1,i>=4){if(!await r())return n;i=0}}if(n.cursor>=n.rows.length){n.status=`done`;try{await e.cron?.cancel(M)}catch{}}return await r(),n}async function ue(e,t){let n=await e.redirects?.list({limit:100,group:t.group}),r=n?.items??[],i=r.length;for(let t of r)try{let n=await e.redirects?.get(t.id);n&&await e.redirects?.delete?.(t.id,{_rev:n._rev}),--i}catch{break}if(i===0&&!n?.hasMore){try{await e.cron?.cancel(M)}catch{}return await e.kv.delete(j),0}return i}async function de(e){let t=[],n;for(let r=0;r<5;r+=1){let r=await e.redirects?.list({limit:100,cursor:n});if(!r||(t.push(...r.items),n=r.hasMore?r.cursor:void 0,!n))break}return{text:re(t),count:t.length}}function N(e,t){return`${e} ${t}${e===1?``:`s`}`}function fe(e){if(e.length===0)return[];let t=[{type:`header`,text:`Skipped (${e.length})`},{type:`context`,text:`Skipped rows are not added. Copy any you still need and add them by hand under Manage > Redirects, which also accepts patterns.`},{type:`table`,page_action_id:`skipped_table`,columns:[{key:`line`,label:`Row`},{key:`source`,label:`Old address`},{key:`reason`,label:`Why`}],rows:e.slice(0,50).map(e=>({line:e.line>0?String(e.line):``,source:e.source,reason:e.reason}))}];return e.length>50&&t.push({type:`context`,text:`And ${e.length-50} more.`}),t}const P=[{value:`301`,label:`Permanent`},{value:`302`,label:`Temporary, the old address will come back`},{value:`410`,label:`Page removed on purpose, no new address`}];function pe(e){return e===301||e===308?`Permanent`:e===302||e===307?`Temporary`:e===410?`Deleted on purpose`:String(e)}async function me(e){let t=await e.redirects?.list({limit:20});return{rows:(t?.items??[]).map(({id:e,source:t,destination:n,type:r,hits:i})=>({id:e,source:t,destination:n,type:r,hits:i})),more:t?.hasMore??!1}}function F(e){let t=e instanceof Error?e.message:String(e);return/already exists/i.test(t)?`There is already a redirect from that old address. Use Change in the list below.`:/must be different/i.test(t)?`The old and new address are the same.`:/loop/i.test(t)?`That would send visitors in a circle.`:/changed since/i.test(t)?`That redirect was just changed elsewhere. Try again.`:`EmDash could not save that redirect. Check both addresses start with /.`}function he(e,t,n){let r=ne(re([{source:e,destination:t,type:Number(n)||301}]),[]);return r.ready[0]??r.skipped[0]?.reason??`Type the old address first.`}async function ge(e,t,n,r){let i=he(t,n,r);if(typeof i==`string`)return`${i}.`;try{return await e.redirects?.create?.(i),null}catch(e){return F(e)}}async function _e(e,t,n,r,i){let a=he(n,r,i);if(typeof a==`string`)return`${a}.`;try{let n=await e.redirects?.get(t);return n?(await e.redirects?.update?.(t,{...a,_rev:n._rev}),null):`That redirect no longer exists.`}catch(e){return F(e)}}async function ve(e,t){try{let n=await e.redirects?.get(t);return n&&await e.redirects?.delete?.(t,{_rev:n._rev}),null}catch(e){return F(e)}}function ye(e){if(!e)return[{type:`context`,text:`Paste a list exported from Redirection, Yoast SEO Premium or Rank Math, or any list of old address, new address. Nothing is added until you confirm.`},{type:`form`,block_id:`redirects`,fields:[{type:`text_input`,action_id:`list`,label:`Your list, one redirect per line`,multiline:!0,placeholder:`/old-page,/new-page`,initial_value:``}],submit:{label:`Check list`,action_id:`check_redirects`}}];if(e.status===`checked`){let t=e.rows.length;return[{type:`banner`,variant:t===0?`alert`:`default`,title:`${t} ready, ${e.skipped.length} skipped`,description:t===0?`Nothing in this list can be added. See why below.`:`Read as ${ie[e.format]}. Nothing has been added yet. Existing redirects are never changed.`},{type:`actions`,elements:[...t>0?[{type:`button`,action_id:`add_redirects`,label:`Add ${N(t,`redirect`)}`,style:`primary`,confirm:{title:`Add ${N(t,`redirect`)}?`,text:`Visitors to these old addresses will be sent to the new ones. You can remove them all again afterwards.`,confirm:`Add`,deny:`Cancel`}}]:[],{type:`button`,action_id:`reset_redirects`,label:`Start again`}]},...fe(e.skipped)]}return e.status===`adding`?[{type:`meter`,label:`${e.added} of ${e.rows.length} added`,value:e.cursor,max:e.rows.length},{type:`context`,text:`The rest are added in the background, a few each minute. You can leave this page.`},{type:`actions`,elements:[{type:`button`,action_id:`more_redirects`,label:`Add more now`,style:`primary`},{type:`button`,action_id:`stop_redirects`,label:`Stop here`}]}]:[{type:`banner`,title:`${N(e.added,`redirect`)} added from your list`,description:`They are labelled "${e.group}".`},{type:`actions`,elements:[{type:`button`,action_id:`reset_redirects`,label:`Import another list`},...e.added>0?[{type:`button`,action_id:`remove_redirects`,label:`Remove the ${e.added} just added`,confirm:{title:`Remove these redirects?`,text:`Only the redirects this import added are removed.`,confirm:`Remove`,deny:`Cancel`,style:`danger`}}]:[]]},...fe(e.skipped)]}function be(e,t){let n=e?String(e.type):`301`;return{type:`form`,block_id:e?`edit_redirect_${e.id}`:`add_one_${t}`,fields:[{type:`text_input`,action_id:`source`,label:`Redirect from`,placeholder:`/old-page`,initial_value:e?.source??``},{type:`text_input`,action_id:`destination`,label:`Redirect to`,placeholder:`/new-page`,initial_value:e?.destination??``,condition:{field:`code`,neq:`410`}},{type:`select`,action_id:`code`,label:`Kind`,options:P.some(e=>e.value===n)?P:[...P,{value:n,label:`${pe(Number(n))} (${n})`}],initial_value:n}],submit:e?{label:`Save changes`,action_id:`save_redirect:${e.id}`}:{label:`Add redirect`,action_id:`add_redirect`}}}function xe(e){let{state:t,rows:n,editing:r}=e,i=r?void 0:e.panel??(t?`import`:void 0),a=n[0]?.id??`first`,o=[{type:`context`,text:`Send visitors from an old address to a new one. Pages you rename in EmDash are redirected for you.`},{type:`actions`,elements:[{type:`button`,action_id:`open_add`,label:`Add a redirect`,...i===void 0&&!r?{style:`primary`}:{}},{type:`button`,action_id:`open_import`,label:`Import from WordPress`},{type:`button`,action_id:`export_redirects`,label:`Export`},...e.managerUrl?[{type:`link`,label:`Full Redirects screen`,target:{kind:`external`,url:e.managerUrl}}]:[]]}],s={type:`actions`,elements:[{type:`button`,action_id:`close_panel`,label:`Close`}]},c=e.problem?[{type:`banner`,variant:`error`,title:e.problem}]:[];return r?o.push({type:`header`,text:`Change a redirect`},...c,be(r,a),s,{type:`divider`}):i===`add`?o.push({type:`header`,text:`Add a redirect`},...c,be(void 0,a),s,{type:`divider`}):i===`import`?(o.push({type:`header`,text:`Import from WordPress`},...ye(t)),t||o.push(s),o.push({type:`divider`})):i===`export`&&o.push({type:`header`,text:`Export`},{type:`context`,text:`Copy this list to keep a backup or to paste into a spreadsheet. It can be imported again here.`},{type:`code`,code:e.exported??``,language:`bash`},s,{type:`divider`}),n.length===0?o.push({type:`empty`,title:`No redirects yet`,description:`Add one, or import a list from your old site.`,size:`sm`}):(o.push({type:`table`,page_action_id:`redirects_table`,columns:[{key:`source`,label:`Redirect from`},{key:`destination`,label:`Redirect to`},{key:`kind`,label:`Kind`},{key:`hits`,label:`Visits`,format:`number`},{key:`edit`,label:``,format:`element`},{key:`remove`,label:``,format:`element`}],rows:n.map(e=>({source:e.source,destination:e.destination||`(none)`,kind:pe(e.type),hits:e.hits,edit:{type:`button`,action_id:`edit_redirect`,label:`Change`,value:e.id},remove:{type:`button`,action_id:`remove_redirect`,label:`Remove`,value:e.id,confirm:{title:`Remove this redirect?`,text:`Visitors to ${e.source} will see a not-found page.`,confirm:`Remove`,deny:`Keep`,style:`danger`}}}))}),e.more&&o.push({type:`context`,text:`Showing the newest ${n.length}. The rest are in the full Redirects screen.`})),o}async function Se(e,t){let n={...t,status:`done`};try{await e.cron?.cancel(M)}catch{}return await e.kv.set(j,n),n}const Ce=new Set("iIl.,:;|!'`jtfr ()[]".split(``)),we=new Set(`mwMWQO@%`.split(``));function I(e){let t=0;for(let n of e)Ce.has(n)?t+=5:we.has(n)?t+=15:n>=`A`&&n<=`Z`?t+=12:t+=9.5;return Math.round(t)}function Te(e){if(I(e)<=580)return e;let t=e;for(;t.length>1&&I(`${t}…`)>580;)t=t.slice(0,-1);return`${t.trimEnd()}…`}function Ee(e){return e.toLowerCase().replace(/\s+/g,` `).trim()}const De=new Set([`home`,`homepage`,`home page`,`index`,`untitled`,`welcome`]),Oe=new Set([`_type`,`_key`,`id`,`slug`,`url`,`href`,`src`,`style`,`icon`]);function ke(e){let t={textLength:0,imagesWithoutAlt:0,hasImage:!1},n=(e,r,i)=>{if(i>12)return;if(typeof e==`string`){!Oe.has(r)&&!/^(https?:)?\//.test(e)&&(t.textLength+=e.trim().length);return}if(Array.isArray(e)){for(let t of e)n(t,r,i+1);return}if(typeof e!=`object`||!e)return;let a=e;(a._type===`image`||typeof a.src==`string`&&`alt`in a)&&(t.hasImage=!0,(typeof a.alt!=`string`||a.alt.trim()===``)&&(t.imagesWithoutAlt+=1));for(let[e,t]of Object.entries(a))n(t,e,i+1)};for(let[t,r]of Object.entries(e))t!==`json_ld`&&n(r,t,0);return t}function Ae(t){let n=[],{seo:r}=t,i=ke(t.data),a=r?.title?.trim()||t.title.trim();if(a===``&&n.push({id:`no-title`,level:`fix`,message:`This page has no title.`,fix:`Give the page a title.`}),De.has(a.toLowerCase())&&n.push({id:`generic-title`,level:`fix`,message:`The title is just "${a}", which is what Google shows as the headline.`,fix:`Put your name and what you do in SEO Title, in the page's SEO panel.`}),r&&!r.description?.trim()&&n.push({id:`no-description`,level:`fix`,message:`No search description, so Google writes its own summary of this page.`,fix:`Fill in Meta Description in the page's SEO panel.`}),t.duplicateTitle&&a!==``&&n.push({id:`duplicate-title`,level:`fix`,message:`Another page has the same title, so Google cannot tell them apart.`,fix:`Give this page its own title in the SEO panel.`}),r?.canonical&&t.slug){let i=e(r.canonical,`${t.siteUrl.replace(/\/+$/,``)}/`),a=i?.pathname.replace(/\/+$/,``).split(`/`).pop(),o=[`home`,`index`].includes(t.slug)&&(a??``)===``;i&&a!==t.slug&&!o&&n.push({id:`canonical-elsewhere`,level:`fix`,message:`This page tells Google to show a different page instead (canonical).`,fix:`Clear the canonical address in the SEO panel unless that is intended.`})}if(a!==``&&I(a)>580&&n.push({id:`title-long`,level:`look`,message:`The end of this title may be cut off in Google.`,fix:`Shorten the title, or put the important words first.`}),r&&!r.image&&!i.hasImage&&n.push({id:`no-social-image`,level:`look`,message:`Shared links will show no picture, unless your site has a default sharing image.`,fix:`Add an image in the SEO panel.`}),t.isArticle&&t.authorCount===0&&n.push({id:`no-author`,level:`look`,message:`This article has no author.`,fix:`Add a byline to the article.`}),i.imagesWithoutAlt>0){let e=i.imagesWithoutAlt;n.push({id:`images-without-alt`,level:`look`,message:`${e} ${e===1?`picture has`:`pictures have`} no description for screen readers.`,fix:`Add alt text to each picture.`})}return i.textLength<80&&n.push({id:`nearly-empty`,level:`look`,message:`This page has almost no text.`,fix:`Add some content, or hide the page from search.`}),n}function je(e,t){return t?`hidden`:e.some(e=>e.level===`fix`)?`fix`:e.length>0?`look`:`ready`}const Me=16*1024;function L(e,t){return`${e}:${t}`}function R(e){return e.storage.entries}function Ne(e){return typeof e==`object`&&!!e&&!Array.isArray(e)&&`@type`in e}function Pe(e){let t=e;if(typeof e==`string`){if(e.trim()===``||e.length>Me)return[];try{t=JSON.parse(e)}catch{return[]}}let n=(Array.isArray(t)?t:[t]).filter(Ne);return JSON.stringify(n).length>Me?[]:n}function Fe(e){let{title:t,name:n}=e.data;return typeof t==`string`?t:typeof n==`string`?n:``}function Ie(e){let{collection:t,entry:n,authors:i,previous:o,config:s,siteUrl:c}=e,l=s.collections[t],u=o?.type??l?.type??a(t),d=n.seo?.title?.trim()||Fe(n),f=n.status===`published`,p=f&&e.hasPages,m=e.hasPages?Ae({slug:n.slug,data:n.data,seo:n.seo,title:Fe(n),siteUrl:c,isArticle:r.includes(u),authorCount:i.length,duplicateTitle:e.duplicateTitle}):[];return{v:1,publishedAt:n.publishedAt,updatedAt:n.updatedAt,authors:i,...o?.type?{type:o.type}:{},custom:Pe(n.data[s.jsonLdField]),collection:t,entryId:n.id,title:d,status:e.hasPages?f?je(m,n.seo?.noIndex===!0||l?.noindex===!0):`draft`:`skip`,titleKey:p?Ee(d):``,issues:m,checkedAt:new Date().toISOString()}}async function z(e,t,n){return t===``?[]:(await R(e).query({where:{titleKey:t},limit:5})).items.filter(e=>e.id!==n)}function B(e,t){let n=(e.issues??[]).filter(e=>e.id!==`duplicate-title`),r=t?[...n,{id:`duplicate-title`,level:`fix`,message:`Another page has the same title, so Google cannot tell them apart.`,fix:`Give this page its own title in the SEO panel.`}]:n;return{...e,issues:r,status:e.status===`hidden`||e.status===`draft`||e.status===`skip`?e.status:je(r,!1)}}async function Le(e,t,n,r){return(await Re(e,t,n,r))?.record??null}async function Re(e,t,n,r){let i=await e.content?.get(t,n);if(!i)return null;let a=R(e),o=L(t,n),[s,c,l]=await Promise.all([e.bylines?.getEntriesBylines(t,[n])??[],a.get(o),e.schema?.getCollection(t)]),u=Ie({collection:t,entry:i,authors:(s[0]?.bylines??[]).map(({byline:e})=>({name:e.displayName,...e.websiteUrl?{url:e.websiteUrl}:{}})),previous:c,config:r,siteUrl:e.site.url,duplicateTitle:!1,hasPages:l?l.routable&&!l.hidden:!0}),d=await z(e,u.titleKey??``,o),f=B(u,d.length>0),p=[{id:o,data:f}];for(let e of d)e.data.issues?.some(e=>e.id===`duplicate-title`)||p.push({id:e.id,data:B(e.data,!0)});let m=c?.titleKey??``;if(m!==``&&m!==f.titleKey){let t=await z(e,m,o),n=t.length===1?t[0]:void 0;n&&p.push({id:n.id,data:B(n.data,!1)})}return await a.putMany(p),{record:f,entry:i}}async function ze(e,t,n){let r=R(e),i=L(t,n),a=await r.get(i);await r.delete(i);let o=await z(e,a?.titleKey??``,i),s=o.length===1?o[0]:void 0;s&&await r.put(s.id,B(s.data,!1))}const V=`scan`,Be=`stats`,H=`seo-scan`;async function Ve(e){return e.kv.get(V)}async function He(e,t,n){let r={status:`running`,collections:t,phase:`entries`,checked:0,tally:{checked:0,fix:0,hidden:0,rows:[],more:!1},startedAt:new Date().toISOString()};try{await e.cron?.schedule(H,{schedule:`* * * * *`})}catch(t){e.log.warn(`could not schedule background check`,{error:String(t)})}return Ke(e,r,n)}async function Ue(e,t,n){let r=t.collections[0];if(r===void 0){t.phase=`titles`;return}let i=await e.content?.list(r,{limit:20,cursor:t.cursor,where:{status:`published`}}),a=i?.items??[];if(a.length>0){let o=R(e),s=a.map(e=>e.id),[c,l]=await Promise.all([e.bylines?.getEntriesBylines(r,s)??[],o.getMany(s.map(e=>L(r,e)))]),u=new Map(c.map(e=>[e.entryId,e.bylines])),d=a.map(t=>{let i=l.get(L(r,t.id))??null;return{id:L(r,t.id),data:Ie({hasPages:!0,collection:r,entry:t,authors:(u.get(t.id)??[]).map(({byline:e})=>({name:e.displayName,...e.websiteUrl?{url:e.websiteUrl}:{}})),previous:i,config:n,siteUrl:e.site.url,duplicateTitle:i?.issues?.some(e=>e.id===`duplicate-title`)??!1})}});if(t.cursor===void 0&&t.checked===0&&!i?.hasMore&&t.collections.length===1){let e=new Map;for(let{data:t}of d)t.titleKey&&e.set(t.titleKey,(e.get(t.titleKey)??0)+1);for(let t of d)t.data=B(t.data,(e.get(t.data.titleKey??``)??0)>1);t.status=`done`,t.finishedAt=new Date().toISOString()}await o.putMany(d);for(let{data:e}of d)Ge(t.tally,e,1);t.checked+=a.length}i?.hasMore&&i.cursor?t.cursor=i.cursor:(t.cursor=void 0,t.collections=t.collections.slice(1),t.collections.length===0&&t.status!==`done`&&(t.phase=`titles`))}async function We(e,t){let n=R(e),r=await n.query({orderBy:{titleKey:`asc`},limit:100,cursor:t.titleCursor}),i=r.items.filter(e=>(e.data.titleKey??``)!==``),a=new Map,o=(e,n,r)=>{if((n.issues?.some(e=>e.id===`duplicate-title`)??!1)===r)return;let i=B(n,r);a.set(e,i),Ge(t.tally,n,-1),Ge(t.tally,i,1)},s=t.lastTitle;for(let e=0;e<i.length;e+=1){let t=i[e];if(!t)continue;let r=t.data.titleKey??``,a=i[e+1],c=s?.key===r,l=a?.data.titleKey===r;if(c&&s&&!s.flagged&&e===0){let e=await n.get(s.id);e&&o(s.id,e,!0)}let u=c||l;o(t.id,t.data,u),s={key:r,id:t.id,flagged:u}}a.size>0&&await n.putMany([...a].map(([e,t])=>({id:e,data:t}))),t.lastTitle=s,r.hasMore&&r.cursor?t.titleCursor=r.cursor:(t.status=`done`,t.finishedAt=new Date().toISOString(),t.titleCursor=void 0,t.lastTitle=void 0)}function U(e,t=`fix`){return{collection:e.collection??``,id:e.entryId??``,title:e.title||`(no title)`,issues:(e.issues??[]).filter(e=>e.level===t).map(e=>e.message)}}function Ge(e,t,n){if(!(!t.status||t.status===`draft`||t.status===`skip`)){if(e.checked+=n,t.status===`hidden`){e.hidden+=n;let r=e.hiddenTitles??[],i=t.title||`(no title)`;e.hiddenTitles=n===1?[...r,i].slice(0,10):r.filter(e=>e!==i)}if(t.status===`look`){let r=e.look??[];e.look=n===1?[...r,U(t,`look`)].slice(0,10):r.filter(e=>e.id!==t.entryId)}t.status===`fix`&&(e.fix+=n,n===1?e.rows.length<10?e.rows.push(U(t)):e.more=!0:e.rows=e.rows.filter(e=>e.id!==t.entryId))}}async function Ke(e,t,n){let r={...t,collections:[...t.collections],tally:{...t.tally,rows:[...t.tally.rows]}};if(r.status===`done`)return r;if(r.phase===`entries`){for(let t=0;t<5&&r.phase===`entries`;t+=1){let t=r.checked;if(await Ue(e,r,n),r.checked!==t||r.status===`done`)break}r.phase===`titles`&&r.checked===0&&(r.status=`done`,r.finishedAt=new Date().toISOString())}else await We(e,r);if(r.status===`done`)try{await e.cron?.cancel(H)}catch{}return await e.kv.set(V,r),r}async function qe(e){let t=R(e),[n,r,i,a,o]=await Promise.all([t.count({status:{in:[`ready`,`look`,`fix`,`hidden`]}}),t.count({status:`fix`}),t.query({where:{status:`fix`},limit:10}),t.query({where:{status:`look`},limit:10}),t.query({where:{status:`hidden`},limit:10})]);return{checked:n,fix:r,hidden:o.hasMore?11:o.items.length,rows:i.items.map(({data:e})=>U(e)),more:i.hasMore,look:a.items.map(({data:e})=>U(e,`look`)),hiddenTitles:o.items.map(({data:e})=>e.title||`(no title)`)}}const W={overview:0,redirects:1,identity:2,types:2},G=[{value:`FoodEstablishment`,label:`Restaurant, cafe or bakery`},{value:`Store`,label:`Shop`},{value:`MedicalBusiness`,label:`Health and medical`},{value:`Dentist`,label:`Dentist`},{value:`LegalService`,label:`Legal`},{value:`FinancialService`,label:`Financial`},{value:`HomeAndConstructionBusiness`,label:`Home services (plumber, electrician, builder)`},{value:`HealthAndBeautyBusiness`,label:`Beauty and hair`},{value:`SportsActivityLocation`,label:`Fitness`},{value:`LodgingBusiness`,label:`Hotel or lodging`},{value:`AutomotiveBusiness`,label:`Automotive`},{value:`ProfessionalService`,label:`Professional service`},{value:`LocalBusiness`,label:`Other`}],Je=[``,`$`,`$$`,`$$$`,`$$$$`],Ye=[{id:`facebook`,label:`Facebook`,hosts:[`www.facebook.com`,`facebook.com`,`fb.com`]},{id:`instagram`,label:`Instagram`,hosts:[`www.instagram.com`,`instagram.com`]},{id:`linkedin`,label:`LinkedIn`,hosts:[`www.linkedin.com`,`linkedin.com`]},{id:`x`,label:`X (Twitter)`,hosts:[`x.com`,`www.x.com`,`twitter.com`,`www.twitter.com`]},{id:`youtube`,label:`YouTube`,hosts:[`www.youtube.com`,`youtube.com`]}];function Xe(t){let n={},r=[];for(let i of t){let t=e(i)?.host??``,a=Ye.find(e=>e.hosts.includes(t));a&&!(a.id in n)?n[a.id]=i:r.push(i)}return{named:n,other:r}}const Ze={needed:`needed`,worth:`worth adding`,optional:`optional`};function K(e,t,n){let r=n.filter(e=>!!e);return r.length>0?`${e}: ${r.join(`, `)}`:`${e} (${Ze[t]}, not added yet)`}function Qe(e){return g.map(t=>{let n=b(t,e.openingHours).replace(`, `,` and `);return n===``?void 0:`${t.slice(0,3)} ${n}`}).filter(e=>e!==void 0)}function $e(e){let{site:t}=e.config,n=q(t),r=[];return n===`local`?((!t.address?.streetAddress||!t.address.addressLocality)&&r.push({what:`Address`,why:`Without it Google cannot treat you as a local business.`}),t.openingHours.length===0&&r.push({what:`Opening hours`,why:`Google cannot show when you are open.`}),t.telephone||r.push({what:`Phone number`,why:`Customers cannot call you from Google.`})):(n===`organization`&&!t.logo&&r.push({what:`Logo`,why:`Google shows it beside your name.`}),t.sameAs.length===0&&r.push({what:`Profiles elsewhere`,why:`They help Google connect your other pages to this site.`})),r}function q(e){return e.type===`Person`?`person`:e.type===`Organization`?`organization`:`local`}function et(t,n){let r=e(t);if(!(!r||r.protocol!==`https:`&&r.protocol!==`http:`))return`${r.origin}${n}`}function tt(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function J(e,t,n=``){let r=e[t];return typeof r==`string`?r.trim():n}function nt(e,t,n){let r=e[t];return typeof r==`boolean`?r:n}function rt(e){return e.split(/\r?\n/).map(e=>e.trim()).filter(e=>e!==``)}async function it(e){let[n,r,i]=await Promise.all([e.kv.list(),e.schema?.listCollections()??[],me(e)]),a=new Map(n.map(e=>[e.key,e.value])),o=a.get(j);return{config:h(a.get(t)),collections:r.filter(e=>e.routable&&!e.hidden),redirects:o&&Array.isArray(o.rows)?o:null,redirectRows:i.rows,moreRedirects:i.more,scan:a.get(V)??null,stats:a.get(Be)??null}}async function at(e,t){let n=await qe(e);return JSON.stringify(n)!==JSON.stringify(t.stats)&&await e.kv.set(Be,n),{...t,stats:n}}function ot(e){return e.stats??(e.scan?.status===`done`?e.scan.tally:null)}function st(e){let{scan:t}=e,n=ot(e);if(!t||t.status===`running`)return[{type:`banner`,title:`Checking your pages`,description:t?`${t.checked} checked so far. The rest are checked in the background.`:`This starts by itself and takes about a minute.`}];if(!n)return[];let r=n.checked-n.fix-n.hidden;return[{type:`stats`,items:[{label:`Pages checked`,value:n.checked},{label:`Nothing to fix`,value:r},{label:`To fix`,value:n.fix}]}]}function ct(e){let{scan:t}=e,n=ot(e);if(!n||!t||t.status===`running`)return[];let r=[];n.checked===0?r.push({type:`empty`,title:`No published pages yet`,size:`sm`}):n.fix===0?r.push({type:`empty`,title:`No pages to fix. Nice work.`,size:`sm`}):(r.push({type:`header`,text:`To fix`},{type:`table`,page_action_id:`fix_table`,columns:[{key:`page`,label:`Page`},{key:`missing`,label:`What's missing`},{key:`open`,label:``,format:`element`}],rows:n.rows.map(e=>({page:e.title,missing:e.issues.join(` `),open:{type:`link`,label:`Open page`,target:{kind:`content`,collection:e.collection,id:e.id}}}))}),n.more&&r.push({type:`context`,text:`Showing the first 10. Fix these and the next ones appear.`}));let i=n.look??[];if(i.length>0&&r.push({type:`accordion`,label:`Worth a look (${i.length}${i.length===10?`+`:``})`,blocks:[{type:`context`,text:`Nothing is wrong with these pages. These are improvements for when you have time.`},{type:`table`,page_action_id:`look_table`,columns:[{key:`page`,label:`Page`},{key:`advice`,label:`Could be better`},{key:`open`,label:``,format:`element`}],rows:i.map(e=>({page:e.title,advice:e.issues.join(` `),open:{type:`link`,label:`Open page`,target:{kind:`content`,collection:e.collection,id:e.id}}}))}]}),n.hidden>0){let e=(n.hiddenTitles??[]).join(`, `);r.push({type:`context`,text:`${n.hidden>10?`More than 10`:n.hidden} ${n.hidden===1?`page is`:`pages are`} hidden from Google and not counted${e?`: ${e}`:``}. To show one again, open it and switch off "Hide from search engines" in its SEO panel.`})}if(t.finishedAt){let e=new Date(t.finishedAt).toLocaleDateString(`en-GB`,{day:`numeric`,month:`short`});r.push({type:`context`,text:`Each page is checked again when it is saved. Last full check: ${e}.`})}return r}function lt(e){if(!e.scan)return[];let t=e.scan.status===`running`;return[{type:`actions`,elements:[{type:`button`,action_id:t?`check_more`:`check_pages`,label:t?`Update`:`Check all pages again`}]}]}function ut(e){return e.collections.filter(t=>{let n=e.config.collections[t.slug];return n?.noindex===!0&&n.hiddenOk!==!0})}function dt(e,t){let n=ut(e);if(n.length===0)return[];let r=[{type:`banner`,variant:`alert`,title:`Some pages are hidden from Google`,description:`Every page in ${n.map(e=>e.label).join(`, `)} is hidden from search engines. If that is a leftover from building the site, switch it back on in the Settings tab, under Content types.`}];return t&&r.push({type:`actions`,elements:[{type:`button`,action_id:`hidden_ok`,label:`This is intended`}]}),r}function ft(e,t){let{steps:n,site:r}=e.config;return n.identity?[]:[{type:`banner`,title:`Tell Google who this site is for`,description:`Google can then show your name and details beside your pages. It takes a minute.`},{type:`accordion`,label:`Set this up (1 minute)`,default_open:!0,blocks:[{type:`form`,block_id:`setup_identity`,fields:[{type:`radio`,action_id:`kind`,label:`This site is for`,options:[{label:`A local business customers visit (shop, cafe, clinic)`,value:`local`},{label:`A company or organisation`,value:`organization`},{label:`A person`,value:`person`}]},{type:`select`,action_id:`business`,label:`Kind of business`,options:[{label:`Choose the closest`,value:``},...G],initial_value:``,condition:{field:`kind`,eq:`local`}},{type:`text_input`,action_id:`name`,label:`Name`,placeholder:`Your name or your business's name`,initial_value:r.name??t},{type:`text_input`,action_id:`street`,label:`Street address`,initial_value:r.address?.streetAddress??``,condition:{field:`kind`,eq:`local`}},{type:`text_input`,action_id:`town`,label:`Town or city`,initial_value:r.address?.addressLocality??``,condition:{field:`kind`,eq:`local`}}],submit:{label:`Save`,action_id:`setup_identity`}}]}]}function pt(e,t){let{site:n}=e.config,r=q(n),i=G.find(e=>e.value===n.type)?.label,a=r===`person`?`a person`:r===`local`?(i??`a local business`).toLowerCase():`a company`,o=[n.address?.streetAddress,n.address?.addressLocality].filter(Boolean).join(`, `),s=Qe(n);return[`Google is told: ${n.name??t}, ${a}`,o?`, ${o}`:``,s.length>0?`, open ${s.join(`, `)}`:``,`. Change this in the Settings tab.`].join(``)}function mt(e,t){if(!e.config.steps.identity)return[];let n=$e(e);return n.length===0?[{type:`context`,text:pt(e,t)}]:[{type:`header`,text:`Your details`},{type:`context`,text:`${n.length} ${n.length===1?`thing`:`things`} Google needs from you. Add ${n.length===1?`it`:`them`} in the Settings tab.`},{type:`table`,page_action_id:`details_table`,columns:[{key:`what`,label:`Missing`},{key:`why`,label:`Why it matters`}],rows:n}]}function ht(e,t){return[...st(e),...dt(e,!0),...ft(e,t),...mt(e,t),...ct(e),...lt(e)]}function gt(e){let t=ot(e),n={type:`actions`,elements:[{type:`link`,label:`Open SEO`,target:{kind:`plugin-page`,path:`/overview`}}]};if(!t||t.checked===0){let{steps:t}=e.config;return{blocks:[{type:`section`,text:t.identity?`Your pages are being checked.`:`One thing left: tell Google who this site is for.`},n]}}return{blocks:[{type:`stats`,items:[t.fix===0?{label:`SEO`,value:`All pages ready`}:{label:t.fix===1?`Page to fix`:`Pages to fix`,value:t.fix}]},n]}}function _t(t,n){let{site:r}=t.config,i=q(r),a=i===`local`,o=t.config.steps.identity,s=Xe(r.sameAs),c=[{type:`context`,text:`Google reads these details from your site and can show them beside your pages. Sections marked needed are followed up on the Overview; the rest are up to you.`},{type:`form`,block_id:`identity`,fields:[{type:`radio`,action_id:`kind`,label:`This site is for`,options:[{label:`A local business customers visit (shop, cafe, clinic)`,value:`local`},{label:`A company or organisation`,value:`organization`},{label:`A person`,value:`person`}],...o?{initial_value:i}:{}},{type:`select`,action_id:`business`,label:`Kind of business`,options:[{label:`Choose the closest`,value:``},...G],initial_value:a?r.type:``,condition:{field:`kind`,eq:`local`}},{type:`text_input`,action_id:`name`,label:`Name`,placeholder:`Your name or your business's name`,initial_value:r.name??n},{type:`text_input`,action_id:`description`,label:`What you do, in one or two sentences`,multiline:!0,initial_value:r.description??``}],submit:{label:`Save`,action_id:`save_identity`}},{type:`context`,text:`Each section below is saved with its own Save button.`}],l={type:`accordion`,label:K(`Address and phone`,a?`needed`:`optional`,[r.address?.streetAddress,r.address?.addressLocality,r.telephone,r.email]),default_open:a&&$e(t).some(e=>e.what!==`Opening hours`),blocks:[{type:`context`,text:a?`Google needs an address before it treats you as a local business. Use the same details as your Google Business Profile, which is what puts you on the map.`:`Leave the address empty if you have no public one.`},{type:`form`,block_id:`contact`,fields:[{type:`text_input`,action_id:`street`,label:`Street address`,initial_value:r.address?.streetAddress??``},{type:`text_input`,action_id:`town`,label:`Town or city`,initial_value:r.address?.addressLocality??``},{type:`text_input`,action_id:`region`,label:`State or region`,initial_value:r.address?.addressRegion??``},{type:`text_input`,action_id:`postcode`,label:`Postcode`,initial_value:r.address?.postalCode??``},{type:`text_input`,action_id:`country`,label:`Country code (GB, US, IN, AU)`,initial_value:r.address?.addressCountry??``},{type:`text_input`,action_id:`telephone`,label:`Phone`,initial_value:r.telephone??``},{type:`text_input`,action_id:`email`,label:`Email`,initial_value:r.email??``},{type:`select`,action_id:`price_range`,label:`Price range`,options:Je.map(e=>({label:e===``?`Not shown`:e,value:e})),initial_value:r.priceRange??``},{type:`text_input`,action_id:`areas`,label:`Areas you serve, one per line (if you travel to customers)`,multiline:!0,initial_value:r.areaServed.join(`
|
|
3
|
+
`)}],submit:{label:`Save address and phone`,action_id:`save_contact`}}]},u=a?{type:`accordion`,label:K(`Opening hours`,`needed`,Qe(r)),blocks:[{type:`context`,text:`Type the hours for each day, like 9:00-17:00. For a break in the day use two ranges: 12:00-14:30, 18:00-22:00. A day left empty is shown as closed.`},{type:`form`,block_id:`hours`,fields:g.map(e=>({type:`text_input`,action_id:`hours_${e.toLowerCase()}`,label:e,placeholder:`Closed`,initial_value:b(e,r.openingHours)})),submit:{label:`Save opening hours`,action_id:`save_hours`}}]}:{type:`accordion`,label:`Opening hours (only for a local business)`,blocks:[{type:`context`,text:`Opening hours are for a place customers visit. Choose "A local business customers visit" above and save, and this section opens up.`}]},d={type:`accordion`,label:K(`Logo`,i===`organization`?`needed`:`worth`,[r.logo?`added`:void 0]),blocks:[{type:`context`,text:`Google can show your logo beside your name. In Media, open your logo and use Copy URL, then paste it here. Use a square image at least 112 pixels wide.`},{type:`form`,block_id:`logo`,fields:[{type:`text_input`,action_id:`logo_url`,label:`Logo link`,placeholder:`https://example.com/logo.png`,initial_value:r.logo??``}],submit:{label:`Save logo`,action_id:`save_logo`}}]},f={type:`accordion`,label:K(`Your profiles elsewhere`,a?`worth`:`needed`,r.sameAs.map(t=>e(t)?.host.replace(/^www\./,``))),blocks:[{type:`context`,text:`Links to your pages on other sites help Google connect them to you. Paste the full address of each profile you have.`},{type:`form`,block_id:`profiles`,fields:[...Ye.map(e=>({type:`text_input`,action_id:`profile_${e.id}`,label:e.label,placeholder:`https://${e.hosts[0]}/yourname`,initial_value:s.named[e.id]??``})),{type:`text_input`,action_id:`profiles`,label:`Other profiles, one per line`,multiline:!0,initial_value:s.other.join(`
|
|
4
|
+
`)}],submit:{label:`Save profiles`,action_id:`save_profiles`}}]},p={type:`accordion`,label:K(`More about you`,`optional`,[r.alternateName,r.legalName,r.foundingDate?`founded ${r.foundingDate}`:void 0]),blocks:[{type:`context`,text:`Nothing here is needed. Skip anything that does not apply.`},{type:`form`,block_id:`company`,fields:[{type:`text_input`,action_id:`alternate_name`,label:`Also known as (a short name or abbreviation)`,initial_value:r.alternateName??``},{type:`text_input`,action_id:`legal_name`,label:`Registered legal name`,initial_value:r.legalName??``},{type:`text_input`,action_id:`founded`,label:`Founded (year, or YYYY-MM-DD)`,placeholder:`2012`,initial_value:r.foundingDate??``}],submit:{label:`Save`,action_id:`save_company`}}]},m=a?[l,u,d,f,p]:i===`organization`?[d,f,l,p,u]:[f,d,l,p,u];return c.push(...m),c}function vt(e,t){return e.config.collections[t]?.type??a(t)}function yt(e,t){let{config:n}=e,r=e.collections.find(e=>e.slug===t),i=[{type:`context`,text:`Google is told what each kind of content is, so it can show it properly: a blog post with its author and date, a service as a service. These were worked out from the names and are usually right. Every kind of content that has its own pages is listed here; content that only appears inside other pages, such as testimonials, is not. To change a single page (an About or Contact page, say), open it and use Change page type in its Google preview panel. Picking from a menu saves straight away.`}];return r&&i.push({type:`banner`,variant:`alert`,title:`Hide every page in ${r.label} from Google?`,description:`People will no longer find these pages through search engines. You can switch this back at any time.`},{type:`actions`,elements:[{type:`button`,action_id:`confirm_hide`,label:`Yes, hide them`,style:`danger`,value:r.slug},{type:`button`,action_id:`cancel_hide`,label:`Cancel`}]}),i.push({type:`table`,page_action_id:`types_table`,empty_text:`This site has no content types with pages yet.`,columns:[{key:`name`,label:`Content type`},{key:`shown`,label:``,format:`element`},{key:`visible`,label:``,format:`element`}],rows:e.collections.map(t=>{let r=n.collections[t.slug]?.noindex===!0;return{name:t.label,shown:{type:`menu`,action_id:`set_type`,label:`Google treats these as: ${S(vt(e,t.slug))}`,items:x.map(({value:e,label:n})=>({label:n,value:`${t.slug}|${e}`}))},visible:{type:`menu`,action_id:`set_visible`,label:r?`Hidden from Google`:`Can appear on Google`,items:[{label:`Can appear on Google`,value:`${t.slug}|show`},{label:`Hide from Google and other search engines`,value:`${t.slug}|hide`}]}}})},{type:`form`,block_id:`breadcrumbs`,fields:[{type:`toggle`,action_id:`breadcrumbs`,label:`Show the path to each page on Google (Home > Services > Cakes)`,description:`Leave on unless your theme already does this.`,initial_value:n.breadcrumbs}],submit:{label:`Save`,action_id:`save_advanced`}}),[{type:`accordion`,label:`Content types (usually right as they are): ${e.collections.map(t=>{let r=n.collections[t.slug]?.noindex===!0;return`${t.label} as ${S(vt(e,t.slug))}${r?`, hidden`:``}`}).join(`; `)}`,default_open:r!==void 0,blocks:i}]}function Y(e,t,n,r){let i=[{type:`header`,text:`SEO`},{type:`context`,text:`How your site appears on Google and other search engines.`}];return n?{blocks:[...i,{type:`tab`,default_tab:r.tab,panels:[{label:`Overview`,blocks:ht(e,t.site.name)},{label:`Redirects`,blocks:xe({state:e.redirects,rows:e.redirectRows,more:e.moreRedirects,editing:r.editing,panel:r.panel,problem:r.problem,exported:r.exported,managerUrl:et(t.site.url,`/_emdash/admin/redirects`)})},{label:`Settings`,blocks:[..._t(e,t.site.name),...yt(e,r.pendingHide)]}]}],...r.toast?{toast:r.toast}:{}}:{blocks:[...i,...st(e),...dt(e,!1),...ct(e),{type:`context`,text:`Business details, opening hours and redirects are set by your site admin. Ask them if something there is wrong.`}]}}function bt(t,n){if(t===``)return{};let r=e(t,`${n.replace(/\/+$/,``)}/`);return!r||r.protocol!==`https:`&&r.protocol!==`http:`?{error:`Couldn't save. The logo link must be a web address starting with https://`}:r.pathname.includes(`/_emdash/api/media/asset/`)?{error:`That link only works when signed in. In Media, use Copy URL on the image instead.`}:{logo:r.href}}function xt(e,t){let n={tab:W.identity},{site:r}=e.config,i=J(t,`kind`,e.config.steps.identity?q(r):``);if(i!==`local`&&i!==`organization`&&i!==`person`)return{error:`Choose who this site is for.`,state:n};let a=J(t,`business`,q(r)===`local`?r.type:``),o=G.find(e=>e.value===a)?.value;if(i===`local`&&!o)return{error:`Choose the kind of business.`,state:n};let s=i===`person`?`Person`:i===`local`?o??`LocalBusiness`:`Organization`,c=J(t,`name`,r.name??``);return{config:{...e.config,site:{...r,type:s,name:c===``?void 0:c,description:J(t,`description`,r.description??``)||void 0,openingHours:i===`local`?r.openingHours:[]},steps:{...e.config.steps,identity:!0}},state:n}}function St(e,t,n){let r={tab:W.identity},i=bt(J(t,`logo_url`,e.config.site.logo??``),n);return`error`in i?{error:i.error,state:r}:{config:{...e.config,site:{...e.config.site,logo:i.logo}},state:r}}function Ct(e,t){let{site:n}=e.config,r={streetAddress:J(t,`street`)||void 0,addressLocality:J(t,`town`)||void 0,addressRegion:J(t,`region`)||void 0,postalCode:J(t,`postcode`)||void 0,addressCountry:J(t,`country`).toUpperCase()||void 0},i=J(t,`price_range`);return{config:{...e.config,site:{...n,email:J(t,`email`)||void 0,telephone:J(t,`telephone`)||void 0,address:Object.values(r).some(e=>e!==void 0)?r:void 0,priceRange:Je.includes(i)&&i!==``?i:void 0,areaServed:rt(J(t,`areas`,n.areaServed.join(`
|
|
5
|
+
`)))}},state:{tab:W.identity}}}function wt(e,t){let n={tab:W.identity},r=[];for(let i of g){let a=y(i,J(t,`hours_${i.toLowerCase()}`,b(i,e.config.site.openingHours)));if(!a)return{error:`Couldn't read the hours for ${i}. Write them like 9:00-17:00, with at most two ranges.`,state:n};r.push(...a)}return{config:{...e.config,site:{...e.config.site,openingHours:r}},state:n}}function Tt(t,n){let r={tab:W.identity},i=Xe(t.config.site.sameAs),a=[...Ye.map(e=>J(n,`profile_${e.id}`,i.named[e.id]??``)),...rt(J(n,`profiles`,i.other.join(`
|
|
6
|
+
`)))].filter(e=>e!==``),o=[];for(let t of a){let n=e(/^https?:\/\//i.test(t)?t:`https://${t}`);if(!n||!n.host.includes(`.`))return{error:`"${t}" is not a web address. Paste the full link to the profile.`,state:r};o.push(n.href)}return{config:{...t.config,site:{...t.config.site,sameAs:o}},state:r}}function Et(e,t){let n={tab:W.identity},{site:r}=e.config,i=J(t,`founded`,r.foundingDate??``);return i!==``&&!/^\d{4}(-\d{2}-\d{2})?$/.test(i)?{error:`Write the founding date as a year, like 2012, or as 2012-05-01.`,state:n}:{config:{...e.config,site:{...r,alternateName:J(t,`alternate_name`,r.alternateName??``)||void 0,legalName:J(t,`legal_name`,r.legalName??``)||void 0,foundingDate:i||void 0}},state:n}}function Dt(e,t){let{robots:n}=e.config,r=J(t,`snippet`,n.nosnippet?`none`:`full`),i=J(t,`image_preview`,n.maxImagePreview);return{config:{...e.config,breadcrumbs:nt(t,`breadcrumbs`,e.config.breadcrumbs),robots:{...n,nosnippet:r===`none`,maxSnippet:r===`short`?160:-1,maxImagePreview:i===`none`||i===`standard`||i===`large`?i:n.maxImagePreview}},state:{tab:W.types}}}function Ot(e,t,n){let r={tab:W.types},[i,a]=typeof t==`string`?t.split(`|`):[];if(!i||!e.collections.some(e=>e.slug===i))return{error:`That content type no longer exists.`,state:r};let o=e.config.collections[i]??{},s=n===`type`?x.find(e=>e.value===a)?.value:o.type??vt(e,i);if(!s)return{error:`Choose one of the listed types.`,state:r};let c=n===`visible`?a===`hide`:o.noindex===!0;return{config:{...e.config,collections:{...e.config.collections,[i]:{type:s,noindex:c}},steps:{...e.config.steps,types:!0}},state:r}}async function kt(e,t){if(t.scan?.status===`done`)return at(e,t);let n=t.scan?await Ke(e,t.scan,t.config):await He(e,t.collections.map(e=>e.slug),t.config);return{...t,scan:n,stats:n.status===`done`?n.tally:null}}async function At(e,n,r){let i=await it(r),a=(n??0)>=50,o=tt(e)?e:{},s=typeof o.action_id==`string`?o.action_id:``;if(o.type===`page_load`&&o.page===`widget:seo`)return gt(await kt(r,i));if(!a||o.type===`page_load`||s===``)return Y(await kt(r,i),r,a,{tab:W.overview});let c=async(e,n=`Saved. Google will pick this up next time it visits.`)=>{if(`error`in e)return Y(i,r,a,{...e.state,toast:{type:`error`,message:e.error}});let o=h(e.config);return await r.kv.set(t,o),Y({...i,config:o},r,a,{...e.state,toast:{type:`success`,message:n}})},l=async(e,t,n)=>{if(e)return Y(i,r,a,{tab:W.redirects,editing:n,...n?{}:{panel:`add`},problem:e});let{rows:o,more:s}=await me(r);return Y({...i,redirectRows:o,moreRedirects:s},r,a,{tab:W.redirects,toast:{type:`success`,message:t}})};if(o.type===`block_action`){if(s===`check_pages`||s===`check_more`){let e=await kt(r,s===`check_pages`?{...i,scan:null}:i);return Y(e,r,a,{tab:W.overview,toast:e.scan?.status===`done`?{type:`success`,message:`All pages checked.`}:{type:`info`,message:`${e.scan?.checked??0} pages checked so far. The rest follow in the background.`}})}if(s===`set_type`)return c(Ot(i,o.value,`type`));if(s===`set_visible`){let[e,t]=typeof o.value==`string`?o.value.split(`|`):[];return t===`hide`?Y(i,r,a,{tab:W.types,pendingHide:e}):c(Ot(i,o.value,`visible`),`These pages can appear on Google again.`)}if(s===`confirm_hide`&&typeof o.value==`string`)return c(Ot(i,`${o.value}|hide`,`visible`),`These pages are now hidden from Google and other search engines.`);if(s===`cancel_hide`)return Y(i,r,a,{tab:W.types});if(s===`hidden_ok`){let e={...i.config.collections};for(let t of ut(i))e[t.slug]={...e[t.slug],hiddenOk:!0};return c({config:{...i.config,collections:e},state:{tab:W.overview}},`Noted. These pages stay hidden from search.`)}if(s===`edit_redirect`){let e=i.redirectRows.find(e=>e.id===o.value);return Y(i,r,a,{tab:W.redirects,editing:e})}if(s===`open_add`)return Y(i,r,a,{tab:W.redirects,panel:`add`});if(s===`open_import`)return Y(i,r,a,{tab:W.redirects,panel:`import`});if(s===`close_panel`)return Y(i,r,a,{tab:W.redirects});if(s===`remove_redirect`&&typeof o.value==`string`)return l(await ve(r,o.value),`Redirect removed.`);if(s===`add_redirects`||s===`more_redirects`){if(!i.redirects||i.redirects.status===`done`)return Y(i,r,a,{tab:W.redirects});let e=await le(r,i.redirects);return Y({...i,redirects:e},r,a,{tab:W.redirects,toast:e.status===`done`?{type:`success`,message:`${N(e.added,`redirect`)} added.`}:{type:`info`,message:`${e.added} added so far. The rest follow in the background.`}})}if(s===`stop_redirects`&&i.redirects?.status===`adding`){let e=await Se(r,i.redirects);return Y({...i,redirects:e},r,a,{tab:W.redirects,toast:{type:`info`,message:`Stopped. ${N(e.added,`redirect`)} added.`}})}if(s===`reset_redirects`)return i.redirects?.status!==`adding`&&await r.kv.delete(j),Y({...i,redirects:i.redirects?.status===`adding`?i.redirects:null},r,a,{tab:W.redirects});if(s===`remove_redirects`&&i.redirects){let e=await ue(r,i.redirects);return Y({...i,redirects:e===0?null:i.redirects},r,a,{tab:W.redirects,toast:e===0?{type:`success`,message:`Removed. Your other redirects are untouched.`}:{type:`info`,message:`${e} still to remove. Press Remove again.`}})}if(s===`export_redirects`){let{text:e,count:t}=await de(r);return Y(i,r,a,{tab:W.redirects,panel:`export`,exported:e,toast:{type:`info`,message:`${N(t,`redirect`)} listed below.`}})}return Y(i,r,a,{tab:W.overview})}if(o.type!==`form_submit`)return Y(i,r,a,{tab:W.overview});let u=tt(o.values)?o.values:{};if(s===`check_redirects`){let e=J(u,`list`);if(e===``)return Y(i,r,a,{tab:W.redirects,toast:{type:`error`,message:`Paste a list first.`}});let t=await se(r,e);return Y({...i,redirects:t},r,a,{tab:W.redirects,toast:{type:`success`,message:`Checked. Nothing has been added yet.`}})}if(s===`add_redirect`){let e=J(u,`source`),t=J(u,`code`,`301`)===`410`?``:J(u,`destination`);return l(await ge(r,e,t,J(u,`code`,`301`)),t===``?`Done. ${e} now tells visitors the page was removed.`:`Redirect added. ${e} now goes to ${t}.`)}if(s.startsWith(`save_redirect:`)){let e=s.slice(14),t=i.redirectRows.find(t=>t.id===e);return l(await _e(r,e,J(u,`source`,t?.source??``),J(u,`destination`,t?.destination??``),J(u,`code`,String(t?.type??301))),`Redirect changed.`,t)}if(s===`setup_identity`){let e=xt(i,u);if(`error`in e)return c(e);let t=J(u,`street`),n=J(u,`town`),{site:r}=e.config,a=t!==``||n!==``?{...r.address,...t?{streetAddress:t}:{},...n?{addressLocality:n}:{}}:r.address;return c({config:{...e.config,site:{...r,address:a}},state:{tab:W.overview}})}return s===`save_identity`?c(xt(i,u)):s===`save_logo`?c(St(i,u,r.site.url)):s===`save_contact`?c(Ct(i,u)):s===`save_hours`?c(wt(i,u)):s===`save_profiles`?c(Tt(i,u)):s===`save_company`?c(Et(i,u)):s===`save_advanced`?c(Dt(i,u)):Y(i,r,a,{tab:W.overview})}function X(e){return{"@id":e}}function Z(e){let t={};for(let[n,r]of Object.entries(e))r==null||r===``||Array.isArray(r)&&r.length===0||(t[n]=r);return t}function jt(t,n){return e(t,n)?.href??t}function Mt(t,n){return(t.siteUrl||n||(e(t.url)?.origin??``)).replace(/\/+$/,``)}function Nt(t,n){if(t.canonical)return jt(t.canonical,`${n}/`);let r=e(t.url,`${n}/`);return r?(r.search=``,r.hash=``,r.href):t.url}function Pt(e){return e.path===`/`||e.path===``}function Ft(e){return r.includes(e)}function It(e,t,n){let r=e.content?t.collections[e.content.collection]:void 0;if(n?.type)return n.type;if(r?.type)return r.type;let i=e.content?a(e.content.collection):`WebPage`;return i===`WebPage`?e.pageType===`article`?`BlogPosting`:`WebPage`:i}function Lt(e,t,n,r){let i=`${n}/#logo`,a=e.type===`Person`,o=!a&&e.type!==`Organization`&&e.address===void 0?`Organization`:e.type,s=!a&&(e.address!==void 0||e.geo!==void 0||e.openingHours.length>0);return Z({"@type":o,"@id":r,name:t,alternateName:e.alternateName,legalName:a?void 0:e.legalName,description:e.description,url:`${n}/`,logo:e.logo&&!a?{"@type":`ImageObject`,"@id":i,url:e.logo,contentUrl:e.logo,caption:t}:void 0,image:e.logo?a?e.logo:X(i):void 0,sameAs:e.sameAs,email:e.email,telephone:e.telephone,address:e.address?{"@type":`PostalAddress`,...e.address}:void 0,geo:e.geo&&!a?{"@type":`GeoCoordinates`,...e.geo}:void 0,openingHoursSpecification:s?e.openingHours.map(e=>({"@type":`OpeningHoursSpecification`,dayOfWeek:e.days,opens:e.opens,closes:e.closes})):void 0,priceRange:a?void 0:e.priceRange,areaServed:a?void 0:e.areaServed,foundingDate:a?void 0:e.foundingDate,vatID:a?void 0:e.vatID,taxID:a?void 0:e.taxID,numberOfEmployees:!a&&e.numberOfEmployees?{"@type":`QuantitativeValue`,value:e.numberOfEmployees}:void 0})}function Rt(e){let t=e;try{t=decodeURIComponent(e)}catch{}let n=t.replace(/[-_]+/g,` `).trim();return n.charAt(0).toUpperCase()+n.slice(1)}function zt(e,t,n){let{page:r,config:i,site:a}=e;if(r.breadcrumbs)return r.breadcrumbs.map(e=>({name:e.name,url:jt(e.url,`${t}/`)}));if(!i.breadcrumbs||Pt(r))return[];let o=r.path.split(`/`).filter(e=>e!==``);if(o.length===0)return[];let s=a.trailingSlash===`always`?`/`:``,c=[{name:`Home`,url:`${t}/`}],l=``;return o.forEach((e,r)=>{l+=`/${e}`;let i=r===o.length-1;c.push({name:i?n:Rt(e),url:`${t}${l}${s}`})}),c}function Bt(e,t){return e.length<=t?e:`${e.slice(0,t-1).trimEnd()}…`}function Vt(e){let{page:t,config:n,record:r,site:i}=e,a=Mt(t,i.url),o=Nt(t,a),s=t.locale??i.locale,c=n.site.name??t.siteName??i.name,l=t.pageTitle??t.title??c,u=n.site.type===`Person`,d=`${a}/#${u?`person`:`organization`}`,f=`${a}/#website`,p=o,m=`${o}#breadcrumb`,h=`${o}#primaryimage`,g=It(t,n,r),_=Ft(g),v=g===`Service`,y=g===`ProfilePage`,b=`${o}#service`,x=`${o}#person`,S=r?.publishedAt??t.articleMeta?.publishedTime??void 0,C=r?.updatedAt??t.articleMeta?.modifiedTime??void 0,w=t.image?jt(t.image,`${a}/`):void 0,T=zt(e,a,l),E=[Lt(n.site,c,a,d),Z({"@type":`WebSite`,"@id":f,url:`${a}/`,name:c,alternateName:n.site.alternateName,description:n.site.description,publisher:X(d),inLanguage:s}),Z({"@type":_||v?`WebPage`:g,"@id":p,url:o,name:l,description:t.description,isPartOf:X(f),about:Pt(t)?X(d):void 0,primaryImageOfPage:w?X(h):void 0,image:w?X(h):void 0,breadcrumb:T.length>0?X(m):void 0,mainEntity:v?X(b):y?X(x):void 0,datePublished:S,dateModified:C,inLanguage:s})];if(_){let e=(r?.authors??[]).map(e=>Z({"@type":`Person`,name:e.name,url:e.url})),n=t.articleMeta?.author?[{"@type":`Person`,name:t.articleMeta.author}]:[X(d)];E.push(Z({"@type":g,"@id":`${o}#article`,isPartOf:X(p),mainEntityOfPage:X(p),headline:Bt(l,110),description:t.description,image:w?X(h):void 0,datePublished:S,dateModified:C,author:e.length>0?e:n,publisher:X(d),inLanguage:s}))}return v&&E.push(Z({"@type":`Service`,"@id":b,name:l,description:t.description,url:o,image:w?X(h):void 0,provider:X(d),areaServed:n.site.areaServed})),y&&E.push(Z({"@type":`Person`,"@id":x,name:l,description:t.description,url:o,image:w?X(h):void 0,worksFor:u?void 0:X(d)})),w&&E.push({"@type":`ImageObject`,"@id":h,url:w,contentUrl:w}),T.length>0&&E.push({"@type":`BreadcrumbList`,"@id":m,itemListElement:T.map((e,t)=>Z({"@type":`ListItem`,position:t+1,name:e.name,item:t===T.length-1?void 0:e.url}))}),E}const Ht=`default`;function Ut(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function Wt(t){let n=e(t);if(!n)return t;let r=n.pathname.split(`/`).filter(e=>e!==``);return[n.host,...r].join(` › `)}function Gt(e,t){return e.length<=t?e:`${e.slice(0,t-1).trimEnd()}…`}async function Kt(n,r,i,o){if(!r)return{blocks:[]};let s=(i??0)>=50,c=Ut(n)?n:{},l=h(await o.kv.get(t)),u=R(o),d=L(r.collection,r.id),f,p=s&&c.type===`block_action`&&c.action_id===`set_type`,[m,g]=await Promise.all([Re(o,r.collection,r.id,l),o.content?.getPublicUrl?.(r.collection,r.id)]),_=m?.record,v=m?.entry;if(_&&p){let e=typeof c.value==`string`?c.value:Ht,t=x.find(t=>t.value===e)?.value,{type:n,...r}=_;_=t?{...r,type:t}:r,await u.put(d,_),f={type:`success`,message:`Saved. Google will pick this up next time it visits.`}}if(!_||!v)return{blocks:[{type:`context`,text:`Save this page to see how it may look on Google.`}]};if(_.status===`skip`)return{blocks:[{type:`context`,text:`This kind of content has no page of its own, so Google only sees it inside other pages. There is nothing to set here.`}]};let y=l.site.name??o.site.name,b=o.site.url.replace(/\/+$/,``),C=[`home`,`index`,``].includes(v.slug??``)?`${b}/`:`${b}/${v.slug}`,w=[`home`,`index`].includes(v.slug??``)?`${b}/`:g??C,T=_.title||`(no title)`,E=v.seo?.description?.trim()??``,D=l.collections[r.collection]?.type??a(r.collection),ee=_.type??D,O=_.status===`hidden`,k=_.issues??[],A=[{type:`context`,text:y?`${y} · ${Wt(w)}`:Wt(w)},{type:`header`,text:Te(T)},{type:`section`,text:E===``?`No description yet. Google will pick some text from the page.`:Gt(E,160)},{type:`context`,text:`What Google sees: the published page, with the SEO title and description as last saved. Google may rewrite them, and your site may add its name to the title.`},{type:`fields`,fields:[{label:`Shown to Google as`,value:S(ee)},{label:`In search results`,value:_.status===`draft`?`Not published yet`:O?`Hidden from search`:`Yes`}]}];if(k.length===0)A.push({type:`context`,text:`Nothing to fix on this page.`});else{let e=[...k].sort((e,t)=>Number(t.level===`fix`)-Number(e.level===`fix`));for(let t of e.slice(0,3))A.push({type:`section`,text:`${t.level===`fix`?`To fix`:`Worth a look`}: ${t.message}`},{type:`context`,text:t.fix});e.length>3&&A.push({type:`context`,text:`And ${e.length-3} more.`})}if(s){let t=Vt({page:{url:w,path:e(w)?.pathname??`/`,locale:v.locale,kind:`content`,pageType:`website`,title:T,pageTitle:T,description:E===``?null:E,canonical:v.seo?.canonical??null,image:null,content:{collection:r.collection,id:r.id,slug:v.slug}},config:l,record:_,site:o.site});A.push({type:`divider`},{type:`actions`,elements:[{type:`menu`,action_id:`set_type`,label:`Change page type`,items:[{label:`Same as other pages here (${S(D)})`,value:Ht},...x.map(({value:e,label:t})=>({value:e,label:t}))]},.../^https?:\/\//.test(w)?[{type:`link`,label:`Test on Google`,target:{kind:`external`,url:`https://search.google.com/test/rich-results?url=${encodeURIComponent(w)}`}}]:[]]},{type:`accordion`,label:`View the markup`,blocks:[{type:`context`,text:`The schema markup (JSON-LD) this page sends to search engines.`},{type:`code`,language:`jsonc`,code:JSON.stringify({"@context":`https://schema.org`,"@graph":t},null,2)}]})}return{blocks:A,...f?{toast:f}:{}}}const qt=/\s*,\s*/;function Jt(e,t,n){let r=(e??``).toLowerCase().split(qt).map(e=>e.trim()).filter(e=>e!==``),i=t||r.includes(`noindex`)||r.includes(`none`),a=r.includes(`nofollow`)||r.includes(`none`),o=[i?`noindex`:`index`,a?`nofollow`:`follow`],s=new Set([`index`,`noindex`,`follow`,`nofollow`,`none`,`all`]),c=r.filter(e=>!s.has(e));if(!i){let e=e=>c.some(t=>t.startsWith(e));n.nosnippet||c.includes(`nosnippet`)?c.includes(`nosnippet`)||c.push(`nosnippet`):e(`max-snippet:`)||c.push(`max-snippet:${n.maxSnippet}`),e(`max-image-preview:`)||c.push(`max-image-preview:${n.maxImagePreview}`),e(`max-video-preview:`)||c.push(`max-video-preview:${n.maxVideoPreview}`)}return[...o,...new Set(c)].join(`, `)}async function Q(e){return h(await e.kv.get(t))}async function $(e,t,n){let r=n?.id;if(typeof r==`string`)try{await Le(e,t,r,await Q(e))}catch(n){e.log.warn(`could not refresh entry record`,{collection:t,id:r,error:String(n)})}}const Yt={hooks:{"page:metadata":async(e,t)=>{let{page:n}=e,r=await Q(t),i=null;if(n.content){let{collection:e,id:a}=n.content;try{i=await R(t).get(L(e,a))??await Le(t,e,a,r)}catch(n){t.log.warn(`could not load entry record`,{collection:e,id:a,error:String(n)})}}let a=n.content?r.collections[n.content.collection]:void 0,o=[{kind:`meta`,name:`robots`,content:Jt(n.seo?.robots,a?.noindex===!0,r.robots)},{kind:`jsonld`,id:`primary`,graph:{"@context":`https://schema.org`,"@graph":Vt({page:n,config:r,record:i,site:t.site})}}];return i&&i.custom.length>0&&o.push({kind:`jsonld`,id:`seo:custom`,graph:i.custom.map(e=>({"@context":`https://schema.org`,...e}))}),o},"content:afterSave":async(e,t)=>{await $(t,e.collection,e.content)},"content:afterPublish":async(e,t)=>{await $(t,e.collection,e.content)},"content:afterUnpublish":async(e,t)=>{await $(t,e.collection,e.content)},"content:afterRestore":async(e,t)=>{await $(t,e.collection,e.content)},"plugin:activate":async(e,t)=>{try{if(await Ve(t))return;await He(t,(await t.schema?.listCollections()??[]).filter(e=>e.routable&&!e.hidden).map(e=>e.slug),await Q(t))}catch(e){t.log.warn(`could not start the first page check`,{error:String(e)})}},cron:async(e,t)=>{if(e.name===H){let e=await Ve(t);e?.status===`running`?await Ke(t,e,await Q(t)):await t.cron?.cancel(H);return}if(e.name!==M)return;let n=await ae(t);n?.status===`adding`?await le(t,n):await t.cron?.cancel(M)},"content:afterDelete":async(e,t)=>{await ze(t,e.collection,e.id)}},routes:{admin:{permission:`plugins:read`,handler:async(e,t)=>At(e.input,e.user?.role,t)},"editor/preview":{permission:`content:read`,handler:async(e,t)=>Kt(e.input,e.ui?.entry,e.user?.role,t)},hello:{handler:async(e,t)=>(t.log.info(`hello route called`,{pluginId:t.plugin.id}),{greeting:`hello`,pluginId:t.plugin.id})}}};export{Yt as default};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
|
|
3
|
+
|
|
4
|
+
"slug": "seo",
|
|
5
|
+
// Listing text. Registry search matches exact phrases in name, description,
|
|
6
|
+
// keywords and sections, so the phrases people type appear verbatim.
|
|
7
|
+
"name": "SEO & Schema Markup",
|
|
8
|
+
"description": "SEO for EmDash: schema markup, structured data, Google preview, noindex rules, redirects import and SEO checks. A Yoast alternative.",
|
|
9
|
+
"keywords": ["seo", "schema markup", "structured data", "yoast alternative", "redirects"],
|
|
10
|
+
"repo": "https://github.com/moorage-dev/moorage",
|
|
11
|
+
"sections": {
|
|
12
|
+
"description": { "file": "listing/description.md" },
|
|
13
|
+
"installation": { "file": "listing/installation.md" },
|
|
14
|
+
"faq": { "file": "listing/faq.md" },
|
|
15
|
+
},
|
|
16
|
+
"publisher": "did:plc:cus3srdqglppyowqrw3hvjfj",
|
|
17
|
+
|
|
18
|
+
"license": "FSL-1.1-MIT",
|
|
19
|
+
"author": { "name": "Moorage" },
|
|
20
|
+
"security": { "email": "security@moorage.dev" },
|
|
21
|
+
|
|
22
|
+
// Trust contract — what runtime APIs the plugin asks for.
|
|
23
|
+
// Empty arrays mean no extra privileges beyond logging,
|
|
24
|
+
// KV, and route/hook registration. Changing these between
|
|
25
|
+
// releases requires a version bump because installed
|
|
26
|
+
// users have consented to the old contract.
|
|
27
|
+
"capabilities": ["content:read", "bylines:read", "schema:read", "redirects:write"],
|
|
28
|
+
"allowedHosts": [],
|
|
29
|
+
"admin": {
|
|
30
|
+
"pages": [{ "path": "/overview", "label": "SEO", "icon": "search", "group": "Marketing" }],
|
|
31
|
+
"widgets": [{ "id": "seo", "title": "SEO", "size": "third" }],
|
|
32
|
+
"editorPanels": [{ "id": "preview", "title": "Google preview", "route": "editor/preview" }],
|
|
33
|
+
},
|
|
34
|
+
"storage": {
|
|
35
|
+
// One record per content entry: dates, authors, schema type, extra JSON-LD.
|
|
36
|
+
"entries": { "indexes": ["status", "titleKey"] },
|
|
37
|
+
},
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
EmDash already handles titles, descriptions, sitemaps, canonicals and social tags. This plugin adds structured data, page checks and redirects import. No account and no key.
|
|
2
|
+
|
|
3
|
+
## What it does
|
|
4
|
+
|
|
5
|
+
- **Schema markup (structured data, JSON-LD).** One linked graph per page in place of EmDash's single object: Organization, Person or a local business with address and opening hours; WebSite; WebPage; blog posts and articles with their authors; services; team members; breadcrumbs.
|
|
6
|
+
- **A page type for every collection**, guessed from its name and changeable per collection or per page.
|
|
7
|
+
- **Pages to fix.** A list of pages with a missing description, a shared title or a canonical pointing elsewhere, each with a link to the page. No score and no traffic light: a list that can reach zero.
|
|
8
|
+
- **Google preview** in the entry editor: the address, title and description as Google may show them, with up to three things to fix.
|
|
9
|
+
- **Hide from search (noindex)** per collection, with a warning on the SEO page whenever something is hidden.
|
|
10
|
+
- **Redirects.** Add, change and remove redirects, or paste a list exported from Redirection, Yoast SEO Premium or Rank Math.
|
|
11
|
+
|
|
12
|
+
## What it does not do
|
|
13
|
+
|
|
14
|
+
- It does not write the page title, the sitemap or robots.txt; EmDash core and your theme do.
|
|
15
|
+
- No keyword density or readability score: Google uses neither.
|
|
16
|
+
- No FAQ, Product or Event schema yet; use a `json_ld` field for those.
|
|
17
|
+
- The checks describe the published page, not what you are typing.
|
|
18
|
+
|
|
19
|
+
## Limits
|
|
20
|
+
|
|
21
|
+
One business location. EmDash has no regular-expression redirects: exact addresses and "everything under a folder" are converted, other patterns are listed for you to rewrite.
|
|
22
|
+
|
|
23
|
+
## Permissions
|
|
24
|
+
|
|
25
|
+
Read content, bylines and the content schema, to check pages and build the markup. Manage redirects, only for an import you confirm; existing redirects are never changed.
|
package/listing/faq.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
## How do I add schema markup to EmDash?
|
|
2
|
+
|
|
3
|
+
Install this plugin. Every page gets structured data (JSON-LD) straight away; fill in "Who you are" to make it accurate.
|
|
4
|
+
|
|
5
|
+
## How do I add structured data to one page?
|
|
6
|
+
|
|
7
|
+
Add a JSON field named `json_ld` to the collection and paste any schema.org object into the entry.
|
|
8
|
+
|
|
9
|
+
## Is there a Yoast alternative for EmDash?
|
|
10
|
+
|
|
11
|
+
EmDash core covers titles, descriptions, sitemaps and canonicals. This plugin adds the schema graph, page checks, the Google preview and redirects import.
|
|
12
|
+
|
|
13
|
+
## How do I import redirects from WordPress?
|
|
14
|
+
|
|
15
|
+
Export them from Redirection, Yoast SEO Premium or Rank Math, open SEO > Redirects > Import a list from WordPress, paste the list and press "Check list". Nothing is added until you confirm.
|
|
16
|
+
|
|
17
|
+
## How do I noindex a collection in EmDash?
|
|
18
|
+
|
|
19
|
+
Open SEO > Settings > Content types and choose "Hide from Google and other search engines" for that content type.
|
|
20
|
+
|
|
21
|
+
## Does it do title templates or llms.txt?
|
|
22
|
+
|
|
23
|
+
Not by itself: a plugin cannot set the page title or serve files from the site root. Both are done by the theme.
|
|
24
|
+
|
|
25
|
+
## Is there an SEO score?
|
|
26
|
+
|
|
27
|
+
No, on purpose. You get a list of things to fix that can reach zero.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Install from **Plugins** in your EmDash admin, or pin it in code:
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
npm install @moorage/plugin-seo
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
// astro.config.mjs
|
|
9
|
+
import seo from "@moorage/plugin-seo";
|
|
10
|
+
|
|
11
|
+
emdash({ sandboxed: [seo] });
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then open **SEO** in the admin sidebar. It works before you change anything; three short steps make the result more accurate.
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moorage/plugin-seo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SEO for EmDash: schema markup, structured data, Google preview, noindex rules, redirects import and SEO checks. A Yoast alternative.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"emdash",
|
|
7
|
+
"emdash-plugin",
|
|
8
|
+
"seo",
|
|
9
|
+
"schema markup",
|
|
10
|
+
"structured data",
|
|
11
|
+
"json-ld",
|
|
12
|
+
"yoast alternative"
|
|
13
|
+
],
|
|
14
|
+
"license": "FSL-1.1-MIT",
|
|
15
|
+
"author": "Moorage (https://moorage.dev)",
|
|
16
|
+
"homepage": "https://github.com/moorage-dev/moorage/tree/main/packages/plugin-seo#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/moorage-dev/moorage.git",
|
|
20
|
+
"directory": "packages/plugin-seo"
|
|
21
|
+
},
|
|
22
|
+
"bugs": "https://github.com/moorage-dev/moorage/issues",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "dist/index.mjs",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"import": "./dist/index.mjs",
|
|
31
|
+
"types": "./dist/index.d.mts"
|
|
32
|
+
},
|
|
33
|
+
"./sandbox": "./dist/plugin.mjs"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"!dist/*.tar.gz",
|
|
38
|
+
"emdash-plugin.jsonc",
|
|
39
|
+
"listing",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"README.md"
|
|
43
|
+
],
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"emdash": ">=1.2.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@emdash-cms/blocks": "1.2.0",
|
|
49
|
+
"@emdash-cms/plugin-cli": "0.13.3",
|
|
50
|
+
"@emdash-cms/plugin-test": "0.2.8",
|
|
51
|
+
"@moorage/typescript-config": "0.0.0",
|
|
52
|
+
"emdash": "1.2.0",
|
|
53
|
+
"typescript": "~6.0.3",
|
|
54
|
+
"vitest": "4.1.10"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"validate": "emdash-plugin validate",
|
|
58
|
+
"build": "emdash-plugin build",
|
|
59
|
+
"dev": "emdash-plugin dev",
|
|
60
|
+
"typecheck": "tsc --noEmit",
|
|
61
|
+
"test": "emdash-plugin validate && vitest run",
|
|
62
|
+
"bundle": "emdash-plugin bundle",
|
|
63
|
+
"login": "emdash-plugin login",
|
|
64
|
+
"registry:publish": "emdash-plugin publish",
|
|
65
|
+
"release:setup": "emdash-plugin release setup"
|
|
66
|
+
}
|
|
6
67
|
}
|