@simmalugnt-se/payload-content-health 0.1.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 +13 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/dist/checks.d.ts +73 -0
- package/dist/checks.js +175 -0
- package/dist/content.d.ts +24 -0
- package/dist/content.js +91 -0
- package/dist/copy.d.ts +45 -0
- package/dist/copy.js +117 -0
- package/dist/exports/rsc.d.ts +1 -0
- package/dist/exports/rsc.js +1 -0
- package/dist/fields.d.ts +48 -0
- package/dist/fields.js +114 -0
- package/dist/group.d.ts +27 -0
- package/dist/group.js +38 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +6 -0
- package/dist/package-name.d.ts +2 -0
- package/dist/package-name.js +2 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.js +51 -0
- package/dist/scan.d.ts +28 -0
- package/dist/scan.js +124 -0
- package/dist/styles/widget.css +173 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.js +12 -0
- package/dist/widget/ContentHealthWidget.d.ts +7 -0
- package/dist/widget/ContentHealthWidget.js +61 -0
- package/dist/widget/Ring.d.ts +12 -0
- package/dist/widget/Ring.js +23 -0
- package/package.json +61 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
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"];
|
|
2
|
+
export type CheckId = (typeof CHECK_IDS)[number];
|
|
3
|
+
export type LengthRange = {
|
|
4
|
+
min: number;
|
|
5
|
+
max: number;
|
|
6
|
+
};
|
|
7
|
+
/** One problem in one field of one document, in one locale when the field is localized. */
|
|
8
|
+
export type Finding = {
|
|
9
|
+
check: CheckId;
|
|
10
|
+
collection: string;
|
|
11
|
+
id: string | number;
|
|
12
|
+
/** The document's title (`useAsTitle`), falling back to the filename and then the id. */
|
|
13
|
+
title: string;
|
|
14
|
+
/** Dot path of the field in the document, e.g. `meta.description`. */
|
|
15
|
+
path: string;
|
|
16
|
+
/** Set when the field is localized. */
|
|
17
|
+
locale?: string;
|
|
18
|
+
/** For length checks: the current length and the range it falls outside. */
|
|
19
|
+
length?: LengthRange & {
|
|
20
|
+
actual: number;
|
|
21
|
+
};
|
|
22
|
+
/** For heading checks: the heading's level and, for a skip, the level before it. */
|
|
23
|
+
heading?: {
|
|
24
|
+
level: number;
|
|
25
|
+
previous?: number;
|
|
26
|
+
count?: number;
|
|
27
|
+
};
|
|
28
|
+
/** For thin content: words on the page and the minimum. */
|
|
29
|
+
words?: {
|
|
30
|
+
actual: number;
|
|
31
|
+
min: number;
|
|
32
|
+
};
|
|
33
|
+
/** Draft status of the checked version, for collections with drafts. */
|
|
34
|
+
status?: "draft" | "published";
|
|
35
|
+
};
|
|
36
|
+
export type SeoOptions = {
|
|
37
|
+
/** Field paths; `false` turns that field's checks off. Defaults follow `@payloadcms/plugin-seo`. */
|
|
38
|
+
title?: string | false;
|
|
39
|
+
description?: string | false;
|
|
40
|
+
image?: string | false;
|
|
41
|
+
/** Allowed length in characters; `false` turns the length check off. Default 50–60. */
|
|
42
|
+
titleLength?: LengthRange | false;
|
|
43
|
+
/** Default 100–150. */
|
|
44
|
+
descriptionLength?: LengthRange | false;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Heading levels per block, as the block components render them: block slug → field path in the
|
|
48
|
+
* block → level. Array fields fan out: `"items.title": 3` makes every item's title an H3.
|
|
49
|
+
*/
|
|
50
|
+
export type HeadingLevels = Record<string, Record<string, number>>;
|
|
51
|
+
export type ContentOptions = {
|
|
52
|
+
/** The blocks or rich text field holding the page body, e.g. `layout`. */
|
|
53
|
+
field: string;
|
|
54
|
+
/**
|
|
55
|
+
* Heading levels of block fields. Headings inside rich text count by their own tag. Without this
|
|
56
|
+
* option the heading checks are off, since only the frontend knows what it renders as an H1.
|
|
57
|
+
*/
|
|
58
|
+
headings?: HeadingLevels;
|
|
59
|
+
/** Fewer words than this is thin content; `false` turns the check off. Default 300. */
|
|
60
|
+
minWords?: number | false;
|
|
61
|
+
};
|
|
62
|
+
export type AltTextOptions = {
|
|
63
|
+
/** Field holding the alt text. Default `alt`. */
|
|
64
|
+
field?: string;
|
|
65
|
+
};
|
|
66
|
+
export type CollectionHealthOptions = {
|
|
67
|
+
/** SEO checks on the fields `@payloadcms/plugin-seo` adds, or the paths given here. */
|
|
68
|
+
seo?: boolean | SeoOptions;
|
|
69
|
+
/** Alt text on images in an upload collection. Files that are not images are skipped. */
|
|
70
|
+
altText?: boolean | AltTextOptions;
|
|
71
|
+
/** Heading structure and thin content, read from the page body. */
|
|
72
|
+
content?: ContentOptions;
|
|
73
|
+
};
|
|
74
|
+
export type ContentHealthPluginOptions = {
|
|
75
|
+
/** Collection slugs and the checks to run on each. */
|
|
76
|
+
collections: Record<string, CollectionHealthOptions>;
|
|
77
|
+
disabled?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Put the widget on the default dashboard, below the collection cards, when the project sets no
|
|
80
|
+
* `admin.dashboard.defaultLayout` of its own. Editors who already saved a layout add it themselves.
|
|
81
|
+
* Default `true`.
|
|
82
|
+
*/
|
|
83
|
+
addToDefaultLayout?: boolean;
|
|
84
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const CHECK_IDS = [
|
|
2
|
+
"media.alt-missing",
|
|
3
|
+
"seo.title-missing",
|
|
4
|
+
"seo.title-length",
|
|
5
|
+
"seo.description-missing",
|
|
6
|
+
"seo.description-length",
|
|
7
|
+
"seo.image-missing",
|
|
8
|
+
"content.h1-missing",
|
|
9
|
+
"content.h1-multiple",
|
|
10
|
+
"content.heading-skip",
|
|
11
|
+
"content.thin",
|
|
12
|
+
];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { WidgetServerProps } from "payload";
|
|
2
|
+
import "../styles/widget.css";
|
|
3
|
+
/**
|
|
4
|
+
* Dashboard widget: the share of checked fields in order, overall and per check, and the documents
|
|
5
|
+
* that need work, each linking to the document in the right locale.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ContentHealthWidget({ req, widgetData }: WidgetServerProps): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { formatAdminURL } from "payload/shared";
|
|
3
|
+
import { COPY, STATUS, TILE_LABELS, uiLanguage } from "../copy.js";
|
|
4
|
+
import { checkStats, countDocuments } from "../group.js";
|
|
5
|
+
import { scanContentHealth } from "../scan.js";
|
|
6
|
+
import { CHECK_IDS } from "../types.js";
|
|
7
|
+
import { Ring } from "./Ring.js";
|
|
8
|
+
import "../styles/widget.css";
|
|
9
|
+
const ROWS_PER_CHECK = 4;
|
|
10
|
+
/**
|
|
11
|
+
* Dashboard widget: the share of checked fields in order, overall and per check, and the documents
|
|
12
|
+
* that need work, each linking to the document in the right locale.
|
|
13
|
+
*/
|
|
14
|
+
export async function ContentHealthWidget({ req, widgetData }) {
|
|
15
|
+
const language = uiLanguage(req.i18n?.language);
|
|
16
|
+
const copy = COPY[language];
|
|
17
|
+
const selected = Array.isArray(widgetData?.checks)
|
|
18
|
+
? widgetData.checks.filter((id) => CHECK_IDS.includes(id))
|
|
19
|
+
: [];
|
|
20
|
+
const result = await scanContentHealth({
|
|
21
|
+
payload: req.payload,
|
|
22
|
+
user: req.user,
|
|
23
|
+
checks: selected,
|
|
24
|
+
});
|
|
25
|
+
const stats = checkStats(result.checks, result.checked, result.findings);
|
|
26
|
+
const total = stats.reduce((sum, stat) => sum + stat.checked, 0);
|
|
27
|
+
const failing = stats.reduce((sum, stat) => sum + stat.failing, 0);
|
|
28
|
+
const passing = stats.filter((stat) => stat.checked > 0 && stat.failing === 0).length;
|
|
29
|
+
const documents = Object.values(result.documents).reduce((sum, count) => sum + count, 0);
|
|
30
|
+
const adminRoute = req.payload.config.routes.admin;
|
|
31
|
+
return (_jsxs("section", { className: "content-health", children: [_jsx("h2", { className: "content-health__heading", children: copy.title }), _jsxs("div", { className: "content-health__panel", children: [_jsxs("div", { className: "content-health__overview", children: [_jsx(Ring, { className: "content-health__ring content-health__ring--large", failing: failing, label: copy.ok(total - failing, total), total: total }), _jsxs("div", { children: [_jsx("p", { className: "content-health__overall", children: copy.overall }), _jsxs("p", { className: "content-health__summary", children: [result.findings.length
|
|
32
|
+
? copy.summary(countDocuments(result.findings), documents)
|
|
33
|
+
: copy.allGood, passing > 0 && result.findings.length > 0 ? ` · ${copy.passing(passing)}` : null] })] })] }), _jsx("ul", { className: "content-health__tiles", children: stats.map((stat) => (_jsx(CheckTile, { adminRoute: adminRoute, language: language, stat: stat }, stat.check))) }), result.problems.length ? (_jsxs("div", { className: "content-health__problems", children: [_jsx("p", { children: copy.problems }), _jsx("ul", { children: result.problems.map((problem) => (_jsx("li", { children: problem }, problem))) })] })) : null] })] }));
|
|
34
|
+
}
|
|
35
|
+
function CheckTile({ adminRoute, language, stat, }) {
|
|
36
|
+
const copy = COPY[language];
|
|
37
|
+
const status = !stat.checked
|
|
38
|
+
? copy.nothingYet
|
|
39
|
+
: stat.failing
|
|
40
|
+
? STATUS[language][stat.check](stat.checked - stat.failing, stat.checked)
|
|
41
|
+
: copy.checkPasses;
|
|
42
|
+
return (_jsxs("li", { className: "content-health__tile", children: [_jsxs("div", { className: "content-health__tile-head", children: [_jsx(Ring, { className: "content-health__ring", failing: stat.failing, label: status, total: stat.checked }), _jsxs("div", { children: [_jsx("h3", { className: "content-health__tile-title", children: TILE_LABELS[language][stat.check] }), _jsx("p", { className: "content-health__tile-status", children: status })] })] }), stat.documents.length ? (_jsxs("ul", { className: "content-health__rows", children: [stat.documents.slice(0, ROWS_PER_CHECK).map((row) => (_jsxs("li", { className: "content-health__row", children: [_jsx("a", { href: editURL(adminRoute, row.findings[0]), children: row.title }), row.findings.map((finding) => (_jsxs("span", { className: "content-health__locale", children: [finding.locale ? (_jsx("a", { className: "content-health__pill", href: editURL(adminRoute, finding), children: finding.locale })) : null, _jsx(Detail, { copy: copy, finding: finding })] }, finding.locale ?? "")))] }, `${row.collection}:${row.id}`))), stat.documents.length > ROWS_PER_CHECK ? (_jsx("li", { className: "content-health__more", children: copy.more(stat.documents.length - ROWS_PER_CHECK) })) : null] })) : null] }));
|
|
43
|
+
}
|
|
44
|
+
function Detail({ copy, finding }) {
|
|
45
|
+
const { heading, length, words } = finding;
|
|
46
|
+
const text = length
|
|
47
|
+
? copy.length(length.actual, length.min, length.max)
|
|
48
|
+
: words
|
|
49
|
+
? copy.words(words.actual, words.min)
|
|
50
|
+
: heading?.previous !== undefined
|
|
51
|
+
? copy.headingSkip(heading.previous, heading.level)
|
|
52
|
+
: heading?.count
|
|
53
|
+
? copy.h1Count(heading.count)
|
|
54
|
+
: undefined;
|
|
55
|
+
return text ? _jsx("span", { className: "content-health__detail", children: text }) : null;
|
|
56
|
+
}
|
|
57
|
+
function editURL(adminRoute, finding) {
|
|
58
|
+
const path = `/collections/${finding.collection}/${encodeURIComponent(String(finding.id))}`;
|
|
59
|
+
const url = formatAdminURL({ adminRoute, path });
|
|
60
|
+
return finding.locale ? `${url}?locale=${encodeURIComponent(finding.locale)}` : url;
|
|
61
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A progress ring: the share of values in order as an arc on a neutral track, with the percentage in
|
|
3
|
+
* the middle. What is missing stays track-colored; the text beside the ring says how much. Sized by
|
|
4
|
+
* CSS; drawn in a 100×100 box.
|
|
5
|
+
*/
|
|
6
|
+
export declare function Ring({ className, failing, label, total, }: {
|
|
7
|
+
className: string;
|
|
8
|
+
failing: number;
|
|
9
|
+
/** Accessible text, e.g. "4 of 6 OK". */
|
|
10
|
+
label: string;
|
|
11
|
+
total: number;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A progress ring: the share of values in order as an arc on a neutral track, with the percentage in
|
|
4
|
+
* the middle. What is missing stays track-colored; the text beside the ring says how much. Sized by
|
|
5
|
+
* CSS; drawn in a 100×100 box.
|
|
6
|
+
*/
|
|
7
|
+
const SIZE = 100;
|
|
8
|
+
const STROKE = 12;
|
|
9
|
+
const RADIUS = (SIZE - STROKE) / 2;
|
|
10
|
+
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
|
11
|
+
export function Ring({ className, failing, label, total, }) {
|
|
12
|
+
const ok = total - failing;
|
|
13
|
+
const okLength = total ? (CIRCUMFERENCE * ok) / total : 0;
|
|
14
|
+
const percent = total ? Math.round((ok / total) * 100) : undefined;
|
|
15
|
+
return (_jsxs("svg", { "aria-label": label, className: className, role: "img", viewBox: `0 0 ${SIZE} ${SIZE}`, children: [_jsx("title", { children: label }), _jsxs("g", { transform: `rotate(-90 ${SIZE / 2} ${SIZE / 2})`, children: [_jsx("circle", { className: "content-health__ring-track", ...circle }), okLength > 0 ? (_jsx("circle", { className: "content-health__ring-ok", ...circle, strokeDasharray: `${okLength} ${CIRCUMFERENCE}`, strokeLinecap: ok < total ? "round" : "butt" })) : null] }), _jsx("text", { className: "content-health__ring-value", dominantBaseline: "central", textAnchor: "middle", x: SIZE / 2, y: SIZE / 2, children: percent === undefined ? "–" : `${percent}%` })] }));
|
|
16
|
+
}
|
|
17
|
+
const circle = {
|
|
18
|
+
cx: SIZE / 2,
|
|
19
|
+
cy: SIZE / 2,
|
|
20
|
+
fill: "none",
|
|
21
|
+
r: RADIUS,
|
|
22
|
+
strokeWidth: STROKE,
|
|
23
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simmalugnt-se/payload-content-health",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Content health checks and dashboard widgets for Payload Admin: alt texts, SEO fields and more",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"payload",
|
|
7
|
+
"payload-plugin"
|
|
8
|
+
],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Simmalugnt AB",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/simmalugnt-se/payload-plugins.git",
|
|
14
|
+
"directory": "packages/payload-content-health"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"CHANGELOG.md"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": [
|
|
25
|
+
"*.css"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"import": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./rsc": {
|
|
34
|
+
"import": "./dist/exports/rsc.js",
|
|
35
|
+
"types": "./dist/exports/rsc.d.ts",
|
|
36
|
+
"default": "./dist/exports/rsc.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"payload": ">=3.90.2 <4",
|
|
44
|
+
"react": "^19.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22",
|
|
48
|
+
"@types/react": "^19.2.18",
|
|
49
|
+
"payload": "3.90.2",
|
|
50
|
+
"react": "19.2.8",
|
|
51
|
+
"tsx": "^4.21.0",
|
|
52
|
+
"typescript": "5.9.3"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R src/styles dist/styles",
|
|
56
|
+
"test": "tsx --test tests/*.test.ts",
|
|
57
|
+
"typecheck": "tsc --noEmit"
|
|
58
|
+
},
|
|
59
|
+
"main": "./dist/index.js",
|
|
60
|
+
"types": "./dist/index.d.ts"
|
|
61
|
+
}
|