@simmalugnt-se/payload-content-health 0.6.0 → 0.7.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 +22 -0
- package/README.md +34 -17
- package/dist/admin/ContentHealthFieldBridge.js +23 -13
- package/dist/categories.js +9 -1
- package/dist/checks.d.ts +18 -0
- package/dist/checks.js +38 -2
- package/dist/content.js +3 -0
- package/dist/copy.d.ts +8 -0
- package/dist/copy.js +54 -1
- package/dist/fields.d.ts +11 -1
- package/dist/fields.js +5 -1
- package/dist/links.js +2 -0
- package/dist/references.d.ts +8 -0
- package/dist/references.js +117 -0
- package/dist/scan.js +88 -11
- package/dist/site.d.ts +35 -0
- package/dist/site.js +67 -0
- package/dist/styles/view.css +14 -2
- package/dist/types.d.ts +25 -1
- package/dist/types.js +3 -0
- package/dist/view/WorkList.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
- Work-list rows have a dividing line and a hover band across the full width, so a row's "Fix with
|
|
6
|
+
the assistant" reads as part of that document on wide screens.
|
|
7
|
+
- A document without a title in a language shows its title in the default language instead of its
|
|
8
|
+
id. The scan now reads the default language first.
|
|
9
|
+
- A work-list link to a field inside a block (such as a rich text field) opens and selects that
|
|
10
|
+
field. On a fresh page load the form's rows were not there yet, so the link settled for the whole
|
|
11
|
+
blocks field and the assistant showed "Layout" as the selection.
|
|
12
|
+
- New checks `seo.title-duplicate` and `seo.description-duplicate`: a meta title or description
|
|
13
|
+
another document uses in the same language, across collections, ignoring case and spacing. On by
|
|
14
|
+
default with `seo`; `seo.duplicates: false` turns them off. Both can be fixed many at once.
|
|
15
|
+
- New check `links.broken` (Structure): internal links in the body, in rich text or relationship
|
|
16
|
+
fields, to documents that are deleted or not published. On by default with `content`;
|
|
17
|
+
`content.links: false` turns it off.
|
|
18
|
+
|
|
19
|
+
## 0.6.1
|
|
20
|
+
|
|
21
|
+
- Work-list filters and field links update Next’s router state, so refresh preserves the current
|
|
22
|
+
filters and does not restore consumed field parameters.
|
|
23
|
+
- Checks that need individual review (including share images) explain why there is no batch action.
|
|
24
|
+
|
|
3
25
|
## 0.6.0
|
|
4
26
|
|
|
5
27
|
- The dashboard widget is a summary: a ring for the share in order, and one row per category (SEO,
|
package/README.md
CHANGED
|
@@ -38,29 +38,37 @@ Requires Payload `>=3.90.2 <4`, Next 16 and React 19. The widget uses Payload's
|
|
|
38
38
|
|
|
39
39
|
## Checks
|
|
40
40
|
|
|
41
|
-
| Check
|
|
42
|
-
|
|
|
43
|
-
| `media.alt-missing`
|
|
44
|
-
| `seo.title-missing`
|
|
45
|
-
| `seo.title-length`
|
|
46
|
-
| `seo.
|
|
47
|
-
| `seo.description-
|
|
48
|
-
| `seo.
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `content.
|
|
52
|
-
| `content.
|
|
53
|
-
| `content.
|
|
41
|
+
| Check | Option | Finds |
|
|
42
|
+
| --------------------------- | ------------------ | ---------------------------------------------- |
|
|
43
|
+
| `media.alt-missing` | `altText` | Images (`image/*`) with an empty alt field |
|
|
44
|
+
| `seo.title-missing` | `seo` | Empty meta title |
|
|
45
|
+
| `seo.title-length` | `seo` | Meta title outside 50–60 characters |
|
|
46
|
+
| `seo.title-duplicate` | `seo` | Meta title another document also uses |
|
|
47
|
+
| `seo.description-missing` | `seo` | Empty meta description |
|
|
48
|
+
| `seo.description-length` | `seo` | Meta description outside 100–150 characters |
|
|
49
|
+
| `seo.description-duplicate` | `seo` | Meta description another document also uses |
|
|
50
|
+
| `seo.image-missing` | `seo` | No share image |
|
|
51
|
+
| `content.h1-missing` | `content.headings` | No H1 on the page |
|
|
52
|
+
| `content.h1-multiple` | `content.headings` | More than one H1 |
|
|
53
|
+
| `content.heading-skip` | `content.headings` | A heading level skipped, e.g. H2 → H4 |
|
|
54
|
+
| `content.thin` | `content` | Fewer than 300 words on the page |
|
|
55
|
+
| `links.broken` | `content` | Internal links to deleted or unpublished pages |
|
|
56
|
+
| `content.untranslated` | `translations` | Localized text empty here, filled in default |
|
|
54
57
|
|
|
55
58
|
`seo: true` reads the fields `@payloadcms/plugin-seo` adds (`meta.title`, `meta.description`,
|
|
56
59
|
`meta.image`) with its default lengths. Point them elsewhere or turn single checks off:
|
|
57
60
|
|
|
61
|
+
Duplicates are compared per language across every collection with SEO checks, ignoring case and
|
|
62
|
+
spacing; each document that shares a text gets a finding naming the others. `duplicates: false`
|
|
63
|
+
turns them off.
|
|
64
|
+
|
|
58
65
|
```ts
|
|
59
66
|
pages: {
|
|
60
67
|
seo: {
|
|
61
68
|
description: "seo.summary",
|
|
62
69
|
descriptionLength: { min: 80, max: 160 },
|
|
63
70
|
image: false,
|
|
71
|
+
duplicates: false,
|
|
64
72
|
},
|
|
65
73
|
},
|
|
66
74
|
media: { altText: { field: "altText" } },
|
|
@@ -82,12 +90,21 @@ pages: {
|
|
|
82
90
|
cards: { heading: 2, "items.title": 3 },
|
|
83
91
|
},
|
|
84
92
|
minWords: 300, // or false
|
|
93
|
+
links: true, // or false
|
|
85
94
|
},
|
|
86
95
|
},
|
|
87
96
|
```
|
|
88
97
|
|
|
89
98
|
Each page gets at most one finding per check: the first skipped level, the second H1.
|
|
90
99
|
|
|
100
|
+
Internal links are the link nodes in rich text (`linkType: "internal"`) and relationship fields in
|
|
101
|
+
the body, such as a link group's reference. A link group whose `type` or `linkType` is `custom`,
|
|
102
|
+
`external` or `url` is an outside link, and the reference it keeps is skipped. A link is broken
|
|
103
|
+
when its document is deleted or, in a collection with drafts, not published, since readers only see
|
|
104
|
+
published pages. Targets are looked up without access control; only their title and whether they
|
|
105
|
+
are published reach the list. Each page gets one finding per language, at the first broken link,
|
|
106
|
+
with how many there are.
|
|
107
|
+
|
|
91
108
|
### Translations
|
|
92
109
|
|
|
93
110
|
`translations: true` compares every language with the default one (Payload's
|
|
@@ -137,15 +154,15 @@ or block the unsaved form no longer has opens the nearest enclosing field instea
|
|
|
137
154
|
|
|
138
155
|
With `@simmalugnt-se/payload-editor-assistant` (0.5.0 or later), rows in the collections the
|
|
139
156
|
assistant works in also get "Fix with the assistant": the same link with a ready prompt that the
|
|
140
|
-
assistant
|
|
141
|
-
missing or
|
|
157
|
+
assistant offers as a task, with the length to aim for when a meta title or description is
|
|
158
|
+
missing, off or shared with another document. The editor starts it. To write a missing alt text, the assistant needs to see the
|
|
142
159
|
image: allow `media.view` on the media collection (0.6.0 or later). The packages do not import each
|
|
143
160
|
other; they share these names:
|
|
144
161
|
|
|
145
162
|
| Name | Direction | Meaning |
|
|
146
163
|
| -------------------------------------------- | ----------------------- | ---------------------------------------- |
|
|
147
164
|
| `?field=<path>` | link → this plugin | Form path to bring into view |
|
|
148
|
-
| `?ask=<prompt>` | link → assistant | Prompt
|
|
165
|
+
| `?ask=<prompt>` | link → assistant | Prompt the assistant offers as a task |
|
|
149
166
|
| `content-health:select` (`detail: { path }`) | this plugin → assistant | The field brought into view is selected |
|
|
150
167
|
| `config.admin.custom.editorAssistant` | assistant → this plugin | `{ collections, batchCollections }` |
|
|
151
168
|
| `?batch=…&batchScope=…&ask=…` | link → assistant | Documents to fix at once, and the prompt |
|
|
@@ -156,7 +173,7 @@ Both parameters are removed from the address once read. `editURL(adminRoute, fin
|
|
|
156
173
|
### Many documents at once
|
|
157
174
|
|
|
158
175
|
Where the assistant allows `content.update` (0.11.0 or later), the sections for missing alt texts,
|
|
159
|
-
meta titles or descriptions (missing
|
|
176
|
+
meta titles or descriptions (missing, the wrong length or duplicated) and missing translations offer "Fix N with
|
|
160
177
|
the assistant" next to their heading. It opens the assistant's panel beside the list with up to 20 of
|
|
161
178
|
the documents in the section and one prompt for all of them; the assistant proposes a change per document, and
|
|
162
179
|
the editor saves the ones they want. Saved documents drop out of the list. `batchURL(adminRoute,
|
|
@@ -48,21 +48,31 @@ export function ContentHealthFieldBridge() {
|
|
|
48
48
|
let cancelled = false;
|
|
49
49
|
const current = () => !cancelled;
|
|
50
50
|
void (async () => {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
const plan = () => {
|
|
52
|
+
const { form, config, getEntityConfig, collectionSlug } = latest.current;
|
|
53
|
+
const fields = form.getFields();
|
|
54
|
+
const rootFields = collectionSlug
|
|
55
|
+
? (getEntityConfig({ collectionSlug })
|
|
56
|
+
?.fields ?? [])
|
|
57
|
+
: [];
|
|
58
|
+
const lookup = {
|
|
59
|
+
blockTypeAt: (rowPath) => rowAt(fields, rowPath)?.blockType,
|
|
60
|
+
blocksBySlug: config.blocksMap,
|
|
61
|
+
};
|
|
62
|
+
const target = revealTarget(path, (candidate) => planReveal(rootFields, candidate, lookup));
|
|
63
|
+
return { form, fields, target };
|
|
60
64
|
};
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
// The form's rows can arrive just after the page loads; until they do, a path in a block
|
|
66
|
+
// only plans as far as its blocks field. Wait for the whole path before settling for less.
|
|
67
|
+
const planned = (await waitFor(() => {
|
|
68
|
+
const attempt = plan();
|
|
69
|
+
return attempt.target?.path === path ? attempt : null;
|
|
70
|
+
})) ?? plan();
|
|
71
|
+
const { target } = planned;
|
|
72
|
+
if (!target || !current()) {
|
|
63
73
|
return;
|
|
64
74
|
}
|
|
65
|
-
expandRows(target.steps, fields, form.dispatchFields);
|
|
75
|
+
expandRows(target.steps, planned.fields, planned.form.dispatchFields);
|
|
66
76
|
const scope = await openSteps(target.steps, formRoot(anchorRef.current), current);
|
|
67
77
|
if (!scope || !current()) {
|
|
68
78
|
return;
|
|
@@ -91,7 +101,7 @@ function takeFieldParam() {
|
|
|
91
101
|
return null;
|
|
92
102
|
}
|
|
93
103
|
url.searchParams.delete(FIELD_PARAM);
|
|
94
|
-
window.history.replaceState(
|
|
104
|
+
window.history.replaceState(null, "", url);
|
|
95
105
|
return path || null;
|
|
96
106
|
}
|
|
97
107
|
function expandRows(steps, fields, dispatchFields) {
|
package/dist/categories.js
CHANGED
|
@@ -4,11 +4,19 @@ export const CATEGORY_CHECKS = {
|
|
|
4
4
|
seo: [
|
|
5
5
|
"seo.title-missing",
|
|
6
6
|
"seo.title-length",
|
|
7
|
+
"seo.title-duplicate",
|
|
7
8
|
"seo.description-missing",
|
|
8
9
|
"seo.description-length",
|
|
10
|
+
"seo.description-duplicate",
|
|
9
11
|
"seo.image-missing",
|
|
10
12
|
],
|
|
11
|
-
structure: [
|
|
13
|
+
structure: [
|
|
14
|
+
"content.h1-missing",
|
|
15
|
+
"content.h1-multiple",
|
|
16
|
+
"content.heading-skip",
|
|
17
|
+
"content.thin",
|
|
18
|
+
"links.broken",
|
|
19
|
+
],
|
|
12
20
|
translations: ["content.untranslated"],
|
|
13
21
|
accessibility: ["media.alt-missing"],
|
|
14
22
|
};
|
package/dist/checks.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ContentSchema } from "./fields.ts";
|
|
2
|
+
import type { LinksEntry, UniqueEntry } from "./site.ts";
|
|
2
3
|
import type { CheckId, CollectionHealthOptions, Finding, HeadingLevels, LengthRange } from "./types.ts";
|
|
3
4
|
export declare const SEO_DEFAULTS: {
|
|
4
5
|
readonly title: "meta.title";
|
|
@@ -26,6 +27,19 @@ export type FieldRule = {
|
|
|
26
27
|
path: string;
|
|
27
28
|
range: LengthRange;
|
|
28
29
|
}
|
|
30
|
+
/** Compared with the other documents once all are read (`site.ts`). */
|
|
31
|
+
| {
|
|
32
|
+
check: CheckId;
|
|
33
|
+
kind: "unique";
|
|
34
|
+
path: string;
|
|
35
|
+
range?: LengthRange;
|
|
36
|
+
}
|
|
37
|
+
/** Internal links in the page body at `path`, followed once all documents are read. */
|
|
38
|
+
| {
|
|
39
|
+
check: CheckId;
|
|
40
|
+
kind: "links";
|
|
41
|
+
path: string;
|
|
42
|
+
}
|
|
29
43
|
/** Heading checks on the page body at `path`: missing H1, several H1s, skipped levels. */
|
|
30
44
|
| {
|
|
31
45
|
check: CheckId;
|
|
@@ -67,6 +81,10 @@ export type DocumentResult = {
|
|
|
67
81
|
* for lengths. With the findings this gives each check's share of values in order.
|
|
68
82
|
*/
|
|
69
83
|
checked: CheckId[];
|
|
84
|
+
/** Meta texts to compare across documents; they count as checked once compared. */
|
|
85
|
+
unique?: UniqueEntry[];
|
|
86
|
+
/** The body's internal links, to look up once every document is read. */
|
|
87
|
+
links?: LinksEntry[];
|
|
70
88
|
};
|
|
71
89
|
export declare function checkDocument(input: DocumentInput): DocumentResult;
|
|
72
90
|
/** What the admin shows as the document's name; an image without alt text still needs one. */
|
package/dist/checks.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* locale at a time. Nothing here talks to Payload, so every check is testable on its own.
|
|
4
4
|
*/
|
|
5
5
|
import { readContent } from "./content.js";
|
|
6
|
+
import { readReferences } from "./references.js";
|
|
6
7
|
export const SEO_DEFAULTS = {
|
|
7
8
|
title: "meta.title",
|
|
8
9
|
description: "meta.description",
|
|
@@ -24,6 +25,7 @@ export function rulesFor(options) {
|
|
|
24
25
|
const image = seo.image ?? SEO_DEFAULTS.image;
|
|
25
26
|
const titleLength = seo.titleLength ?? SEO_DEFAULTS.titleLength;
|
|
26
27
|
const descriptionLength = seo.descriptionLength ?? SEO_DEFAULTS.descriptionLength;
|
|
28
|
+
const duplicates = seo.duplicates ?? true;
|
|
27
29
|
if (title) {
|
|
28
30
|
rules.push({
|
|
29
31
|
check: "seo.title-missing",
|
|
@@ -34,6 +36,14 @@ export function rulesFor(options) {
|
|
|
34
36
|
if (titleLength) {
|
|
35
37
|
rules.push({ check: "seo.title-length", kind: "length", path: title, range: titleLength });
|
|
36
38
|
}
|
|
39
|
+
if (duplicates) {
|
|
40
|
+
rules.push({
|
|
41
|
+
check: "seo.title-duplicate",
|
|
42
|
+
kind: "unique",
|
|
43
|
+
path: title,
|
|
44
|
+
...(titleLength ? { range: titleLength } : {}),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
37
47
|
}
|
|
38
48
|
if (description) {
|
|
39
49
|
rules.push({
|
|
@@ -50,13 +60,21 @@ export function rulesFor(options) {
|
|
|
50
60
|
range: descriptionLength,
|
|
51
61
|
});
|
|
52
62
|
}
|
|
63
|
+
if (duplicates) {
|
|
64
|
+
rules.push({
|
|
65
|
+
check: "seo.description-duplicate",
|
|
66
|
+
kind: "unique",
|
|
67
|
+
path: description,
|
|
68
|
+
...(descriptionLength ? { range: descriptionLength } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
53
71
|
}
|
|
54
72
|
if (image) {
|
|
55
73
|
rules.push({ check: "seo.image-missing", kind: "required", path: image });
|
|
56
74
|
}
|
|
57
75
|
}
|
|
58
76
|
if (options.content) {
|
|
59
|
-
const { field, headings, minWords = DEFAULT_MIN_WORDS } = options.content;
|
|
77
|
+
const { field, headings, minWords = DEFAULT_MIN_WORDS, links = true } = options.content;
|
|
60
78
|
if (headings) {
|
|
61
79
|
for (const check of ["content.h1-missing", "content.h1-multiple", "content.heading-skip"]) {
|
|
62
80
|
rules.push({ check: check, kind: "outline", path: field, headings });
|
|
@@ -65,6 +83,9 @@ export function rulesFor(options) {
|
|
|
65
83
|
if (minWords !== false) {
|
|
66
84
|
rules.push({ check: "content.thin", kind: "words", path: field, min: minWords });
|
|
67
85
|
}
|
|
86
|
+
if (links) {
|
|
87
|
+
rules.push({ check: "links.broken", kind: "links", path: field });
|
|
88
|
+
}
|
|
68
89
|
}
|
|
69
90
|
return rules;
|
|
70
91
|
}
|
|
@@ -74,6 +95,8 @@ export function checkDocument(input) {
|
|
|
74
95
|
const status = doc._status === "draft" || doc._status === "published" ? doc._status : undefined;
|
|
75
96
|
const findings = [];
|
|
76
97
|
const checked = [];
|
|
98
|
+
const unique = [];
|
|
99
|
+
const links = [];
|
|
77
100
|
const bodies = new Map();
|
|
78
101
|
// The word count and the heading checks read the same body once, with the heading levels.
|
|
79
102
|
const body = (rule) => {
|
|
@@ -108,7 +131,20 @@ export function checkDocument(input) {
|
|
|
108
131
|
}
|
|
109
132
|
continue;
|
|
110
133
|
}
|
|
134
|
+
if (rule.kind === "links") {
|
|
135
|
+
const references = rule.content
|
|
136
|
+
? readReferences(valueAt(doc, rule.path), rule.path, rule.content)
|
|
137
|
+
: [];
|
|
138
|
+
links.push({ base, references });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
111
141
|
const value = valueAt(doc, rule.path);
|
|
142
|
+
if (rule.kind === "unique") {
|
|
143
|
+
if (typeof value === "string" && !isEmpty(value)) {
|
|
144
|
+
unique.push({ base: rule.range ? { ...base, range: rule.range } : base, value });
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
112
148
|
if (rule.kind === "required") {
|
|
113
149
|
if (rule.imagesOnly && !isImage(doc)) {
|
|
114
150
|
continue;
|
|
@@ -129,7 +165,7 @@ export function checkDocument(input) {
|
|
|
129
165
|
findings.push({ ...base, length: { ...rule.range, actual } });
|
|
130
166
|
}
|
|
131
167
|
}
|
|
132
|
-
return { findings, checked };
|
|
168
|
+
return { findings, checked, unique, links };
|
|
133
169
|
}
|
|
134
170
|
/** At most one finding per document and check, so counts are documents, like the other checks. */
|
|
135
171
|
function checkBody(rule, { headings, words }, base) {
|
package/dist/content.js
CHANGED
|
@@ -16,6 +16,9 @@ export function readContent(value, root, schema, headingLevels = {}) {
|
|
|
16
16
|
for (const field of schema.blocks[blockType] ?? []) {
|
|
17
17
|
for (const { path, value: fieldValue } of fanOut(block, field.path)) {
|
|
18
18
|
const fullPath = `${root}.${index}.${path}`;
|
|
19
|
+
if (field.kind === "relationship") {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
19
22
|
if (field.kind === "richText") {
|
|
20
23
|
readLexical(fieldValue, fullPath, content);
|
|
21
24
|
continue;
|
package/dist/copy.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export declare const COPY: {
|
|
|
21
21
|
readonly h1Count: (count: number) => string;
|
|
22
22
|
readonly untranslated: (count: number) => string;
|
|
23
23
|
readonly words: (actual: number, min: number) => string;
|
|
24
|
+
readonly duplicate: (titles: string[], more: number) => string;
|
|
25
|
+
readonly linkMissing: (count: number) => string;
|
|
26
|
+
readonly linkUnpublished: (target: string, count: number) => string;
|
|
24
27
|
readonly problems: "Some checks could not run:";
|
|
25
28
|
};
|
|
26
29
|
readonly sv: {
|
|
@@ -38,6 +41,9 @@ export declare const COPY: {
|
|
|
38
41
|
readonly h1Count: (count: number) => string;
|
|
39
42
|
readonly untranslated: (count: number) => string;
|
|
40
43
|
readonly words: (actual: number, min: number) => string;
|
|
44
|
+
readonly duplicate: (titles: string[], more: number) => string;
|
|
45
|
+
readonly linkMissing: (count: number) => string;
|
|
46
|
+
readonly linkUnpublished: (target: string, count: number) => string;
|
|
41
47
|
readonly problems: "Några kontroller kunde inte köras:";
|
|
42
48
|
};
|
|
43
49
|
};
|
|
@@ -68,6 +74,7 @@ export declare const LIST_COPY: {
|
|
|
68
74
|
readonly draft: "Draft";
|
|
69
75
|
readonly fix: "Fix with the assistant";
|
|
70
76
|
readonly fixMany: (count: number, total: number) => string;
|
|
77
|
+
readonly individualFix: "Review and fix these one document at a time with the assistant.";
|
|
71
78
|
readonly none: "No issues found.";
|
|
72
79
|
readonly noneFiltered: "No issues match these filters.";
|
|
73
80
|
};
|
|
@@ -85,6 +92,7 @@ export declare const LIST_COPY: {
|
|
|
85
92
|
readonly draft: "Utkast";
|
|
86
93
|
readonly fix: "Åtgärda med assistenten";
|
|
87
94
|
readonly fixMany: (count: number, total: number) => string;
|
|
95
|
+
readonly individualFix: "Granska och åtgärda dessa ett dokument i taget med assistenten.";
|
|
88
96
|
readonly none: "Inga problem hittades.";
|
|
89
97
|
readonly noneFiltered: "Inga problem matchar filtren.";
|
|
90
98
|
};
|
package/dist/copy.js
CHANGED
|
@@ -6,26 +6,32 @@ export const CHECK_LABELS = {
|
|
|
6
6
|
"media.alt-missing": "Missing alt text",
|
|
7
7
|
"seo.title-missing": "Missing meta title",
|
|
8
8
|
"seo.title-length": "Meta title length",
|
|
9
|
+
"seo.title-duplicate": "Duplicate meta title",
|
|
9
10
|
"seo.description-missing": "Missing meta description",
|
|
10
11
|
"seo.description-length": "Meta description length",
|
|
12
|
+
"seo.description-duplicate": "Duplicate meta description",
|
|
11
13
|
"seo.image-missing": "Missing share image",
|
|
12
14
|
"content.h1-missing": "Missing H1",
|
|
13
15
|
"content.h1-multiple": "More than one H1",
|
|
14
16
|
"content.heading-skip": "Skipped heading levels",
|
|
15
17
|
"content.thin": "Thin content",
|
|
18
|
+
"links.broken": "Broken internal links",
|
|
16
19
|
"content.untranslated": "Not translated",
|
|
17
20
|
},
|
|
18
21
|
sv: {
|
|
19
22
|
"media.alt-missing": "Alt-text saknas",
|
|
20
23
|
"seo.title-missing": "Metatitel saknas",
|
|
21
24
|
"seo.title-length": "Metatitelns längd",
|
|
25
|
+
"seo.title-duplicate": "Dubblerad metatitel",
|
|
22
26
|
"seo.description-missing": "Metabeskrivning saknas",
|
|
23
27
|
"seo.description-length": "Metabeskrivningens längd",
|
|
28
|
+
"seo.description-duplicate": "Dubblerad metabeskrivning",
|
|
24
29
|
"seo.image-missing": "Delningsbild saknas",
|
|
25
30
|
"content.h1-missing": "H1 saknas",
|
|
26
31
|
"content.h1-multiple": "Fler än en H1",
|
|
27
32
|
"content.heading-skip": "Överhoppade rubriknivåer",
|
|
28
33
|
"content.thin": "Tunt innehåll",
|
|
34
|
+
"links.broken": "Trasiga interna länkar",
|
|
29
35
|
"content.untranslated": "Saknar översättning",
|
|
30
36
|
},
|
|
31
37
|
};
|
|
@@ -59,6 +65,9 @@ export const COPY = {
|
|
|
59
65
|
h1Count: (count) => `${count} H1s`,
|
|
60
66
|
untranslated: (count) => `${count} ${count === 1 ? "field" : "fields"} not translated`,
|
|
61
67
|
words: (actual, min) => `${actual} ${actual === 1 ? "word" : "words"}, aim for ${min}+`,
|
|
68
|
+
duplicate: (titles, more) => `Also on ${list("en", titles)}${more ? ` and ${more} more` : ""}`,
|
|
69
|
+
linkMissing: (count) => `Links to a deleted document${count > 1 ? `, and ${count - 1} more` : ""}`,
|
|
70
|
+
linkUnpublished: (target, count) => `Links to “${target}”, not published${count > 1 ? `, and ${count - 1} more` : ""}`,
|
|
62
71
|
problems: "Some checks could not run:",
|
|
63
72
|
},
|
|
64
73
|
sv: {
|
|
@@ -76,6 +85,9 @@ export const COPY = {
|
|
|
76
85
|
h1Count: (count) => `${count} H1:or`,
|
|
77
86
|
untranslated: (count) => `${count} fält saknar översättning`,
|
|
78
87
|
words: (actual, min) => `${actual} ord, sikta på minst ${min}`,
|
|
88
|
+
duplicate: (titles, more) => `Även på ${list("sv", titles)}${more ? ` och ${more} till` : ""}`,
|
|
89
|
+
linkMissing: (count) => `Länkar till ett borttaget dokument${count > 1 ? `, och ${count - 1} till` : ""}`,
|
|
90
|
+
linkUnpublished: (target, count) => `Länkar till ”${target}”, som inte är publicerad${count > 1 ? `, och ${count - 1} till` : ""}`,
|
|
79
91
|
problems: "Några kontroller kunde inte köras:",
|
|
80
92
|
},
|
|
81
93
|
};
|
|
@@ -98,6 +110,8 @@ export const ASK = {
|
|
|
98
110
|
"seo.description-length": ({ length }) => length
|
|
99
111
|
? `The meta description is ${length.actual} characters. Rewrite it to ${length.min}–${length.max} characters.`
|
|
100
112
|
: "Rewrite the meta description to a better length.",
|
|
113
|
+
"seo.title-duplicate": (finding) => `This meta title is also used on ${list("en", finding.duplicate?.titles ?? [])}. Rewrite it so it fits this page only${lengthHint("en", finding)}.`,
|
|
114
|
+
"seo.description-duplicate": (finding) => `This meta description is also used on ${list("en", finding.duplicate?.titles ?? [])}. Rewrite it so it fits this page only${lengthHint("en", finding)}.`,
|
|
101
115
|
"seo.image-missing": () => "Pick a share image for this page.",
|
|
102
116
|
"content.h1-missing": () => "This page has no main heading (H1). Suggest how to add one that says what the page is about.",
|
|
103
117
|
"content.h1-multiple": () => "This page has more than one main heading (H1). Suggest how to keep only one.",
|
|
@@ -107,6 +121,9 @@ export const ASK = {
|
|
|
107
121
|
"content.thin": ({ words }) => words
|
|
108
122
|
? `This page has ${words.actual} words. Suggest how to expand the text to at least ${words.min} words.`
|
|
109
123
|
: "This page has little text. Suggest how to expand it.",
|
|
124
|
+
"links.broken": ({ links }) => links?.target
|
|
125
|
+
? `This links to “${links.target}”, which is not published. Suggest how to fix the link: another page to link to, or removing it.`
|
|
126
|
+
: "This links to a document that no longer exists. Suggest how to fix the link: another page to link to, or removing it.",
|
|
110
127
|
"content.untranslated": ({ untranslated }) => untranslated
|
|
111
128
|
? `Translate the empty fields from ${languageName("en", untranslated.source)}.`
|
|
112
129
|
: "Translate the empty fields.",
|
|
@@ -125,6 +142,8 @@ export const ASK = {
|
|
|
125
142
|
"seo.description-length": ({ length }) => length
|
|
126
143
|
? `Metabeskrivningen är ${length.actual} tecken. Skriv om den till ${length.min}–${length.max} tecken.`
|
|
127
144
|
: "Skriv om metabeskrivningen till en bättre längd.",
|
|
145
|
+
"seo.title-duplicate": (finding) => `Samma metatitel används på ${list("sv", finding.duplicate?.titles ?? [])}. Skriv om den så att den bara passar den här sidan${lengthHint("sv", finding)}.`,
|
|
146
|
+
"seo.description-duplicate": (finding) => `Samma metabeskrivning används på ${list("sv", finding.duplicate?.titles ?? [])}. Skriv om den så att den bara passar den här sidan${lengthHint("sv", finding)}.`,
|
|
128
147
|
"seo.image-missing": () => "Välj en delningsbild för den här sidan.",
|
|
129
148
|
"content.h1-missing": () => "Sidan saknar huvudrubrik (H1). Föreslå hur jag lägger till en som säger vad sidan handlar om.",
|
|
130
149
|
"content.h1-multiple": () => "Sidan har fler än en huvudrubrik (H1). Föreslå hur jag behåller bara en.",
|
|
@@ -134,11 +153,22 @@ export const ASK = {
|
|
|
134
153
|
"content.thin": ({ words }) => words
|
|
135
154
|
? `Sidan har ${words.actual} ord. Föreslå hur jag bygger ut texten till minst ${words.min} ord.`
|
|
136
155
|
: "Sidan har lite text. Föreslå hur jag bygger ut den.",
|
|
156
|
+
"links.broken": ({ links }) => links?.target
|
|
157
|
+
? `Här länkas till ”${links.target}”, som inte är publicerad. Föreslå hur jag rättar länken: en annan sida att länka till, eller att ta bort den.`
|
|
158
|
+
: "Här länkas till ett dokument som inte finns längre. Föreslå hur jag rättar länken: en annan sida att länka till, eller att ta bort den.",
|
|
137
159
|
"content.untranslated": ({ untranslated }) => untranslated
|
|
138
160
|
? `Översätt de tomma fälten från ${languageName("sv", untranslated.source)}.`
|
|
139
161
|
: "Översätt de tomma fälten.",
|
|
140
162
|
},
|
|
141
163
|
};
|
|
164
|
+
/** ", 50–60 characters" when the finding carries the length the length check expects. */
|
|
165
|
+
function lengthHint(language, { range }) {
|
|
166
|
+
if (!range)
|
|
167
|
+
return "";
|
|
168
|
+
return language === "sv"
|
|
169
|
+
? `, ${range.min}–${range.max} tecken`
|
|
170
|
+
: `, ${range.min}–${range.max} characters`;
|
|
171
|
+
}
|
|
142
172
|
/** The range a length finding aims for, from either kind of meta finding. */
|
|
143
173
|
const aim = ({ length, range }) => length ?? range;
|
|
144
174
|
/**
|
|
@@ -160,6 +190,8 @@ export const BATCH_ASK = {
|
|
|
160
190
|
? `Rewrite the meta title to ${range.min}–${range.max} characters.`
|
|
161
191
|
: "Rewrite the meta title to a better length.";
|
|
162
192
|
},
|
|
193
|
+
"seo.title-duplicate": (finding) => `Rewrite the meta title so it fits the page only, not the other pages that use it${lengthHint("en", finding)}.`,
|
|
194
|
+
"seo.description-duplicate": (finding) => `Rewrite the meta description so it fits the page only, not the other pages that use it${lengthHint("en", finding)}.`,
|
|
163
195
|
"seo.description-missing": (finding) => {
|
|
164
196
|
const range = aim(finding);
|
|
165
197
|
return range
|
|
@@ -190,6 +222,8 @@ export const BATCH_ASK = {
|
|
|
190
222
|
? `Skriv om metatiteln till ${range.min}–${range.max} tecken.`
|
|
191
223
|
: "Skriv om metatiteln till en bättre längd.";
|
|
192
224
|
},
|
|
225
|
+
"seo.title-duplicate": (finding) => `Skriv om metatiteln så att den bara passar sidan, inte de andra sidorna som använder den${lengthHint("sv", finding)}.`,
|
|
226
|
+
"seo.description-duplicate": (finding) => `Skriv om metabeskrivningen så att den bara passar sidan, inte de andra sidorna som använder den${lengthHint("sv", finding)}.`,
|
|
193
227
|
"seo.description-missing": (finding) => {
|
|
194
228
|
const range = aim(finding);
|
|
195
229
|
return range
|
|
@@ -224,6 +258,7 @@ export const LIST_COPY = {
|
|
|
224
258
|
fixMany: (count, total) => count < total
|
|
225
259
|
? `Fix ${count} of ${total} with the assistant`
|
|
226
260
|
: `Fix ${count} with the assistant`,
|
|
261
|
+
individualFix: "Review and fix these one document at a time with the assistant.",
|
|
227
262
|
none: "No issues found.",
|
|
228
263
|
noneFiltered: "No issues match these filters.",
|
|
229
264
|
},
|
|
@@ -243,6 +278,7 @@ export const LIST_COPY = {
|
|
|
243
278
|
fixMany: (count, total) => count < total
|
|
244
279
|
? `Åtgärda ${count} av ${total} med assistenten`
|
|
245
280
|
: `Åtgärda ${count} med assistenten`,
|
|
281
|
+
individualFix: "Granska och åtgärda dessa ett dokument i taget med assistenten.",
|
|
246
282
|
none: "Inga problem hittades.",
|
|
247
283
|
noneFiltered: "Inga problem matchar filtren.",
|
|
248
284
|
},
|
|
@@ -250,10 +286,18 @@ export const LIST_COPY = {
|
|
|
250
286
|
/** A finding's detail: its length, word count, heading levels or untranslated fields, if any. */
|
|
251
287
|
export function findingDetail(language, finding) {
|
|
252
288
|
const copy = COPY[language];
|
|
253
|
-
const { heading, length, untranslated, words } = finding;
|
|
289
|
+
const { duplicate, heading, length, links, untranslated, words } = finding;
|
|
254
290
|
if (untranslated) {
|
|
255
291
|
return copy.untranslated(untranslated.count);
|
|
256
292
|
}
|
|
293
|
+
if (duplicate) {
|
|
294
|
+
return copy.duplicate(duplicate.titles, duplicate.count - duplicate.titles.length);
|
|
295
|
+
}
|
|
296
|
+
if (links) {
|
|
297
|
+
return links.target
|
|
298
|
+
? copy.linkUnpublished(links.target, links.count)
|
|
299
|
+
: copy.linkMissing(links.count);
|
|
300
|
+
}
|
|
257
301
|
return length
|
|
258
302
|
? copy.length(length.actual, length.min, length.max)
|
|
259
303
|
: words
|
|
@@ -264,6 +308,15 @@ export function findingDetail(language, finding) {
|
|
|
264
308
|
? copy.h1Count(heading.count)
|
|
265
309
|
: undefined;
|
|
266
310
|
}
|
|
311
|
+
/** Names as a sentence lists them: "About, Home and Contact". */
|
|
312
|
+
function list(language, names) {
|
|
313
|
+
try {
|
|
314
|
+
return new Intl.ListFormat(language, { type: "conjunction" }).format(names);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return names.join(", ");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
267
320
|
/** A locale code as a language name in Admin's language: "engelska", "English". */
|
|
268
321
|
export function languageName(language, code) {
|
|
269
322
|
try {
|
package/dist/fields.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type FieldLike = {
|
|
|
12
12
|
tabs?: FieldLike[];
|
|
13
13
|
blocks?: BlockLike[];
|
|
14
14
|
blockReferences?: (BlockLike | string)[];
|
|
15
|
+
/** On relationship and upload fields. */
|
|
16
|
+
relationTo?: string | string[];
|
|
15
17
|
};
|
|
16
18
|
export type BlockLike = {
|
|
17
19
|
slug: string;
|
|
@@ -26,10 +28,18 @@ export type PathInfo = {
|
|
|
26
28
|
export declare function lookupPath(fields: FieldLike[], path: string): PathInfo | undefined;
|
|
27
29
|
/** Deep-merges Local API `select` objects. */
|
|
28
30
|
export declare function mergeSelect(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
|
|
29
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* A field in a block that the body checks read, by its path in the block; arrays and groups add
|
|
33
|
+
* segments. Text counts as words and headings; relationship fields are internal links. A
|
|
34
|
+
* relationship to several collections stores `{ relationTo, value }`, to one the plain id.
|
|
35
|
+
*/
|
|
30
36
|
export type TextField = {
|
|
31
37
|
path: string;
|
|
32
38
|
kind: "text" | "richText";
|
|
39
|
+
} | {
|
|
40
|
+
path: string;
|
|
41
|
+
kind: "relationship";
|
|
42
|
+
relationTo: string | string[];
|
|
33
43
|
};
|
|
34
44
|
/** Where a page body keeps its text: one rich text field, or text fields per block. */
|
|
35
45
|
export type ContentSchema = {
|
package/dist/fields.js
CHANGED
|
@@ -86,7 +86,7 @@ export function contentSchema(field, resolveBlock) {
|
|
|
86
86
|
}
|
|
87
87
|
return { schema: { kind: "blocks", blocks }, localized };
|
|
88
88
|
}
|
|
89
|
-
/** Collects
|
|
89
|
+
/** Collects the fields in config order; returns whether any of them is localized. */
|
|
90
90
|
function collectTextFields(fields, prefix, out) {
|
|
91
91
|
let localized = false;
|
|
92
92
|
for (const field of fields) {
|
|
@@ -99,6 +99,10 @@ function collectTextFields(fields, prefix, out) {
|
|
|
99
99
|
out.push({ path, kind: "richText" });
|
|
100
100
|
localized ||= Boolean(field.localized);
|
|
101
101
|
}
|
|
102
|
+
else if (path && field.type === "relationship" && field.relationTo) {
|
|
103
|
+
out.push({ path, kind: "relationship", relationTo: field.relationTo });
|
|
104
|
+
localized ||= Boolean(field.localized);
|
|
105
|
+
}
|
|
102
106
|
else if (field.type === "tabs") {
|
|
103
107
|
localized = collectTextFields(field.tabs ?? [], prefix, out) || localized;
|
|
104
108
|
}
|
package/dist/links.js
CHANGED
|
@@ -45,8 +45,10 @@ export const BATCH_CHECKS = {
|
|
|
45
45
|
"media.alt-missing": "field",
|
|
46
46
|
"seo.title-missing": "field",
|
|
47
47
|
"seo.title-length": "field",
|
|
48
|
+
"seo.title-duplicate": "field",
|
|
48
49
|
"seo.description-missing": "field",
|
|
49
50
|
"seo.description-length": "field",
|
|
51
|
+
"seo.description-duplicate": "field",
|
|
50
52
|
"content.untranslated": "translation",
|
|
51
53
|
};
|
|
52
54
|
/**
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ContentSchema } from "./fields.ts";
|
|
2
|
+
export type Reference = {
|
|
3
|
+
/** Form path of the field holding the link, e.g. `layout.2.content` or `layout.1.link.reference`. */
|
|
4
|
+
path: string;
|
|
5
|
+
relationTo: string;
|
|
6
|
+
id: string | number;
|
|
7
|
+
};
|
|
8
|
+
export declare function readReferences(value: unknown, root: string, schema: ContentSchema): Reference[];
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal links in a page body: link nodes in rich text and relationship fields in blocks. Plain
|
|
3
|
+
* functions over the body as the Local API returns it at depth 0, like `content.ts`.
|
|
4
|
+
*/
|
|
5
|
+
import { fanOut } from "./content.js";
|
|
6
|
+
/**
|
|
7
|
+
* Link types that point outside the site. A link group whose `type` or `linkType` says so keeps an
|
|
8
|
+
* old reference hidden by a condition; that reference is not a link a reader sees.
|
|
9
|
+
*/
|
|
10
|
+
const EXTERNAL_TYPES = new Set(["custom", "external", "url"]);
|
|
11
|
+
export function readReferences(value, root, schema) {
|
|
12
|
+
const references = [];
|
|
13
|
+
if (schema.kind === "richText") {
|
|
14
|
+
readLexical(value, root, references);
|
|
15
|
+
return references;
|
|
16
|
+
}
|
|
17
|
+
if (!Array.isArray(value)) {
|
|
18
|
+
return references;
|
|
19
|
+
}
|
|
20
|
+
for (const [index, block] of value.entries()) {
|
|
21
|
+
const blockType = block?.blockType;
|
|
22
|
+
if (typeof blockType !== "string") {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
for (const field of schema.blocks[blockType] ?? []) {
|
|
26
|
+
if (field.kind === "text") {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
for (const { path, value: fieldValue, parent } of withParents(block, field.path)) {
|
|
30
|
+
const fullPath = `${root}.${index}.${path}`;
|
|
31
|
+
if (field.kind === "richText") {
|
|
32
|
+
readLexical(fieldValue, fullPath, references);
|
|
33
|
+
}
|
|
34
|
+
else if (field.kind === "relationship" && !isExternal(parent)) {
|
|
35
|
+
references.push(...relationValues(fieldValue, field.relationTo, fullPath));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return references;
|
|
41
|
+
}
|
|
42
|
+
/** `fanOut` with the object each value sits in, so a link field's siblings can be read. */
|
|
43
|
+
function withParents(block, path) {
|
|
44
|
+
const cut = path.lastIndexOf(".");
|
|
45
|
+
const name = path.slice(cut + 1);
|
|
46
|
+
const parents = cut < 0 ? [{ path: "", value: block }] : fanOut(block, path.slice(0, cut));
|
|
47
|
+
return parents.flatMap((parent) => {
|
|
48
|
+
// An array field as the parent: its rows hold the field.
|
|
49
|
+
const rows = Array.isArray(parent.value)
|
|
50
|
+
? parent.value.map((row, index) => ({ path: join(parent.path, String(index)), value: row }))
|
|
51
|
+
: [parent];
|
|
52
|
+
return rows.flatMap((row) => row.value !== null && typeof row.value === "object"
|
|
53
|
+
? [
|
|
54
|
+
{
|
|
55
|
+
path: join(row.path, name),
|
|
56
|
+
value: row.value[name],
|
|
57
|
+
parent: row.value,
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
: []);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function isExternal(parent) {
|
|
64
|
+
return [parent.type, parent.linkType].some((value) => typeof value === "string" && EXTERNAL_TYPES.has(value));
|
|
65
|
+
}
|
|
66
|
+
/** A relationship value at depth 0, populated or not, single or `hasMany`. */
|
|
67
|
+
function relationValues(value, relationTo, path) {
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
return value.flatMap((item) => relationValues(item, relationTo, path));
|
|
70
|
+
}
|
|
71
|
+
if (value === null || value === undefined || value === "") {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
if (typeof value === "object") {
|
|
75
|
+
const entry = value;
|
|
76
|
+
if (typeof entry.relationTo === "string") {
|
|
77
|
+
const id = idOf(entry.value);
|
|
78
|
+
return id === undefined ? [] : [{ path, relationTo: entry.relationTo, id }];
|
|
79
|
+
}
|
|
80
|
+
const id = idOf(entry);
|
|
81
|
+
return id !== undefined && typeof relationTo === "string" ? [{ path, relationTo, id }] : [];
|
|
82
|
+
}
|
|
83
|
+
const id = idOf(value);
|
|
84
|
+
return id !== undefined && typeof relationTo === "string" ? [{ path, relationTo, id }] : [];
|
|
85
|
+
}
|
|
86
|
+
function idOf(value) {
|
|
87
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
88
|
+
return value === "" ? undefined : value;
|
|
89
|
+
}
|
|
90
|
+
if (value && typeof value === "object") {
|
|
91
|
+
return idOf(value.id);
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
/** Lexical's link nodes: `fields.linkType` is `internal` and `fields.doc` names the document. */
|
|
96
|
+
function readLexical(value, path, references) {
|
|
97
|
+
const root = value?.root;
|
|
98
|
+
if (root) {
|
|
99
|
+
walk(root);
|
|
100
|
+
}
|
|
101
|
+
function walk(node) {
|
|
102
|
+
if (node.type === "link" || node.type === "autolink") {
|
|
103
|
+
const fields = node.fields;
|
|
104
|
+
if (fields?.linkType === "internal") {
|
|
105
|
+
references.push(...relationValues(fields.doc, [], path));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(node.children)) {
|
|
109
|
+
for (const child of node.children) {
|
|
110
|
+
walk(child);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function join(base, segment) {
|
|
116
|
+
return base ? `${base}.${segment}` : segment;
|
|
117
|
+
}
|
package/dist/scan.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { checkDocument, documentTitle, rulesFor } from "./checks.js";
|
|
2
2
|
import { contentSchema, lookupPath, mergeSelect, } from "./fields.js";
|
|
3
|
+
import { duplicateFindings, linkFindings, targetKey, } from "./site.js";
|
|
3
4
|
import { checkTranslation } from "./translations.js";
|
|
4
5
|
import { CHECK_IDS } from "./types.js";
|
|
5
6
|
/** Where the plugin keeps its options in the (server-only) `config.custom`. */
|
|
@@ -20,6 +21,9 @@ export async function scanContentHealth({ payload, user, checks }) {
|
|
|
20
21
|
problems: [],
|
|
21
22
|
};
|
|
22
23
|
const ran = new Set();
|
|
24
|
+
// Compared and followed once every collection is read.
|
|
25
|
+
const unique = [];
|
|
26
|
+
const links = [];
|
|
23
27
|
const options = contentHealthOptions(payload.config);
|
|
24
28
|
if (!options) {
|
|
25
29
|
result.problems.push("contentHealthPlugin is not installed.");
|
|
@@ -27,8 +31,13 @@ export async function scanContentHealth({ payload, user, checks }) {
|
|
|
27
31
|
}
|
|
28
32
|
const blocks = (payload.config.blocks ?? []);
|
|
29
33
|
const resolveBlock = (slug) => blocks.find((block) => block.slug === slug);
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
// The default language first, so other languages can borrow its titles.
|
|
35
|
+
const localization = payload.config.localization;
|
|
36
|
+
const locales = localization
|
|
37
|
+
? [
|
|
38
|
+
localization.defaultLocale,
|
|
39
|
+
...localization.localeCodes.filter((code) => code !== localization.defaultLocale),
|
|
40
|
+
]
|
|
32
41
|
: [undefined];
|
|
33
42
|
for (const [slug, collectionOptions] of Object.entries(options.collections)) {
|
|
34
43
|
const collection = payload.config.collections.find((collection) => collection.slug === slug);
|
|
@@ -50,7 +59,7 @@ export async function scanContentHealth({ payload, user, checks }) {
|
|
|
50
59
|
}
|
|
51
60
|
let localized = info.localized;
|
|
52
61
|
let content;
|
|
53
|
-
if (rule.kind === "outline" || rule.kind === "words") {
|
|
62
|
+
if (rule.kind === "outline" || rule.kind === "words" || rule.kind === "links") {
|
|
54
63
|
const body = contentSchema(info.field, resolveBlock);
|
|
55
64
|
if (!body) {
|
|
56
65
|
result.problems.push(`${slug}: "${rule.path}" is not a blocks or rich text field.`);
|
|
@@ -85,6 +94,8 @@ export async function scanContentHealth({ payload, user, checks }) {
|
|
|
85
94
|
mergeSelect(select, { _status: true });
|
|
86
95
|
}
|
|
87
96
|
const anyLocalized = rules.some((rule) => rule.localized);
|
|
97
|
+
// Titles in the default language, for documents not yet titled in another one.
|
|
98
|
+
const titles = new Map();
|
|
88
99
|
for (const [index, locale] of locales.entries()) {
|
|
89
100
|
if (!rules.length || (index > 0 && !anyLocalized)) {
|
|
90
101
|
break;
|
|
@@ -103,18 +114,23 @@ export async function scanContentHealth({ payload, user, checks }) {
|
|
|
103
114
|
});
|
|
104
115
|
result.documents[slug] = docs.length;
|
|
105
116
|
for (const doc of docs) {
|
|
106
|
-
const
|
|
117
|
+
const id = String(doc.id);
|
|
118
|
+
const own = documentTitle(doc, useAsTitle);
|
|
119
|
+
if (index === 0) {
|
|
120
|
+
titles.set(id, own);
|
|
121
|
+
}
|
|
122
|
+
const { findings, checked, ...entries } = checkDocument({
|
|
107
123
|
collection: slug,
|
|
108
124
|
doc,
|
|
109
125
|
rules,
|
|
110
|
-
title:
|
|
126
|
+
title: own === id ? (titles.get(id) ?? own) : own,
|
|
111
127
|
...(locale ? { locale } : {}),
|
|
112
128
|
includeUnlocalized: index === 0,
|
|
113
129
|
});
|
|
114
130
|
result.findings.push(...findings);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
131
|
+
countChecked(result, checked);
|
|
132
|
+
unique.push(...(entries.unique ?? []));
|
|
133
|
+
links.push(...(entries.links ?? []));
|
|
118
134
|
}
|
|
119
135
|
}
|
|
120
136
|
catch (error) {
|
|
@@ -142,9 +158,72 @@ export async function scanContentHealth({ payload, user, checks }) {
|
|
|
142
158
|
}
|
|
143
159
|
}
|
|
144
160
|
}
|
|
161
|
+
result.findings.push(...duplicateFindings(unique));
|
|
162
|
+
countChecked(result, unique.map((entry) => entry.base.check));
|
|
163
|
+
if (links.length) {
|
|
164
|
+
try {
|
|
165
|
+
const targets = await linkTargets(payload, links.flatMap((entry) => entry.references));
|
|
166
|
+
const followed = linkFindings(links, targets);
|
|
167
|
+
result.findings.push(...followed.findings);
|
|
168
|
+
countChecked(result, followed.checked);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
result.problems.push(`links: ${error instanceof Error ? error.message : String(error)}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
145
174
|
result.checks = CHECK_IDS.filter((check) => ran.has(check));
|
|
146
175
|
return result;
|
|
147
176
|
}
|
|
177
|
+
function countChecked(result, checked) {
|
|
178
|
+
for (const check of checked) {
|
|
179
|
+
result.checked[check] = (result.checked[check] ?? 0) + 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The documents the links lead to, as a reader would find them: the published version when the
|
|
184
|
+
* collection has drafts. Read without access control, so a link the editor cannot follow is not
|
|
185
|
+
* reported as broken; only whether the target exists, is published and its title leave here.
|
|
186
|
+
*/
|
|
187
|
+
async function linkTargets(payload, references) {
|
|
188
|
+
const targets = new Map();
|
|
189
|
+
const ids = new Map();
|
|
190
|
+
for (const { relationTo, id } of references) {
|
|
191
|
+
const own = ids.get(relationTo) ?? new Map();
|
|
192
|
+
own.set(String(id), id);
|
|
193
|
+
ids.set(relationTo, own);
|
|
194
|
+
}
|
|
195
|
+
for (const [slug, own] of ids) {
|
|
196
|
+
const collection = payload.config.collections.find((entry) => entry.slug === slug);
|
|
197
|
+
if (!collection) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const useAsTitle = collection.admin?.useAsTitle;
|
|
201
|
+
const drafts = Boolean(collection.versions && collection.versions.drafts);
|
|
202
|
+
const select = {};
|
|
203
|
+
if (useAsTitle && useAsTitle !== "id")
|
|
204
|
+
mergeSelect(select, { [useAsTitle]: true });
|
|
205
|
+
if (collection.upload)
|
|
206
|
+
mergeSelect(select, { filename: true });
|
|
207
|
+
if (drafts)
|
|
208
|
+
mergeSelect(select, { _status: true });
|
|
209
|
+
const { docs } = await payload.find({
|
|
210
|
+
collection: slug,
|
|
211
|
+
depth: 0,
|
|
212
|
+
draft: false,
|
|
213
|
+
overrideAccess: true,
|
|
214
|
+
pagination: false,
|
|
215
|
+
select: select,
|
|
216
|
+
where: { id: { in: [...own.values()] } },
|
|
217
|
+
});
|
|
218
|
+
for (const doc of docs) {
|
|
219
|
+
targets.set(targetKey(slug, doc.id), {
|
|
220
|
+
title: documentTitle(doc, useAsTitle),
|
|
221
|
+
published: !drafts || doc._status !== "draft",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return targets;
|
|
226
|
+
}
|
|
148
227
|
/** Compares every other language with the default one, document by document. */
|
|
149
228
|
async function scanTranslations(input) {
|
|
150
229
|
const { payload, slug, result } = input;
|
|
@@ -188,9 +267,7 @@ async function scanTranslations(input) {
|
|
|
188
267
|
target,
|
|
189
268
|
});
|
|
190
269
|
result.findings.push(...findings);
|
|
191
|
-
|
|
192
|
-
result.checked[check] = (result.checked[check] ?? 0) + 1;
|
|
193
|
-
}
|
|
270
|
+
countChecked(result, checked);
|
|
194
271
|
}
|
|
195
272
|
}
|
|
196
273
|
}
|
package/dist/site.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks that compare documents with each other or with what they link to. A document check hands
|
|
3
|
+
* over what it found (a meta text, the body's links); once every document is read, these functions
|
|
4
|
+
* make the findings. Pure, like `checks.ts`: the scan does the reading.
|
|
5
|
+
*/
|
|
6
|
+
import type { Reference } from "./references.ts";
|
|
7
|
+
import type { CheckId, Finding } from "./types.ts";
|
|
8
|
+
/** A meta text to compare with the other documents' in the same locale. */
|
|
9
|
+
export type UniqueEntry = {
|
|
10
|
+
base: Finding;
|
|
11
|
+
value: string;
|
|
12
|
+
};
|
|
13
|
+
/** The internal links of one document's body, in one locale. */
|
|
14
|
+
export type LinksEntry = {
|
|
15
|
+
base: Finding;
|
|
16
|
+
references: Reference[];
|
|
17
|
+
};
|
|
18
|
+
/** Case and spacing do not make a meta text unique. */
|
|
19
|
+
export declare function normalizeText(value: string): string;
|
|
20
|
+
/** One finding per document whose meta text another document in the same locale also uses. */
|
|
21
|
+
export declare function duplicateFindings(entries: UniqueEntry[]): Finding[];
|
|
22
|
+
/** What a link's target looks like to a reader; unset when it does not exist. */
|
|
23
|
+
export type LinkTarget = {
|
|
24
|
+
title: string;
|
|
25
|
+
published: boolean;
|
|
26
|
+
};
|
|
27
|
+
export declare function targetKey(collection: string, id: string | number): string;
|
|
28
|
+
/**
|
|
29
|
+
* One finding per document and locale with links to documents that are deleted or not published,
|
|
30
|
+
* at the first such link. Documents without internal links are not counted as checked.
|
|
31
|
+
*/
|
|
32
|
+
export declare function linkFindings(entries: LinksEntry[], targets: Map<string, LinkTarget>): {
|
|
33
|
+
findings: Finding[];
|
|
34
|
+
checked: CheckId[];
|
|
35
|
+
};
|
package/dist/site.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Other documents named in a duplicate finding. */
|
|
2
|
+
const MAX_TITLES = 3;
|
|
3
|
+
/** Case and spacing do not make a meta text unique. */
|
|
4
|
+
export function normalizeText(value) {
|
|
5
|
+
return value.trim().replace(/\s+/g, " ").toLocaleLowerCase();
|
|
6
|
+
}
|
|
7
|
+
/** One finding per document whose meta text another document in the same locale also uses. */
|
|
8
|
+
export function duplicateFindings(entries) {
|
|
9
|
+
const groups = new Map();
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const text = normalizeText(entry.value);
|
|
12
|
+
if (!text)
|
|
13
|
+
continue;
|
|
14
|
+
const key = JSON.stringify([entry.base.check, entry.base.locale ?? "", text]);
|
|
15
|
+
groups.set(key, [...(groups.get(key) ?? []), entry]);
|
|
16
|
+
}
|
|
17
|
+
const findings = [];
|
|
18
|
+
for (const group of groups.values()) {
|
|
19
|
+
const documents = new Set(group.map(({ base }) => `${base.collection}:${base.id}`));
|
|
20
|
+
if (documents.size < 2)
|
|
21
|
+
continue;
|
|
22
|
+
for (const { base } of group) {
|
|
23
|
+
const others = group.filter((other) => other.base.collection !== base.collection || other.base.id !== base.id);
|
|
24
|
+
findings.push({
|
|
25
|
+
...base,
|
|
26
|
+
duplicate: {
|
|
27
|
+
count: others.length,
|
|
28
|
+
titles: others.slice(0, MAX_TITLES).map((other) => other.base.title),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return findings;
|
|
34
|
+
}
|
|
35
|
+
export function targetKey(collection, id) {
|
|
36
|
+
return `${collection}:${id}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One finding per document and locale with links to documents that are deleted or not published,
|
|
40
|
+
* at the first such link. Documents without internal links are not counted as checked.
|
|
41
|
+
*/
|
|
42
|
+
export function linkFindings(entries, targets) {
|
|
43
|
+
const findings = [];
|
|
44
|
+
const checked = [];
|
|
45
|
+
for (const { base, references } of entries) {
|
|
46
|
+
if (!references.length)
|
|
47
|
+
continue;
|
|
48
|
+
checked.push(base.check);
|
|
49
|
+
const broken = references.flatMap((reference) => {
|
|
50
|
+
const target = targets.get(targetKey(reference.relationTo, reference.id));
|
|
51
|
+
return !target ? [{ reference }] : target.published ? [] : [{ reference, target }];
|
|
52
|
+
});
|
|
53
|
+
const first = broken[0];
|
|
54
|
+
if (!first)
|
|
55
|
+
continue;
|
|
56
|
+
findings.push({
|
|
57
|
+
...base,
|
|
58
|
+
path: first.reference.path,
|
|
59
|
+
links: {
|
|
60
|
+
count: broken.length,
|
|
61
|
+
reason: first.target ? "unpublished" : "missing",
|
|
62
|
+
...(first.target ? { target: first.target.title } : {}),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return { findings, checked };
|
|
67
|
+
}
|
package/dist/styles/view.css
CHANGED
|
@@ -200,7 +200,18 @@
|
|
|
200
200
|
flex-wrap: wrap;
|
|
201
201
|
align-items: baseline;
|
|
202
202
|
gap: calc(var(--base) * 0.25) calc(var(--base) * 0.5);
|
|
203
|
-
|
|
203
|
+
margin-inline: calc(var(--base) * -0.5);
|
|
204
|
+
padding: calc(var(--base) * 0.35) calc(var(--base) * 0.5);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* On a wide screen the assistant link is far from the title: lines and a hover band keep a row together. */
|
|
208
|
+
.content-health-list__item + .content-health-list__item {
|
|
209
|
+
border-top: 1px solid var(--theme-elevation-100);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.content-health-list__item:hover,
|
|
213
|
+
.content-health-list__item:focus-within {
|
|
214
|
+
background: var(--theme-elevation-50);
|
|
204
215
|
}
|
|
205
216
|
|
|
206
217
|
.content-health-list__item a {
|
|
@@ -219,7 +230,8 @@
|
|
|
219
230
|
font-size: 0.9em;
|
|
220
231
|
}
|
|
221
232
|
|
|
222
|
-
.content-health-list__item a.content-health-list__fix
|
|
233
|
+
.content-health-list__item:hover a.content-health-list__fix,
|
|
234
|
+
.content-health-list__item a.content-health-list__fix:focus-visible {
|
|
223
235
|
color: var(--content-health-accent-text);
|
|
224
236
|
}
|
|
225
237
|
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const CHECK_IDS: readonly ["media.alt-missing", "seo.title-missing", "seo.title-length", "seo.description-missing", "seo.description-length", "seo.image-missing", "content.h1-missing", "content.h1-multiple", "content.heading-skip", "content.thin", "content.untranslated"];
|
|
1
|
+
export declare const CHECK_IDS: readonly ["media.alt-missing", "seo.title-missing", "seo.title-length", "seo.title-duplicate", "seo.description-missing", "seo.description-length", "seo.description-duplicate", "seo.image-missing", "content.h1-missing", "content.h1-multiple", "content.heading-skip", "content.thin", "links.broken", "content.untranslated"];
|
|
2
2
|
export type CheckId = (typeof CHECK_IDS)[number];
|
|
3
3
|
export type LengthRange = {
|
|
4
4
|
min: number;
|
|
@@ -37,6 +37,20 @@ export type Finding = {
|
|
|
37
37
|
count: number;
|
|
38
38
|
source: string;
|
|
39
39
|
};
|
|
40
|
+
/** For a duplicate meta text: the other documents with the same text in this locale. */
|
|
41
|
+
duplicate?: {
|
|
42
|
+
count: number;
|
|
43
|
+
titles: string[];
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* For broken links: how many internal links lead nowhere a reader can follow, and the first one,
|
|
47
|
+
* with its target's title when it still exists.
|
|
48
|
+
*/
|
|
49
|
+
links?: {
|
|
50
|
+
count: number;
|
|
51
|
+
reason: "missing" | "unpublished";
|
|
52
|
+
target?: string;
|
|
53
|
+
};
|
|
40
54
|
/** Draft status of the checked version, for collections with drafts. */
|
|
41
55
|
status?: "draft" | "published";
|
|
42
56
|
};
|
|
@@ -49,6 +63,11 @@ export type SeoOptions = {
|
|
|
49
63
|
titleLength?: LengthRange | false;
|
|
50
64
|
/** Default 100–150. */
|
|
51
65
|
descriptionLength?: LengthRange | false;
|
|
66
|
+
/**
|
|
67
|
+
* Meta titles and descriptions used by more than one document in a language, across every
|
|
68
|
+
* collection with SEO checks. Default `true`.
|
|
69
|
+
*/
|
|
70
|
+
duplicates?: boolean;
|
|
52
71
|
};
|
|
53
72
|
/**
|
|
54
73
|
* Heading levels per block, as the block components render them: block slug → field path in the
|
|
@@ -65,6 +84,11 @@ export type ContentOptions = {
|
|
|
65
84
|
headings?: HeadingLevels;
|
|
66
85
|
/** Fewer words than this is thin content; `false` turns the check off. Default 300. */
|
|
67
86
|
minWords?: number | false;
|
|
87
|
+
/**
|
|
88
|
+
* Internal links in the body (rich text links and relationship fields) to documents that are
|
|
89
|
+
* deleted or not published. Default `true`.
|
|
90
|
+
*/
|
|
91
|
+
links?: boolean;
|
|
68
92
|
};
|
|
69
93
|
export type AltTextOptions = {
|
|
70
94
|
/** Field holding the alt text. Default `alt`. */
|
package/dist/types.js
CHANGED
|
@@ -2,12 +2,15 @@ export const CHECK_IDS = [
|
|
|
2
2
|
"media.alt-missing",
|
|
3
3
|
"seo.title-missing",
|
|
4
4
|
"seo.title-length",
|
|
5
|
+
"seo.title-duplicate",
|
|
5
6
|
"seo.description-missing",
|
|
6
7
|
"seo.description-length",
|
|
8
|
+
"seo.description-duplicate",
|
|
7
9
|
"seo.image-missing",
|
|
8
10
|
"content.h1-missing",
|
|
9
11
|
"content.h1-multiple",
|
|
10
12
|
"content.heading-skip",
|
|
11
13
|
"content.thin",
|
|
14
|
+
"links.broken",
|
|
12
15
|
"content.untranslated",
|
|
13
16
|
];
|
package/dist/view/WorkList.js
CHANGED
|
@@ -35,7 +35,7 @@ export function WorkList({ adminRoute, batchAsk, categories, collections, initia
|
|
|
35
35
|
const update = (next) => {
|
|
36
36
|
setFilters(next);
|
|
37
37
|
const query = filterQuery(next);
|
|
38
|
-
window.history.replaceState(
|
|
38
|
+
window.history.replaceState(null, "", `${window.location.pathname}${query ? `?${query}` : ""}`);
|
|
39
39
|
};
|
|
40
40
|
const moveTab = (event, index) => {
|
|
41
41
|
const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
|
@@ -59,7 +59,7 @@ function Section({ adminRoute, ask, copy, filters, rows, }) {
|
|
|
59
59
|
const batchHref = ask && targets.length > 1
|
|
60
60
|
? batchURL(adminRoute, { ...filters, check }, targets, ask)
|
|
61
61
|
: undefined;
|
|
62
|
-
return (_jsxs("section", { className: "content-health-list__section", children: [_jsxs("header", { className: "content-health-list__section-head", children: [_jsx("h2", { children: rows[0]?.checkLabel }), _jsx("span", { className: "content-health-list__pill", children: copy.documents(documents) }), batchHref ? (_jsx(Button, { buttonStyle: "secondary", className: "content-health-list__batch", el: "link", margin: false, size: "small", url: batchHref, children: copy.fixMany(Math.min(targets.length, MAX_BATCH_TARGETS), targets.length) })) : null] }), _jsx("ul", { className: "content-health-list__items", children: rows.map((row) => (_jsxs("li", { className: "content-health-list__item", children: [_jsx(Link, { href: row.href, prefetch: false, children: row.title }), row.locale ? _jsx("span", { className: "content-health-list__pill", children: row.locale }) : null, row.draft ? (_jsx("span", { className: "content-health-list__pill content-health-list__pill--draft", children: copy.draft })) : null, row.detail ? _jsx("span", { className: "content-health-list__detail", children: row.detail }) : null, row.askHref ? (_jsx(Link, { className: "content-health-list__fix", href: row.askHref, prefetch: false, children: copy.fix })) : null] }, row.key))) })] }));
|
|
62
|
+
return (_jsxs("section", { className: "content-health-list__section", children: [_jsxs("header", { className: "content-health-list__section-head", children: [_jsx("h2", { children: rows[0]?.checkLabel }), _jsx("span", { className: "content-health-list__pill", children: copy.documents(documents) }), batchHref ? (_jsx(Button, { buttonStyle: "secondary", className: "content-health-list__batch", el: "link", margin: false, size: "small", url: batchHref, children: copy.fixMany(Math.min(targets.length, MAX_BATCH_TARGETS), targets.length) })) : null] }), !BATCH_CHECKS[check] && rows.some((row) => row.askHref) ? (_jsx("p", { className: "content-health-list__detail", children: copy.individualFix })) : null, _jsx("ul", { className: "content-health-list__items", children: rows.map((row) => (_jsxs("li", { className: "content-health-list__item", children: [_jsx(Link, { href: row.href, prefetch: false, children: row.title }), row.locale ? _jsx("span", { className: "content-health-list__pill", children: row.locale }) : null, row.draft ? (_jsx("span", { className: "content-health-list__pill content-health-list__pill--draft", children: copy.draft })) : null, row.detail ? _jsx("span", { className: "content-health-list__detail", children: row.detail }) : null, row.askHref ? (_jsx(Link, { className: "content-health-list__fix", href: row.askHref, prefetch: false, children: copy.fix })) : null] }, row.key))) })] }));
|
|
63
63
|
}
|
|
64
64
|
function Filter({ all, label, onChange, options, value, }) {
|
|
65
65
|
const inputId = useId();
|
package/package.json
CHANGED