@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 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 and thin content. No AI; every finding points at one field of one document, in one
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` | Internal links to deleted or unpublished pages |
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? }`, where
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, collectionSlug });
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, collectionSlug };
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, collectionSlug } = latest.current;
53
+ const { form, config, getEntityConfig, entity } = latest.current;
53
54
  const fields = form.getFields();
54
- const rootFields = collectionSlug
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. Wait for the whole path before settling for less.
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 ? attempt : null;
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: { type: "string", description: "Collection slug, such as pages or images." },
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
- : undefined;
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) ? "assistant" : "by hand";
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);
@@ -16,6 +16,7 @@ export const CATEGORY_CHECKS = {
16
16
  "content.heading-skip",
17
17
  "content.thin",
18
18
  "links.broken",
19
+ "links.orphan",
19
20
  ],
20
21
  translations: ["content.untranslated"],
21
22
  accessibility: ["media.alt-missing", "media.video-description-missing"],
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
- /** Collects the fields in config order; returns whether any of them is localized. */
90
- function collectTextFields(fields, prefix, out) {
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
- /** The document in the finding's locale with its field brought into view, and a prompt when given. */
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
- /** The document in the finding's locale with its field brought into view, and a prompt when given. */
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 = `/collections/${finding.collection}/${encodeURIComponent(String(finding.id))}`;
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
- params.set(FIELD_PARAM, finding.path);
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
- return `${formatAdminURL({ adminRoute, path })}?${params}`;
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
+ }
@@ -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[];
@@ -1,6 +1,6 @@
1
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`.
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
- for (const field of schema.blocks[blockType] ?? []) {
26
- if (field.kind === "text") {
27
- continue;
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
- 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
- }
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 { duplicateFindings, linkFindings, targetKey, } from "./site.js";
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
- 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", "content.untranslated"];
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
- /** One problem in one field of one document, in one locale when the field is localized. */
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
@@ -13,5 +13,6 @@ export const CHECK_IDS = [
13
13
  "content.heading-skip",
14
14
  "content.thin",
15
15
  "links.broken",
16
+ "links.orphan",
16
17
  "content.untranslated",
17
18
  ];
@@ -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, ASK[language][finding.check](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, language) ?? slug,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-content-health",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Content health checks and dashboard widgets for Payload Admin: alt texts, SEO fields and more",
5
5
  "keywords": [
6
6
  "payload",