@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 +15 -0
- package/README.md +23 -6
- package/dist/checks.d.ts +1 -0
- package/dist/checks.js +14 -3
- package/dist/copy.d.ts +5 -1
- package/dist/copy.js +40 -6
- package/dist/scan.js +74 -2
- package/dist/translations.d.ts +22 -0
- package/dist/translations.js +120 -0
- package/dist/types.d.ts +13 -1
- package/dist/types.js +1 -0
- package/package.json +1 -1
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
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({
|
|
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({
|
|
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
|
-
|
|
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
|
|
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": () =>
|
|
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": () =>
|
|
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": () =>
|
|
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": () =>
|
|
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
|
|
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
|
-
|
|
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
package/package.json
CHANGED