@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- First release: `contentHealthPlugin` with checks for missing alt text on images and for meta
|
|
6
|
+
titles, descriptions and share images (defaults follow `@payloadcms/plugin-seo`), per locale for
|
|
7
|
+
localized fields.
|
|
8
|
+
- Page body checks with `content`: missing H1, more than one H1, skipped heading levels (block
|
|
9
|
+
heading levels from a per-block map, rich text headings by their tag) and thin content (default
|
|
10
|
+
under 300 words).
|
|
11
|
+
- Dashboard widget `content-health` listing findings per check, linking to each document in the
|
|
12
|
+
right locale.
|
|
13
|
+
- `scanContentHealth` runs the configured checks with a user's access.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simmalugnt AB
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# @simmalugnt-se/payload-content-health
|
|
2
|
+
|
|
3
|
+
Deterministic content checks for Payload, shown as a dashboard widget: images without alt text,
|
|
4
|
+
missing or badly sized meta titles and descriptions, and pages without a share image. No AI; every
|
|
5
|
+
finding points at one field of one document, in one locale when the field is localized.
|
|
6
|
+
|
|
7
|
+
Requires Payload `>=3.90.2 <4` and React 19. The widget uses Payload's modular dashboard
|
|
8
|
+
(`admin.dashboard.widgets`), which Payload still marks as experimental.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
1. Install:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add @simmalugnt-se/payload-content-health
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
2. Choose the checks per collection, then regenerate the import map:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { contentHealthPlugin } from "@simmalugnt-se/payload-content-health";
|
|
22
|
+
|
|
23
|
+
plugins: [
|
|
24
|
+
seoPlugin({ collections: ["pages"], uploadsCollection: "media" }),
|
|
25
|
+
contentHealthPlugin({
|
|
26
|
+
collections: {
|
|
27
|
+
pages: { seo: true },
|
|
28
|
+
media: { altText: true },
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
];
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pnpm payload generate:importmap
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Checks
|
|
39
|
+
|
|
40
|
+
| Check | Option | Finds |
|
|
41
|
+
| ------------------------- | ------------------- | ---------------------------------------------- |
|
|
42
|
+
| `media.alt-missing` | `altText` | Images (`image/*`) with an empty alt field |
|
|
43
|
+
| `seo.title-missing` | `seo` | Empty meta title |
|
|
44
|
+
| `seo.title-length` | `seo` | Meta title outside 50–60 characters |
|
|
45
|
+
| `seo.description-missing` | `seo` | Empty meta description |
|
|
46
|
+
| `seo.description-length` | `seo` | Meta description outside 100–150 characters |
|
|
47
|
+
| `seo.image-missing` | `seo` | No share image |
|
|
48
|
+
| `content.h1-missing` | `content.headings` | No H1 on the page |
|
|
49
|
+
| `content.h1-multiple` | `content.headings` | More than one H1 |
|
|
50
|
+
| `content.heading-skip` | `content.headings` | A heading level skipped, e.g. H2 → H4 |
|
|
51
|
+
| `content.thin` | `content` | Fewer than 300 words on the page |
|
|
52
|
+
|
|
53
|
+
`seo: true` reads the fields `@payloadcms/plugin-seo` adds (`meta.title`, `meta.description`,
|
|
54
|
+
`meta.image`) with its default lengths. Point them elsewhere or turn single checks off:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
pages: {
|
|
58
|
+
seo: {
|
|
59
|
+
description: "seo.summary",
|
|
60
|
+
descriptionLength: { min: 80, max: 160 },
|
|
61
|
+
image: false,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
media: { altText: { field: "altText" } },
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Page body
|
|
68
|
+
|
|
69
|
+
`content` points at the blocks or rich text field holding the page body. Word counts cover every
|
|
70
|
+
text, textarea and rich text field in it. Only the frontend knows which block field it renders as
|
|
71
|
+
which heading, so the heading checks need a map of levels per block; headings inside rich text count
|
|
72
|
+
by their own tag. Array fields fan out, so `"items.title": 3` makes every item's title an H3:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
pages: {
|
|
76
|
+
content: {
|
|
77
|
+
field: "layout",
|
|
78
|
+
headings: {
|
|
79
|
+
hero: { headline: 1 },
|
|
80
|
+
cards: { heading: 2, "items.title": 3 },
|
|
81
|
+
},
|
|
82
|
+
minWords: 300, // or false
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Each page gets at most one finding per check: the first skipped level, the second H1.
|
|
88
|
+
|
|
89
|
+
Checks read the latest version of each document, drafts included, with the signed-in user's access.
|
|
90
|
+
Localized fields are checked in every locale without fallback, so a missing Swedish alt text is
|
|
91
|
+
reported even when the English one exists.
|
|
92
|
+
|
|
93
|
+
## Dashboard widget
|
|
94
|
+
|
|
95
|
+
The plugin registers the widget `content-health`. When the project sets no
|
|
96
|
+
`admin.dashboard.defaultLayout`, the plugin sets one with Payload's collection cards followed by the
|
|
97
|
+
widget. Payload keeps each editor's own layout as a preference once they edit the dashboard; they add
|
|
98
|
+
the widget from the dashboard's edit mode, and can limit it to some checks in its settings.
|
|
99
|
+
|
|
100
|
+
Pass `addToDefaultLayout: false` to only register the widget.
|
|
101
|
+
|
|
102
|
+
## Running the checks yourself
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { scanContentHealth } from "@simmalugnt-se/payload-content-health";
|
|
106
|
+
|
|
107
|
+
const { findings, problems } = await scanContentHealth({ payload, user: req.user });
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Each finding is `{ check, collection, id, title, path, locale?, length?, status? }`. `problems`
|
|
111
|
+
lists configured checks that could not run, such as a field path that does not exist.
|
package/dist/checks.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ContentSchema } from "./fields.ts";
|
|
2
|
+
import type { CheckId, CollectionHealthOptions, Finding, HeadingLevels, LengthRange } from "./types.ts";
|
|
3
|
+
export declare const SEO_DEFAULTS: {
|
|
4
|
+
readonly title: "meta.title";
|
|
5
|
+
readonly description: "meta.description";
|
|
6
|
+
readonly image: "meta.image";
|
|
7
|
+
readonly titleLength: {
|
|
8
|
+
readonly min: 50;
|
|
9
|
+
readonly max: 60;
|
|
10
|
+
};
|
|
11
|
+
readonly descriptionLength: {
|
|
12
|
+
readonly min: 100;
|
|
13
|
+
readonly max: 150;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export declare const DEFAULT_MIN_WORDS = 300;
|
|
17
|
+
export type FieldRule = {
|
|
18
|
+
check: CheckId;
|
|
19
|
+
kind: "required";
|
|
20
|
+
path: string;
|
|
21
|
+
imagesOnly?: boolean;
|
|
22
|
+
} | {
|
|
23
|
+
check: CheckId;
|
|
24
|
+
kind: "length";
|
|
25
|
+
path: string;
|
|
26
|
+
range: LengthRange;
|
|
27
|
+
}
|
|
28
|
+
/** Heading checks on the page body at `path`: missing H1, several H1s, skipped levels. */
|
|
29
|
+
| {
|
|
30
|
+
check: CheckId;
|
|
31
|
+
kind: "outline";
|
|
32
|
+
path: string;
|
|
33
|
+
headings: HeadingLevels;
|
|
34
|
+
} | {
|
|
35
|
+
check: CheckId;
|
|
36
|
+
kind: "words";
|
|
37
|
+
path: string;
|
|
38
|
+
min: number;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* A rule with what the collection's config says about its field; page body rules also carry where
|
|
42
|
+
* the body keeps its text.
|
|
43
|
+
*/
|
|
44
|
+
export type ResolvedRule = FieldRule & {
|
|
45
|
+
localized: boolean;
|
|
46
|
+
content?: ContentSchema;
|
|
47
|
+
};
|
|
48
|
+
export declare function rulesFor(options: CollectionHealthOptions): FieldRule[];
|
|
49
|
+
export type DocumentInput = {
|
|
50
|
+
collection: string;
|
|
51
|
+
doc: Record<string, unknown>;
|
|
52
|
+
rules: ResolvedRule[];
|
|
53
|
+
title: string;
|
|
54
|
+
/** The locale the document was read in; unset without localization. */
|
|
55
|
+
locale?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Run the rules on fields that are not localized too. The scan reads a document once per locale,
|
|
58
|
+
* so it sets this on the first read only and those fields are reported once.
|
|
59
|
+
*/
|
|
60
|
+
includeUnlocalized: boolean;
|
|
61
|
+
};
|
|
62
|
+
export type DocumentResult = {
|
|
63
|
+
findings: Finding[];
|
|
64
|
+
/**
|
|
65
|
+
* One entry per field value a check applied to: images only for alt text, non-empty values only
|
|
66
|
+
* for lengths. With the findings this gives each check's share of values in order.
|
|
67
|
+
*/
|
|
68
|
+
checked: CheckId[];
|
|
69
|
+
};
|
|
70
|
+
export declare function checkDocument(input: DocumentInput): DocumentResult;
|
|
71
|
+
/** What the admin shows as the document's name; an image without alt text still needs one. */
|
|
72
|
+
export declare function documentTitle(doc: Record<string, unknown>, useAsTitle: string | undefined): string;
|
|
73
|
+
export declare function valueAt(doc: unknown, path: string): unknown;
|
package/dist/checks.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The check engine: plain functions over documents as the Local API returns them, read in one
|
|
3
|
+
* locale at a time. Nothing here talks to Payload, so every check is testable on its own.
|
|
4
|
+
*/
|
|
5
|
+
import { readContent } from "./content.js";
|
|
6
|
+
export const SEO_DEFAULTS = {
|
|
7
|
+
title: "meta.title",
|
|
8
|
+
description: "meta.description",
|
|
9
|
+
image: "meta.image",
|
|
10
|
+
titleLength: { min: 50, max: 60 },
|
|
11
|
+
descriptionLength: { min: 100, max: 150 },
|
|
12
|
+
};
|
|
13
|
+
export const DEFAULT_MIN_WORDS = 300;
|
|
14
|
+
export function rulesFor(options) {
|
|
15
|
+
const rules = [];
|
|
16
|
+
if (options.altText) {
|
|
17
|
+
const field = (options.altText === true ? undefined : options.altText.field) ?? "alt";
|
|
18
|
+
rules.push({ check: "media.alt-missing", kind: "required", path: field, imagesOnly: true });
|
|
19
|
+
}
|
|
20
|
+
if (options.seo) {
|
|
21
|
+
const seo = options.seo === true ? {} : options.seo;
|
|
22
|
+
const title = seo.title ?? SEO_DEFAULTS.title;
|
|
23
|
+
const description = seo.description ?? SEO_DEFAULTS.description;
|
|
24
|
+
const image = seo.image ?? SEO_DEFAULTS.image;
|
|
25
|
+
const titleLength = seo.titleLength ?? SEO_DEFAULTS.titleLength;
|
|
26
|
+
const descriptionLength = seo.descriptionLength ?? SEO_DEFAULTS.descriptionLength;
|
|
27
|
+
if (title) {
|
|
28
|
+
rules.push({ check: "seo.title-missing", kind: "required", path: title });
|
|
29
|
+
if (titleLength) {
|
|
30
|
+
rules.push({ check: "seo.title-length", kind: "length", path: title, range: titleLength });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (description) {
|
|
34
|
+
rules.push({ check: "seo.description-missing", kind: "required", path: description });
|
|
35
|
+
if (descriptionLength) {
|
|
36
|
+
rules.push({
|
|
37
|
+
check: "seo.description-length",
|
|
38
|
+
kind: "length",
|
|
39
|
+
path: description,
|
|
40
|
+
range: descriptionLength,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (image) {
|
|
45
|
+
rules.push({ check: "seo.image-missing", kind: "required", path: image });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (options.content) {
|
|
49
|
+
const { field, headings, minWords = DEFAULT_MIN_WORDS } = options.content;
|
|
50
|
+
if (headings) {
|
|
51
|
+
for (const check of ["content.h1-missing", "content.h1-multiple", "content.heading-skip"]) {
|
|
52
|
+
rules.push({ check: check, kind: "outline", path: field, headings });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (minWords !== false) {
|
|
56
|
+
rules.push({ check: "content.thin", kind: "words", path: field, min: minWords });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return rules;
|
|
60
|
+
}
|
|
61
|
+
export function checkDocument(input) {
|
|
62
|
+
const { collection, doc, locale, title } = input;
|
|
63
|
+
const id = doc.id;
|
|
64
|
+
const status = doc._status === "draft" || doc._status === "published" ? doc._status : undefined;
|
|
65
|
+
const findings = [];
|
|
66
|
+
const checked = [];
|
|
67
|
+
const bodies = new Map();
|
|
68
|
+
// The word count and the heading checks read the same body once, with the heading levels.
|
|
69
|
+
const body = (rule) => {
|
|
70
|
+
let content = bodies.get(rule.path);
|
|
71
|
+
if (!content) {
|
|
72
|
+
const outline = input.rules.find((other) => other.kind === "outline" && other.path === rule.path);
|
|
73
|
+
content = rule.content
|
|
74
|
+
? readContent(valueAt(doc, rule.path), rule.path, rule.content, outline?.headings)
|
|
75
|
+
: { headings: [], words: 0 };
|
|
76
|
+
bodies.set(rule.path, content);
|
|
77
|
+
}
|
|
78
|
+
return content;
|
|
79
|
+
};
|
|
80
|
+
for (const rule of input.rules) {
|
|
81
|
+
if (!rule.localized && !input.includeUnlocalized) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const base = {
|
|
85
|
+
check: rule.check,
|
|
86
|
+
collection,
|
|
87
|
+
id,
|
|
88
|
+
title,
|
|
89
|
+
path: rule.path,
|
|
90
|
+
...(rule.localized && locale ? { locale } : {}),
|
|
91
|
+
...(status ? { status } : {}),
|
|
92
|
+
};
|
|
93
|
+
if (rule.kind === "outline" || rule.kind === "words") {
|
|
94
|
+
checked.push(rule.check);
|
|
95
|
+
const finding = checkBody(rule, body(rule), base);
|
|
96
|
+
if (finding) {
|
|
97
|
+
findings.push(finding);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const value = valueAt(doc, rule.path);
|
|
102
|
+
if (rule.kind === "required") {
|
|
103
|
+
if (rule.imagesOnly && !isImage(doc)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
checked.push(rule.check);
|
|
107
|
+
if (isEmpty(value)) {
|
|
108
|
+
findings.push(base);
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (typeof value !== "string" || isEmpty(value)) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
checked.push(rule.check);
|
|
116
|
+
const actual = [...value.trim()].length;
|
|
117
|
+
if (actual < rule.range.min || actual > rule.range.max) {
|
|
118
|
+
findings.push({ ...base, length: { ...rule.range, actual } });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { findings, checked };
|
|
122
|
+
}
|
|
123
|
+
/** At most one finding per document and check, so counts are documents, like the other checks. */
|
|
124
|
+
function checkBody(rule, { headings, words }, base) {
|
|
125
|
+
if (rule.kind === "words") {
|
|
126
|
+
return words < rule.min ? { ...base, words: { actual: words, min: rule.min } } : undefined;
|
|
127
|
+
}
|
|
128
|
+
const h1s = headings.filter((heading) => heading.level === 1);
|
|
129
|
+
switch (rule.check) {
|
|
130
|
+
case "content.h1-missing":
|
|
131
|
+
return h1s.length ? undefined : base;
|
|
132
|
+
case "content.h1-multiple": {
|
|
133
|
+
const second = h1s[1];
|
|
134
|
+
return second
|
|
135
|
+
? { ...base, path: second.path, heading: { level: 1, count: h1s.length } }
|
|
136
|
+
: undefined;
|
|
137
|
+
}
|
|
138
|
+
case "content.heading-skip": {
|
|
139
|
+
for (const [index, heading] of headings.entries()) {
|
|
140
|
+
const previous = headings[index - 1]?.level;
|
|
141
|
+
if (previous !== undefined && heading.level > previous + 1) {
|
|
142
|
+
return { ...base, path: heading.path, heading: { level: heading.level, previous } };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
default:
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** What the admin shows as the document's name; an image without alt text still needs one. */
|
|
152
|
+
export function documentTitle(doc, useAsTitle) {
|
|
153
|
+
for (const candidate of [useAsTitle && valueAt(doc, useAsTitle), doc.filename]) {
|
|
154
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
155
|
+
return candidate.trim();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return String(doc.id);
|
|
159
|
+
}
|
|
160
|
+
export function valueAt(doc, path) {
|
|
161
|
+
let value = doc;
|
|
162
|
+
for (const segment of path.split(".")) {
|
|
163
|
+
if (value === null || typeof value !== "object") {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
value = value[segment];
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
function isEmpty(value) {
|
|
171
|
+
return value === undefined || value === null || (typeof value === "string" && !value.trim());
|
|
172
|
+
}
|
|
173
|
+
function isImage(doc) {
|
|
174
|
+
return typeof doc.mimeType === "string" && doc.mimeType.startsWith("image/");
|
|
175
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a page body as a reader sees it: its headings in order, with their levels, and its word
|
|
3
|
+
* count. Block fields get their level from the project's heading map; rich text headings carry
|
|
4
|
+
* their own tag.
|
|
5
|
+
*/
|
|
6
|
+
import type { ContentSchema } from "./fields.ts";
|
|
7
|
+
import type { HeadingLevels } from "./types.ts";
|
|
8
|
+
export type Heading = {
|
|
9
|
+
level: number;
|
|
10
|
+
text: string;
|
|
11
|
+
/** Form path of the field holding the heading, e.g. `layout.1.heading`. */
|
|
12
|
+
path: string;
|
|
13
|
+
};
|
|
14
|
+
export type PageContent = {
|
|
15
|
+
headings: Heading[];
|
|
16
|
+
words: number;
|
|
17
|
+
};
|
|
18
|
+
export declare function readContent(value: unknown, root: string, schema: ContentSchema, headingLevels?: HeadingLevels): PageContent;
|
|
19
|
+
/** Values at a dot path, one per array item on the way, with their concrete paths. */
|
|
20
|
+
export declare function fanOut(value: unknown, path: string): {
|
|
21
|
+
path: string;
|
|
22
|
+
value: unknown;
|
|
23
|
+
}[];
|
|
24
|
+
export declare function countWords(text: string): number;
|
package/dist/content.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export function readContent(value, root, schema, headingLevels = {}) {
|
|
2
|
+
const content = { headings: [], words: 0 };
|
|
3
|
+
if (schema.kind === "richText") {
|
|
4
|
+
readLexical(value, root, content);
|
|
5
|
+
return content;
|
|
6
|
+
}
|
|
7
|
+
if (!Array.isArray(value)) {
|
|
8
|
+
return content;
|
|
9
|
+
}
|
|
10
|
+
for (const [index, block] of value.entries()) {
|
|
11
|
+
const blockType = block?.blockType;
|
|
12
|
+
if (typeof blockType !== "string") {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const levels = headingLevels[blockType] ?? {};
|
|
16
|
+
for (const field of schema.blocks[blockType] ?? []) {
|
|
17
|
+
for (const { path, value: fieldValue } of fanOut(block, field.path)) {
|
|
18
|
+
const fullPath = `${root}.${index}.${path}`;
|
|
19
|
+
if (field.kind === "richText") {
|
|
20
|
+
readLexical(fieldValue, fullPath, content);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (typeof fieldValue !== "string" || !fieldValue.trim()) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
content.words += countWords(fieldValue);
|
|
27
|
+
const level = levels[field.path];
|
|
28
|
+
if (level) {
|
|
29
|
+
content.headings.push({ level, text: fieldValue.trim(), path: fullPath });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return content;
|
|
35
|
+
}
|
|
36
|
+
/** Values at a dot path, one per array item on the way, with their concrete paths. */
|
|
37
|
+
export function fanOut(value, path) {
|
|
38
|
+
let current = [{ path: "", value }];
|
|
39
|
+
for (const segment of path.split(".")) {
|
|
40
|
+
const next = [];
|
|
41
|
+
for (const entry of current) {
|
|
42
|
+
const items = Array.isArray(entry.value)
|
|
43
|
+
? entry.value.map((item, index) => ({ path: join(entry.path, String(index)), value: item }))
|
|
44
|
+
: [entry];
|
|
45
|
+
for (const item of items) {
|
|
46
|
+
if (item.value !== null && typeof item.value === "object") {
|
|
47
|
+
next.push({
|
|
48
|
+
path: join(item.path, segment),
|
|
49
|
+
value: item.value[segment],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
current = next;
|
|
55
|
+
}
|
|
56
|
+
return current;
|
|
57
|
+
}
|
|
58
|
+
function readLexical(value, path, content) {
|
|
59
|
+
const root = value?.root;
|
|
60
|
+
if (root) {
|
|
61
|
+
walk(root);
|
|
62
|
+
}
|
|
63
|
+
function walk(node) {
|
|
64
|
+
if (node.type === "text" && typeof node.text === "string") {
|
|
65
|
+
content.words += countWords(node.text);
|
|
66
|
+
}
|
|
67
|
+
if (node.type === "heading" && typeof node.tag === "string" && /^h[1-6]$/.test(node.tag)) {
|
|
68
|
+
const text = textOf(node).trim();
|
|
69
|
+
if (text) {
|
|
70
|
+
content.headings.push({ level: Number(node.tag.slice(1)), text, path });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(node.children)) {
|
|
74
|
+
for (const child of node.children) {
|
|
75
|
+
walk(child);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function textOf(node) {
|
|
81
|
+
if (typeof node.text === "string") {
|
|
82
|
+
return node.text;
|
|
83
|
+
}
|
|
84
|
+
return Array.isArray(node.children) ? node.children.map(textOf).join("") : "";
|
|
85
|
+
}
|
|
86
|
+
export function countWords(text) {
|
|
87
|
+
return text.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu)?.length ?? 0;
|
|
88
|
+
}
|
|
89
|
+
function join(base, segment) {
|
|
90
|
+
return base ? `${base}.${segment}` : segment;
|
|
91
|
+
}
|
package/dist/copy.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Everything the widget says, in Admin's language. */
|
|
2
|
+
import type { CheckId } from "./types.ts";
|
|
3
|
+
export type UiLanguage = "sv" | "en";
|
|
4
|
+
export declare function uiLanguage(adminLanguage: string | undefined): UiLanguage;
|
|
5
|
+
export declare const CHECK_LABELS: Record<UiLanguage, Record<CheckId, string>>;
|
|
6
|
+
/** Tile headings: what is measured, so "0 of 4 filled in" reads without a double negative. */
|
|
7
|
+
export declare const TILE_LABELS: Record<UiLanguage, Record<CheckId, string>>;
|
|
8
|
+
type Status = (ok: number, total: number) => string;
|
|
9
|
+
/** A tile's "x of y" line, phrased as what is in order for that check. */
|
|
10
|
+
export declare const STATUS: Record<UiLanguage, Record<CheckId, Status>>;
|
|
11
|
+
export declare const COPY: {
|
|
12
|
+
readonly en: {
|
|
13
|
+
readonly title: "Content health";
|
|
14
|
+
readonly summary: (withIssues: number, checked: number) => string;
|
|
15
|
+
readonly allGood: "No issues found.";
|
|
16
|
+
readonly overall: "of everything checked is in order";
|
|
17
|
+
readonly ok: (ok: number, total: number) => string;
|
|
18
|
+
readonly checkPasses: "All good";
|
|
19
|
+
readonly nothingYet: "Nothing to measure yet";
|
|
20
|
+
readonly passing: (count: number) => string;
|
|
21
|
+
readonly more: (count: number) => string;
|
|
22
|
+
readonly length: (actual: number, min: number, max: number) => string;
|
|
23
|
+
readonly headingSkip: (previous: number, level: number) => string;
|
|
24
|
+
readonly h1Count: (count: number) => string;
|
|
25
|
+
readonly words: (actual: number, min: number) => string;
|
|
26
|
+
readonly problems: "Some checks could not run:";
|
|
27
|
+
};
|
|
28
|
+
readonly sv: {
|
|
29
|
+
readonly title: "Innehållshälsa";
|
|
30
|
+
readonly summary: (withIssues: number, checked: number) => string;
|
|
31
|
+
readonly allGood: "Inga problem hittades.";
|
|
32
|
+
readonly overall: "av allt som kontrolleras är i ordning";
|
|
33
|
+
readonly ok: (ok: number, total: number) => string;
|
|
34
|
+
readonly checkPasses: "Allt i ordning";
|
|
35
|
+
readonly nothingYet: "Inget att mäta än";
|
|
36
|
+
readonly passing: (count: number) => string;
|
|
37
|
+
readonly more: (count: number) => string;
|
|
38
|
+
readonly length: (actual: number, min: number, max: number) => string;
|
|
39
|
+
readonly headingSkip: (previous: number, level: number) => string;
|
|
40
|
+
readonly h1Count: (count: number) => string;
|
|
41
|
+
readonly words: (actual: number, min: number) => string;
|
|
42
|
+
readonly problems: "Några kontroller kunde inte köras:";
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
export {};
|
package/dist/copy.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export function uiLanguage(adminLanguage) {
|
|
2
|
+
return adminLanguage?.startsWith("sv") ? "sv" : "en";
|
|
3
|
+
}
|
|
4
|
+
export const CHECK_LABELS = {
|
|
5
|
+
en: {
|
|
6
|
+
"media.alt-missing": "Missing alt text",
|
|
7
|
+
"seo.title-missing": "Missing meta title",
|
|
8
|
+
"seo.title-length": "Meta title length",
|
|
9
|
+
"seo.description-missing": "Missing meta description",
|
|
10
|
+
"seo.description-length": "Meta description length",
|
|
11
|
+
"seo.image-missing": "Missing share image",
|
|
12
|
+
"content.h1-missing": "Missing H1",
|
|
13
|
+
"content.h1-multiple": "More than one H1",
|
|
14
|
+
"content.heading-skip": "Skipped heading levels",
|
|
15
|
+
"content.thin": "Thin content",
|
|
16
|
+
},
|
|
17
|
+
sv: {
|
|
18
|
+
"media.alt-missing": "Alt-text saknas",
|
|
19
|
+
"seo.title-missing": "Metatitel saknas",
|
|
20
|
+
"seo.title-length": "Metatitelns längd",
|
|
21
|
+
"seo.description-missing": "Metabeskrivning saknas",
|
|
22
|
+
"seo.description-length": "Metabeskrivningens längd",
|
|
23
|
+
"seo.image-missing": "Delningsbild saknas",
|
|
24
|
+
"content.h1-missing": "H1 saknas",
|
|
25
|
+
"content.h1-multiple": "Fler än en H1",
|
|
26
|
+
"content.heading-skip": "Överhoppade rubriknivåer",
|
|
27
|
+
"content.thin": "Tunt innehåll",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
/** Tile headings: what is measured, so "0 of 4 filled in" reads without a double negative. */
|
|
31
|
+
export const TILE_LABELS = {
|
|
32
|
+
en: {
|
|
33
|
+
"media.alt-missing": "Alt texts",
|
|
34
|
+
"seo.title-missing": "Meta titles",
|
|
35
|
+
"seo.title-length": "Meta title length",
|
|
36
|
+
"seo.description-missing": "Meta descriptions",
|
|
37
|
+
"seo.description-length": "Meta description length",
|
|
38
|
+
"seo.image-missing": "Share images",
|
|
39
|
+
"content.h1-missing": "Main heading",
|
|
40
|
+
"content.h1-multiple": "Single main heading",
|
|
41
|
+
"content.heading-skip": "Heading order",
|
|
42
|
+
"content.thin": "Page length",
|
|
43
|
+
},
|
|
44
|
+
sv: {
|
|
45
|
+
"media.alt-missing": "Alt-texter",
|
|
46
|
+
"seo.title-missing": "Metatitlar",
|
|
47
|
+
"seo.title-length": "Metatitlarnas längd",
|
|
48
|
+
"seo.description-missing": "Metabeskrivningar",
|
|
49
|
+
"seo.description-length": "Metabeskrivningarnas längd",
|
|
50
|
+
"seo.image-missing": "Delningsbilder",
|
|
51
|
+
"content.h1-missing": "Huvudrubrik",
|
|
52
|
+
"content.h1-multiple": "En huvudrubrik",
|
|
53
|
+
"content.heading-skip": "Rubrikordning",
|
|
54
|
+
"content.thin": "Sidlängd",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
/** A tile's "x of y" line, phrased as what is in order for that check. */
|
|
58
|
+
export const STATUS = {
|
|
59
|
+
en: {
|
|
60
|
+
"media.alt-missing": (ok, total) => `${ok} of ${total} filled in`,
|
|
61
|
+
"seo.title-missing": (ok, total) => `${ok} of ${total} filled in`,
|
|
62
|
+
"seo.title-length": (ok, total) => `${ok} of ${total} the right length`,
|
|
63
|
+
"seo.description-missing": (ok, total) => `${ok} of ${total} filled in`,
|
|
64
|
+
"seo.description-length": (ok, total) => `${ok} of ${total} the right length`,
|
|
65
|
+
"seo.image-missing": (ok, total) => `${ok} of ${total} filled in`,
|
|
66
|
+
"content.h1-missing": (ok, total) => `${ok} of ${total} pages have an H1`,
|
|
67
|
+
"content.h1-multiple": (ok, total) => `${ok} of ${total} pages have at most one`,
|
|
68
|
+
"content.heading-skip": (ok, total) => `${ok} of ${total} pages in order`,
|
|
69
|
+
"content.thin": (ok, total) => `${ok} of ${total} pages long enough`,
|
|
70
|
+
},
|
|
71
|
+
sv: {
|
|
72
|
+
"media.alt-missing": (ok, total) => `${ok} av ${total} ifyllda`,
|
|
73
|
+
"seo.title-missing": (ok, total) => `${ok} av ${total} ifyllda`,
|
|
74
|
+
"seo.title-length": (ok, total) => `${ok} av ${total} med rätt längd`,
|
|
75
|
+
"seo.description-missing": (ok, total) => `${ok} av ${total} ifyllda`,
|
|
76
|
+
"seo.description-length": (ok, total) => `${ok} av ${total} med rätt längd`,
|
|
77
|
+
"seo.image-missing": (ok, total) => `${ok} av ${total} ifyllda`,
|
|
78
|
+
"content.h1-missing": (ok, total) => `${ok} av ${total} sidor har en H1`,
|
|
79
|
+
"content.h1-multiple": (ok, total) => `${ok} av ${total} sidor har högst en`,
|
|
80
|
+
"content.heading-skip": (ok, total) => `${ok} av ${total} sidor i ordning`,
|
|
81
|
+
"content.thin": (ok, total) => `${ok} av ${total} sidor tillräckligt långa`,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
export const COPY = {
|
|
85
|
+
en: {
|
|
86
|
+
title: "Content health",
|
|
87
|
+
summary: (withIssues, checked) => `${withIssues} of ${checked} documents need attention`,
|
|
88
|
+
allGood: "No issues found.",
|
|
89
|
+
overall: "of everything checked is in order",
|
|
90
|
+
ok: (ok, total) => `${ok} of ${total} OK`,
|
|
91
|
+
checkPasses: "All good",
|
|
92
|
+
nothingYet: "Nothing to measure yet",
|
|
93
|
+
passing: (count) => `${count} ${count === 1 ? "check passes" : "checks pass"}`,
|
|
94
|
+
more: (count) => `${count} more`,
|
|
95
|
+
length: (actual, min, max) => `${actual} characters, aim for ${min}–${max}`,
|
|
96
|
+
headingSkip: (previous, level) => `H${previous} → H${level}`,
|
|
97
|
+
h1Count: (count) => `${count} H1s`,
|
|
98
|
+
words: (actual, min) => `${actual} ${actual === 1 ? "word" : "words"}, aim for ${min}+`,
|
|
99
|
+
problems: "Some checks could not run:",
|
|
100
|
+
},
|
|
101
|
+
sv: {
|
|
102
|
+
title: "Innehållshälsa",
|
|
103
|
+
summary: (withIssues, checked) => `${withIssues} av ${checked} dokument behöver åtgärdas`,
|
|
104
|
+
allGood: "Inga problem hittades.",
|
|
105
|
+
overall: "av allt som kontrolleras är i ordning",
|
|
106
|
+
ok: (ok, total) => `${ok} av ${total} OK`,
|
|
107
|
+
checkPasses: "Allt i ordning",
|
|
108
|
+
nothingYet: "Inget att mäta än",
|
|
109
|
+
passing: (count) => `${count} ${count === 1 ? "kontroll godkänd" : "kontroller godkända"}`,
|
|
110
|
+
more: (count) => `${count} till`,
|
|
111
|
+
length: (actual, min, max) => `${actual} tecken, sikta på ${min}–${max}`,
|
|
112
|
+
headingSkip: (previous, level) => `H${previous} → H${level}`,
|
|
113
|
+
h1Count: (count) => `${count} H1:or`,
|
|
114
|
+
words: (actual, min) => `${actual} ord, sikta på minst ${min}`,
|
|
115
|
+
problems: "Några kontroller kunde inte köras:",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ContentHealthWidget } from "../widget/ContentHealthWidget.tsx";
|