drupal-mcp-connector 2.26.0 → 3.0.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/.agents/commands/drupal-audit-site-health.md +1 -1
- package/.agents/commands/drupal-report-accessibility-audit.md +4 -2
- package/.agents/commands/drupal-report-alias-coverage.md +4 -2
- package/.agents/commands/drupal-report-broken-embeds.md +4 -2
- package/.agents/commands/drupal-report-broken-links.md +4 -2
- package/.agents/commands/drupal-report-content-by-author.md +4 -2
- package/.agents/commands/drupal-report-duplicate-content.md +4 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-orphan-pages.md +4 -2
- package/.agents/commands/drupal-report-orphaned-references.md +4 -2
- package/.agents/commands/drupal-report-pii-exposure.md +4 -2
- package/.agents/commands/drupal-report-readability.md +4 -2
- package/.agents/commands/drupal-report-recently-published.md +1 -1
- package/.agents/commands/drupal-report-revision-hotspots.md +4 -2
- package/.agents/commands/drupal-report-scheduled-content.md +4 -4
- package/.agents/commands/drupal-report-seo-audit.md +4 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +4 -2
- package/.agents/commands/drupal-report-stale-content.md +4 -2
- package/.agents/commands/drupal-report-taxonomy-usage.md +2 -2
- package/.agents/commands/drupal-report-translation-coverage.md +4 -2
- package/.agents/commands/drupal-report-unpublished.md +1 -1
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +4 -2
- package/.agents/commands/drupal-schedule-publish.md +4 -4
- package/.agents/commands/drupal-search-content.md +1 -1
- package/.agents/commands/drupal-search.md +1 -1
- package/CHANGELOG.md +46 -0
- package/package.json +1 -1
- package/src/lib/backends/jsonapi.js +19 -7
- package/src/lib/reports-support.js +68 -0
- package/src/tools/audit-composite.js +21 -11
- package/src/tools/nodes.js +12 -6
- package/src/tools/reports-content.js +106 -38
- package/src/tools/reports-extra.js +56 -32
- package/src/tools/reports-links.js +13 -13
- package/src/tools/reports.js +65 -42
- package/src/tools/scheduler.js +90 -62
- package/src/tools/search.js +16 -12
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { getSiteConfig } from "../lib/config.js";
|
|
14
14
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
15
15
|
import { resolveSecurityConfig, assertReadAllowed, assertEntityTypeAllowed } from "../lib/security.js";
|
|
16
|
-
import { collectEntities, fieldPresence } from "../lib/reports-support.js";
|
|
16
|
+
import { collectEntities, fieldPresence, requireContentType, readableNodeBundles } from "../lib/reports-support.js";
|
|
17
17
|
import { httpStatusOf } from "../lib/error-status.js";
|
|
18
18
|
|
|
19
19
|
/** Author base fields that only ever point at `user`. */
|
|
@@ -100,11 +100,14 @@ function refsOf(rel) {
|
|
|
100
100
|
// ---------------------------------------------------------------------------
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* Unpublished / draft content
|
|
103
|
+
* Unpublished / draft content. With `type`, reads that bundle. Without it,
|
|
104
|
+
* reads every node bundle the connector may read, merges by `changed`
|
|
105
|
+
* (newest first) and keeps `limit` findings; a bundle that fails to load is
|
|
106
|
+
* listed in `bundleErrors` (#403).
|
|
104
107
|
*
|
|
105
|
-
* @param {object} args - { site?, type?, limit? }.
|
|
106
|
-
* @returns {Promise<{contentType: string, approximate: boolean,
|
|
107
|
-
* totalUnpublished: number, findings: object[]}>} Matching unpublished nodes.
|
|
108
|
+
* @param {object} args - { site?, type?, limit? }.
|
|
109
|
+
* @returns {Promise<{contentType: ?string, approximate: boolean,
|
|
110
|
+
* totalUnpublished: number, findings: object[], bundleErrors?: object[]}>} Matching unpublished nodes.
|
|
108
111
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
109
112
|
*/
|
|
110
113
|
async function unpublished({ site: siteName, type, limit = 50 }) {
|
|
@@ -112,24 +115,45 @@ async function unpublished({ site: siteName, type, limit = 50 }) {
|
|
|
112
115
|
const sec = resolveSecurityConfig(site);
|
|
113
116
|
assertReadAllowed(sec, "node", type);
|
|
114
117
|
const backend = await resolveBackend(site);
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
const bundles = type ? [type] : await readableNodeBundles(backend, sec);
|
|
119
|
+
const findings = [];
|
|
120
|
+
const bundleErrors = [];
|
|
121
|
+
let approximate = false;
|
|
122
|
+
let totalUnpublished = 0;
|
|
123
|
+
for (const bundle of bundles) {
|
|
124
|
+
let res;
|
|
125
|
+
try {
|
|
126
|
+
res = await backend.listEntities({
|
|
127
|
+
entityType: "node", bundle,
|
|
128
|
+
filters: [{ field: "status", op: "eq", value: false }],
|
|
129
|
+
sort: [{ field: "changed", dir: "desc" }],
|
|
130
|
+
page: { limit },
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (type) throw err;
|
|
134
|
+
bundleErrors.push({ contentType: bundle, error: err?.message || String(err) });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
approximate = approximate || (res.approximate ?? false);
|
|
138
|
+
totalUnpublished += res.page?.total ?? res.entities.length;
|
|
139
|
+
for (const n of res.entities) {
|
|
140
|
+
findings.push({
|
|
141
|
+
id: n.id,
|
|
142
|
+
title: n.title,
|
|
143
|
+
status: "unpublished",
|
|
144
|
+
changed: n.changed,
|
|
145
|
+
path: n.url,
|
|
146
|
+
...(type ? {} : { contentType: bundle }),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!type) findings.sort((x, y) => String(y.changed ?? "").localeCompare(String(x.changed ?? "")));
|
|
122
151
|
return {
|
|
123
|
-
contentType,
|
|
124
|
-
approximate
|
|
125
|
-
totalUnpublished
|
|
126
|
-
findings:
|
|
127
|
-
|
|
128
|
-
title: n.title,
|
|
129
|
-
status: "unpublished",
|
|
130
|
-
changed: n.changed,
|
|
131
|
-
path: n.url,
|
|
132
|
-
})),
|
|
152
|
+
contentType: type || null,
|
|
153
|
+
approximate,
|
|
154
|
+
totalUnpublished,
|
|
155
|
+
findings: findings.slice(0, limit),
|
|
156
|
+
...(bundleErrors.length ? { bundleErrors } : {}),
|
|
133
157
|
};
|
|
134
158
|
}
|
|
135
159
|
|
|
@@ -153,12 +177,12 @@ async function unpublished({ site: siteName, type, limit = 50 }) {
|
|
|
153
177
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
154
178
|
*/
|
|
155
179
|
async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
|
|
180
|
+
const contentType = requireContentType(type, "drupal_report_missing_field");
|
|
156
181
|
const site = getSiteConfig(siteName);
|
|
157
182
|
const sec = resolveSecurityConfig(site);
|
|
158
|
-
assertReadAllowed(sec, "node",
|
|
183
|
+
assertReadAllowed(sec, "node", contentType);
|
|
159
184
|
if (!field) throw new Error("missingField requires a field machine name.");
|
|
160
185
|
const backend = await resolveBackend(site);
|
|
161
|
-
const contentType = type || "article";
|
|
162
186
|
const entities = await collectEntities(
|
|
163
187
|
backend,
|
|
164
188
|
{ entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
|
|
@@ -213,16 +237,16 @@ async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
|
|
|
213
237
|
* orphan; 401/403 and connector policy denials are unverifiable (#205).
|
|
214
238
|
* Sampling-bounded, so `approximate` is set when the entity scan is capped.
|
|
215
239
|
*
|
|
216
|
-
* @param {object} args - { site?, type
|
|
240
|
+
* @param {object} args - { site?, type, sampleSize? }. `type` is required.
|
|
217
241
|
* @returns {Promise<object>} Orphaned-reference findings plus scan metadata.
|
|
218
242
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
219
243
|
*/
|
|
220
244
|
async function orphanedReferences({ site: siteName, type, sampleSize = 50 }) {
|
|
245
|
+
const contentType = requireContentType(type, "drupal_report_orphaned_references");
|
|
221
246
|
const site = getSiteConfig(siteName);
|
|
222
247
|
const sec = resolveSecurityConfig(site);
|
|
223
|
-
assertReadAllowed(sec, "node",
|
|
248
|
+
assertReadAllowed(sec, "node", contentType);
|
|
224
249
|
const backend = await resolveBackend(site);
|
|
225
|
-
const contentType = type || "article";
|
|
226
250
|
const entities = await collectEntities(
|
|
227
251
|
backend,
|
|
228
252
|
{ entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
|
|
@@ -331,7 +355,7 @@ export const definitions = [
|
|
|
331
355
|
type: "object",
|
|
332
356
|
properties: {
|
|
333
357
|
site: { type: "string" },
|
|
334
|
-
type: { type: "string", description: "Content type machine name (
|
|
358
|
+
type: { type: "string", description: "Content type machine name. Omit to read every node bundle (results merged, newest first; unreadable bundles listed in bundleErrors)." },
|
|
335
359
|
limit: { type: "number", default: 50, description: "Max unpublished nodes to return" },
|
|
336
360
|
},
|
|
337
361
|
},
|
|
@@ -340,10 +364,10 @@ export const definitions = [
|
|
|
340
364
|
name: "drupal_report_missing_field",
|
|
341
365
|
description: "Find entities of a content type where a given field is empty (e.g. a missing meta description, image, or summary). Works for scalar fields and entity-reference fields. Sampling-bounded — flags 'approximate' when the scan is capped. An absent field is not an empty one: JSON:API leaves out a field this account may not view. When the field is absent from every sampled entity the result is `notVisible: true` with no findings (the field may be denied to this account, not exist on the bundle, or be misspelled) instead of every entity counted as missing. Otherwise each finding has `reason` 'empty' (key present, no value) or 'absent' (key missing, possibly access-denied).",
|
|
342
366
|
inputSchema: {
|
|
343
|
-
type: "object", required: ["field"],
|
|
367
|
+
type: "object", required: ["field", "type"],
|
|
344
368
|
properties: {
|
|
345
369
|
site: { type: "string" },
|
|
346
|
-
type: { type: "string", description: "Content type machine name (
|
|
370
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
347
371
|
field: { type: "string", description: "Field machine name to check for emptiness, e.g. 'field_meta_description', 'field_image'" },
|
|
348
372
|
sampleSize: { type: "number", default: 100, description: "Max entities to scan" },
|
|
349
373
|
},
|
|
@@ -353,10 +377,10 @@ export const definitions = [
|
|
|
353
377
|
name: "drupal_report_orphaned_references",
|
|
354
378
|
description: "Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. A 404 (or unaddressable ref) is an orphan; 401/403 and connector policy denials are counted as unverifiable, not missing. uid/revision_uid are skipped when the policy denies user. Flags 'approximate' when sampling-bounded.",
|
|
355
379
|
inputSchema: {
|
|
356
|
-
type: "object",
|
|
380
|
+
type: "object", required: ["type"],
|
|
357
381
|
properties: {
|
|
358
382
|
site: { type: "string" },
|
|
359
|
-
type: { type: "string", description: "Content type machine name
|
|
383
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
360
384
|
sampleSize: { type: "number", default: 50, description: "Max entities to scan for broken references" },
|
|
361
385
|
},
|
|
362
386
|
},
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { getSiteConfig } from "../lib/config.js";
|
|
19
19
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
20
20
|
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
21
|
-
import { collectEntities, gatedReport, fieldValue } from "../lib/reports-support.js";
|
|
21
|
+
import { collectEntities, gatedReport, fieldValue, requireContentType } from "../lib/reports-support.js";
|
|
22
22
|
import { runPrivileged } from "../lib/audit-sources.js";
|
|
23
23
|
import { sshDrush, parseDrush } from "./drush.js";
|
|
24
24
|
import {
|
|
@@ -258,11 +258,11 @@ async function redirectHealth({ site: siteName, limit = 1000 }) {
|
|
|
258
258
|
* @returns {Promise<object>} Link inventory plus optional live results.
|
|
259
259
|
*/
|
|
260
260
|
async function brokenLinks({ site: siteName, type, sampleSize = 100, checkLive = false, includeExternal = false }) {
|
|
261
|
+
const contentType = requireContentType(type, "drupal_report_broken_links");
|
|
261
262
|
const site = getSiteConfig(siteName);
|
|
262
263
|
const sec = resolveSecurityConfig(site);
|
|
263
|
-
assertReadAllowed(sec, "node",
|
|
264
|
+
assertReadAllowed(sec, "node", contentType);
|
|
264
265
|
const backend = await resolveBackend(site);
|
|
265
|
-
const contentType = type || "article";
|
|
266
266
|
const baseUrl = site.baseUrl;
|
|
267
267
|
const baseHost = hostOf(baseUrl);
|
|
268
268
|
|
|
@@ -345,11 +345,11 @@ async function brokenLinks({ site: siteName, type, sampleSize = 100, checkLive =
|
|
|
345
345
|
* @returns {Promise<object>} Alias-coverage findings.
|
|
346
346
|
*/
|
|
347
347
|
async function aliasCoverage({ site: siteName, type, sampleSize = 200 }) {
|
|
348
|
+
const contentType = requireContentType(type, "drupal_report_alias_coverage");
|
|
348
349
|
const site = getSiteConfig(siteName);
|
|
349
350
|
const sec = resolveSecurityConfig(site);
|
|
350
|
-
assertReadAllowed(sec, "node",
|
|
351
|
+
assertReadAllowed(sec, "node", contentType);
|
|
351
352
|
const backend = await resolveBackend(site);
|
|
352
|
-
const contentType = type || "article";
|
|
353
353
|
|
|
354
354
|
const nodes = await collectEntities(
|
|
355
355
|
backend,
|
|
@@ -463,11 +463,11 @@ async function menuIntegrity({ site: siteName, limit = 1000 }) {
|
|
|
463
463
|
* @returns {Promise<object>} Embed inventory and any malformed embeds.
|
|
464
464
|
*/
|
|
465
465
|
async function brokenEmbeds({ site: siteName, type, sampleSize = 100 }) {
|
|
466
|
+
const contentType = requireContentType(type, "drupal_report_broken_embeds");
|
|
466
467
|
const site = getSiteConfig(siteName);
|
|
467
468
|
const sec = resolveSecurityConfig(site);
|
|
468
|
-
assertReadAllowed(sec, "node",
|
|
469
|
+
assertReadAllowed(sec, "node", contentType);
|
|
469
470
|
const backend = await resolveBackend(site);
|
|
470
|
-
const contentType = type || "article";
|
|
471
471
|
|
|
472
472
|
const entities = await collectEntities(
|
|
473
473
|
backend,
|
|
@@ -538,10 +538,10 @@ export const definitions = [
|
|
|
538
538
|
name: "drupal_report_broken_links",
|
|
539
539
|
description: "Inventory links in published body content (internal/external/images), aggregate external hosts, and flag malformed hrefs. With checkLive:true, verifies links via bounded, SSRF-guarded outbound HTTP (internal always; external only if includeExternal and host-allowlisted). No network egress unless checkLive is set.",
|
|
540
540
|
inputSchema: {
|
|
541
|
-
type: "object",
|
|
541
|
+
type: "object", required: ["type"],
|
|
542
542
|
properties: {
|
|
543
543
|
site: { type: "string" },
|
|
544
|
-
type: { type: "string", description: "Content type (
|
|
544
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
545
545
|
sampleSize: { type: "number", default: 100, description: "Max nodes to scan" },
|
|
546
546
|
checkLive: { type: "boolean", default: false, description: "Perform live HTTP checks (off by default)" },
|
|
547
547
|
includeExternal: { type: "boolean", default: false, description: "When checkLive, also check allowlisted external hosts" },
|
|
@@ -552,10 +552,10 @@ export const definitions = [
|
|
|
552
552
|
name: "drupal_report_alias_coverage",
|
|
553
553
|
description: "URL-alias coverage for a content type: nodes whose canonical URL is still /node/N (no alias / pathauto gap), plus conflicting aliases (one alias mapped to multiple system paths) when the path_alias entity is exposed.",
|
|
554
554
|
inputSchema: {
|
|
555
|
-
type: "object",
|
|
555
|
+
type: "object", required: ["type"],
|
|
556
556
|
properties: {
|
|
557
557
|
site: { type: "string" },
|
|
558
|
-
type: { type: "string", description: "Content type (
|
|
558
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
559
559
|
sampleSize: { type: "number", default: 200, description: "Max nodes to scan" },
|
|
560
560
|
},
|
|
561
561
|
},
|
|
@@ -575,10 +575,10 @@ export const definitions = [
|
|
|
575
575
|
name: "drupal_report_broken_embeds",
|
|
576
576
|
description: "Scan published body content for embedded entities (media/entity embeds) and report usage by type, flagging embeds with a missing/malformed data-entity-uuid. Full target-existence verification is gated on a future server-tool.",
|
|
577
577
|
inputSchema: {
|
|
578
|
-
type: "object",
|
|
578
|
+
type: "object", required: ["type"],
|
|
579
579
|
properties: {
|
|
580
580
|
site: { type: "string" },
|
|
581
|
-
type: { type: "string", description: "Content type (
|
|
581
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
582
582
|
sampleSize: { type: "number", default: 100, description: "Max nodes to scan" },
|
|
583
583
|
},
|
|
584
584
|
},
|
package/src/tools/reports.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { getSiteConfig } from "../lib/config.js";
|
|
12
12
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
13
13
|
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
14
|
-
import { collectEntities, gatedReport, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince } from "../lib/reports-support.js";
|
|
14
|
+
import { collectEntities, gatedReport, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince, requireContentType, readableNodeBundles } from "../lib/reports-support.js";
|
|
15
15
|
import { fetchRenderedMetaDescriptions } from "../lib/metatag-audit.js";
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -68,11 +68,11 @@ async function contentSummary({ site: siteName }) {
|
|
|
68
68
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
69
69
|
*/
|
|
70
70
|
async function staleContent({ site: siteName, type, days = 180, status, limit = 50 }) {
|
|
71
|
+
const contentType = requireContentType(type, "drupal_report_stale_content");
|
|
71
72
|
const site = getSiteConfig(siteName);
|
|
72
73
|
const sec = resolveSecurityConfig(site);
|
|
73
|
-
assertReadAllowed(sec, "node",
|
|
74
|
+
assertReadAllowed(sec, "node", contentType);
|
|
74
75
|
const backend = await resolveBackend(site);
|
|
75
|
-
const contentType = type || "article";
|
|
76
76
|
// `changed` is an integer Unix timestamp (seconds). Filter with epoch seconds,
|
|
77
77
|
// not an ISO string — PostgreSQL rejects the string against an integer column.
|
|
78
78
|
const cutoffMs = Date.now() - days * 86400000;
|
|
@@ -112,11 +112,11 @@ async function staleContent({ site: siteName, type, days = 180, status, limit =
|
|
|
112
112
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
113
113
|
*/
|
|
114
114
|
async function contentByAuthor({ site: siteName, type, limit = 100 }) {
|
|
115
|
+
const contentType = requireContentType(type, "drupal_report_content_by_author");
|
|
115
116
|
const site = getSiteConfig(siteName);
|
|
116
117
|
const sec = resolveSecurityConfig(site);
|
|
117
|
-
assertReadAllowed(sec, "node",
|
|
118
|
+
assertReadAllowed(sec, "node", contentType);
|
|
118
119
|
const backend = await resolveBackend(site);
|
|
119
|
-
const contentType = type || "article";
|
|
120
120
|
const entities = await collectEntities(
|
|
121
121
|
backend,
|
|
122
122
|
{ entityType: "node", bundle: contentType, include: ["uid"] },
|
|
@@ -141,7 +141,10 @@ async function contentByAuthor({ site: siteName, type, limit = 100 }) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
* Recently published content
|
|
144
|
+
* Recently published content, newest first. With `type`, reads that bundle.
|
|
145
|
+
* Without it, reads every node bundle the connector may read, merges by
|
|
146
|
+
* `created`, and keeps the newest `limit`; a bundle that fails to load is
|
|
147
|
+
* listed in `bundleErrors` (#403).
|
|
145
148
|
*
|
|
146
149
|
* @param {object} args - { site?, type?, limit? }.
|
|
147
150
|
* @returns {Promise<object>} The most recently created published nodes.
|
|
@@ -152,22 +155,42 @@ async function recentlyPublished({ site: siteName, type, limit = 20 }) {
|
|
|
152
155
|
const sec = resolveSecurityConfig(site);
|
|
153
156
|
assertReadAllowed(sec, "node", type);
|
|
154
157
|
const backend = await resolveBackend(site);
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
const bundles = type ? [type] : await readableNodeBundles(backend, sec);
|
|
159
|
+
const nodes = [];
|
|
160
|
+
const bundleErrors = [];
|
|
161
|
+
let approximate = false;
|
|
162
|
+
for (const bundle of bundles) {
|
|
163
|
+
let res;
|
|
164
|
+
try {
|
|
165
|
+
res = await backend.listEntities({
|
|
166
|
+
entityType: "node", bundle,
|
|
167
|
+
filters: [{ field: "status", op: "eq", value: true }],
|
|
168
|
+
sort: [{ field: "created", dir: "desc" }],
|
|
169
|
+
page: { limit },
|
|
170
|
+
});
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (type) throw err;
|
|
173
|
+
bundleErrors.push({ contentType: bundle, error: err?.message || String(err) });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
approximate = approximate || (res.approximate ?? false);
|
|
177
|
+
for (const n of res.entities) {
|
|
178
|
+
nodes.push({
|
|
179
|
+
id: n.id,
|
|
180
|
+
title: n.title,
|
|
181
|
+
created: n.created,
|
|
182
|
+
changed: n.changed,
|
|
183
|
+
path: n.url,
|
|
184
|
+
...(type ? {} : { contentType: bundle }),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!type) nodes.sort((x, y) => String(y.created ?? "").localeCompare(String(x.created ?? "")));
|
|
161
189
|
return {
|
|
162
|
-
contentType: type ||
|
|
163
|
-
approximate
|
|
164
|
-
nodes:
|
|
165
|
-
|
|
166
|
-
title: n.title,
|
|
167
|
-
created: n.created,
|
|
168
|
-
changed: n.changed,
|
|
169
|
-
path: n.url,
|
|
170
|
-
})),
|
|
190
|
+
contentType: type || null,
|
|
191
|
+
approximate,
|
|
192
|
+
nodes: nodes.slice(0, limit),
|
|
193
|
+
...(bundleErrors.length ? { bundleErrors } : {}),
|
|
171
194
|
};
|
|
172
195
|
}
|
|
173
196
|
|
|
@@ -255,12 +278,13 @@ async function fieldCompleteness({ site: siteName, type, fields, sampleSize = 10
|
|
|
255
278
|
* Per-term counts that error out are reported as null nodeCount.
|
|
256
279
|
*
|
|
257
280
|
* @param {object} args - { site?, vocabulary, contentType?, referenceField?, limit? }.
|
|
258
|
-
* `referenceField` defaults to `field_{vocabulary}`; `contentType`
|
|
281
|
+
* `referenceField` defaults to `field_{vocabulary}`; `contentType` is required.
|
|
259
282
|
* @returns {Promise<object>} Terms sorted by usage, plus an unused-term count.
|
|
260
283
|
* @throws {Error} If no vocabulary is supplied.
|
|
261
284
|
* @throws {SecurityError} If reading the vocabulary is not permitted.
|
|
262
285
|
*/
|
|
263
286
|
async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenceField, limit = 100 }) {
|
|
287
|
+
const ctype = requireContentType(contentType, "drupal_report_taxonomy_usage", "contentType");
|
|
264
288
|
const site = getSiteConfig(siteName);
|
|
265
289
|
const sec = resolveSecurityConfig(site);
|
|
266
290
|
assertReadAllowed(sec, "taxonomy_term", vocabulary);
|
|
@@ -272,7 +296,6 @@ async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenc
|
|
|
272
296
|
limit
|
|
273
297
|
);
|
|
274
298
|
const field = referenceField || `field_${vocabulary}`;
|
|
275
|
-
const ctype = contentType || "article";
|
|
276
299
|
let approximate = false;
|
|
277
300
|
const results = await Promise.all(terms.map(async (term) => {
|
|
278
301
|
try {
|
|
@@ -306,9 +329,10 @@ async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenc
|
|
|
306
329
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
307
330
|
*/
|
|
308
331
|
async function revisionHotspots({ site: siteName, type, limit = 20 }) {
|
|
332
|
+
const contentType = requireContentType(type, "drupal_report_revision_hotspots");
|
|
309
333
|
const site = getSiteConfig(siteName);
|
|
310
334
|
const sec = resolveSecurityConfig(site);
|
|
311
|
-
assertReadAllowed(sec, "node",
|
|
335
|
+
assertReadAllowed(sec, "node", contentType);
|
|
312
336
|
const backend = await resolveBackend(site);
|
|
313
337
|
if (!backend.capabilities().revisions) {
|
|
314
338
|
return gatedReport(
|
|
@@ -317,7 +341,6 @@ async function revisionHotspots({ site: siteName, type, limit = 20 }) {
|
|
|
317
341
|
"This backend does not expose entity revisions. Use a JSON:API site."
|
|
318
342
|
);
|
|
319
343
|
}
|
|
320
|
-
const contentType = type || "article";
|
|
321
344
|
const recent = await backend.listEntities({
|
|
322
345
|
entityType: "node", bundle: contentType,
|
|
323
346
|
sort: [{ field: "changed", dir: "desc" }],
|
|
@@ -431,11 +454,11 @@ async function userActivity({ site: siteName, inactiveDays = 90, limit = 50 }) {
|
|
|
431
454
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
432
455
|
*/
|
|
433
456
|
async function seoAudit({ site: siteName, type, sampleSize = 100 }) {
|
|
457
|
+
const contentType = requireContentType(type, "drupal_report_seo_audit");
|
|
434
458
|
const site = getSiteConfig(siteName);
|
|
435
459
|
const sec = resolveSecurityConfig(site);
|
|
436
|
-
assertReadAllowed(sec, "node",
|
|
460
|
+
assertReadAllowed(sec, "node", contentType);
|
|
437
461
|
const backend = await resolveBackend(site);
|
|
438
|
-
const contentType = type || "article";
|
|
439
462
|
const issueKeys = ["missingMetaDescription", "titleTooLong", "titleTooShort", "thinContent"];
|
|
440
463
|
const issues = new Map(issueKeys.map((k) => [k, []]));
|
|
441
464
|
const entities = await collectEntities(
|
|
@@ -524,11 +547,11 @@ function resolveJsonapiMetaSource(entities) {
|
|
|
524
547
|
* @throws {SecurityError} If reading the content type is not permitted.
|
|
525
548
|
*/
|
|
526
549
|
async function accessibilityAudit({ site: siteName, type, sampleSize = 100 }) {
|
|
550
|
+
const contentType = requireContentType(type, "drupal_report_accessibility_audit");
|
|
527
551
|
const site = getSiteConfig(siteName);
|
|
528
552
|
const sec = resolveSecurityConfig(site);
|
|
529
|
-
assertReadAllowed(sec, "node",
|
|
553
|
+
assertReadAllowed(sec, "node", contentType);
|
|
530
554
|
const backend = await resolveBackend(site);
|
|
531
|
-
const contentType = type || "article";
|
|
532
555
|
const issueKeys = ["imagesWithoutAlt", "inlineH1", "nonDescriptiveLinkText", "tablesWithoutCaption"];
|
|
533
556
|
const issues = new Map(issueKeys.map((k) => [k, []]));
|
|
534
557
|
const badLinkText = />\s*(click here|read more|learn more|here|more|link)\s*</i;
|
|
@@ -572,10 +595,10 @@ export const definitions = [
|
|
|
572
595
|
name: "drupal_report_stale_content",
|
|
573
596
|
description: "Find content that hasn't been updated in N days. Returns a sorted list with titles, status, and days-since-update.",
|
|
574
597
|
inputSchema: {
|
|
575
|
-
type: "object",
|
|
598
|
+
type: "object", required: ["type"],
|
|
576
599
|
properties: {
|
|
577
600
|
site: { type: "string" },
|
|
578
|
-
type: { type: "string", description: "Content type (
|
|
601
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
579
602
|
days: { type: "number", default: 180, description: "Stale threshold in days" },
|
|
580
603
|
status: { type: "boolean", description: "Filter by publish status" },
|
|
581
604
|
limit: { type: "number", default: 50 },
|
|
@@ -586,10 +609,10 @@ export const definitions = [
|
|
|
586
609
|
name: "drupal_report_content_by_author",
|
|
587
610
|
description: "Count nodes per author for a given content type. Returns author UUIDs and counts sorted by most prolific.",
|
|
588
611
|
inputSchema: {
|
|
589
|
-
type: "object",
|
|
612
|
+
type: "object", required: ["type"],
|
|
590
613
|
properties: {
|
|
591
614
|
site: { type: "string" },
|
|
592
|
-
type: { type: "string", description: "Content type (
|
|
615
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
593
616
|
limit: { type: "number", default: 100, description: "Max nodes to scan" },
|
|
594
617
|
},
|
|
595
618
|
},
|
|
@@ -601,7 +624,7 @@ export const definitions = [
|
|
|
601
624
|
type: "object",
|
|
602
625
|
properties: {
|
|
603
626
|
site: { type: "string" },
|
|
604
|
-
type: { type: "string", description: "Content type (
|
|
627
|
+
type: { type: "string", description: "Content type machine name. Omit to read every node bundle (results merged, newest first; unreadable bundles listed in bundleErrors)." },
|
|
605
628
|
limit: { type: "number", default: 20 },
|
|
606
629
|
},
|
|
607
630
|
},
|
|
@@ -623,11 +646,11 @@ export const definitions = [
|
|
|
623
646
|
name: "drupal_report_taxonomy_usage",
|
|
624
647
|
description: "Count how many nodes use each term in a vocabulary. Identifies over-used, under-used, and orphaned terms.",
|
|
625
648
|
inputSchema: {
|
|
626
|
-
type: "object", required: ["vocabulary"],
|
|
649
|
+
type: "object", required: ["vocabulary", "contentType"],
|
|
627
650
|
properties: {
|
|
628
651
|
site: { type: "string" },
|
|
629
652
|
vocabulary: { type: "string", description: "Vocabulary machine name, e.g. 'tags', 'category'" },
|
|
630
|
-
contentType: { type: "string", description: "Content type
|
|
653
|
+
contentType: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
631
654
|
referenceField: { type: "string", description: "Field referencing the vocabulary (default: field_{vocabulary})" },
|
|
632
655
|
limit: { type: "number", default: 100 },
|
|
633
656
|
},
|
|
@@ -637,10 +660,10 @@ export const definitions = [
|
|
|
637
660
|
name: "drupal_report_revision_hotspots",
|
|
638
661
|
description: "Find nodes with the most revision activity — useful for spotting churn or content that needs editorial process review. Requires Drupal 9.3+ JSON:API revisions.",
|
|
639
662
|
inputSchema: {
|
|
640
|
-
type: "object",
|
|
663
|
+
type: "object", required: ["type"],
|
|
641
664
|
properties: {
|
|
642
665
|
site: { type: "string" },
|
|
643
|
-
type: { type: "string", description: "Content type (
|
|
666
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
644
667
|
limit: { type: "number", default: 20 },
|
|
645
668
|
},
|
|
646
669
|
},
|
|
@@ -661,10 +684,10 @@ export const definitions = [
|
|
|
661
684
|
name: "drupal_report_seo_audit",
|
|
662
685
|
description: "SEO audit for a content type: missing meta descriptions, title length issues, and thin content (under 300 words). Returns node lists for each issue category. Meta descriptions use the rendered Metatag output via GraphQL when available (reported as `metaSource`); when no description source is readable it reports the meta check as unavailable rather than a false zero.",
|
|
663
686
|
inputSchema: {
|
|
664
|
-
type: "object",
|
|
687
|
+
type: "object", required: ["type"],
|
|
665
688
|
properties: {
|
|
666
689
|
site: { type: "string" },
|
|
667
|
-
type: { type: "string", description: "Content type (
|
|
690
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
668
691
|
sampleSize: { type: "number", default: 100 },
|
|
669
692
|
},
|
|
670
693
|
},
|
|
@@ -673,10 +696,10 @@ export const definitions = [
|
|
|
673
696
|
name: "drupal_report_accessibility_audit",
|
|
674
697
|
description: "Accessibility audit for body content: images without alt text, inline H1 tags, non-descriptive link text ('click here', 'read more'), and tables without captions.",
|
|
675
698
|
inputSchema: {
|
|
676
|
-
type: "object",
|
|
699
|
+
type: "object", required: ["type"],
|
|
677
700
|
properties: {
|
|
678
701
|
site: { type: "string" },
|
|
679
|
-
type: { type: "string", description: "Content type (
|
|
702
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
680
703
|
sampleSize: { type: "number", default: 100 },
|
|
681
704
|
},
|
|
682
705
|
},
|