@simmalugnt-se/payload-content-health 0.1.0 → 0.2.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 +36 -6
- package/dist/admin/ContentHealthFieldBridge.d.ts +8 -0
- package/dist/admin/ContentHealthFieldBridge.js +291 -0
- package/dist/admin/ContentHealthNavLink.d.ts +3 -0
- package/dist/admin/ContentHealthNavLink.js +19 -0
- package/dist/admin/reveal-plan.d.ts +50 -0
- package/dist/admin/reveal-plan.js +135 -0
- package/dist/admin/target.d.ts +9 -0
- package/dist/admin/target.js +15 -0
- package/dist/copy.d.ts +47 -2
- package/dist/copy.js +96 -0
- package/dist/exports/client.d.ts +3 -0
- package/dist/exports/client.js +3 -0
- package/dist/exports/rsc.d.ts +1 -0
- package/dist/exports/rsc.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/links.d.ts +37 -0
- package/dist/links.js +86 -0
- package/dist/package-name.d.ts +1 -0
- package/dist/package-name.js +1 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +41 -1
- package/dist/styles/admin.css +27 -0
- package/dist/styles/view.css +148 -0
- package/dist/styles/widget.css +21 -5
- package/dist/view/ContentHealthView.d.ts +8 -0
- package/dist/view/ContentHealthView.js +77 -0
- package/dist/view/WorkList.d.ts +34 -0
- package/dist/view/WorkList.js +26 -0
- package/dist/widget/ContentHealthWidget.js +15 -20
- package/package.json +16 -2
package/dist/copy.js
CHANGED
|
@@ -115,3 +115,99 @@ export const COPY = {
|
|
|
115
115
|
problems: "Några kontroller kunde inte köras:",
|
|
116
116
|
},
|
|
117
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* The ready prompt behind "Fix with the assistant", written as the editor would ask. The document is
|
|
120
|
+
* open in the finding's locale with the field selected, so the prompt does not name either.
|
|
121
|
+
*/
|
|
122
|
+
export const ASK = {
|
|
123
|
+
en: {
|
|
124
|
+
"media.alt-missing": () => "Write an alt text for this image.",
|
|
125
|
+
"seo.title-missing": () => "Write a meta title for this page.",
|
|
126
|
+
"seo.title-length": ({ length }) => length
|
|
127
|
+
? `The meta title is ${length.actual} characters. Rewrite it to ${length.min}–${length.max} characters.`
|
|
128
|
+
: "Rewrite the meta title to a better length.",
|
|
129
|
+
"seo.description-missing": () => "Write a meta description for this page.",
|
|
130
|
+
"seo.description-length": ({ length }) => length
|
|
131
|
+
? `The meta description is ${length.actual} characters. Rewrite it to ${length.min}–${length.max} characters.`
|
|
132
|
+
: "Rewrite the meta description to a better length.",
|
|
133
|
+
"seo.image-missing": () => "Pick a share image for this page.",
|
|
134
|
+
"content.h1-missing": () => "This page has no main heading (H1). Suggest how to add one that says what the page is about.",
|
|
135
|
+
"content.h1-multiple": () => "This page has more than one main heading (H1). Suggest how to keep only one.",
|
|
136
|
+
"content.heading-skip": ({ heading }) => heading?.previous !== undefined
|
|
137
|
+
? `The headings skip a level here (H${heading.previous} → H${heading.level}). Suggest how to fix the heading order.`
|
|
138
|
+
: "The headings skip a level here. Suggest how to fix the heading order.",
|
|
139
|
+
"content.thin": ({ words }) => words
|
|
140
|
+
? `This page has ${words.actual} words. Suggest how to expand the text to at least ${words.min} words.`
|
|
141
|
+
: "This page has little text. Suggest how to expand it.",
|
|
142
|
+
},
|
|
143
|
+
sv: {
|
|
144
|
+
"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.",
|
|
146
|
+
"seo.title-length": ({ length }) => length
|
|
147
|
+
? `Metatiteln är ${length.actual} tecken. Skriv om den till ${length.min}–${length.max} tecken.`
|
|
148
|
+
: "Skriv om metatiteln till en bättre längd.",
|
|
149
|
+
"seo.description-missing": () => "Skriv en metabeskrivning för den här sidan.",
|
|
150
|
+
"seo.description-length": ({ length }) => length
|
|
151
|
+
? `Metabeskrivningen är ${length.actual} tecken. Skriv om den till ${length.min}–${length.max} tecken.`
|
|
152
|
+
: "Skriv om metabeskrivningen till en bättre längd.",
|
|
153
|
+
"seo.image-missing": () => "Välj en delningsbild för den här sidan.",
|
|
154
|
+
"content.h1-missing": () => "Sidan saknar huvudrubrik (H1). Föreslå hur jag lägger till en som säger vad sidan handlar om.",
|
|
155
|
+
"content.h1-multiple": () => "Sidan har fler än en huvudrubrik (H1). Föreslå hur jag behåller bara en.",
|
|
156
|
+
"content.heading-skip": ({ heading }) => heading?.previous !== undefined
|
|
157
|
+
? `Rubrikerna hoppar över en nivå här (H${heading.previous} → H${heading.level}). Föreslå hur jag rättar rubrikordningen.`
|
|
158
|
+
: "Rubrikerna hoppar över en nivå här. Föreslå hur jag rättar rubrikordningen.",
|
|
159
|
+
"content.thin": ({ words }) => words
|
|
160
|
+
? `Sidan har ${words.actual} ord. Föreslå hur jag bygger ut texten till minst ${words.min} ord.`
|
|
161
|
+
: "Sidan har lite text. Föreslå hur jag bygger ut den.",
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
export const LIST_COPY = {
|
|
165
|
+
en: {
|
|
166
|
+
title: "Content health",
|
|
167
|
+
nav: "Content health",
|
|
168
|
+
intro: "Everything the checks found, in the latest version of each document, drafts included.",
|
|
169
|
+
summary: (findings, documents) => `${findings} ${findings === 1 ? "issue" : "issues"} in ${documents} ${documents === 1 ? "document" : "documents"}`,
|
|
170
|
+
check: "Check",
|
|
171
|
+
collection: "Collection",
|
|
172
|
+
locale: "Locale",
|
|
173
|
+
all: "All",
|
|
174
|
+
clear: "Clear filters",
|
|
175
|
+
document: "Document",
|
|
176
|
+
issue: "Issue",
|
|
177
|
+
draft: "Draft",
|
|
178
|
+
fix: "Fix with the assistant",
|
|
179
|
+
none: "No issues found.",
|
|
180
|
+
noneFiltered: "No issues match these filters.",
|
|
181
|
+
},
|
|
182
|
+
sv: {
|
|
183
|
+
title: "Innehållshälsa",
|
|
184
|
+
nav: "Innehållshälsa",
|
|
185
|
+
intro: "Allt kontrollerna hittade, i den senaste versionen av varje dokument, utkast inräknade.",
|
|
186
|
+
summary: (findings, documents) => `${findings} problem i ${documents} dokument`,
|
|
187
|
+
check: "Kontroll",
|
|
188
|
+
collection: "Samling",
|
|
189
|
+
locale: "Språk",
|
|
190
|
+
all: "Alla",
|
|
191
|
+
clear: "Rensa filter",
|
|
192
|
+
document: "Dokument",
|
|
193
|
+
issue: "Problem",
|
|
194
|
+
draft: "Utkast",
|
|
195
|
+
fix: "Åtgärda med assistenten",
|
|
196
|
+
none: "Inga problem hittades.",
|
|
197
|
+
noneFiltered: "Inga problem matchar filtren.",
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
/** A finding's detail: its length, word count or heading levels, when it has one. */
|
|
201
|
+
export function findingDetail(language, finding) {
|
|
202
|
+
const copy = COPY[language];
|
|
203
|
+
const { heading, length, words } = finding;
|
|
204
|
+
return length
|
|
205
|
+
? copy.length(length.actual, length.min, length.max)
|
|
206
|
+
: words
|
|
207
|
+
? copy.words(words.actual, words.min)
|
|
208
|
+
: heading?.previous !== undefined
|
|
209
|
+
? copy.headingSkip(heading.previous, heading.level)
|
|
210
|
+
: heading?.count
|
|
211
|
+
? copy.h1Count(heading.count)
|
|
212
|
+
: undefined;
|
|
213
|
+
}
|
package/dist/exports/rsc.d.ts
CHANGED
package/dist/exports/rsc.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ export type { Heading, PageContent } from "./content.ts";
|
|
|
4
4
|
export { countWords, readContent } from "./content.ts";
|
|
5
5
|
export type { CheckGroup, CheckStats, DocumentFindings } from "./group.ts";
|
|
6
6
|
export { checkStats, countDocuments, groupFindings } from "./group.ts";
|
|
7
|
-
export {
|
|
7
|
+
export type { ListFilters } from "./links.ts";
|
|
8
|
+
export { ASK_PARAM, ASSISTANT_CUSTOM_KEY, editURL, FIELD_PARAM, listURL, SELECT_EVENT, VIEW_PATH, } from "./links.ts";
|
|
9
|
+
export { contentHealthPlugin, VIEW_KEY, WIDGET_SLUG } from "./plugin.ts";
|
|
8
10
|
export type { ScanArgs, ScanResult } from "./scan.ts";
|
|
9
11
|
export { scanContentHealth } from "./scan.ts";
|
|
10
12
|
export type { AltTextOptions, CheckId, CollectionHealthOptions, ContentHealthPluginOptions, ContentOptions, Finding, HeadingLevels, LengthRange, SeoOptions, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { checkDocument, DEFAULT_MIN_WORDS, rulesFor, SEO_DEFAULTS } from "./checks.js";
|
|
2
2
|
export { countWords, readContent } from "./content.js";
|
|
3
3
|
export { checkStats, countDocuments, groupFindings } from "./group.js";
|
|
4
|
-
export {
|
|
4
|
+
export { ASK_PARAM, ASSISTANT_CUSTOM_KEY, editURL, FIELD_PARAM, listURL, SELECT_EVENT, VIEW_PATH, } from "./links.js";
|
|
5
|
+
export { contentHealthPlugin, VIEW_KEY, WIDGET_SLUG } from "./plugin.js";
|
|
5
6
|
export { scanContentHealth } from "./scan.js";
|
|
6
7
|
export { CHECK_IDS } from "./types.js";
|
package/dist/links.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type CheckId, type Finding } from "./types.ts";
|
|
2
|
+
/** Admin path of the work list. */
|
|
3
|
+
export declare const VIEW_PATH = "/content-health";
|
|
4
|
+
/**
|
|
5
|
+
* Edit view parameter, read by this plugin's field bridge: the form path to bring into view,
|
|
6
|
+
* focus and highlight, e.g. `meta.title` or `layout.1.items.0.title`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const FIELD_PARAM = "field";
|
|
9
|
+
/**
|
|
10
|
+
* Edit view parameter, read by `@simmalugnt-se/payload-editor-assistant`: a prompt to put in the
|
|
11
|
+
* assistant's message box. The assistant opens and fills it in; the editor sends it.
|
|
12
|
+
*/
|
|
13
|
+
export declare const ASK_PARAM = "ask";
|
|
14
|
+
/**
|
|
15
|
+
* Announced once the field bridge has brought a field into view: `detail: { path }`. The assistant
|
|
16
|
+
* takes it as the editor's selection, like a click in the visual editing preview.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SELECT_EVENT = "content-health:select";
|
|
19
|
+
/**
|
|
20
|
+
* Where `@simmalugnt-se/payload-editor-assistant` lists the collections it works in:
|
|
21
|
+
* `config.admin.custom.editorAssistant.collections`. The work list offers the assistant only there.
|
|
22
|
+
*/
|
|
23
|
+
export declare const ASSISTANT_CUSTOM_KEY = "editorAssistant";
|
|
24
|
+
export declare function assistantCollections(adminCustom: unknown): string[];
|
|
25
|
+
/** The document in the finding's locale with its field brought into view, and a prompt when given. */
|
|
26
|
+
export declare function editURL(adminRoute: string, finding: Finding, ask?: string): string;
|
|
27
|
+
export type ListFilters = {
|
|
28
|
+
check?: CheckId;
|
|
29
|
+
collection?: string;
|
|
30
|
+
locale?: string;
|
|
31
|
+
};
|
|
32
|
+
/** The work list, filtered. */
|
|
33
|
+
export declare function listURL(adminRoute: string, filters?: ListFilters): string;
|
|
34
|
+
export declare function filterQuery(filters: ListFilters): string;
|
|
35
|
+
/** Filters from the list's search params; unknown checks are dropped. */
|
|
36
|
+
export declare function readFilters(searchParams: Record<string, string | string[] | undefined> | undefined): ListFilters;
|
|
37
|
+
export declare function matchesFilters(finding: Pick<Finding, "check" | "collection" | "locale">, filters: ListFilters): boolean;
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the work list talks to the edit view and the editor assistant without importing either: URL
|
|
3
|
+
* parameters on the edit view's address and one `window` event. Pure, so the server components and
|
|
4
|
+
* the client bridge build and read the same addresses.
|
|
5
|
+
*/
|
|
6
|
+
import { formatAdminURL } from "payload/shared";
|
|
7
|
+
import { CHECK_IDS } from "./types.js";
|
|
8
|
+
/** Admin path of the work list. */
|
|
9
|
+
export const VIEW_PATH = "/content-health";
|
|
10
|
+
/**
|
|
11
|
+
* Edit view parameter, read by this plugin's field bridge: the form path to bring into view,
|
|
12
|
+
* focus and highlight, e.g. `meta.title` or `layout.1.items.0.title`.
|
|
13
|
+
*/
|
|
14
|
+
export const FIELD_PARAM = "field";
|
|
15
|
+
/**
|
|
16
|
+
* Edit view parameter, read by `@simmalugnt-se/payload-editor-assistant`: a prompt to put in the
|
|
17
|
+
* assistant's message box. The assistant opens and fills it in; the editor sends it.
|
|
18
|
+
*/
|
|
19
|
+
export const ASK_PARAM = "ask";
|
|
20
|
+
/**
|
|
21
|
+
* Announced once the field bridge has brought a field into view: `detail: { path }`. The assistant
|
|
22
|
+
* takes it as the editor's selection, like a click in the visual editing preview.
|
|
23
|
+
*/
|
|
24
|
+
export const SELECT_EVENT = "content-health:select";
|
|
25
|
+
/**
|
|
26
|
+
* Where `@simmalugnt-se/payload-editor-assistant` lists the collections it works in:
|
|
27
|
+
* `config.admin.custom.editorAssistant.collections`. The work list offers the assistant only there.
|
|
28
|
+
*/
|
|
29
|
+
export const ASSISTANT_CUSTOM_KEY = "editorAssistant";
|
|
30
|
+
export function assistantCollections(adminCustom) {
|
|
31
|
+
const entry = adminCustom?.[ASSISTANT_CUSTOM_KEY];
|
|
32
|
+
const collections = entry?.collections;
|
|
33
|
+
return Array.isArray(collections)
|
|
34
|
+
? collections.filter((slug) => typeof slug === "string")
|
|
35
|
+
: [];
|
|
36
|
+
}
|
|
37
|
+
/** The document in the finding's locale with its field brought into view, and a prompt when given. */
|
|
38
|
+
export function editURL(adminRoute, finding, ask) {
|
|
39
|
+
const path = `/collections/${finding.collection}/${encodeURIComponent(String(finding.id))}`;
|
|
40
|
+
const params = new URLSearchParams();
|
|
41
|
+
if (finding.locale) {
|
|
42
|
+
params.set("locale", finding.locale);
|
|
43
|
+
}
|
|
44
|
+
params.set(FIELD_PARAM, finding.path);
|
|
45
|
+
if (ask) {
|
|
46
|
+
params.set(ASK_PARAM, ask);
|
|
47
|
+
}
|
|
48
|
+
return `${formatAdminURL({ adminRoute, path })}?${params}`;
|
|
49
|
+
}
|
|
50
|
+
/** The work list, filtered. */
|
|
51
|
+
export function listURL(adminRoute, filters = {}) {
|
|
52
|
+
const url = formatAdminURL({ adminRoute, path: VIEW_PATH });
|
|
53
|
+
const query = filterQuery(filters);
|
|
54
|
+
return query ? `${url}?${query}` : url;
|
|
55
|
+
}
|
|
56
|
+
export function filterQuery(filters) {
|
|
57
|
+
const params = new URLSearchParams();
|
|
58
|
+
for (const key of ["check", "collection", "locale"]) {
|
|
59
|
+
const value = filters[key];
|
|
60
|
+
if (value) {
|
|
61
|
+
params.set(key, value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return params.toString();
|
|
65
|
+
}
|
|
66
|
+
/** Filters from the list's search params; unknown checks are dropped. */
|
|
67
|
+
export function readFilters(searchParams) {
|
|
68
|
+
const first = (key) => {
|
|
69
|
+
const value = searchParams?.[key];
|
|
70
|
+
return (Array.isArray(value) ? value[0] : value) || undefined;
|
|
71
|
+
};
|
|
72
|
+
const check = first("check");
|
|
73
|
+
const collection = first("collection");
|
|
74
|
+
const locale = first("locale");
|
|
75
|
+
return {
|
|
76
|
+
...(check && CHECK_IDS.includes(check) ? { check: check } : {}),
|
|
77
|
+
...(collection ? { collection } : {}),
|
|
78
|
+
...(locale ? { locale } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function matchesFilters(finding, filters) {
|
|
82
|
+
return ((!filters.check || finding.check === filters.check) &&
|
|
83
|
+
(!filters.collection || finding.collection === filters.collection) &&
|
|
84
|
+
// Unlocalized fields belong to every locale.
|
|
85
|
+
(!filters.locale || !finding.locale || finding.locale === filters.locale));
|
|
86
|
+
}
|
package/dist/package-name.d.ts
CHANGED
package/dist/package-name.js
CHANGED
package/dist/plugin.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Plugin } from "payload";
|
|
2
2
|
import { type ContentHealthPluginOptions } from "./types.ts";
|
|
3
3
|
export declare const WIDGET_SLUG = "content-health";
|
|
4
|
+
/** Key of the work list in `admin.components.views`. */
|
|
5
|
+
export declare const VIEW_KEY = "contentHealth";
|
|
4
6
|
export declare function contentHealthPlugin(options: ContentHealthPluginOptions): Plugin;
|
package/dist/plugin.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { CHECK_LABELS } from "./copy.js";
|
|
2
|
-
import {
|
|
2
|
+
import { VIEW_PATH } from "./links.js";
|
|
3
|
+
import { CLIENT_ENTRY, RSC_ENTRY } from "./package-name.js";
|
|
3
4
|
import { CUSTOM_KEY } from "./scan.js";
|
|
4
5
|
import { CHECK_IDS } from "./types.js";
|
|
5
6
|
export const WIDGET_SLUG = "content-health";
|
|
7
|
+
/** Key of the work list in `admin.components.views`. */
|
|
8
|
+
export const VIEW_KEY = "contentHealth";
|
|
6
9
|
const widget = {
|
|
7
10
|
slug: WIDGET_SLUG,
|
|
8
11
|
Component: `${RSC_ENTRY}#ContentHealthWidget`,
|
|
@@ -35,10 +38,27 @@ export function contentHealthPlugin(options) {
|
|
|
35
38
|
{ widgetSlug: "collections", width: "full" },
|
|
36
39
|
{ widgetSlug: WIDGET_SLUG, width: "full" },
|
|
37
40
|
]);
|
|
41
|
+
const components = config.admin?.components;
|
|
38
42
|
return {
|
|
39
43
|
...config,
|
|
44
|
+
collections: (config.collections ?? []).map((collection) => options.collections[collection.slug] ? withFieldBridge(collection) : collection),
|
|
40
45
|
admin: {
|
|
41
46
|
...config.admin,
|
|
47
|
+
components: {
|
|
48
|
+
...components,
|
|
49
|
+
beforeNavLinks: [
|
|
50
|
+
...(components?.beforeNavLinks ?? []),
|
|
51
|
+
`${CLIENT_ENTRY}#ContentHealthNavLink`,
|
|
52
|
+
],
|
|
53
|
+
views: {
|
|
54
|
+
...components?.views,
|
|
55
|
+
[VIEW_KEY]: {
|
|
56
|
+
Component: `${RSC_ENTRY}#ContentHealthView`,
|
|
57
|
+
path: VIEW_PATH,
|
|
58
|
+
meta: { title: "Content health" },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
42
62
|
dashboard: {
|
|
43
63
|
...dashboard,
|
|
44
64
|
widgets: [...(dashboard?.widgets ?? []), widget],
|
|
@@ -49,3 +69,23 @@ export function contentHealthPlugin(options) {
|
|
|
49
69
|
};
|
|
50
70
|
};
|
|
51
71
|
}
|
|
72
|
+
/** Brings the field a work list link names (`?field=`) into view in the edit form. */
|
|
73
|
+
function withFieldBridge(collection) {
|
|
74
|
+
const edit = collection.admin?.components?.edit;
|
|
75
|
+
return {
|
|
76
|
+
...collection,
|
|
77
|
+
admin: {
|
|
78
|
+
...collection.admin,
|
|
79
|
+
components: {
|
|
80
|
+
...collection.admin?.components,
|
|
81
|
+
edit: {
|
|
82
|
+
...edit,
|
|
83
|
+
beforeDocumentControls: [
|
|
84
|
+
...(edit?.beforeDocumentControls ?? []),
|
|
85
|
+
`${CLIENT_ENTRY}#ContentHealthFieldBridge`,
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Edit view and nav. The field highlight is the one visual editing uses (.pve-highlight), so both
|
|
3
|
+
* read as the same selection.
|
|
4
|
+
*/
|
|
5
|
+
.content-health-highlight {
|
|
6
|
+
border-radius: 4px;
|
|
7
|
+
animation: content-health-highlight 1.6s ease-out;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@keyframes content-health-highlight {
|
|
11
|
+
0%,
|
|
12
|
+
40% {
|
|
13
|
+
outline: 2px solid #2563eb;
|
|
14
|
+
outline-offset: 4px;
|
|
15
|
+
background-color: rgba(37, 99, 235, 0.12);
|
|
16
|
+
}
|
|
17
|
+
100% {
|
|
18
|
+
outline: 2px solid transparent;
|
|
19
|
+
outline-offset: 4px;
|
|
20
|
+
background-color: transparent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* The work list's nav link sits above Payload's groups; space it like a group. */
|
|
25
|
+
.content-health-nav {
|
|
26
|
+
margin-bottom: calc(var(--base) * 0.75);
|
|
27
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/* The work list. Colors come from Payload's theme; the accent is the blue the widget uses. */
|
|
2
|
+
.content-health-list {
|
|
3
|
+
--content-health-accent: #2563eb;
|
|
4
|
+
padding-bottom: calc(var(--base) * 2);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
html[data-theme="dark"] .content-health-list {
|
|
8
|
+
--content-health-accent: #60a5fa;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.content-health-list__header {
|
|
12
|
+
padding: calc(var(--base) * 1.5) 0 var(--base);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.content-health-list__header h1 {
|
|
16
|
+
margin: 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.content-health-list__intro {
|
|
20
|
+
margin: calc(var(--base) * 0.5) 0 0;
|
|
21
|
+
max-width: 44rem;
|
|
22
|
+
color: var(--theme-elevation-600);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.content-health-list__filters {
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-wrap: wrap;
|
|
28
|
+
align-items: flex-end;
|
|
29
|
+
gap: calc(var(--base) * 0.75);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.content-health-list__filter {
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
gap: calc(var(--base) * 0.25);
|
|
36
|
+
font-size: 0.9em;
|
|
37
|
+
color: var(--theme-elevation-600);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.content-health-list__filter select {
|
|
41
|
+
min-width: 12rem;
|
|
42
|
+
height: calc(var(--base) * 1.6);
|
|
43
|
+
padding: 0 calc(var(--base) * 0.5);
|
|
44
|
+
border: 1px solid var(--theme-elevation-150);
|
|
45
|
+
border-radius: var(--style-radius-s);
|
|
46
|
+
background: var(--theme-input-bg, var(--theme-elevation-0));
|
|
47
|
+
color: var(--theme-text);
|
|
48
|
+
font: inherit;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.content-health-list__summary {
|
|
52
|
+
min-height: 1.5em;
|
|
53
|
+
margin: var(--base) 0 calc(var(--base) * 0.5);
|
|
54
|
+
color: var(--theme-elevation-600);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* One grid for all rows, so the columns line up; each row takes its tracks through subgrid. */
|
|
58
|
+
.content-health-list__rows {
|
|
59
|
+
display: grid;
|
|
60
|
+
grid-template-columns: minmax(12rem, 1fr) minmax(12rem, 1.5fr) auto;
|
|
61
|
+
margin: 0;
|
|
62
|
+
padding: 0;
|
|
63
|
+
list-style: none;
|
|
64
|
+
border: 1px solid var(--theme-elevation-150);
|
|
65
|
+
border-radius: var(--style-radius-m);
|
|
66
|
+
background: var(--theme-elevation-0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.content-health-list__row {
|
|
70
|
+
display: grid;
|
|
71
|
+
grid-column: 1 / -1;
|
|
72
|
+
grid-template-columns: subgrid;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: calc(var(--base) * 0.75);
|
|
75
|
+
padding: calc(var(--base) * 0.6) calc(var(--base) * 0.8);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.content-health-list__row + .content-health-list__row {
|
|
79
|
+
border-top: 1px solid var(--theme-elevation-100);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.content-health-list__what,
|
|
83
|
+
.content-health-list__document {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-wrap: wrap;
|
|
86
|
+
align-items: baseline;
|
|
87
|
+
gap: calc(var(--base) * 0.25) calc(var(--base) * 0.5);
|
|
88
|
+
min-width: 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.content-health-list__what {
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
align-items: flex-start;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.content-health-list__check {
|
|
97
|
+
font-weight: 500;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.content-health-list__detail {
|
|
101
|
+
color: var(--theme-elevation-600);
|
|
102
|
+
font-size: 0.9em;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.content-health-list__document a {
|
|
106
|
+
color: var(--content-health-accent);
|
|
107
|
+
text-decoration: none;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.content-health-list__document a:hover {
|
|
111
|
+
text-decoration: underline;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.content-health-list__pill {
|
|
115
|
+
padding: 0 calc(var(--base) * 0.3);
|
|
116
|
+
border-radius: var(--style-radius-s);
|
|
117
|
+
background: var(--theme-elevation-100);
|
|
118
|
+
color: var(--theme-elevation-800);
|
|
119
|
+
font-size: 0.8em;
|
|
120
|
+
text-transform: uppercase;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.content-health-list__pill--draft {
|
|
124
|
+
text-transform: none;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.content-health-list__actions {
|
|
128
|
+
justify-self: end;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.content-health-list__empty {
|
|
132
|
+
color: var(--theme-elevation-600);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.content-health-list__problems {
|
|
136
|
+
margin-top: var(--base);
|
|
137
|
+
color: var(--theme-error-600);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@media (max-width: 768px) {
|
|
141
|
+
.content-health-list__rows {
|
|
142
|
+
grid-template-columns: 1fr;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.content-health-list__actions {
|
|
146
|
+
justify-self: start;
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/styles/widget.css
CHANGED
|
@@ -115,7 +115,7 @@ html[data-theme="dark"] .content-health {
|
|
|
115
115
|
.content-health__rows {
|
|
116
116
|
display: flex;
|
|
117
117
|
flex-direction: column;
|
|
118
|
-
gap: calc(var(--base) * 0.
|
|
118
|
+
gap: calc(var(--base) * 0.6);
|
|
119
119
|
margin: 0;
|
|
120
120
|
padding: calc(var(--base) * 0.6) 0 0;
|
|
121
121
|
border-top: 1px solid var(--theme-elevation-100);
|
|
@@ -123,16 +123,28 @@ html[data-theme="dark"] .content-health {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
.content-health__row {
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
gap: calc(var(--base) * 0.25);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.content-health__locales {
|
|
126
132
|
display: flex;
|
|
127
133
|
flex-wrap: wrap;
|
|
128
|
-
|
|
129
|
-
|
|
134
|
+
gap: calc(var(--base) * 0.25);
|
|
135
|
+
margin: 0;
|
|
136
|
+
padding: 0;
|
|
137
|
+
list-style: none;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.content-health__locales--stacked {
|
|
141
|
+
flex-direction: column;
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
.content-health__locale {
|
|
133
|
-
display:
|
|
145
|
+
display: flex;
|
|
134
146
|
align-items: baseline;
|
|
135
|
-
gap: calc(var(--base) * 0.
|
|
147
|
+
gap: calc(var(--base) * 0.3);
|
|
136
148
|
}
|
|
137
149
|
|
|
138
150
|
.content-health__pill {
|
|
@@ -171,3 +183,7 @@ html[data-theme="dark"] .content-health {
|
|
|
171
183
|
height: 5.5rem;
|
|
172
184
|
}
|
|
173
185
|
}
|
|
186
|
+
|
|
187
|
+
.content-health__more a {
|
|
188
|
+
color: inherit;
|
|
189
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AdminViewServerProps } from "payload";
|
|
2
|
+
import "../styles/view.css";
|
|
3
|
+
/**
|
|
4
|
+
* The work list: every finding with filters on check, collection and locale. Each row opens the
|
|
5
|
+
* document in the finding's locale with the field selected; where the editor assistant works, a
|
|
6
|
+
* second link opens it with a ready prompt too. Scans once per visit; the filters work in the browser.
|
|
7
|
+
*/
|
|
8
|
+
export declare function ContentHealthView({ initPageResult, params, searchParams, }: AdminViewServerProps): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { DefaultTemplate } from "@payloadcms/next/templates";
|
|
3
|
+
import { Gutter, SetStepNav } from "@payloadcms/ui";
|
|
4
|
+
import { ASK, CHECK_LABELS, findingDetail, LIST_COPY, uiLanguage } from "../copy.js";
|
|
5
|
+
import { assistantCollections, editURL, readFilters } from "../links.js";
|
|
6
|
+
import { scanContentHealth } from "../scan.js";
|
|
7
|
+
import { CHECK_IDS } from "../types.js";
|
|
8
|
+
import { WorkList } from "./WorkList.js";
|
|
9
|
+
import "../styles/view.css";
|
|
10
|
+
/**
|
|
11
|
+
* The work list: every finding with filters on check, collection and locale. Each row opens the
|
|
12
|
+
* document in the finding's locale with the field selected; where the editor assistant works, a
|
|
13
|
+
* second link opens it with a ready prompt too. Scans once per visit; the filters work in the browser.
|
|
14
|
+
*/
|
|
15
|
+
export async function ContentHealthView({ initPageResult, params, searchParams, }) {
|
|
16
|
+
const { req, locale, permissions, visibleEntities } = initPageResult;
|
|
17
|
+
const language = uiLanguage(req.i18n?.language);
|
|
18
|
+
const copy = LIST_COPY[language];
|
|
19
|
+
const { config } = req.payload;
|
|
20
|
+
const adminRoute = config.routes.admin;
|
|
21
|
+
const result = await scanContentHealth({ payload: req.payload, user: req.user });
|
|
22
|
+
const withAssistant = new Set(assistantCollections(config.admin?.custom));
|
|
23
|
+
const rows = sortFindings(result.findings).map((finding, index) => {
|
|
24
|
+
const detail = findingDetail(language, finding);
|
|
25
|
+
return {
|
|
26
|
+
key: `${index}:${finding.collection}:${finding.id}:${finding.check}:${finding.path}:${finding.locale ?? ""}`,
|
|
27
|
+
check: finding.check,
|
|
28
|
+
checkLabel: CHECK_LABELS[language][finding.check],
|
|
29
|
+
collection: finding.collection,
|
|
30
|
+
title: finding.title,
|
|
31
|
+
draft: finding.status === "draft",
|
|
32
|
+
href: editURL(adminRoute, finding),
|
|
33
|
+
...(finding.locale ? { locale: finding.locale } : {}),
|
|
34
|
+
...(detail ? { detail } : {}),
|
|
35
|
+
...(withAssistant.has(finding.collection)
|
|
36
|
+
? { askHref: editURL(adminRoute, finding, ASK[language][finding.check](finding)) }
|
|
37
|
+
: {}),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
const checks = result.checks.map((check) => ({
|
|
41
|
+
value: check,
|
|
42
|
+
label: CHECK_LABELS[language][check],
|
|
43
|
+
}));
|
|
44
|
+
const collections = Object.keys(result.documents).map((slug) => ({
|
|
45
|
+
value: slug,
|
|
46
|
+
label: label(req.payload.collections[slug]?.config.labels?.plural, language) ?? slug,
|
|
47
|
+
}));
|
|
48
|
+
const locales = config.localization
|
|
49
|
+
? config.localization.locales.map((locale) => ({
|
|
50
|
+
value: locale.code,
|
|
51
|
+
label: label(locale.label, language) ?? locale.code,
|
|
52
|
+
}))
|
|
53
|
+
: [];
|
|
54
|
+
// Payload wraps only its own views in the Admin shell; a custom view brings nav and header itself.
|
|
55
|
+
return (_jsx(DefaultTemplate, { i18n: req.i18n, locale: locale, params: params, payload: req.payload, permissions: permissions, req: req, searchParams: searchParams, user: req.user ?? undefined, visibleEntities: visibleEntities, children: _jsxs(Gutter, { className: "content-health-list", children: [_jsx(SetStepNav, { nav: [{ label: copy.title }] }), _jsxs("header", { className: "content-health-list__header", children: [_jsx("h1", { children: copy.title }), _jsx("p", { className: "content-health-list__intro", children: copy.intro })] }), _jsx(WorkList, { checks: checks, collections: collections, initialFilters: readFilters(searchParams), language: language, locales: locales, rows: rows }), result.problems.length ? (_jsx("ul", { className: "content-health-list__problems", children: result.problems.map((problem) => (_jsx("li", { children: problem }, problem))) })) : null] }) }));
|
|
56
|
+
}
|
|
57
|
+
/** By check, then document, then locale, so a document's locales sit together. */
|
|
58
|
+
function sortFindings(findings) {
|
|
59
|
+
return [...findings].sort((a, b) => CHECK_IDS.indexOf(a.check) - CHECK_IDS.indexOf(b.check) ||
|
|
60
|
+
a.collection.localeCompare(b.collection) ||
|
|
61
|
+
a.title.localeCompare(b.title) ||
|
|
62
|
+
String(a.id).localeCompare(String(b.id)) ||
|
|
63
|
+
a.path.localeCompare(b.path) ||
|
|
64
|
+
(a.locale ?? "").localeCompare(b.locale ?? ""));
|
|
65
|
+
}
|
|
66
|
+
/** A config label: a string, or one per language. Functions are left to Payload. */
|
|
67
|
+
function label(value, language) {
|
|
68
|
+
if (typeof value === "string") {
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
if (value && typeof value === "object") {
|
|
72
|
+
const labels = value;
|
|
73
|
+
const text = labels[language] ?? labels.en ?? Object.values(labels)[0];
|
|
74
|
+
return typeof text === "string" ? text : undefined;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|