@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/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
+ }
@@ -0,0 +1,3 @@
1
+ export { ContentHealthFieldBridge } from "../admin/ContentHealthFieldBridge.tsx";
2
+ export { ContentHealthNavLink } from "../admin/ContentHealthNavLink.tsx";
3
+ export { WorkList } from "../view/WorkList.tsx";
@@ -0,0 +1,3 @@
1
+ export { ContentHealthFieldBridge } from "../admin/ContentHealthFieldBridge.js";
2
+ export { ContentHealthNavLink } from "../admin/ContentHealthNavLink.js";
3
+ export { WorkList } from "../view/WorkList.js";
@@ -1 +1,2 @@
1
+ export { ContentHealthView } from "../view/ContentHealthView.tsx";
1
2
  export { ContentHealthWidget } from "../widget/ContentHealthWidget.tsx";
@@ -1 +1,2 @@
1
+ export { ContentHealthView } from "../view/ContentHealthView.js";
1
2
  export { ContentHealthWidget } from "../widget/ContentHealthWidget.js";
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 { contentHealthPlugin, WIDGET_SLUG } from "./plugin.ts";
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 { contentHealthPlugin, WIDGET_SLUG } from "./plugin.js";
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";
@@ -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
+ }
@@ -1,2 +1,3 @@
1
1
  export declare const PACKAGE_NAME = "@simmalugnt-se/payload-content-health";
2
2
  export declare const RSC_ENTRY = "@simmalugnt-se/payload-content-health/rsc";
3
+ export declare const CLIENT_ENTRY = "@simmalugnt-se/payload-content-health/client";
@@ -1,2 +1,3 @@
1
1
  export const PACKAGE_NAME = "@simmalugnt-se/payload-content-health";
2
2
  export const RSC_ENTRY = `${PACKAGE_NAME}/rsc`;
3
+ export const CLIENT_ENTRY = `${PACKAGE_NAME}/client`;
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 { RSC_ENTRY } from "./package-name.js";
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
+ }
@@ -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.3);
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
- align-items: baseline;
129
- gap: calc(var(--base) * 0.3);
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: inline-flex;
145
+ display: flex;
134
146
  align-items: baseline;
135
- gap: calc(var(--base) * 0.2);
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
+ }