@simmalugnt-se/payload-content-health 0.2.1 → 0.4.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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - New check `content.untranslated`, turned on per collection with `translations: true`: localized
6
+ text, textarea and rich text fields that are filled in the default language and empty in
7
+ another. One finding per document and language, at the first such field, with how many there are
8
+ (`untranslated: { count, source }`). "Fix with the assistant" asks it to translate the empty
9
+ fields from the default language ("Översätt de tomma fälten från engelska.").
10
+
11
+ ## 0.3.0
12
+
13
+ - "Fix with the assistant" on a missing meta title or description says how long to write it
14
+ ("Write a meta title for this page, 50–60 characters."), from the same range as the length check.
15
+ Without it the assistant wrote titles well under the range. Findings for a missing meta title or
16
+ description carry that range as `range`.
17
+
3
18
  ## 0.2.1
4
19
 
5
20
  - The work list at `/admin/content-health` requires Admin access. Payload leaves custom Admin views
package/README.md CHANGED
@@ -50,6 +50,7 @@ Requires Payload `>=3.90.2 <4`, Next 16 and React 19. The widget uses Payload's
50
50
  | `content.h1-multiple` | `content.headings` | More than one H1 |
51
51
  | `content.heading-skip` | `content.headings` | A heading level skipped, e.g. H2 → H4 |
52
52
  | `content.thin` | `content` | Fewer than 300 words on the page |
53
+ | `content.untranslated` | `translations` | Localized text empty here, filled in default |
53
54
 
54
55
  `seo: true` reads the fields `@payloadcms/plugin-seo` adds (`meta.title`, `meta.description`,
55
56
  `meta.image`) with its default lengths. Point them elsewhere or turn single checks off:
