@simmalugnt-se/payload-content-health 0.6.1 → 0.7.1

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