@simmalugnt-se/payload-content-health 0.3.0 → 0.4.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ - Signing in from the work list leads back to it. The login link's `redirect` lacked the Admin route
6
+ (`/content-health` instead of `/admin/content-health`), so Payload sent the editor to a page on
7
+ the site that does not exist.
8
+
9
+ ## 0.4.0
10
+
11
+ - New check `content.untranslated`, turned on per collection with `translations: true`: localized
12
+ text, textarea and rich text fields that are filled in the default language and empty in
13
+ another. One finding per document and language, at the first such field, with how many there are
14
+ (`untranslated: { count, source }`). "Fix with the assistant" asks it to translate the empty
15
+ fields from the default language ("Översätt de tomma fälten från engelska.").
16
+
3
17
  ## 0.3.0
4
18
 
5
19
  - "Fix with the assistant" on a missing meta title or description says how long to write it
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.
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
  },
@@ -143,6 +151,9 @@ export const ASK = {
143
151
  "content.thin": ({ words }) => words
144
152
  ? `This page has ${words.actual} words. Suggest how to expand the text to at least ${words.min} words.`
145
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.",
146
157
  },
147
158
  sv: {
148
159
  "media.alt-missing": () => "Skriv en alt-text för den här bilden.",
@@ -167,6 +178,9 @@ export const ASK = {
167
178
  "content.thin": ({ words }) => words
168
179
  ? `Sidan har ${words.actual} ord. Föreslå hur jag bygger ut texten till minst ${words.min} ord.`
169
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.",
170
184
  },
171
185
  };
172
186
  export const LIST_COPY = {
@@ -205,10 +219,13 @@ export const LIST_COPY = {
205
219
  noneFiltered: "Inga problem matchar filtren.",
206
220
  },
207
221
  };
208
- /** 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. */
209
223
  export function findingDetail(language, finding) {
210
224
  const copy = COPY[language];
211
- const { heading, length, words } = finding;
225
+ const { heading, length, untranslated, words } = finding;
226
+ if (untranslated) {
227
+ return copy.untranslated(untranslated.count);
228
+ }
212
229
  return length
213
230
  ? copy.length(length.actual, length.min, length.max)
214
231
  : words
@@ -219,3 +236,12 @@ export function findingDetail(language, finding) {
219
236
  ? copy.h1Count(heading.count)
220
237
  : undefined;
221
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/links.js CHANGED
@@ -48,7 +48,9 @@ export function accessRedirect(args) {
48
48
  query.append(key, item);
49
49
  }
50
50
  }
51
- const back = `${VIEW_PATH}${query.size ? `?${query}` : ""}`;
51
+ // Payload's login goes to `redirect` as it is, so it is the full path, Admin route included.
52
+ const view = formatAdminURL({ adminRoute: routes.admin, path: VIEW_PATH });
53
+ const back = `${view}${query.size ? `?${query}` : ""}`;
52
54
  const login = formatAdminURL({ adminRoute: routes.admin, path: routes.login });
53
55
  return `${login}?${new URLSearchParams({ redirect: back })}`;
54
56
  }
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;
@@ -32,6 +32,11 @@ export type Finding = {
32
32
  actual: number;
33
33
  min: number;
34
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
+ };
35
40
  /** Draft status of the checked version, for collections with drafts. */
36
41
  status?: "draft" | "published";
37
42
  };
@@ -72,6 +77,11 @@ export type CollectionHealthOptions = {
72
77
  altText?: boolean | AltTextOptions;
73
78
  /** Heading structure and thin content, read from the page body. */
74
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;
75
85
  };
76
86
  export type ContentHealthPluginOptions = {
77
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.3.0",
3
+ "version": "0.4.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",