@@ -87,6 +88,19 @@ pages: {
87
88
 
88
89
  Each page gets at most one finding per check: the first skipped level, the second H1.
89
90
 
91
+ ### Translations
92
+
93
+ `translations: true` compares every language with the default one (Payload's
94
+ `localization.defaultLocale`): a localized text, textarea or rich text field that is filled there
95
+ and empty here is not translated. Each document gets one finding per language, at the first such
96
+ field, saying how many there are ("5 fields not translated"). With the editor assistant, "Fix with
97
+ the assistant" asks it to translate the empty fields from the default language. Fields that are not
98
+ localized, and blocks whose type differs between the languages, are left out.
99
+
100
+ ```ts
101
+ pages: { seo: true, translations: true },
102
+ ```
103
+
90
104
  Checks read the latest version of each document, drafts included, with the signed-in user's access.
91
105
  Localized fields are checked in every locale without fallback, so a missing Swedish alt text is
92
106
  reported even when the English one exists.
@@ -115,9 +129,10 @@ or block the unsaved form no longer has opens the nearest enclosing field instea
115
129
 
116
130
  With `@simmalugnt-se/payload-editor-assistant` (0.5.0 or later), rows in the collections the
117
131
  assistant works in also get "Fix with the assistant": the same link with a ready prompt that the
118
- assistant puts in its message box. The editor sends it. To write a missing alt text, the assistant
119
- needs to see the image: allow `media.view` on the media collection (0.6.0 or later). The packages
120
- do not import each other; they share these names:
132
+ assistant puts in its message box, with the length to aim for when a meta title or description is
133
+ missing or off. The editor sends it. To write a missing alt text, the assistant needs to see the
134
+ image: allow `media.view` on the media collection (0.6.0 or later). The packages do not import each
135
+ other; they share these names:
121
136
 
122
137
  | Name | Direction | Meaning |
123
138
  | -------------------------------------------- | ----------------------- | ---------------------------------------- |
@@ -137,6 +152,8 @@ import { scanContentHealth } from "@simmalugnt-se/payload-content-health";
137
152
  const { findings, problems } = await scanContentHealth({ payload, user: req.user });
138
153
  ```
139
154
 
140
- Each finding is `{ check, collection, id, title, path, locale?, length?, heading?, words?, status? }`,
141
- where `path` is the form path with row indexes (`layout.1.items.0.title`). `problems`
142
- lists configured checks that could not run, such as a field path that does not exist.
155
+ Each finding is
156
+ `{ check, collection, id, title, path, locale?, length?, range?, heading?, words?, status? }`, where
157
+ `path` is the form path with row indexes (`layout.1.items.0.title`) and `range` is the length a
158
+ missing meta title or description should have. `problems` lists configured checks that could not
159
+ run, such as a field path that does not exist.
package/dist/checks.d.ts CHANGED
@@ -19,6 +19,7 @@ export type FieldRule = {
19
19
  kind: "required";
20
20
  path: string;
21
21
  imagesOnly?: boolean;
22
+ range?: LengthRange;
22
23
  } | {
23
24
  check: CheckId;
24
25
  kind: "length";
package/dist/checks.js CHANGED
@@ -25,13 +25,23 @@ export function rulesFor(options) {
25
25
  const titleLength = seo.titleLength ?? SEO_DEFAULTS.titleLength;
26
26
  const descriptionLength = seo.descriptionLength ?? SEO_DEFAULTS.descriptionLength;
27
27
  if (title) {
28
- rules.push({ check: "seo.title-missing", kind: "required", path: title });
28
+ rules.push({
29
+ check: "seo.title-missing",
30
+ kind: "required",
31
+ path: title,
32
+ ...(titleLength ? { range: titleLength } : {}),
33
+ });
29
34
  if (titleLength) {
30
35
  rules.push({ check: "seo.title-length", kind: "length", path: title, range: titleLength });
31
36
  }
32
37
  }
33
38
  if (description) {
34
- rules.push({ check: "seo.description-missing", kind: "required", path: description });
39
+ rules.push({
40
+ check: "seo.description-missing",
41
+ kind: "required",
42
+ path: description,
43
+ ...(descriptionLength ? { range: descriptionLength } : {}),
44
+ });
35
45
  if (descriptionLength) {
36
46
  rules.push({
37
47
  check: "seo.description-length",
@@ -105,7 +115,8 @@ export function checkDocument(input) {
105
115
  }
106
116
  checked.push(rule.check);
107
117
  if (isEmpty(value)) {
108
- findings.push(base);
118
+ // The assistant writes to the length the length check will expect.
119
+ findings.push(rule.range ? { ...base, range: rule.range } : base);
109
120
  }
110
121
  continue;
111
122
  }
package/dist/copy.d.ts CHANGED
@@ -22,6 +22,7 @@ export declare const COPY: {
22
22
  readonly length: (actual: number, min: number, max: number) => string;
23
23
  readonly headingSkip: (previous: number, level: number) => string;
24
24
  readonly h1Count: (count: number) => string;
25
+ readonly untranslated: (count: number) => string;
25
26
  readonly words: (actual: number, min: number) => string;
26
27
  readonly problems: "Some checks could not run:";
27
28
  };
@@ -38,6 +39,7 @@ export declare const COPY: {
38
39
  readonly length: (actual: number, min: number, max: number) => string;
39
40
  readonly headingSkip: (previous: number, level: number) => string;
40
41
  readonly h1Count: (count: number) => string;
42
+ readonly untranslated: (count: number) => string;
41
43
  readonly words: (actual: number, min: number) => string;
42
44
  readonly problems: "Några kontroller kunde inte köras:";
43
45
  };
@@ -85,6 +87,8 @@ export declare const LIST_COPY: {
85
87
  };
86
88
  };
87
89
  export type ListCopy = (typeof LIST_COPY)[UiLanguage];
88
- /** A finding's detail: its length, word count or heading levels, when it has one. */
90
+ /** A finding's detail: its length, word count, heading levels or untranslated fields, if any. */
89
91
  export declare function findingDetail(language: UiLanguage, finding: Finding): string | undefined;
92
+ /** A locale code as a language name in Admin's language: "engelska", "English". */
93
+ export declare function languageName(language: UiLanguage, code: string): string;
90
94
  export {};
package/dist/copy.js CHANGED
@@ -13,6 +13,7 @@ export const CHECK_LABELS = {
13
13
  "content.h1-multiple": "More than one H1",
14
14
  "content.heading-skip": "Skipped heading levels",
15
15
  "content.thin": "Thin content",
16
+ "content.untranslated": "Not translated",
16
17
  },
17
18
  sv: {
18
19
  "media.alt-missing": "Alt-text saknas",
@@ -25,6 +26,7 @@ export const CHECK_LABELS = {
25
26
  "content.h1-multiple": "Fler än en H1",
26
27
  "content.heading-skip": "Överhoppade rubriknivåer",
27
28
  "content.thin": "Tunt innehåll",
29
+ "content.untranslated": "Saknar översättning",
28
30
  },
29
31
  };
30
32
  /** Tile headings: what is measured, so "0 of 4 filled in" reads without a double negative. */
@@ -40,6 +42,7 @@ export const TILE_LABELS = {
40
42
  "content.h1-multiple": "Single main heading",
41
43
  "content.heading-skip": "Heading order",
42
44
  "content.thin": "Page length",
45
+ "content.untranslated": "Translations",
43
46
  },
44
47
  sv: {
45
48
  "media.alt-missing": "Alt-texter",
@@ -52,6 +55,7 @@ export const TILE_LABELS = {
52
55
  "content.h1-multiple": "En huvudrubrik",
53
56
  "content.heading-skip": "Rubrikordning",
54
57
  "content.thin": "Sidlängd",
58
+ "content.untranslated": "Översättningar",
55
59
  },
56
60
  };
57
61
  /** A tile's "x of y" line, phrased as what is in order for that check. */
@@ -67,6 +71,7 @@ export const STATUS = {
67
71
  "content.h1-multiple": (ok, total) => `${ok} of ${total} pages have at most one`,
68
72
  "content.heading-skip": (ok, total) => `${ok} of ${total} pages in order`,
69
73
  "content.thin": (ok, total) => `${ok} of ${total} pages long enough`,
74
+ "content.untranslated": (ok, total) => `${ok} of ${total} translated`,
70
75
  },
71
76
  sv: {
72
77
  "media.alt-missing": (ok, total) => `${ok} av ${total} ifyllda`,
@@ -79,6 +84,7 @@ export const STATUS = {
79
84
  "content.h1-multiple": (ok, total) => `${ok} av ${total} sidor har högst en`,
80
85
  "content.heading-skip": (ok, total) => `${ok} av ${total} sidor i ordning`,
81
86
  "content.thin": (ok, total) => `${ok} av ${total} sidor tillräckligt långa`,
87
+ "content.untranslated": (ok, total) => `${ok} av ${total} översatta`,
82
88
  },
83
89
  };
84
90
  export const COPY = {
@@ -95,6 +101,7 @@ export const COPY = {
95
101
  length: (actual, min, max) => `${actual} characters, aim for ${min}–${max}`,
96
102
  headingSkip: (previous, level) => `H${previous} → H${level}`,
97
103
  h1Count: (count) => `${count} H1s`,
104
+ untranslated: (count) => `${count} ${count === 1 ? "field" : "fields"} not translated`,
98
105
  words: (actual, min) => `${actual} ${actual === 1 ? "word" : "words"}, aim for ${min}+`,
99
106
  problems: "Some checks could not run:",
100
107
  },
@@ -111,6 +118,7 @@ export const COPY = {
111
118
  length: (actual, min, max) => `${actual} tecken, sikta på ${min}–${max}`,
112
119
  headingSkip: (previous, level) => `H${previous} → H${level}`,
113
120
  h1Count: (count) => `${count} H1:or`,
121
+ untranslated: (count) => `${count} fält saknar översättning`,
114
122
  words: (actual, min) => `${actual} ord, sikta på minst ${min}`,
115
123
  problems: "Några kontroller kunde inte köras:",
116
124
  },
@@ -122,11 +130,15 @@ export const COPY = {
122
130
  export const ASK = {
123
131
  en: {
124
132
  "media.alt-missing": () => "Write an alt text for this image.",
125
- "seo.title-missing": () => "Write a meta title for this page.",
133
+ "seo.title-missing": ({ range }) => range
134
+ ? `Write a meta title for this page, ${range.min}–${range.max} characters.`
135
+ : "Write a meta title for this page.",
126
136
  "seo.title-length": ({ length }) => length
127
137
  ? `The meta title is ${length.actual} characters. Rewrite it to ${length.min}–${length.max} characters.`
128
138
  : "Rewrite the meta title to a better length.",
129
- "seo.description-missing": () => "Write a meta description for this page.",
139
+ "seo.description-missing": ({ range }) => range
140
+ ? `Write a meta description for this page, ${range.min}–${range.max} characters.`
141
+ : "Write a meta description for this page.",
130
142
  "seo.description-length": ({ length }) => length
131
143
  ? `The meta description is ${length.actual} characters. Rewrite it to ${length.min}–${length.max} characters.`
132
144
  : "Rewrite the meta description to a better length.",
@@ -139,14 +151,21 @@ export const ASK = {
139
151
  "content.thin": ({ words }) => words
140
152
  ? `This page has ${words.actual} words. Suggest how to expand the text to at least ${words.min} words.`
141
153
  : "This page has little text. Suggest how to expand it.",
154
+ "content.untranslated": ({ untranslated }) => untranslated
155
+ ? `Translate the empty fields from ${languageName("en", untranslated.source)}.`
156
+ : "Translate the empty fields.",
142
157
  },
143
158
  sv: {
144
159
  "media.alt-missing": () => "Skriv en alt-text för den här bilden.",
145
- "seo.title-missing": () => "Skriv en metatitel för den här sidan.",
160
+ "seo.title-missing": ({ range }) => range
161
+ ? `Skriv en metatitel för den här sidan, ${range.min}–${range.max} tecken.`
162
+ : "Skriv en metatitel för den här sidan.",
146
163
  "seo.title-length": ({ length }) => length
147
164
  ? `Metatiteln är ${length.actual} tecken. Skriv om den till ${length.min}–${length.max} tecken.`
148
165
  : "Skriv om metatiteln till en bättre längd.",
149
- "seo.description-missing": () => "Skriv en metabeskrivning för den här sidan.",
166
+ "seo.description-missing": ({ range }) => range
167
+ ? `Skriv en metabeskrivning för den här sidan, ${range.min}–${range.max} tecken.`
168
+ : "Skriv en metabeskrivning för den här sidan.",
150
169
  "seo.description-length": ({ length }) => length
151
170
  ? `Metabeskrivningen är ${length.actual} tecken. Skriv om den till ${length.min}–${length.max} tecken.`
152
171
  : "Skriv om metabeskrivningen till en bättre längd.",
@@ -159,6 +178,9 @@ export const ASK = {
159
178
  "content.thin": ({ words }) => words
160
179
  ? `Sidan har ${words.actual} ord. Föreslå hur jag bygger ut texten till minst ${words.min} ord.`
161
180
  : "Sidan har lite text. Föreslå hur jag bygger ut den.",
181
+ "content.untranslated": ({ untranslated }) => untranslated
182
+ ? `Översätt de tomma fälten från ${languageName("sv", untranslated.source)}.`
183
+ : "Översätt de tomma fälten.",
162
184
  },
163
185
  };
164
186
  export const LIST_COPY = {
@@ -197,10 +219,13 @@ export const LIST_COPY = {
197
219
  noneFiltered: "Inga problem matchar filtren.",
198
220
  },
199
221
  };
200
- /** A finding's detail: its length, word count or heading levels, when it has one. */
222
+ /** A finding's detail: its length, word count, heading levels or untranslated fields, if any. */
201
223
  export function findingDetail(language, finding) {
202
224
  const copy = COPY[language];
203
- const { heading, length, words } = finding;
225
+ const { heading, length, untranslated, words } = finding;
226
+ if (untranslated) {
227
+ return copy.untranslated(untranslated.count);
228
+ }
204
229
  return length
205
230
  ? copy.length(length.actual, length.min, length.max)
206
231
  : words
@@ -211,3 +236,12 @@ export function findingDetail(language, finding) {
211
236
  ? copy.h1Count(heading.count)
212
237
  : undefined;
213
238
  }
239
+ /** A locale code as a language name in Admin's language: "engelska", "English". */
240
+ export function languageName(language, code) {
241
+ try {
242
+ return new Intl.DisplayNames([language], { type: "language" }).of(code) ?? code;
243
+ }
244
+ catch {
245
+ return code;
246
+ }
247
+ }
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 { checkTranslation } from "./translations.js";
3
4
  import { CHECK_IDS } from "./types.js";
4
5
  /** Where the plugin keeps its options in the (server-only) `config.custom`. */
5
6
  export const CUSTOM_KEY = "contentHealth";
@@ -66,7 +67,10 @@ export async function scanContentHealth({ payload, user, checks }) {
66
67
  });
67
68
  mergeSelect(select, info.select);
68
69
  }
69
- if (!rules.length) {
70
+ const translate = Boolean(collectionOptions.translations) &&
71
+ locales.length > 1 &&
72
+ (!checks?.length || checks.includes("content.untranslated"));
73
+ if (!rules.length && !translate) {
70
74
  continue;
71
75
  }
72
76
  const useAsTitle = collection.admin?.useAsTitle;
@@ -82,7 +86,7 @@ export async function scanContentHealth({ payload, user, checks }) {
82
86
  }
83
87
  const anyLocalized = rules.some((rule) => rule.localized);
84
88
  for (const [index, locale] of locales.entries()) {
85
- if (index > 0 && !anyLocalized) {
89
+ if (!rules.length || (index > 0 && !anyLocalized)) {
86
90
  break;
87
91
  }
88
92
  try {
@@ -118,7 +122,75 @@ export async function scanContentHealth({ payload, user, checks }) {
118
122
  break;
119
123
  }
120
124
  }
125
+ if (translate) {
126
+ ran.add("content.untranslated");
127
+ try {
128
+ await scanTranslations({
129
+ payload,
130
+ user,
131
+ slug,
132
+ fields,
133
+ resolveBlock,
134
+ drafts,
135
+ useAsTitle,
136
+ locales: locales,
137
+ result,
138
+ });
139
+ }
140
+ catch (error) {
141
+ result.problems.push(`${slug}: ${error instanceof Error ? error.message : String(error)}`);
142
+ }
143
+ }
121
144
  }
122
145
  result.checks = CHECK_IDS.filter((check) => ran.has(check));
123
146
  return result;
124
147
  }
148
+ /** Compares every other language with the default one, document by document. */
149
+ async function scanTranslations(input) {
150
+ const { payload, slug, result } = input;
151
+ const localization = payload.config.localization;
152
+ const sourceLocale = localization ? localization.defaultLocale : undefined;
153
+ if (!sourceLocale) {
154
+ return;
155
+ }
156
+ // Without fallback, so a field nobody translated reads as empty.
157
+ const read = async (locale) => (await payload.find({
158
+ collection: slug,
159
+ depth: 0,
160
+ draft: input.drafts,
161
+ fallbackLocale: false,
162
+ locale,
163
+ overrideAccess: false,
164
+ pagination: false,
165
+ user: input.user,
166
+ })).docs;
167
+ const sources = new Map((await read(sourceLocale)).map((doc) => [String(doc.id), doc]));
168
+ for (const locale of input.locales) {
169
+ if (locale === sourceLocale) {
170
+ continue;
171
+ }
172
+ for (const target of await read(locale)) {
173
+ const source = sources.get(String(target.id));
174
+ if (!source) {
175
+ continue;
176
+ }
177
+ const title = documentTitle(target, input.useAsTitle) === String(target.id)
178
+ ? documentTitle(source, input.useAsTitle)
179
+ : documentTitle(target, input.useAsTitle);
180
+ const { findings, checked } = checkTranslation({
181
+ collection: slug,
182
+ title,
183
+ locale,
184
+ sourceLocale,
185
+ fields: input.fields,
186
+ resolveBlock: input.resolveBlock,
187
+ source,
188
+ target,
189
+ });
190
+ result.findings.push(...findings);
191
+ for (const check of checked) {
192
+ result.checked[check] = (result.checked[check] ?? 0) + 1;
193
+ }
194
+ }
195
+ }
196
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Missing translations: localized text that is filled in the default language and empty in another.
3
+ * Both versions are read from the database without fallback, so an empty field reads as empty.
4
+ */
5
+ import type { DocumentResult } from "./checks.ts";
6
+ import type { BlockLike, FieldLike } from "./fields.ts";
7
+ /** Paths, with array and block indexes, of localized text filled in `source` and empty in `target`. */
8
+ export declare function untranslatedPaths(fields: FieldLike[], resolveBlock: (slug: string) => BlockLike | undefined, source: Record<string, unknown>, target: Record<string, unknown>): string[];
9
+ export type TranslationInput = {
10
+ collection: string;
11
+ title: string;
12
+ locale: string;
13
+ sourceLocale: string;
14
+ fields: FieldLike[];
15
+ resolveBlock: (slug: string) => BlockLike | undefined;
16
+ /** The document in the default language. */
17
+ source: Record<string, unknown>;
18
+ /** The same document in `locale`. */
19
+ target: Record<string, unknown>;
20
+ };
21
+ /** One finding per document and language, pointing at the first field that is not translated. */
22
+ export declare function checkTranslation(input: TranslationInput): DocumentResult;
@@ -0,0 +1,120 @@
1
+ const TEXT_TYPES = new Set(["text", "textarea", "richText"]);
2
+ /** Paths, with array and block indexes, of localized text filled in `source` and empty in `target`. */
3
+ export function untranslatedPaths(fields, resolveBlock, source, target) {
4
+ const walk = { resolveBlock, paths: [] };
5
+ walkFields(fields, source, target, "", false, walk);
6
+ return walk.paths;
7
+ }
8
+ /** One finding per document and language, pointing at the first field that is not translated. */
9
+ export function checkTranslation(input) {
10
+ const { target } = input;
11
+ const paths = untranslatedPaths(input.fields, input.resolveBlock, input.source, target);
12
+ const status = target._status === "draft" || target._status === "published" ? target._status : undefined;
13
+ const findings = paths[0]
14
+ ? [
15
+ {
16
+ check: "content.untranslated",
17
+ collection: input.collection,
18
+ id: target.id,
19
+ title: input.title,
20
+ path: paths[0],
21
+ locale: input.locale,
22
+ untranslated: { count: paths.length, source: input.sourceLocale },
23
+ ...(status ? { status } : {}),
24
+ },
25
+ ]
26
+ : [];
27
+ return { findings, checked: ["content.untranslated"] };
28
+ }
29
+ function walkFields(fields, source, target, prefix, localized, walk) {
30
+ for (const field of fields) {
31
+ if (field.type === "tabs") {
32
+ for (const tab of field.tabs ?? []) {
33
+ if (tab.name) {
34
+ walkField({ ...tab, type: "group" }, source, target, prefix, localized, walk);
35
+ }
36
+ else {
37
+ walkFields(tab.fields ?? [], source, target, prefix, localized, walk);
38
+ }
39
+ }
40
+ continue;
41
+ }
42
+ if (!field.name) {
43
+ // Rows and collapsibles add no segment.
44
+ walkFields(field.fields ?? [], source, target, prefix, localized, walk);
45
+ continue;
46
+ }
47
+ walkField(field, source, target, prefix, localized, walk);
48
+ }
49
+ }
50
+ function walkField(field, source, target, prefix, inherited, walk) {
51
+ const name = field.name;
52
+ const path = prefix ? `${prefix}.${name}` : name;
53
+ const localized = inherited || Boolean(field.localized);
54
+ const from = child(source, name);
55
+ const to = child(target, name);
56
+ if (field.type && TEXT_TYPES.has(field.type)) {
57
+ if (localized && hasText(from) && !hasText(to)) {
58
+ walk.paths.push(path);
59
+ }
60
+ return;
61
+ }
62
+ if (field.type === "group") {
63
+ walkFields(field.fields ?? [], from, to, path, localized, walk);
64
+ return;
65
+ }
66
+ if (field.type !== "array" && field.type !== "blocks") {
67
+ return;
68
+ }
69
+ const sourceRows = Array.isArray(from) ? from : [];
70
+ const targetRows = Array.isArray(to) ? to : [];
71
+ // A localized list that is empty here is one untranslated field; its rows do not exist yet.
72
+ if (field.localized && sourceRows.length > 0 && targetRows.length === 0) {
73
+ walk.paths.push(path);
74
+ return;
75
+ }
76
+ for (const [index, sourceRow] of sourceRows.entries()) {
77
+ const targetRow = targetRows[index];
78
+ if (!targetRow || typeof targetRow !== "object") {
79
+ continue;
80
+ }
81
+ const rowPath = `${path}.${index}`;
82
+ if (field.type === "array") {
83
+ walkFields(field.fields ?? [], sourceRow, targetRow, rowPath, localized, walk);
84
+ continue;
85
+ }
86
+ const type = sourceRow.blockType;
87
+ if (typeof type !== "string" || targetRow.blockType !== type) {
88
+ continue;
89
+ }
90
+ const block = blockFor(field, type, walk.resolveBlock);
91
+ if (block) {
92
+ walkFields(block.fields, sourceRow, targetRow, rowPath, localized, walk);
93
+ }
94
+ }
95
+ }
96
+ function blockFor(field, slug, resolveBlock) {
97
+ const inline = field.blocks?.find((block) => block.slug === slug);
98
+ if (inline) {
99
+ return inline;
100
+ }
101
+ const reference = field.blockReferences?.find((block) => typeof block === "string" ? block === slug : block.slug === slug);
102
+ return typeof reference === "string" ? resolveBlock(reference) : reference;
103
+ }
104
+ function child(value, name) {
105
+ return value && typeof value === "object" ? value[name] : undefined;
106
+ }
107
+ /** Text with something in it: a non-blank string, or rich text with a non-blank text node. */
108
+ function hasText(value) {
109
+ if (typeof value === "string") {
110
+ return value.trim().length > 0;
111
+ }
112
+ const root = value?.root;
113
+ return root ? nodeHasText(root) : false;
114
+ }
115
+ function nodeHasText(node) {
116
+ if (typeof node.text === "string" && node.text.trim()) {
117
+ return true;
118
+ }
119
+ return Array.isArray(node.children) && node.children.some(nodeHasText);
120
+ }
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"];
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"];
2
2
  export type CheckId = (typeof CHECK_IDS)[number];
3
3
  export type LengthRange = {
4
4
  min: number;
@@ -19,6 +19,8 @@ export type Finding = {
19
19
  length?: LengthRange & {
20
20
  actual: number;
21
21
  };
22
+ /** For a missing meta title or description: the length its length check expects. */
23
+ range?: LengthRange;
22
24
  /** For heading checks: the heading's level and, for a skip, the level before it. */
23
25
  heading?: {
24
26
  level: number;
@@ -30,6 +32,11 @@ export type Finding = {
30
32
  actual: number;
31
33
  min: number;
32
34
  };
35
+ /** For a missing translation: how many fields are empty here, and the language they come from. */
36
+ untranslated?: {
37
+ count: number;
38
+ source: string;
39
+ };
33
40
  /** Draft status of the checked version, for collections with drafts. */
34
41
  status?: "draft" | "published";
35
42
  };
@@ -70,6 +77,11 @@ export type CollectionHealthOptions = {
70
77
  altText?: boolean | AltTextOptions;
71
78
  /** Heading structure and thin content, read from the page body. */
72
79
  content?: ContentOptions;
80
+ /**
81
+ * Localized text, textarea and rich text fields that are empty in a language but filled in the
82
+ * default one. One finding per document and language. Needs Payload's `localization`.
83
+ */
84
+ translations?: boolean;
73
85
  };
74
86
  export type ContentHealthPluginOptions = {
75
87
  /** Collection slugs and the checks to run on each. */
package/dist/types.js CHANGED
@@ -9,4 +9,5 @@ export const CHECK_IDS = [
9
9
  "content.h1-multiple",
10
10
  "content.heading-skip",
11
11
  "content.thin",
12
+ "content.untranslated",
12
13
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-content-health",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",