@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 +14 -0
- package/README.md +14 -0
- package/dist/copy.d.ts +5 -1
- package/dist/copy.js +28 -2
- package/dist/links.js +3 -1
- package/dist/scan.js +74 -2
- package/dist/translations.d.ts +22 -0
- package/dist/translations.js +120 -0
- package/dist/types.d.ts +11 -1
- package/dist/types.js +1 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED