@simmalugnt-se/payload-content-health 0.9.0 → 0.10.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 +14 -0
- package/README.md +57 -5
- package/dist/admin/ContentHealthFieldBridge.d.ts +1 -1
- package/dist/admin/ContentHealthFieldBridge.js +25 -11
- package/dist/assistant.js +13 -5
- package/dist/categories.js +1 -0
- package/dist/copy.d.ts +5 -2
- package/dist/copy.js +9 -1
- package/dist/fields.d.ts +9 -0
- package/dist/fields.js +19 -4
- package/dist/links.d.ts +4 -1
- package/dist/links.js +14 -4
- package/dist/plugin.js +21 -0
- package/dist/references.d.ts +3 -1
- package/dist/references.js +23 -14
- package/dist/scan.js +210 -4
- package/dist/site.d.ts +13 -0
- package/dist/site.js +19 -0
- package/dist/types.d.ts +28 -3
- package/dist/types.js +1 -0
- package/dist/view/ContentHealthView.js +5 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
- Links in globals: `globals: { header: { links: true }, footer: { links: true } }` checks every
|
|
6
|
+
link field in those globals for `links.broken`. A finding in a global is marked `global: true`,
|
|
7
|
+
with the global's slug as `collection` and `id`; its work-list row shows the global's name and
|
|
8
|
+
opens the global with the link's field in view, and the assistant's `content-health.findings`
|
|
9
|
+
includes it.
|
|
10
|
+
- New check `links.orphan` (Structure, "Nothing links here"): a published document that no
|
|
11
|
+
published page body or global with `links` links to, in any language, not counting links to
|
|
12
|
+
itself. A global counts as the site reads it, even one never published. Off by default; turn it on per collection with `orphans: true`, or
|
|
13
|
+
`orphans: { exclude: <where> }` to leave out the start page or other intentionally unlinked pages.
|
|
14
|
+
- Opening a field inside an array row from a finding waits until the form has the row, so a footer
|
|
15
|
+
link in a collapsed row opens and is selected.
|
|
16
|
+
|
|
3
17
|
## 0.9.0
|
|
4
18
|
|
|
5
19
|
- New check `media.video-description-missing` (Accessibility), turned on per collection with
|
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Deterministic content checks for Payload, shown as a dashboard widget and a work list: images
|
|
4
4
|
without alt text, missing or badly sized meta titles and descriptions, pages without a share image,
|
|
5
|
-
heading structure
|
|
5
|
+
heading structure, thin content, broken internal links (in pages and in globals such as the header
|
|
6
|
+
and footer) and published pages nothing links to. No AI; every finding points at one field of one document, in one
|
|
6
7
|
locale when the field is localized, and opens that field in Admin.
|
|
7
8
|
|
|
8
9
|
Requires Payload `>=3.90.2 <4`, Next 16 and React 19. The widget uses Payload's modular dashboard
|
|
@@ -54,7 +55,8 @@ Requires Payload `>=3.90.2 <4`, Next 16 and React 19. The widget uses Payload's
|
|
|
54
55
|
| `content.h1-multiple` | `content.headings` | More than one H1 |
|
|
55
56
|
| `content.heading-skip` | `content.headings` | A heading level skipped, e.g. H2 → H4 |
|
|
56
57
|
| `content.thin` | `content` | Fewer than 300 words on the page |
|
|
57
|
-
| `links.broken` | `content`
|
|
58
|
+
| `links.broken` | `content`, `globals` | Internal links to deleted or unpublished pages |
|
|
59
|
+
| `links.orphan` | `orphans` | Published pages nothing links to |
|
|
58
60
|
| `content.untranslated` | `translations` | Localized text empty here, filled in default |
|
|
59
61
|
|
|
60
62
|
`seo: true` reads the fields `@payloadcms/plugin-seo` adds (`meta.title`, `meta.description`,
|
|
@@ -107,6 +109,56 @@ published pages. Targets are looked up without access control; only their title
|
|
|
107
109
|
are published reach the list. Each page gets one finding per language, at the first broken link,
|
|
108
110
|
with how many there are.
|
|
109
111
|
|
|
112
|
+
### Header, footer and other globals
|
|
113
|
+
|
|
114
|
+
A menu link to an unpublished page is the broken link most readers meet, but it lives in a global,
|
|
115
|
+
not in a page body. `globals` turns on `links.broken` for globals by slug:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
contentHealthPlugin({
|
|
119
|
+
collections: { pages: { content: { field: "layout" } } },
|
|
120
|
+
globals: { header: { links: true }, footer: { links: true } },
|
|
121
|
+
}),
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Every link field in the global is read: relationship fields (such as a link group's reference,
|
|
125
|
+
skipped when the group is an outside link) and rich text, also inside arrays, groups and tabs.
|
|
126
|
+
Blocks fields in a global are not read. A global gets one finding per language like a page; the row
|
|
127
|
+
is labeled with the global's name and opens the global with the link's field in view
|
|
128
|
+
(`/admin/globals/footer?field=navItems.2.link.reference`). The assistant's `content-health.findings`
|
|
129
|
+
takes a global's slug as `collection`. Only links are checked in globals.
|
|
130
|
+
|
|
131
|
+
### Pages nothing links to
|
|
132
|
+
|
|
133
|
+
`orphans: true` reports published documents of the collection that nothing links to
|
|
134
|
+
(`links.orphan`, "Nothing links here", under Structure): a reader cannot reach them by following
|
|
135
|
+
links on the site. One finding per document; it opens the document, and the row says where a link
|
|
136
|
+
usually comes from (the menu or a related page). Off by default, since a site with a search page or
|
|
137
|
+
campaign landing pages may want pages without links.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
collections: {
|
|
141
|
+
pages: {
|
|
142
|
+
content: { field: "layout" },
|
|
143
|
+
orphans: { exclude: { slug: { equals: "home" } } },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
globals: { header: { links: true }, footer: { links: true } },
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A link counts when a reader can follow it today:
|
|
150
|
+
|
|
151
|
+
- it is in the published version of a page body (`content`), so a link only in a page's draft does
|
|
152
|
+
not count yet, or in a global with `links` as the site reads it (`draft: false`): a global saved
|
|
153
|
+
as a draft but never published is still shown, so its links count;
|
|
154
|
+
- in any language;
|
|
155
|
+
- from another document: a page linking to itself does not count.
|
|
156
|
+
|
|
157
|
+
Redirects are not links. The plugin cannot tell which page is the start page, so leave it out with
|
|
158
|
+
`exclude`, a Payload `where` query; any other intentionally unlinked page can go there too. Drafts
|
|
159
|
+
are never reported. Without `globals`, every page reached only from the menu looks unlinked, so turn
|
|
160
|
+
on the globals that hold the site's navigation first.
|
|
161
|
+
|
|
110
162
|
### Translations
|
|
111
163
|
|
|
112
164
|
`translations: true` compares every language with the default one (Payload's
|
|
@@ -133,7 +185,7 @@ the widget from the dashboard's edit mode, and can limit it to some checks in it
|
|
|
133
185
|
|
|
134
186
|
The widget is a summary: the share of checked values in order, and one row per category with a bar
|
|
135
187
|
and how much there is to fix. Categories group the checks: SEO (meta titles and descriptions, their
|
|
136
|
-
lengths, share images), Structure (headings, page length), Translations and Accessibility (alt
|
|
188
|
+
lengths, share images), Structure (headings, page length, links), Translations and Accessibility (alt
|
|
137
189
|
texts). A category none of the configured checks belong to is left out. Each row opens the work list
|
|
138
190
|
on that category, and "Open details" opens the list itself.
|
|
139
191
|
|
|
@@ -197,7 +249,7 @@ const { findings, problems } = await scanContentHealth({ payload, user: req.user
|
|
|
197
249
|
```
|
|
198
250
|
|
|
199
251
|
Each finding is
|
|
200
|
-
`{ check, collection, id, title, path, locale?, length?, range?, heading?, words?, status? }`,
|
|
201
|
-
`path` is the form path with row indexes (`layout.1.items.0.title`) and `range` is the length a
|
|
252
|
+
`{ check, collection, id, title, path, locale?, length?, range?, heading?, words?, status?, global? }`,
|
|
253
|
+
where `global: true` marks a finding in a global (its slug is both `collection` and `id`) and `path` is the form path with row indexes (`layout.1.items.0.title`) and `range` is the length a
|
|
202
254
|
missing meta title or description should have. `problems` lists configured checks that could not
|
|
203
255
|
run, such as a field path that does not exist.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import "../styles/admin.css";
|
|
2
2
|
/**
|
|
3
|
-
* Mounted in the edit view of each checked collection. When the address carries `?field=<path>`
|
|
3
|
+
* Mounted in the edit view of each checked collection and global. When the address carries `?field=<path>`
|
|
4
4
|
* (a link from the work list or the widget), it opens the tabs and rows around that field, scrolls
|
|
5
5
|
* to it, focuses and highlights it, then announces it with `content-health:select` so the editor
|
|
6
6
|
* assistant takes it as the selection. The parameter is removed from the address once read.
|
|
@@ -21,7 +21,7 @@ const STABLE_FRAMES = 3;
|
|
|
21
21
|
/** Roughly how long a smooth scroll takes. */
|
|
22
22
|
const SCROLL_MS = 600;
|
|
23
23
|
/**
|
|
24
|
-
* Mounted in the edit view of each checked collection. When the address carries `?field=<path>`
|
|
24
|
+
* Mounted in the edit view of each checked collection and global. When the address carries `?field=<path>`
|
|
25
25
|
* (a link from the work list or the widget), it opens the tabs and rows around that field, scrolls
|
|
26
26
|
* to it, focuses and highlights it, then announces it with `content-health:select` so the editor
|
|
27
27
|
* assistant takes it as the selection. The parameter is removed from the address once read.
|
|
@@ -29,13 +29,14 @@ const SCROLL_MS = 600;
|
|
|
29
29
|
export function ContentHealthFieldBridge() {
|
|
30
30
|
const form = useDocumentForm();
|
|
31
31
|
const { config, getEntityConfig } = useConfig();
|
|
32
|
-
const { collectionSlug } = useDocumentInfo();
|
|
32
|
+
const { collectionSlug, globalSlug } = useDocumentInfo();
|
|
33
|
+
const entity = { collectionSlug, globalSlug };
|
|
33
34
|
const anchorRef = useRef(null);
|
|
34
|
-
const latest = useRef({ form, config, getEntityConfig,
|
|
35
|
+
const latest = useRef({ form, config, getEntityConfig, entity });
|
|
35
36
|
// Kept across effect re-runs: the parameter is gone from the address after the first read.
|
|
36
37
|
const requested = useRef(undefined);
|
|
37
38
|
useEffect(() => {
|
|
38
|
-
latest.current = { form, config, getEntityConfig,
|
|
39
|
+
latest.current = { form, config, getEntityConfig, entity };
|
|
39
40
|
});
|
|
40
41
|
useEffect(() => {
|
|
41
42
|
if (requested.current === undefined) {
|
|
@@ -49,12 +50,9 @@ export function ContentHealthFieldBridge() {
|
|
|
49
50
|
const current = () => !cancelled;
|
|
50
51
|
void (async () => {
|
|
51
52
|
const plan = () => {
|
|
52
|
-
const { form, config, getEntityConfig,
|
|
53
|
+
const { form, config, getEntityConfig, entity } = latest.current;
|
|
53
54
|
const fields = form.getFields();
|
|
54
|
-
const rootFields =
|
|
55
|
-
? (getEntityConfig({ collectionSlug })
|
|
56
|
-
?.fields ?? [])
|
|
57
|
-
: [];
|
|
55
|
+
const rootFields = rootFieldsOf(getEntityConfig, entity);
|
|
58
56
|
const lookup = {
|
|
59
57
|
blockTypeAt: (rowPath) => rowAt(fields, rowPath)?.blockType,
|
|
60
58
|
blocksBySlug: config.blocksMap,
|
|
@@ -63,10 +61,13 @@ export function ContentHealthFieldBridge() {
|
|
|
63
61
|
return { form, fields, target };
|
|
64
62
|
};
|
|
65
63
|
// 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
|
|
64
|
+
// only plans as far as its blocks field, and an array row plans but cannot be expanded.
|
|
65
|
+
// Wait for the whole path and its rows before settling for less.
|
|
67
66
|
const planned = (await waitFor(() => {
|
|
68
67
|
const attempt = plan();
|
|
69
|
-
return attempt.target?.path === path
|
|
68
|
+
return attempt.target?.path === path && rowsLoaded(attempt.target.steps, attempt.fields)
|
|
69
|
+
? attempt
|
|
70
|
+
: null;
|
|
70
71
|
})) ?? plan();
|
|
71
72
|
const { target } = planned;
|
|
72
73
|
if (!target || !current()) {
|
|
@@ -120,6 +121,10 @@ function expandRows(steps, fields, dispatchFields) {
|
|
|
120
121
|
});
|
|
121
122
|
}
|
|
122
123
|
}
|
|
124
|
+
/** Whether the form state has every row the steps enter, so collapsed ones can be expanded. */
|
|
125
|
+
function rowsLoaded(steps, fields) {
|
|
126
|
+
return steps.every((step) => step.kind !== "row" || rowAt(fields, `${step.parentPath}.${step.index}`) !== undefined);
|
|
127
|
+
}
|
|
123
128
|
function rowAt(fields, rowPath) {
|
|
124
129
|
const cut = rowPath.lastIndexOf(".");
|
|
125
130
|
return fields[rowPath.slice(0, cut)]?.rows?.[Number(rowPath.slice(cut + 1))];
|
|
@@ -323,3 +328,12 @@ function isFocusable(element) {
|
|
|
323
328
|
element instanceof HTMLSelectElement ||
|
|
324
329
|
element.isContentEditable);
|
|
325
330
|
}
|
|
331
|
+
/** The edited collection's or global's fields, as `VisualEditingAdminBridge` reads them. */
|
|
332
|
+
function rootFieldsOf(getEntityConfig, { collectionSlug, globalSlug }) {
|
|
333
|
+
const entity = collectionSlug
|
|
334
|
+
? getEntityConfig({ collectionSlug })
|
|
335
|
+
: globalSlug
|
|
336
|
+
? getEntityConfig({ globalSlug })
|
|
337
|
+
: undefined;
|
|
338
|
+
return entity?.fields ?? [];
|
|
339
|
+
}
|
package/dist/assistant.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CATEGORY_IDS, categoryOf, categoryStats } from "./categories.js";
|
|
2
|
-
import { BATCH_ASK, CATEGORY_LABELS, CHECK_LABELS, findingDetail, uiLanguage } from "./copy.js";
|
|
2
|
+
import { ASK, BATCH_ASK, CATEGORY_LABELS, CHECK_LABELS, findingDetail, uiLanguage, } from "./copy.js";
|
|
3
3
|
import { checkStats, countDocuments, sortFindings } from "./group.js";
|
|
4
4
|
import { assistantCollections, BATCH_CHECKS, batchCollections, batchTarget, batchURL, listURL, MAX_BATCH_TARGETS, matchesFilters, VIEW_PATH, } from "./links.js";
|
|
5
5
|
import { scanContentHealth } from "./scan.js";
|
|
@@ -34,7 +34,10 @@ const filterInput = {
|
|
|
34
34
|
description: "seo, structure, translations or accessibility.",
|
|
35
35
|
},
|
|
36
36
|
check: { type: "string", enum: [...CHECK_IDS], description: "One check, by id." },
|
|
37
|
-
collection: {
|
|
37
|
+
collection: {
|
|
38
|
+
type: "string",
|
|
39
|
+
description: "Collection or global slug, such as pages, images or footer.",
|
|
40
|
+
},
|
|
38
41
|
locale: { type: "string", description: "Only findings in this content language." },
|
|
39
42
|
};
|
|
40
43
|
/** `scan` is replaceable for tests. */
|
|
@@ -43,7 +46,7 @@ export function assistantExtension({ scan: run = scanContentHealth, } = {}) {
|
|
|
43
46
|
return {
|
|
44
47
|
id: EXTENSION_ID,
|
|
45
48
|
instructions: [
|
|
46
|
-
"Content health checks the site's content: SEO (meta titles, descriptions, share images, duplicates), structure (H1, heading levels, thin content, broken internal links), translations (fields empty in a language) and accessibility (image alt texts).",
|
|
49
|
+
"Content health checks the site's content: SEO (meta titles, descriptions, share images, duplicates), structure (H1, heading levels, thin content, broken internal links, also in the header and footer, and pages nothing links to), translations (fields empty in a language) and accessibility (image alt texts).",
|
|
47
50
|
"When the editor asks what needs work, how the site or a page is doing, or what to fix first, call content-health.findings and answer from it, naming pages and problems. Never ask for a screenshot of content health.",
|
|
48
51
|
'Each finding says how it is fixed: "batch" (many documents at once from the work list), "assistant" (on the document, with you) or "by hand". To start fixing, call content-health.open: with batch true for a batch check, else the work list. It takes the editor there; they start the fix and review each change themselves. Say where they are now ("The 9 missing alt texts are open; Fix 9 with the assistant starts them."); never say anything is fixed or started, and do not propose changes yourself for findings outside the open document. Do not mention findings the tool left out unless the editor asks for more.',
|
|
49
52
|
].join(" "),
|
|
@@ -105,7 +108,9 @@ async function findings(input, { req, open }, result) {
|
|
|
105
108
|
? { collection: filters.collection, id: String(input.documentId) }
|
|
106
109
|
: !filtered && open?.collection && open.id !== undefined
|
|
107
110
|
? { collection: open.collection, id: String(open.id) }
|
|
108
|
-
:
|
|
111
|
+
: !filtered && open?.global
|
|
112
|
+
? { collection: open.global, id: open.global }
|
|
113
|
+
: undefined;
|
|
109
114
|
const matching = sortFindings(result.findings).filter((finding) => matchesFilters(finding, filters) &&
|
|
110
115
|
(!document ||
|
|
111
116
|
((!document.collection || finding.collection === document.collection) &&
|
|
@@ -133,6 +138,7 @@ async function findings(input, { req, open }, result) {
|
|
|
133
138
|
detail: findingDetail("en", finding),
|
|
134
139
|
collection: finding.collection,
|
|
135
140
|
id: finding.id,
|
|
141
|
+
...(finding.global ? { global: true } : {}),
|
|
136
142
|
title: finding.title,
|
|
137
143
|
field: finding.path,
|
|
138
144
|
...(finding.locale ? { locale: finding.locale } : {}),
|
|
@@ -187,7 +193,9 @@ async function open(input, { req }, scan) {
|
|
|
187
193
|
function fixFor(finding, inBatches, withAssistant) {
|
|
188
194
|
if (BATCH_CHECKS[finding.check] && inBatches.has(finding.collection))
|
|
189
195
|
return "batch";
|
|
190
|
-
return withAssistant.has(finding.collection)
|
|
196
|
+
return !finding.global && ASK.en[finding.check] && withAssistant.has(finding.collection)
|
|
197
|
+
? "assistant"
|
|
198
|
+
: "by hand";
|
|
191
199
|
}
|
|
192
200
|
function readInput(input) {
|
|
193
201
|
const text = (value) => (typeof value === "string" && value ? value : undefined);
|
package/dist/categories.js
CHANGED
package/dist/copy.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export declare const COPY: {
|
|
|
24
24
|
readonly duplicate: (titles: string[], more: number) => string;
|
|
25
25
|
readonly linkMissing: (count: number) => string;
|
|
26
26
|
readonly linkUnpublished: (target: string, count: number) => string;
|
|
27
|
+
readonly orphan: "No published page, header or footer links here. Link to it from the menu or a related page.";
|
|
27
28
|
readonly problems: "Some checks could not run:";
|
|
28
29
|
};
|
|
29
30
|
readonly sv: {
|
|
@@ -44,15 +45,17 @@ export declare const COPY: {
|
|
|
44
45
|
readonly duplicate: (titles: string[], more: number) => string;
|
|
45
46
|
readonly linkMissing: (count: number) => string;
|
|
46
47
|
readonly linkUnpublished: (target: string, count: number) => string;
|
|
48
|
+
readonly orphan: "Ingen publicerad sida, sidhuvud eller sidfot länkar hit. Länka hit från menyn eller en närliggande sida.";
|
|
47
49
|
readonly problems: "Några kontroller kunde inte köras:";
|
|
48
50
|
};
|
|
49
51
|
};
|
|
50
52
|
type Ask = (finding: Finding) => string;
|
|
51
53
|
/**
|
|
52
54
|
* The ready prompt behind "Fix with the assistant", written as the editor would ask. The document is
|
|
53
|
-
* open in the finding's locale with the field selected, so the prompt does not name either.
|
|
55
|
+
* open in the finding's locale with the field selected, so the prompt does not name either. A check
|
|
56
|
+
* without one is fixed elsewhere: nothing on an unlinked page needs changing.
|
|
54
57
|
*/
|
|
55
|
-
export declare const ASK: Record<UiLanguage, Record<CheckId, Ask
|
|
58
|
+
export declare const ASK: Record<UiLanguage, Partial<Record<CheckId, Ask>>>;
|
|
56
59
|
type BatchAsk = (finding: Finding) => string;
|
|
57
60
|
/**
|
|
58
61
|
* The prompt behind "Fix N with the assistant", once for every document of a check. The assistant
|
package/dist/copy.js
CHANGED
|
@@ -17,6 +17,7 @@ export const CHECK_LABELS = {
|
|
|
17
17
|
"content.heading-skip": "Skipped heading levels",
|
|
18
18
|
"content.thin": "Thin content",
|
|
19
19
|
"links.broken": "Broken internal links",
|
|
20
|
+
"links.orphan": "Nothing links here",
|
|
20
21
|
"content.untranslated": "Not translated",
|
|
21
22
|
},
|
|
22
23
|
sv: {
|
|
@@ -34,6 +35,7 @@ export const CHECK_LABELS = {
|
|
|
34
35
|
"content.heading-skip": "Överhoppade rubriknivåer",
|
|
35
36
|
"content.thin": "Tunt innehåll",
|
|
36
37
|
"links.broken": "Trasiga interna länkar",
|
|
38
|
+
"links.orphan": "Inget länkar hit",
|
|
37
39
|
"content.untranslated": "Saknar översättning",
|
|
38
40
|
},
|
|
39
41
|
};
|
|
@@ -70,6 +72,7 @@ export const COPY = {
|
|
|
70
72
|
duplicate: (titles, more) => `Also on ${list("en", titles)}${more ? ` and ${more} more` : ""}`,
|
|
71
73
|
linkMissing: (count) => `Links to a deleted document${count > 1 ? `, and ${count - 1} more` : ""}`,
|
|
72
74
|
linkUnpublished: (target, count) => `Links to “${target}”, not published${count > 1 ? `, and ${count - 1} more` : ""}`,
|
|
75
|
+
orphan: "No published page, header or footer links here. Link to it from the menu or a related page.",
|
|
73
76
|
problems: "Some checks could not run:",
|
|
74
77
|
},
|
|
75
78
|
sv: {
|
|
@@ -90,12 +93,14 @@ export const COPY = {
|
|
|
90
93
|
duplicate: (titles, more) => `Även på ${list("sv", titles)}${more ? ` och ${more} till` : ""}`,
|
|
91
94
|
linkMissing: (count) => `Länkar till ett borttaget dokument${count > 1 ? `, och ${count - 1} till` : ""}`,
|
|
92
95
|
linkUnpublished: (target, count) => `Länkar till ”${target}”, som inte är publicerad${count > 1 ? `, och ${count - 1} till` : ""}`,
|
|
96
|
+
orphan: "Ingen publicerad sida, sidhuvud eller sidfot länkar hit. Länka hit från menyn eller en närliggande sida.",
|
|
93
97
|
problems: "Några kontroller kunde inte köras:",
|
|
94
98
|
},
|
|
95
99
|
};
|
|
96
100
|
/**
|
|
97
101
|
* The ready prompt behind "Fix with the assistant", written as the editor would ask. The document is
|
|
98
|
-
* open in the finding's locale with the field selected, so the prompt does not name either.
|
|
102
|
+
* open in the finding's locale with the field selected, so the prompt does not name either. A check
|
|
103
|
+
* without one is fixed elsewhere: nothing on an unlinked page needs changing.
|
|
99
104
|
*/
|
|
100
105
|
export const ASK = {
|
|
101
106
|
en: {
|
|
@@ -291,6 +296,9 @@ export const LIST_COPY = {
|
|
|
291
296
|
export function findingDetail(language, finding) {
|
|
292
297
|
const copy = COPY[language];
|
|
293
298
|
const { duplicate, heading, length, links, untranslated, words } = finding;
|
|
299
|
+
if (finding.check === "links.orphan") {
|
|
300
|
+
return copy.orphan;
|
|
301
|
+
}
|
|
294
302
|
if (untranslated) {
|
|
295
303
|
return copy.untranslated(untranslated.count);
|
|
296
304
|
}
|
package/dist/fields.d.ts
CHANGED
|
@@ -56,3 +56,12 @@ export declare function contentSchema(field: FieldLike, resolveBlock: (slug: str
|
|
|
56
56
|
schema: ContentSchema;
|
|
57
57
|
localized: boolean;
|
|
58
58
|
} | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* The fields of a whole document that can hold an internal link, such as a global's menu: rich text
|
|
61
|
+
* and relationships, in link groups and arrays too. `localized` is set when any of them is, so a
|
|
62
|
+
* label that is localized does not make the links be read once per locale.
|
|
63
|
+
*/
|
|
64
|
+
export declare function linkFields(fields: FieldLike[]): {
|
|
65
|
+
fields: TextField[];
|
|
66
|
+
localized: boolean;
|
|
67
|
+
};
|
package/dist/fields.js
CHANGED
|
@@ -86,12 +86,27 @@ export function contentSchema(field, resolveBlock) {
|
|
|
86
86
|
}
|
|
87
87
|
return { schema: { kind: "blocks", blocks }, localized };
|
|
88
88
|
}
|
|
89
|
-
/**
|
|
90
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The fields of a whole document that can hold an internal link, such as a global's menu: rich text
|
|
91
|
+
* and relationships, in link groups and arrays too. `localized` is set when any of them is, so a
|
|
92
|
+
* label that is localized does not make the links be read once per locale.
|
|
93
|
+
*/
|
|
94
|
+
export function linkFields(fields) {
|
|
95
|
+
const found = [];
|
|
96
|
+
const localized = collectTextFields(fields, "", found, true);
|
|
97
|
+
return { fields: found, localized };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Collects the fields in config order; returns whether any of them is localized. `linksOnly`
|
|
101
|
+
* leaves out plain text.
|
|
102
|
+
*/
|
|
103
|
+
function collectTextFields(fields, prefix, out, linksOnly = false) {
|
|
91
104
|
let localized = false;
|
|
92
105
|
for (const field of fields) {
|
|
93
106
|
const path = field.name ? `${prefix}${field.name}` : undefined;
|
|
94
107
|
if (path && (field.type === "text" || field.type === "textarea")) {
|
|
108
|
+
if (linksOnly)
|
|
109
|
+
continue;
|
|
95
110
|
out.push({ path, kind: "text" });
|
|
96
111
|
localized ||= Boolean(field.localized);
|
|
97
112
|
}
|
|
@@ -104,13 +119,13 @@ function collectTextFields(fields, prefix, out) {
|
|
|
104
119
|
localized ||= Boolean(field.localized);
|
|
105
120
|
}
|
|
106
121
|
else if (field.type === "tabs") {
|
|
107
|
-
localized = collectTextFields(field.tabs ?? [], prefix, out) || localized;
|
|
122
|
+
localized = collectTextFields(field.tabs ?? [], prefix, out, linksOnly) || localized;
|
|
108
123
|
}
|
|
109
124
|
else if (field.fields && field.type !== "blocks") {
|
|
110
125
|
// Groups, arrays and named tabs add a segment; rows, collapsibles and unnamed tabs do not.
|
|
111
126
|
// A localized group or array localizes the text fields inside it.
|
|
112
127
|
const before = out.length;
|
|
113
|
-
const nested = collectTextFields(field.fields, path ? `${path}.` : prefix, out);
|
|
128
|
+
const nested = collectTextFields(field.fields, path ? `${path}.` : prefix, out, linksOnly);
|
|
114
129
|
localized ||= nested || (Boolean(field.localized) && out.length > before);
|
|
115
130
|
}
|
|
116
131
|
}
|
package/dist/links.d.ts
CHANGED
|
@@ -61,7 +61,10 @@ export declare function batchTarget(finding: Finding): string;
|
|
|
61
61
|
* `targets` with `ask`. The check filter says how far each fix may reach.
|
|
62
62
|
*/
|
|
63
63
|
export declare function batchURL(adminRoute: string, filters: ListFilters, targets: string[], ask: string): string;
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* The document or global in the finding's locale with its field brought into view, and a prompt
|
|
66
|
+
* when given.
|
|
67
|
+
*/
|
|
65
68
|
export declare function editURL(adminRoute: string, finding: Finding, ask?: string): string;
|
|
66
69
|
/**
|
|
67
70
|
* The work list's filters. `category` picks the tab. `check` is only an entry point (an older link,
|
package/dist/links.js
CHANGED
|
@@ -110,18 +110,28 @@ export function batchURL(adminRoute, filters, targets, ask) {
|
|
|
110
110
|
params.set(ASK_PARAM, ask);
|
|
111
111
|
return `${formatAdminURL({ adminRoute, path: VIEW_PATH })}?${params}`;
|
|
112
112
|
}
|
|
113
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* The document or global in the finding's locale with its field brought into view, and a prompt
|
|
115
|
+
* when given.
|
|
116
|
+
*/
|
|
114
117
|
export function editURL(adminRoute, finding, ask) {
|
|
115
|
-
const path =
|
|
118
|
+
const path = finding.global
|
|
119
|
+
? `/globals/${finding.collection}`
|
|
120
|
+
: `/collections/${finding.collection}/${encodeURIComponent(String(finding.id))}`;
|
|
116
121
|
const params = new URLSearchParams();
|
|
117
122
|
if (finding.locale) {
|
|
118
123
|
params.set("locale", finding.locale);
|
|
119
124
|
}
|
|
120
|
-
|
|
125
|
+
if (finding.path) {
|
|
126
|
+
params.set(FIELD_PARAM, finding.path);
|
|
127
|
+
}
|
|
121
128
|
if (ask) {
|
|
122
129
|
params.set(ASK_PARAM, ask);
|
|
123
130
|
}
|
|
124
|
-
|
|
131
|
+
const query = params.toString();
|
|
132
|
+
return query
|
|
133
|
+
? `${formatAdminURL({ adminRoute, path })}?${query}`
|
|
134
|
+
: formatAdminURL({ adminRoute, path });
|
|
125
135
|
}
|
|
126
136
|
/** The work list, filtered. */
|
|
127
137
|
export function listURL(adminRoute, filters = {}) {
|
package/dist/plugin.js
CHANGED
|
@@ -43,6 +43,7 @@ export function contentHealthPlugin(options) {
|
|
|
43
43
|
return {
|
|
44
44
|
...config,
|
|
45
45
|
collections: (config.collections ?? []).map((collection) => options.collections[collection.slug] ? withFieldBridge(collection) : collection),
|
|
46
|
+
globals: (config.globals ?? []).map((global) => options.globals?.[global.slug] ? withGlobalFieldBridge(global) : global),
|
|
46
47
|
admin: {
|
|
47
48
|
...config.admin,
|
|
48
49
|
components: {
|
|
@@ -98,3 +99,23 @@ function withFieldBridge(collection) {
|
|
|
98
99
|
},
|
|
99
100
|
};
|
|
100
101
|
}
|
|
102
|
+
/** The same for a global, whose edit view takes its components under `elements`. */
|
|
103
|
+
function withGlobalFieldBridge(global) {
|
|
104
|
+
const elements = global.admin?.components?.elements;
|
|
105
|
+
return {
|
|
106
|
+
...global,
|
|
107
|
+
admin: {
|
|
108
|
+
...global.admin,
|
|
109
|
+
components: {
|
|
110
|
+
...global.admin?.components,
|
|
111
|
+
elements: {
|
|
112
|
+
...elements,
|
|
113
|
+
beforeDocumentControls: [
|
|
114
|
+
...(elements?.beforeDocumentControls ?? []),
|
|
115
|
+
`${CLIENT_ENTRY}#ContentHealthFieldBridge`,
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
package/dist/references.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContentSchema } from "./fields.ts";
|
|
1
|
+
import type { ContentSchema, TextField } from "./fields.ts";
|
|
2
2
|
export type Reference = {
|
|
3
3
|
/** Form path of the field holding the link, e.g. `layout.2.content` or `layout.1.link.reference`. */
|
|
4
4
|
path: string;
|
|
@@ -6,3 +6,5 @@ export type Reference = {
|
|
|
6
6
|
id: string | number;
|
|
7
7
|
};
|
|
8
8
|
export declare function readReferences(value: unknown, root: string, schema: ContentSchema): Reference[];
|
|
9
|
+
/** Internal links in a whole document, such as a global, from the fields `linkFields` found. */
|
|
10
|
+
export declare function readDocumentReferences(doc: unknown, fields: TextField[]): Reference[];
|
package/dist/references.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Internal links in a page body: link nodes in rich text and relationship
|
|
3
|
-
* functions over
|
|
2
|
+
* Internal links in a page body or a whole document: link nodes in rich text and relationship
|
|
3
|
+
* fields. Plain functions over values as the Local API returns them at depth 0, like `content.ts`.
|
|
4
4
|
*/
|
|
5
5
|
import { fanOut } from "./content.js";
|
|
6
6
|
/**
|
|
@@ -22,22 +22,31 @@ export function readReferences(value, root, schema) {
|
|
|
22
22
|
if (typeof blockType !== "string") {
|
|
23
23
|
continue;
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
readFields(block, schema.blocks[blockType] ?? [], `${root}.${index}`, references);
|
|
26
|
+
}
|
|
27
|
+
return references;
|
|
28
|
+
}
|
|
29
|
+
/** Internal links in a whole document, such as a global, from the fields `linkFields` found. */
|
|
30
|
+
export function readDocumentReferences(doc, fields) {
|
|
31
|
+
const references = [];
|
|
32
|
+
readFields(doc, fields, "", references);
|
|
33
|
+
return references;
|
|
34
|
+
}
|
|
35
|
+
function readFields(container, fields, root, out) {
|
|
36
|
+
for (const field of fields) {
|
|
37
|
+
if (field.kind === "text") {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
for (const { path, value, parent } of withParents(container, field.path)) {
|
|
41
|
+
const fullPath = join(root, path);
|
|
42
|
+
if (field.kind === "richText") {
|
|
43
|
+
readLexical(value, fullPath, out);
|
|
28
44
|
}
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
}
|
|
45
|
+
else if (field.kind === "relationship" && !isExternal(parent)) {
|
|
46
|
+
out.push(...relationValues(value, field.relationTo, fullPath));
|
|
37
47
|
}
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
|
-
return references;
|
|
41
50
|
}
|
|
42
51
|
/** `fanOut` with the object each value sits in, so a link field's siblings can be read. */
|
|
43
52
|
function withParents(block, path) {
|
package/dist/scan.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { checkDocument, documentTitle, rulesFor } from "./checks.js";
|
|
2
|
-
import { contentSchema, lookupPath, mergeSelect, } from "./fields.js";
|
|
3
|
-
import {
|
|
1
|
+
import { checkDocument, documentTitle, rulesFor, valueAt } from "./checks.js";
|
|
2
|
+
import { contentSchema, linkFields, lookupPath, mergeSelect, } from "./fields.js";
|
|
3
|
+
import { readDocumentReferences, readReferences } from "./references.js";
|
|
4
|
+
import { duplicateFindings, linkedKeys, linkFindings, orphanFindings, targetKey, } from "./site.js";
|
|
4
5
|
import { checkTranslation } from "./translations.js";
|
|
5
|
-
import { CHECK_IDS } from "./types.js";
|
|
6
|
+
import { CHECK_IDS, } from "./types.js";
|
|
6
7
|
/** Where the plugin keeps its options in the (server-only) `config.custom`. */
|
|
7
8
|
export const CUSTOM_KEY = "contentHealth";
|
|
8
9
|
export function contentHealthOptions(config) {
|
|
@@ -160,6 +161,52 @@ export async function scanContentHealth({ payload, user, checks }) {
|
|
|
160
161
|
}
|
|
161
162
|
result.findings.push(...duplicateFindings(unique));
|
|
162
163
|
countChecked(result, unique.map((entry) => entry.base.check));
|
|
164
|
+
// A global's links, such as the menu, are followed with the collections'.
|
|
165
|
+
if (!checks?.length || checks.includes("links.broken")) {
|
|
166
|
+
for (const slug of linkGlobals(options)) {
|
|
167
|
+
const source = globalSource(payload, slug);
|
|
168
|
+
if (!source) {
|
|
169
|
+
result.problems.push(`Global "${slug}" does not exist.`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
ran.add("links.broken");
|
|
173
|
+
result.documents[slug] = 1;
|
|
174
|
+
for (const [index, locale] of locales.entries()) {
|
|
175
|
+
if (index > 0 && !source.localized) {
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const doc = await payload.findGlobal({
|
|
180
|
+
slug: slug,
|
|
181
|
+
depth: 0,
|
|
182
|
+
draft: source.drafts,
|
|
183
|
+
fallbackLocale: false,
|
|
184
|
+
...(locale ? { locale: locale } : {}),
|
|
185
|
+
overrideAccess: false,
|
|
186
|
+
user,
|
|
187
|
+
});
|
|
188
|
+
const status = doc._status;
|
|
189
|
+
links.push({
|
|
190
|
+
base: {
|
|
191
|
+
check: "links.broken",
|
|
192
|
+
collection: slug,
|
|
193
|
+
id: slug,
|
|
194
|
+
global: true,
|
|
195
|
+
title: source.title,
|
|
196
|
+
path: "",
|
|
197
|
+
...(source.localized && locale ? { locale } : {}),
|
|
198
|
+
...(status === "draft" || status === "published" ? { status } : {}),
|
|
199
|
+
},
|
|
200
|
+
references: readDocumentReferences(doc, source.fields),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
result.problems.push(`${slug}: ${error instanceof Error ? error.message : String(error)}`);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
163
210
|
if (links.length) {
|
|
164
211
|
try {
|
|
165
212
|
const targets = await linkTargets(payload, links.flatMap((entry) => entry.references));
|
|
@@ -171,9 +218,168 @@ export async function scanContentHealth({ payload, user, checks }) {
|
|
|
171
218
|
result.problems.push(`links: ${error instanceof Error ? error.message : String(error)}`);
|
|
172
219
|
}
|
|
173
220
|
}
|
|
221
|
+
const orphans = Object.entries(options.collections).filter(([, own]) => own.orphans);
|
|
222
|
+
if (orphans.length && (!checks?.length || checks.includes("links.orphan"))) {
|
|
223
|
+
ran.add("links.orphan");
|
|
224
|
+
try {
|
|
225
|
+
const linked = linkedKeys(await publishedLinks({ payload, user, options, locales, resolveBlock }));
|
|
226
|
+
for (const [slug, own] of orphans) {
|
|
227
|
+
const candidates = await orphanCandidates(payload, user, slug, own.orphans);
|
|
228
|
+
const followed = orphanFindings(candidates, linked);
|
|
229
|
+
result.findings.push(...followed.findings);
|
|
230
|
+
countChecked(result, followed.checked);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
result.problems.push(`orphans: ${error instanceof Error ? error.message : String(error)}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
174
237
|
result.checks = CHECK_IDS.filter((check) => ran.has(check));
|
|
175
238
|
return result;
|
|
176
239
|
}
|
|
240
|
+
/** Globals whose links content health reads. */
|
|
241
|
+
function linkGlobals(options) {
|
|
242
|
+
return Object.entries(options.globals ?? {})
|
|
243
|
+
.filter(([, own]) => own.links)
|
|
244
|
+
.map(([slug]) => slug);
|
|
245
|
+
}
|
|
246
|
+
/** A global's link fields, its name in Admin and whether it has drafts; undefined if missing. */
|
|
247
|
+
function globalSource(payload, slug) {
|
|
248
|
+
const global = payload.config.globals.find((entry) => entry.slug === slug);
|
|
249
|
+
if (!global) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
...linkFields(global.fields),
|
|
254
|
+
drafts: Boolean(global.versions && global.versions.drafts),
|
|
255
|
+
title: labelText(global.label) ?? slug,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/** A config label as one string: English when it has several. Functions are left out. */
|
|
259
|
+
function labelText(value) {
|
|
260
|
+
if (typeof value === "string") {
|
|
261
|
+
return value;
|
|
262
|
+
}
|
|
263
|
+
if (value && typeof value === "object") {
|
|
264
|
+
const labels = value;
|
|
265
|
+
const text = labels.en ?? Object.values(labels)[0];
|
|
266
|
+
return typeof text === "string" ? text : undefined;
|
|
267
|
+
}
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
const PUBLISHED = { _status: { equals: "published" } };
|
|
271
|
+
/**
|
|
272
|
+
* The links a reader can follow today: the published version of every collection body content
|
|
273
|
+
* health reads and every global with `links` as the site reads it (`draft: false`), in every
|
|
274
|
+
* language.
|
|
275
|
+
*/
|
|
276
|
+
async function publishedLinks(input) {
|
|
277
|
+
const { payload, user, options, locales, resolveBlock } = input;
|
|
278
|
+
const sources = [];
|
|
279
|
+
for (const [slug, own] of Object.entries(options.collections)) {
|
|
280
|
+
const collection = payload.config.collections.find((entry) => entry.slug === slug);
|
|
281
|
+
const field = own.content?.field;
|
|
282
|
+
const info = collection && field && lookupPath(collection.fields, field);
|
|
283
|
+
const body = info && contentSchema(info.field, resolveBlock);
|
|
284
|
+
if (!field || !info || !body) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const drafts = Boolean(collection.versions && collection.versions.drafts);
|
|
288
|
+
const localized = Boolean(payload.config.localization) && (info.localized || body.localized);
|
|
289
|
+
for (const [index, locale] of locales.entries()) {
|
|
290
|
+
if (index > 0 && !localized) {
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
const { docs } = await payload.find({
|
|
294
|
+
collection: slug,
|
|
295
|
+
depth: 0,
|
|
296
|
+
draft: false,
|
|
297
|
+
fallbackLocale: false,
|
|
298
|
+
...(locale ? { locale: locale } : {}),
|
|
299
|
+
overrideAccess: false,
|
|
300
|
+
pagination: false,
|
|
301
|
+
select: info.select,
|
|
302
|
+
user,
|
|
303
|
+
...(drafts ? { where: PUBLISHED } : {}),
|
|
304
|
+
});
|
|
305
|
+
for (const doc of docs) {
|
|
306
|
+
sources.push({
|
|
307
|
+
collection: slug,
|
|
308
|
+
id: doc.id,
|
|
309
|
+
references: readReferences(valueAt(doc, field), field, body.schema),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for (const slug of linkGlobals(options)) {
|
|
315
|
+
const source = globalSource(payload, slug);
|
|
316
|
+
if (!source) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
for (const [index, locale] of locales.entries()) {
|
|
320
|
+
if (index > 0 && !source.localized) {
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
const doc = (await payload.findGlobal({
|
|
324
|
+
slug: slug,
|
|
325
|
+
depth: 0,
|
|
326
|
+
draft: false,
|
|
327
|
+
fallbackLocale: false,
|
|
328
|
+
...(locale ? { locale: locale } : {}),
|
|
329
|
+
overrideAccess: false,
|
|
330
|
+
user,
|
|
331
|
+
}));
|
|
332
|
+
// No status check: a global saved as a draft but never published still has `_status: "draft"`,
|
|
333
|
+
// and Payload returns it here, so the site shows its links.
|
|
334
|
+
sources.push({
|
|
335
|
+
collection: slug,
|
|
336
|
+
id: slug,
|
|
337
|
+
references: readDocumentReferences(doc, source.fields),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return sources;
|
|
342
|
+
}
|
|
343
|
+
/** A collection's published documents, less the excluded ones, as `links.orphan` findings to be. */
|
|
344
|
+
async function orphanCandidates(payload, user, slug, orphans) {
|
|
345
|
+
const collection = payload.config.collections.find((entry) => entry.slug === slug);
|
|
346
|
+
if (!collection) {
|
|
347
|
+
return [];
|
|
348
|
+
}
|
|
349
|
+
const drafts = Boolean(collection.versions && collection.versions.drafts);
|
|
350
|
+
const useAsTitle = collection.admin?.useAsTitle;
|
|
351
|
+
const select = {};
|
|
352
|
+
if (useAsTitle && useAsTitle !== "id")
|
|
353
|
+
mergeSelect(select, { [useAsTitle]: true });
|
|
354
|
+
if (collection.upload)
|
|
355
|
+
mergeSelect(select, { filename: true });
|
|
356
|
+
const read = (where) => payload.find({
|
|
357
|
+
collection: slug,
|
|
358
|
+
depth: 0,
|
|
359
|
+
draft: false,
|
|
360
|
+
overrideAccess: false,
|
|
361
|
+
pagination: false,
|
|
362
|
+
select: select,
|
|
363
|
+
user,
|
|
364
|
+
...(where ? { where } : {}),
|
|
365
|
+
});
|
|
366
|
+
const published = drafts ? PUBLISHED : undefined;
|
|
367
|
+
const exclude = typeof orphans === "object" ? orphans.exclude : undefined;
|
|
368
|
+
const excluded = exclude
|
|
369
|
+
? new Set((await read(published ? { and: [published, exclude] } : exclude)).docs.map((doc) => String(doc.id)))
|
|
370
|
+
: new Set();
|
|
371
|
+
const { docs } = await read(published);
|
|
372
|
+
return docs
|
|
373
|
+
.filter((doc) => !excluded.has(String(doc.id)))
|
|
374
|
+
.map((doc) => ({
|
|
375
|
+
check: "links.orphan",
|
|
376
|
+
collection: slug,
|
|
377
|
+
id: doc.id,
|
|
378
|
+
title: documentTitle(doc, useAsTitle),
|
|
379
|
+
path: "",
|
|
380
|
+
...(drafts ? { status: "published" } : {}),
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
177
383
|
function countChecked(result, checked) {
|
|
178
384
|
for (const check of checked) {
|
|
179
385
|
result.checked[check] = (result.checked[check] ?? 0) + 1;
|
package/dist/site.d.ts
CHANGED
|
@@ -33,3 +33,16 @@ export declare function linkFindings(entries: LinksEntry[], targets: Map<string,
|
|
|
33
33
|
findings: Finding[];
|
|
34
34
|
checked: CheckId[];
|
|
35
35
|
};
|
|
36
|
+
/** The links of one published document, page or global, as a reader can follow them. */
|
|
37
|
+
export type LinkSource = {
|
|
38
|
+
collection: string;
|
|
39
|
+
id: string | number;
|
|
40
|
+
references: Reference[];
|
|
41
|
+
};
|
|
42
|
+
/** Every document some other published document links to, by `targetKey`. */
|
|
43
|
+
export declare function linkedKeys(sources: LinkSource[]): Set<string>;
|
|
44
|
+
/** The published documents nothing links to; every candidate counts as checked. */
|
|
45
|
+
export declare function orphanFindings(candidates: Finding[], linked: Set<string>): {
|
|
46
|
+
findings: Finding[];
|
|
47
|
+
checked: CheckId[];
|
|
48
|
+
};
|
package/dist/site.js
CHANGED
|
@@ -65,3 +65,22 @@ export function linkFindings(entries, targets) {
|
|
|
65
65
|
}
|
|
66
66
|
return { findings, checked };
|
|
67
67
|
}
|
|
68
|
+
/** Every document some other published document links to, by `targetKey`. */
|
|
69
|
+
export function linkedKeys(sources) {
|
|
70
|
+
const linked = new Set();
|
|
71
|
+
for (const source of sources) {
|
|
72
|
+
for (const { relationTo, id } of source.references) {
|
|
73
|
+
if (relationTo === source.collection && String(id) === String(source.id))
|
|
74
|
+
continue;
|
|
75
|
+
linked.add(targetKey(relationTo, id));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return linked;
|
|
79
|
+
}
|
|
80
|
+
/** The published documents nothing links to; every candidate counts as checked. */
|
|
81
|
+
export function orphanFindings(candidates, linked) {
|
|
82
|
+
return {
|
|
83
|
+
findings: candidates.filter((candidate) => !linked.has(targetKey(candidate.collection, candidate.id))),
|
|
84
|
+
checked: candidates.map((candidate) => candidate.check),
|
|
85
|
+
};
|
|
86
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
import type { Where } from "payload";
|
|
2
|
+
export declare const CHECK_IDS: readonly ["media.alt-missing", "media.video-description-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", "links.orphan", "content.untranslated"];
|
|
2
3
|
export type CheckId = (typeof CHECK_IDS)[number];
|
|
3
4
|
export type LengthRange = {
|
|
4
5
|
min: number;
|
|
5
6
|
max: number;
|
|
6
7
|
};
|
|
7
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* One problem in one field of one document, in one locale when the field is localized. A global's
|
|
10
|
+
* finding has `global` set and the global's slug as both `collection` and `id`.
|
|
11
|
+
*/
|
|
8
12
|
export type Finding = {
|
|
9
13
|
check: CheckId;
|
|
10
14
|
collection: string;
|
|
11
15
|
id: string | number;
|
|
16
|
+
/** Set when the document is a global, such as a header. */
|
|
17
|
+
global?: true;
|
|
12
18
|
/** The document's title (`useAsTitle`), falling back to the filename and then the id. */
|
|
13
19
|
title: string;
|
|
14
|
-
/** Dot path of the field in the document, e.g. `meta.description
|
|
20
|
+
/** Dot path of the field in the document, e.g. `meta.description`; empty for the whole document. */
|
|
15
21
|
path: string;
|
|
16
22
|
/** Set when the field is localized. */
|
|
17
23
|
locale?: string;
|
|
@@ -115,10 +121,29 @@ export type CollectionHealthOptions = {
|
|
|
115
121
|
* default one. One finding per document and language. Needs Payload's `localization`.
|
|
116
122
|
*/
|
|
117
123
|
translations?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Published documents that no published page or global links to (`links.orphan`). Links are read
|
|
126
|
+
* where content health reads them: the `content` field of each collection and the `globals` with
|
|
127
|
+
* `links`. `exclude` leaves out documents matching a Payload query, such as the start page:
|
|
128
|
+
* `{ slug: { equals: "home" } }`. Off by default.
|
|
129
|
+
*/
|
|
130
|
+
orphans?: boolean | {
|
|
131
|
+
exclude?: Where;
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
export type GlobalHealthOptions = {
|
|
135
|
+
/**
|
|
136
|
+
* Internal links anywhere in the global (link groups, relationships, rich text) to documents that
|
|
137
|
+
* are deleted or not published, as `links.broken`. Also makes the global a link source for
|
|
138
|
+
* `orphans`.
|
|
139
|
+
*/
|
|
140
|
+
links?: boolean;
|
|
118
141
|
};
|
|
119
142
|
export type ContentHealthPluginOptions = {
|
|
120
143
|
/** Collection slugs and the checks to run on each. */
|
|
121
144
|
collections: Record<string, CollectionHealthOptions>;
|
|
145
|
+
/** Global slugs and the checks to run on each, such as a header's menu links. */
|
|
146
|
+
globals?: Record<string, GlobalHealthOptions>;
|
|
122
147
|
disabled?: boolean;
|
|
123
148
|
/**
|
|
124
149
|
* Put the widget on the default dashboard, below the collection cards, when the project sets no
|
package/dist/types.js
CHANGED
|
@@ -40,6 +40,7 @@ export async function ContentHealthView({ initPageResult, params, searchParams,
|
|
|
40
40
|
const batchAsk = {};
|
|
41
41
|
const rows = sortFindings(result.findings).map((finding, index) => {
|
|
42
42
|
const detail = findingDetail(language, finding);
|
|
43
|
+
const ask = ASK[language][finding.check];
|
|
43
44
|
const batchable = Boolean(BATCH_CHECKS[finding.check]) && inBatches.has(finding.collection);
|
|
44
45
|
if (batchable && !batchAsk[finding.check]) {
|
|
45
46
|
batchAsk[finding.check] = BATCH_ASK[language][finding.check]?.(finding);
|
|
@@ -54,8 +55,8 @@ export async function ContentHealthView({ initPageResult, params, searchParams,
|
|
|
54
55
|
href: editURL(adminRoute, finding),
|
|
55
56
|
...(finding.locale ? { locale: finding.locale } : {}),
|
|
56
57
|
...(detail ? { detail } : {}),
|
|
57
|
-
...(withAssistant.has(finding.collection)
|
|
58
|
-
? { askHref: editURL(adminRoute, finding,
|
|
58
|
+
...(ask && !finding.global && withAssistant.has(finding.collection)
|
|
59
|
+
? { askHref: editURL(adminRoute, finding, ask(finding)) }
|
|
59
60
|
: {}),
|
|
60
61
|
...(batchable ? { batchTarget: batchTarget(finding) } : {}),
|
|
61
62
|
};
|
|
@@ -82,7 +83,8 @@ export async function ContentHealthView({ initPageResult, params, searchParams,
|
|
|
82
83
|
const failing = stats.reduce((sum, stat) => sum + stat.failing, 0);
|
|
83
84
|
const collections = Object.keys(result.documents).map((slug) => ({
|
|
84
85
|
value: slug,
|
|
85
|
-
label: label(config.collections.find((collection) => collection.slug === slug)?.labels?.plural
|
|
86
|
+
label: label(config.collections.find((collection) => collection.slug === slug)?.labels?.plural ??
|
|
87
|
+
config.globals.find((global) => global.slug === slug)?.label, language) ?? slug,
|
|
86
88
|
}));
|
|
87
89
|
const locales = config.localization
|
|
88
90
|
? config.localization.locales.map((locale) => ({
|
package/package.json
CHANGED