@simmalugnt-se/payload-content-health 0.7.1 → 0.8.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 +6 -0
- package/README.md +10 -3
- package/dist/assistant.d.ts +56 -0
- package/dist/assistant.js +204 -0
- package/dist/group.d.ts +2 -0
- package/dist/group.js +9 -0
- package/dist/plugin.js +10 -1
- package/dist/view/ContentHealthView.js +2 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- The editor assistant (0.15.0 or later, with `extensions: { "content-health": true }`) reads the
|
|
6
|
+
findings itself: `content-health.findings` and `content-health.open`. Editors can ask what to fix
|
|
7
|
+
first or what is wrong with a page, and be taken to the work list or a batch.
|
|
8
|
+
|
|
3
9
|
## 0.7.1
|
|
4
10
|
|
|
5
11
|
- npm links to simmalugnt.se instead of the private source repository.
|
package/README.md
CHANGED
|
@@ -22,11 +22,11 @@ Requires Payload `>=3.90.2 <4`, Next 16 and React 19. The widget uses Payload's
|
|
|
22
22
|
import { contentHealthPlugin } from "@simmalugnt-se/payload-content-health";
|
|
23
23
|
|
|
24
24
|
plugins: [
|
|
25
|
-
seoPlugin({ collections: ["pages"], uploadsCollection: "
|
|
25
|
+
seoPlugin({ collections: ["pages"], uploadsCollection: "images" }),
|
|
26
26
|
contentHealthPlugin({
|
|
27
27
|
collections: {
|
|
28
28
|
pages: { seo: true },
|
|
29
|
-
|
|
29
|
+
images: { altText: true },
|
|
30
30
|
},
|
|
31
31
|
}),
|
|
32
32
|
];
|
|
@@ -71,7 +71,7 @@ pages: {
|
|
|
71
71
|
duplicates: false,
|
|
72
72
|
},
|
|
73
73
|
},
|
|
74
|
-
|
|
74
|
+
images: { altText: { field: "altText" } },
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
### Page body
|
|
@@ -167,6 +167,13 @@ other; they share these names:
|
|
|
167
167
|
| `config.admin.custom.editorAssistant` | assistant → this plugin | `{ collections, batchCollections }` |
|
|
168
168
|
| `?batch=…&batchScope=…&ask=…` | link → assistant | Documents to fix at once, and the prompt |
|
|
169
169
|
|
|
170
|
+
With the assistant's `extensions: { "content-health": true }` (editor-assistant 0.15.0 or later),
|
|
171
|
+
the assistant also reads the findings itself, through the extension this plugin registers in
|
|
172
|
+
`config.custom.editorAssistantExtensions`: `content-health.findings` (a summary per category, then
|
|
173
|
+
findings filtered by category, check, collection, document or locale, at most 30) and
|
|
174
|
+
`content-health.open` (the work list, or the batch for one check). Editors can ask "what should I
|
|
175
|
+
fix first?" on the dashboard or "what is wrong with this page?" on a document.
|
|
176
|
+
|
|
170
177
|
Both parameters are removed from the address once read. `editURL(adminRoute, finding, prompt?)` and
|
|
171
178
|
`listURL(adminRoute, filters)` build these links.
|
|
172
179
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import { type ScanResult, scanContentHealth } from "./scan.ts";
|
|
3
|
+
/**
|
|
4
|
+
* How content health gives `@simmalugnt-se/payload-editor-assistant` its findings, without an
|
|
5
|
+
* import: an extension in `config.custom[EXTENSIONS_CUSTOM_KEY]`, which the assistant uses once the
|
|
6
|
+
* host turns it on (`extensions: { "content-health": true }`). The key and types are copies of the
|
|
7
|
+
* assistant's (`extensions/contract.ts`); the assistant's contract tests compare them.
|
|
8
|
+
*/
|
|
9
|
+
export declare const EXTENSIONS_CUSTOM_KEY = "editorAssistantExtensions";
|
|
10
|
+
export type AssistantExtension = {
|
|
11
|
+
id: string;
|
|
12
|
+
instructions?: string;
|
|
13
|
+
views?: Array<{
|
|
14
|
+
path: string;
|
|
15
|
+
description: string;
|
|
16
|
+
}>;
|
|
17
|
+
tools?: AssistantExtensionTool[];
|
|
18
|
+
};
|
|
19
|
+
export type AssistantExtensionTool = {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
input: Record<string, unknown>;
|
|
23
|
+
risk: "auto";
|
|
24
|
+
run: (input: Record<string, unknown>, context: AssistantExtensionContext) => Promise<AssistantExtensionResult>;
|
|
25
|
+
};
|
|
26
|
+
export type AssistantExtensionContext = {
|
|
27
|
+
req: PayloadRequest;
|
|
28
|
+
path?: string;
|
|
29
|
+
open?: {
|
|
30
|
+
collection?: string;
|
|
31
|
+
global?: string;
|
|
32
|
+
id?: string | number;
|
|
33
|
+
locale: string;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
export type AssistantExtensionResult = {
|
|
37
|
+
ok: true;
|
|
38
|
+
data: unknown;
|
|
39
|
+
navigate?: {
|
|
40
|
+
href: string;
|
|
41
|
+
label: string;
|
|
42
|
+
};
|
|
43
|
+
} | {
|
|
44
|
+
ok: false;
|
|
45
|
+
error: string;
|
|
46
|
+
message: string;
|
|
47
|
+
};
|
|
48
|
+
export declare const EXTENSION_ID = "content-health";
|
|
49
|
+
/** Findings one call returns at most; `more` says how many the filters left out. */
|
|
50
|
+
export declare const MAX_FINDINGS = 30;
|
|
51
|
+
type Scan = (args: Parameters<typeof scanContentHealth>[0]) => Promise<ScanResult>;
|
|
52
|
+
/** `scan` is replaceable for tests. */
|
|
53
|
+
export declare function assistantExtension({ scan: run, }?: {
|
|
54
|
+
scan?: Scan;
|
|
55
|
+
}): AssistantExtension;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { CATEGORY_IDS, categoryOf, categoryStats } from "./categories.js";
|
|
2
|
+
import { BATCH_ASK, CATEGORY_LABELS, CHECK_LABELS, findingDetail, uiLanguage } from "./copy.js";
|
|
3
|
+
import { checkStats, countDocuments, sortFindings } from "./group.js";
|
|
4
|
+
import { assistantCollections, BATCH_CHECKS, batchCollections, batchTarget, batchURL, listURL, MAX_BATCH_TARGETS, matchesFilters, VIEW_PATH, } from "./links.js";
|
|
5
|
+
import { scanContentHealth } from "./scan.js";
|
|
6
|
+
import { CHECK_IDS } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* How content health gives `@simmalugnt-se/payload-editor-assistant` its findings, without an
|
|
9
|
+
* import: an extension in `config.custom[EXTENSIONS_CUSTOM_KEY]`, which the assistant uses once the
|
|
10
|
+
* host turns it on (`extensions: { "content-health": true }`). The key and types are copies of the
|
|
11
|
+
* assistant's (`extensions/contract.ts`); the assistant's contract tests compare them.
|
|
12
|
+
*/
|
|
13
|
+
export const EXTENSIONS_CUSTOM_KEY = "editorAssistantExtensions";
|
|
14
|
+
export const EXTENSION_ID = "content-health";
|
|
15
|
+
/** Findings one call returns at most; `more` says how many the filters left out. */
|
|
16
|
+
export const MAX_FINDINGS = 30;
|
|
17
|
+
const DEFAULT_FINDINGS = 20;
|
|
18
|
+
/** One scan per turn: the turn's tool calls share the editor's request. */
|
|
19
|
+
function scanOncePerRequest(run) {
|
|
20
|
+
const scans = new WeakMap();
|
|
21
|
+
return (req) => {
|
|
22
|
+
let pending = scans.get(req);
|
|
23
|
+
if (!pending) {
|
|
24
|
+
pending = run({ payload: req.payload, user: req.user ?? null });
|
|
25
|
+
scans.set(req, pending);
|
|
26
|
+
}
|
|
27
|
+
return pending;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const filterInput = {
|
|
31
|
+
category: {
|
|
32
|
+
type: "string",
|
|
33
|
+
enum: [...CATEGORY_IDS],
|
|
34
|
+
description: "seo, structure, translations or accessibility.",
|
|
35
|
+
},
|
|
36
|
+
check: { type: "string", enum: [...CHECK_IDS], description: "One check, by id." },
|
|
37
|
+
collection: { type: "string", description: "Collection slug, such as pages or images." },
|
|
38
|
+
locale: { type: "string", description: "Only findings in this content language." },
|
|
39
|
+
};
|
|
40
|
+
/** `scan` is replaceable for tests. */
|
|
41
|
+
export function assistantExtension({ scan: run = scanContentHealth, } = {}) {
|
|
42
|
+
const scan = scanOncePerRequest(run);
|
|
43
|
+
return {
|
|
44
|
+
id: EXTENSION_ID,
|
|
45
|
+
instructions: [
|
|
46
|
+
"Content health checks the site's content: SEO (meta titles, descriptions, share images, duplicates), structure (H1, heading levels, thin content, broken internal links), translations (fields empty in a language) and accessibility (image alt texts).",
|
|
47
|
+
"When the editor asks what needs work, how the site or a page is doing, or what to fix first, call content-health.findings and answer from it, naming pages and problems. Never ask for a screenshot of content health.",
|
|
48
|
+
'Each finding says how it is fixed: "batch" (many documents at once from the work list), "assistant" (on the document, with you) or "by hand". To start fixing, call content-health.open: with batch true for a batch check, else the work list. It takes the editor there; they start the fix and review each change themselves. Say where they are now ("The 9 missing alt texts are open; Fix 9 with the assistant starts them."); never say anything is fixed or started, and do not propose changes yourself for findings outside the open document. Do not mention findings the tool left out unless the editor asks for more.',
|
|
49
|
+
].join(" "),
|
|
50
|
+
views: [
|
|
51
|
+
{
|
|
52
|
+
path: VIEW_PATH,
|
|
53
|
+
description: "The editor is on the content health work list: issues by category and check, each with a link to the document, and buttons to fix one with the assistant or several at once.",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
path: "/",
|
|
57
|
+
description: "The dashboard shows a content health widget: the share of checked fields in order, per category.",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
tools: [
|
|
61
|
+
{
|
|
62
|
+
name: `${EXTENSION_ID}.findings`,
|
|
63
|
+
description: `Content health's findings: first a summary per category (issues, fields checked, documents needing attention), then the findings that match the filters, at most ${MAX_FINDINGS}, each with the document, the problem, the field, the language and how to fix it. Without filters and with a document open: that document's findings; on other views: the whole site's. Read with the editor's access.`,
|
|
64
|
+
input: {
|
|
65
|
+
type: "object",
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
properties: {
|
|
68
|
+
...filterInput,
|
|
69
|
+
documentId: {
|
|
70
|
+
type: ["string", "number"],
|
|
71
|
+
description: "Only this document (with collection).",
|
|
72
|
+
},
|
|
73
|
+
site: {
|
|
74
|
+
type: "boolean",
|
|
75
|
+
description: "The whole site even when a document is open.",
|
|
76
|
+
},
|
|
77
|
+
limit: { type: "integer", minimum: 1, maximum: MAX_FINDINGS },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
risk: "auto",
|
|
81
|
+
run: async (input, context) => findings(input, context, await scan(context.req)),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: `${EXTENSION_ID}.open`,
|
|
85
|
+
description: `Takes the editor to the content health work list, filtered, or with batch true to the batch that fixes up to ${MAX_BATCH_TARGETS} documents of one check at once (the editor starts it and reviews each). Needs check for a batch.`,
|
|
86
|
+
input: {
|
|
87
|
+
type: "object",
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
properties: { ...filterInput, batch: { type: "boolean" } },
|
|
90
|
+
},
|
|
91
|
+
risk: "auto",
|
|
92
|
+
run: async (input, context) => open(input, context, () => scan(context.req)),
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function findings(input, { req, open }, result) {
|
|
98
|
+
const custom = req.payload.config.admin?.custom;
|
|
99
|
+
const withAssistant = new Set(assistantCollections(custom));
|
|
100
|
+
const inBatches = new Set(batchCollections(custom));
|
|
101
|
+
const filters = readInput(input);
|
|
102
|
+
const filtered = Boolean(filters.category || filters.check || filters.collection || input.documentId || input.site);
|
|
103
|
+
// With a document open and nothing asked for, its own findings.
|
|
104
|
+
const document = input.documentId !== undefined
|
|
105
|
+
? { collection: filters.collection, id: String(input.documentId) }
|
|
106
|
+
: !filtered && open?.collection && open.id !== undefined
|
|
107
|
+
? { collection: open.collection, id: String(open.id) }
|
|
108
|
+
: undefined;
|
|
109
|
+
const matching = sortFindings(result.findings).filter((finding) => matchesFilters(finding, filters) &&
|
|
110
|
+
(!document ||
|
|
111
|
+
((!document.collection || finding.collection === document.collection) &&
|
|
112
|
+
String(finding.id) === document.id)));
|
|
113
|
+
const limit = typeof input.limit === "number" ? input.limit : DEFAULT_FINDINGS;
|
|
114
|
+
const stats = checkStats(result.checks, result.checked, result.findings);
|
|
115
|
+
const documents = Object.values(result.documents).reduce((sum, count) => sum + count, 0);
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
data: {
|
|
119
|
+
summary: {
|
|
120
|
+
documentsChecked: documents,
|
|
121
|
+
documentsNeedingAttention: countDocuments(result.findings),
|
|
122
|
+
categories: categoryStats(stats).map((stat) => ({
|
|
123
|
+
category: stat.category,
|
|
124
|
+
label: CATEGORY_LABELS.en[stat.category],
|
|
125
|
+
issues: stat.failing,
|
|
126
|
+
fieldsChecked: stat.checked,
|
|
127
|
+
})),
|
|
128
|
+
},
|
|
129
|
+
scope: document ? "one document" : filtered ? "filtered" : "whole site",
|
|
130
|
+
findings: matching.slice(0, limit).map((finding) => ({
|
|
131
|
+
check: finding.check,
|
|
132
|
+
problem: CHECK_LABELS.en[finding.check],
|
|
133
|
+
detail: findingDetail("en", finding),
|
|
134
|
+
collection: finding.collection,
|
|
135
|
+
id: finding.id,
|
|
136
|
+
title: finding.title,
|
|
137
|
+
field: finding.path,
|
|
138
|
+
...(finding.locale ? { locale: finding.locale } : {}),
|
|
139
|
+
...(finding.status === "draft" ? { draft: true } : {}),
|
|
140
|
+
fix: fixFor(finding, inBatches, withAssistant),
|
|
141
|
+
})),
|
|
142
|
+
more: Math.max(0, matching.length - limit),
|
|
143
|
+
...(result.problems.length ? { notChecked: result.problems } : {}),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async function open(input, { req }, scan) {
|
|
148
|
+
const filters = readInput(input);
|
|
149
|
+
const adminRoute = req.payload.config.routes.admin;
|
|
150
|
+
if (input.batch !== true) {
|
|
151
|
+
const category = filters.category ?? (filters.check ? categoryOf(filters.check) : undefined);
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
data: { link: "work list" },
|
|
155
|
+
navigate: {
|
|
156
|
+
href: listURL(adminRoute, { ...filters, category, check: undefined }),
|
|
157
|
+
label: "Content health",
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const check = filters.check;
|
|
162
|
+
if (!check || !BATCH_CHECKS[check]) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: "not_batchable",
|
|
166
|
+
message: "Only some checks can be fixed in a batch: name one whose findings say batch.",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const inBatches = new Set(batchCollections(req.payload.config.admin?.custom));
|
|
170
|
+
const targets = sortFindings((await scan()).findings).filter((finding) => matchesFilters(finding, filters) && inBatches.has(finding.collection));
|
|
171
|
+
if (!targets.length) {
|
|
172
|
+
return { ok: false, error: "nothing_to_fix", message: "No findings of that check to fix." };
|
|
173
|
+
}
|
|
174
|
+
const language = uiLanguage(req.i18n?.language);
|
|
175
|
+
const ask = BATCH_ASK[language][check]?.(targets[0]) ?? "";
|
|
176
|
+
const count = Math.min(targets.length, MAX_BATCH_TARGETS);
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
data: { documents: count, more: targets.length - count },
|
|
180
|
+
navigate: {
|
|
181
|
+
href: batchURL(adminRoute, { ...filters, check }, targets.map(batchTarget), ask),
|
|
182
|
+
label: language === "sv" ? `Åtgärda ${count} med assistenten` : `Fix ${count} with the assistant`,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** How a finding is fixed: many at once, on its document with the assistant, or by hand. */
|
|
187
|
+
function fixFor(finding, inBatches, withAssistant) {
|
|
188
|
+
if (BATCH_CHECKS[finding.check] && inBatches.has(finding.collection))
|
|
189
|
+
return "batch";
|
|
190
|
+
return withAssistant.has(finding.collection) ? "assistant" : "by hand";
|
|
191
|
+
}
|
|
192
|
+
function readInput(input) {
|
|
193
|
+
const text = (value) => (typeof value === "string" && value ? value : undefined);
|
|
194
|
+
const category = text(input.category);
|
|
195
|
+
const check = text(input.check);
|
|
196
|
+
return {
|
|
197
|
+
...(category && CATEGORY_IDS.includes(category)
|
|
198
|
+
? { category: category }
|
|
199
|
+
: {}),
|
|
200
|
+
...(check && CHECK_IDS.includes(check) ? { check: check } : {}),
|
|
201
|
+
...(text(input.collection) ? { collection: text(input.collection) } : {}),
|
|
202
|
+
...(text(input.locale) ? { locale: text(input.locale) } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
package/dist/group.d.ts
CHANGED
|
@@ -25,3 +25,5 @@ export type CheckStats = {
|
|
|
25
25
|
};
|
|
26
26
|
/** Per check that ran: how many values it looked at and which ones need work. */
|
|
27
27
|
export declare function checkStats(checks: CheckId[], checked: Partial<Record<CheckId, number>>, findings: Finding[]): CheckStats[];
|
|
28
|
+
/** By check, then document, then locale, so a document's locales sit together. */
|
|
29
|
+
export declare function sortFindings(findings: Finding[]): Finding[];
|
package/dist/group.js
CHANGED
|
@@ -36,3 +36,12 @@ export function checkStats(checks, checked, findings) {
|
|
|
36
36
|
return { check, checked: checked[check] ?? 0, failing, documents };
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
|
+
/** By check, then document, then locale, so a document's locales sit together. */
|
|
40
|
+
export function sortFindings(findings) {
|
|
41
|
+
return [...findings].sort((a, b) => CHECK_IDS.indexOf(a.check) - CHECK_IDS.indexOf(b.check) ||
|
|
42
|
+
a.collection.localeCompare(b.collection) ||
|
|
43
|
+
a.title.localeCompare(b.title) ||
|
|
44
|
+
String(a.id).localeCompare(String(b.id)) ||
|
|
45
|
+
a.path.localeCompare(b.path) ||
|
|
46
|
+
(a.locale ?? "").localeCompare(b.locale ?? ""));
|
|
47
|
+
}
|
package/dist/plugin.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assistantExtension, EXTENSIONS_CUSTOM_KEY } from "./assistant.js";
|
|
1
2
|
import { CHECK_LABELS } from "./copy.js";
|
|
2
3
|
import { VIEW_PATH } from "./links.js";
|
|
3
4
|
import { CLIENT_ENTRY, RSC_ENTRY } from "./package-name.js";
|
|
@@ -65,7 +66,15 @@ export function contentHealthPlugin(options) {
|
|
|
65
66
|
...(defaultLayout ? { defaultLayout } : {}),
|
|
66
67
|
},
|
|
67
68
|
},
|
|
68
|
-
custom: {
|
|
69
|
+
custom: {
|
|
70
|
+
...config.custom,
|
|
71
|
+
[CUSTOM_KEY]: options,
|
|
72
|
+
// Used by the editor assistant once the host turns it on; harmless without it.
|
|
73
|
+
[EXTENSIONS_CUSTOM_KEY]: [
|
|
74
|
+
...(config.custom?.[EXTENSIONS_CUSTOM_KEY] ?? []),
|
|
75
|
+
assistantExtension(),
|
|
76
|
+
],
|
|
77
|
+
},
|
|
69
78
|
};
|
|
70
79
|
};
|
|
71
80
|
}
|
|
@@ -4,10 +4,10 @@ import { Gutter, SetStepNav } from "@payloadcms/ui";
|
|
|
4
4
|
import { redirect } from "next/navigation";
|
|
5
5
|
import { categoryOf, categoryStats, categoryTone, defaultCategory, } from "../categories.js";
|
|
6
6
|
import { ASK, BATCH_ASK, CATEGORY_LABELS, CHECK_LABELS, COPY, findingDetail, LIST_COPY, uiLanguage, } from "../copy.js";
|
|
7
|
-
import { checkStats, countDocuments } from "../group.js";
|
|
7
|
+
import { checkStats, countDocuments, sortFindings } from "../group.js";
|
|
8
8
|
import { accessRedirect, assistantCollections, BATCH_CHECKS, batchCollections, batchTarget, editURL, readFilters, } from "../links.js";
|
|
9
9
|
import { scanContentHealth } from "../scan.js";
|
|
10
|
-
import {
|
|
10
|
+
import {} from "../types.js";
|
|
11
11
|
import { Ring } from "../widget/Ring.js";
|
|
12
12
|
import { WorkList, } from "./WorkList.js";
|
|
13
13
|
import "../styles/tokens.css";
|
|
@@ -95,15 +95,6 @@ export async function ContentHealthView({ initPageResult, params, searchParams,
|
|
|
95
95
|
? copy.summary(result.findings.length, countDocuments(result.findings))
|
|
96
96
|
: copy.none })] })] }), _jsx(WorkList, { adminRoute: adminRoute, batchAsk: batchAsk, categories: categories, collections: collections, initialFilters: initialFilters, 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] }) }));
|
|
97
97
|
}
|
|
98
|
-
/** By check, then document, then locale, so a document's locales sit together. */
|
|
99
|
-
function sortFindings(findings) {
|
|
100
|
-
return [...findings].sort((a, b) => CHECK_IDS.indexOf(a.check) - CHECK_IDS.indexOf(b.check) ||
|
|
101
|
-
a.collection.localeCompare(b.collection) ||
|
|
102
|
-
a.title.localeCompare(b.title) ||
|
|
103
|
-
String(a.id).localeCompare(String(b.id)) ||
|
|
104
|
-
a.path.localeCompare(b.path) ||
|
|
105
|
-
(a.locale ?? "").localeCompare(b.locale ?? ""));
|
|
106
|
-
}
|
|
107
98
|
/** A config label: a string, or one per language. Functions are left to Payload. */
|
|
108
99
|
function label(value, language) {
|
|
109
100
|
if (typeof value === "string") {
|
package/package.json
CHANGED