drupal-mcp-connector 1.5.0 → 1.6.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 +88 -0
- package/README.md +7 -2
- package/config/config.example.json +12 -1
- package/package.json +1 -1
- package/src/index.js +27 -1
- package/src/lib/audit-sources.js +66 -0
- package/src/lib/audit-support.js +170 -0
- package/src/lib/backends/backend-interface.js +10 -0
- package/src/lib/backends/jsonapi.js +23 -0
- package/src/lib/link-checker.js +201 -0
- package/src/lib/server-tools.js +17 -0
- package/src/tools/audit-composite.js +152 -0
- package/src/tools/drush.js +25 -3
- package/src/tools/nodes.js +137 -9
- package/src/tools/redirects.js +30 -8
- package/src/tools/reports-config.js +634 -0
- package/src/tools/reports-content.js +649 -0
- package/src/tools/reports-links.js +595 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.6.0] - 2026-06-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Audit command suite — 22 new read-only audit tools across four groups**, expanding
|
|
14
|
+
the connector from content reporting into link/404 integrity and configuration
|
|
15
|
+
posture. All follow the existing `drupal_report_*` / `drupal_audit_*` convention
|
|
16
|
+
(auto-classified read-only), degrade with a `gatedReport`/`gated` payload when a
|
|
17
|
+
required source is absent, and flag `approximate`/`truncated` when sampling-bounded.
|
|
18
|
+
- **Links & 404 integrity** (`reports-links.js`): `drupal_report_404_log`,
|
|
19
|
+
`drupal_report_redirect_health`, `drupal_report_broken_links`,
|
|
20
|
+
`drupal_report_alias_coverage`, `drupal_report_menu_integrity`,
|
|
21
|
+
`drupal_report_broken_embeds`.
|
|
22
|
+
- **Config & site-health** (`reports-config.js`): `drupal_report_config_drift`,
|
|
23
|
+
`drupal_audit_config_best_practices`, `drupal_report_module_audit`,
|
|
24
|
+
`drupal_report_permission_audit`, `drupal_report_status_report`,
|
|
25
|
+
`drupal_report_text_format_audit`, `drupal_report_cache_config`.
|
|
26
|
+
- **Content quality & governance** (`reports-content.js`):
|
|
27
|
+
`drupal_report_duplicate_content`, `drupal_report_workflow_bottlenecks`,
|
|
28
|
+
`drupal_report_translation_coverage`, `drupal_report_scheduled_content`,
|
|
29
|
+
`drupal_report_readability`, `drupal_report_orphan_pages`,
|
|
30
|
+
`drupal_report_pii_exposure`, `drupal_report_seo_meta_coverage`.
|
|
31
|
+
- **Composite** (`audit-composite.js`): `drupal_audit_site_health` — a scored
|
|
32
|
+
dashboard that runs a configurable battery of the above and rolls them into one
|
|
33
|
+
letter grade, with each section degrading independently.
|
|
34
|
+
- **`drupal-full-audit` MCP prompt** — walks a client through running the composite
|
|
35
|
+
audit and turning the dashboard into a prioritized action plan.
|
|
36
|
+
- **Opt-in live link checking.** `drupal_report_broken_links` performs no network
|
|
37
|
+
egress by default; with `checkLive: true` it verifies links via a bounded,
|
|
38
|
+
SSRF-guarded checker (`src/lib/link-checker.js`) that refuses
|
|
39
|
+
loopback/private/link-local/metadata addresses, requires a host allowlist for
|
|
40
|
+
external hosts, and caps concurrency, timeout, and link count. Configurable per site
|
|
41
|
+
via an optional `audit` block (`linkCheckAllowedHosts`, `linkCheckConcurrency`,
|
|
42
|
+
`linkCheckTimeoutMs`, `linkCheckMaxLinks`).
|
|
43
|
+
- **Self-sufficient privileged audits.** Log/config/module/permission/requirements
|
|
44
|
+
audits read their data through the connector's own **drush bridge** (`watchdog:show`,
|
|
45
|
+
`config:status`/`config:get`, `pm:list`/`pm:security`, `role:list`,
|
|
46
|
+
`core:requirements`, and a read-only `sql:query` to enumerate `filter.format.*`), so
|
|
47
|
+
they work against stock Drupal with **no companion module required**. The
|
|
48
|
+
config-inspection audits additionally prefer the existing governed config server-tool
|
|
49
|
+
when a site has `serverTools` configured. Each returns a `gated`/`unavailable` payload
|
|
50
|
+
(never throws) when no source is configured.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
- `sshDrush` and `parseDrush` are now exported from `src/tools/drush.js`, and a
|
|
54
|
+
`toolResultData` helper is exported from `src/lib/server-tools.js`, so the audit tool
|
|
55
|
+
groups can reuse the hardened drush bridge and the existing governed config transport.
|
|
56
|
+
|
|
57
|
+
### Security
|
|
58
|
+
- The drush bridge no longer logs secret-bearing flag values (`--password`/`--token`/
|
|
59
|
+
`--secret`/`--api-key`) in clear text — they are redacted to `***` in the operational
|
|
60
|
+
stderr log line (`redactSecretArgs`). Clears a `js/clear-text-logging` finding.
|
|
61
|
+
|
|
62
|
+
## [1.5.1] - 2026-06-29
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
- **Node URL aliases set via the connector now actually persist (DEV-116).** Setting an
|
|
66
|
+
alias with `drupal_update_node` (`fields.path = { alias, pathauto: 0 }`) returned
|
|
67
|
+
success but silently reverted, causing nav 404s. Root cause: JSON:API deserialized the
|
|
68
|
+
`path` field without the existing alias's **`pid`**, so Drupal's `PathItem::postSave`
|
|
69
|
+
*created a duplicate* `path_alias` (the older one stayed canonical) instead of updating
|
|
70
|
+
in place. The connector now reads the current alias's `pid` (new
|
|
71
|
+
`backend.getPathInfo`) and round-trips it, so the alias is **updated in place** — one
|
|
72
|
+
canonical alias, no duplicate. Verified end-to-end over JSON:API on Drupal 11.
|
|
73
|
+
- **Path-less updates no longer create duplicate aliases.** The DEV-114 "preserve" path
|
|
74
|
+
re-pinned the current alias *without* its `pid`, hitting the same duplicate bug; it now
|
|
75
|
+
round-trips the `pid` too.
|
|
76
|
+
- **Honest write responses.** `drupal_create_node` / `drupal_update_node` now **re-read**
|
|
77
|
+
the node after writing and return the *persisted* `url`, instead of echoing the
|
|
78
|
+
requested value (which masked the revert).
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
- **Automatic rename redirect.** When an explicit alias change replaces a different
|
|
82
|
+
existing alias, the connector creates a 301 redirect from the old path to the node
|
|
83
|
+
(`entity:node/<id>`, alias-independent), so the previous URL keeps resolving. Idempotent
|
|
84
|
+
— skipped when a redirect for that source already exists or the alias is unchanged.
|
|
85
|
+
- **`backend.getPathInfo(ref)`** on the backend interface — exposes the raw `path` field
|
|
86
|
+
(`alias` / `pid` / `langcode`) and internal id; default returns nulls (read-only/
|
|
87
|
+
path-less backends are unaffected). `buildRedirectAttributes()` is now exported from the
|
|
88
|
+
redirects module for reuse.
|
|
89
|
+
|
|
90
|
+
### Notes
|
|
91
|
+
- Connector-created nodes still rely on Pathauto to generate their alias when no explicit
|
|
92
|
+
`path` is given. A **separate, server-side** Pathauto pattern misconfiguration (some
|
|
93
|
+
`pathauto.pattern.*` had `bundles` stored as a sequential array instead of the
|
|
94
|
+
associative map the `entity_bundle` condition requires) prevented alias generation for
|
|
95
|
+
affected bundles (e.g. `industry`, `platform`); that fix lives in the Drupal site
|
|
96
|
+
(webcms), not in the connector.
|
|
97
|
+
|
|
10
98
|
## [1.5.0] - 2026-06-29
|
|
11
99
|
|
|
12
100
|
### Added
|
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
|
|
|
51
51
|
|
|
52
52
|
## Features
|
|
53
53
|
|
|
54
|
-
###
|
|
54
|
+
### 119 Tools Across 26 Modules
|
|
55
55
|
|
|
56
56
|
| Module | Tools |
|
|
57
57
|
|--------|-------|
|
|
@@ -76,6 +76,10 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
|
|
|
76
76
|
| **Redirects** | Create active URL redirects (301/302) + update/repoint existing redirects (Redirect module) |
|
|
77
77
|
| **Search** | Best-effort content search (title match; Search API/Solr-ready) |
|
|
78
78
|
| **Reports (extra)** | Orphaned references, unpublished content, missing-field audits |
|
|
79
|
+
| **Reports — Links & 404** | 404-log → redirect candidates, redirect-table health (chains/loops/duplicates), body-link inventory with opt-in live checking, URL-alias coverage, menu-link integrity, embedded-entity scan |
|
|
80
|
+
| **Reports — Config & Health** | Config drift, best-practice/security config linter, module audit (dev/debug + security updates), permission audit, Drupal status report, text-format safety, cache posture |
|
|
81
|
+
| **Reports — Content Quality** | Duplicate content, workflow bottlenecks, translation coverage, scheduled content, readability (Flesch), orphan pages, PII exposure (masked), structured-meta SEO coverage |
|
|
82
|
+
| **Audit (composite)** | `drupal_audit_site_health` — scored content/links/config dashboard with a roll-up grade |
|
|
79
83
|
| **Config & Governance** | Governed config get/list/set via the server-tool bridge; `drupal_mcp_whoami` tier/capability report |
|
|
80
84
|
|
|
81
85
|
**Preview writes with `dryRun`.** The node and entity create/update/delete tools accept an optional `dryRun: true` flag that validates the request and returns a preview of exactly what would be written — without committing anything to Drupal.
|
|
@@ -89,6 +93,7 @@ Browsable, always-fresh context the client can read without calling a tool:
|
|
|
89
93
|
### MCP Prompts
|
|
90
94
|
Workflow templates usable as slash-commands from any MCP client:
|
|
91
95
|
- `drupal-content-audit` — walk through a full site content audit
|
|
96
|
+
- `drupal-full-audit` — run the composite content/links/config audit and turn the scored dashboard into a prioritized action plan
|
|
92
97
|
- `drupal-create-article` — guided article creation with all fields
|
|
93
98
|
- `drupal-seo-fix` — find and fix SEO gaps
|
|
94
99
|
- `drupal-user-cleanup` — identify and handle inactive accounts
|
|
@@ -189,7 +194,7 @@ Governance keys off the authenticated account's role and OAuth scopes — not re
|
|
|
189
194
|
| [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
|
|
190
195
|
| [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
|
|
191
196
|
| [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
|
|
192
|
-
| [Tools Reference](docs/tools-reference.md) | Full reference for all
|
|
197
|
+
| [Tools Reference](docs/tools-reference.md) | Full reference for all 119 tools |
|
|
193
198
|
| [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
|
|
194
199
|
| [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
|
|
195
200
|
| [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
|
|
@@ -78,6 +78,13 @@
|
|
|
78
78
|
"port": 22,
|
|
79
79
|
"allowedCommands": ["config:export", "config:status"]
|
|
80
80
|
},
|
|
81
|
+
"audit": {
|
|
82
|
+
"_comment": "Optional. Controls drupal_report_broken_links live checking (off unless checkLive:true is passed). linkCheckAllowedHosts gates external hosts; same-origin links are always allowed when checking live.",
|
|
83
|
+
"linkCheckAllowedHosts": ["www.drupal.org", "wilkesliberty.com"],
|
|
84
|
+
"linkCheckConcurrency": 5,
|
|
85
|
+
"linkCheckTimeoutMs": 5000,
|
|
86
|
+
"linkCheckMaxLinks": 200
|
|
87
|
+
},
|
|
81
88
|
"security": { "preset": "config-editor" }
|
|
82
89
|
},
|
|
83
90
|
|
|
@@ -108,7 +115,11 @@
|
|
|
108
115
|
},
|
|
109
116
|
|
|
110
117
|
"_server_tools": {
|
|
111
|
-
"_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. Authenticated with the same OAuth bearer."
|
|
118
|
+
"_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. The config-inspection audits will also use it (governed path) when present, but fall back to the drush bridge, so it is optional for the audits. Authenticated with the same OAuth bearer."
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
"_audit_tools": {
|
|
122
|
+
"_comment": "The audit tool groups (drupal_report_* links/config/content, drupal_audit_site_health) are read-only. Privileged audits (404 log, config drift/best-practices, module/permission/status) are self-sufficient via the connector's own drush bridge (drushSsh) — no companion module required; the config-inspection audits additionally prefer the governed config server-tool when serverTools is configured. They report 'unavailable' when no source is configured for the site. drupal_report_broken_links does no outbound HTTP unless called with checkLive:true; see each site's optional 'audit' block for live-check limits."
|
|
112
123
|
},
|
|
113
124
|
|
|
114
125
|
"_mcp_client_registration": {
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -68,6 +68,10 @@ import * as structure from "./tools/structure.js";
|
|
|
68
68
|
import * as redirects from "./tools/redirects.js";
|
|
69
69
|
import * as search from "./tools/search.js";
|
|
70
70
|
import * as reportsExtra from "./tools/reports-extra.js";
|
|
71
|
+
import * as reportsLinks from "./tools/reports-links.js";
|
|
72
|
+
import * as reportsConfig from "./tools/reports-config.js";
|
|
73
|
+
import * as reportsContent from "./tools/reports-content.js";
|
|
74
|
+
import * as auditComposite from "./tools/audit-composite.js";
|
|
71
75
|
import * as config from "./tools/config.js";
|
|
72
76
|
|
|
73
77
|
// ---------------------------------------------------------------------------
|
|
@@ -75,7 +79,8 @@ import * as config from "./tools/config.js";
|
|
|
75
79
|
// ---------------------------------------------------------------------------
|
|
76
80
|
|
|
77
81
|
const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
|
|
78
|
-
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
|
|
82
|
+
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
|
|
83
|
+
reportsLinks, reportsConfig, reportsContent, auditComposite, config];
|
|
79
84
|
|
|
80
85
|
// Flatten every module's tool definitions into one ListTools payload, and merge
|
|
81
86
|
// their handler maps into a single closed dispatch table keyed by tool name.
|
|
@@ -247,6 +252,14 @@ const PROMPTS = [
|
|
|
247
252
|
description: "Identify inactive, never-logged-in, or overly permissioned user accounts and take action.",
|
|
248
253
|
arguments: [{ name: "site", description: "Target site", required: false }],
|
|
249
254
|
},
|
|
255
|
+
{
|
|
256
|
+
name: "drupal-full-audit",
|
|
257
|
+
description: "Run a full site-health audit — content, link/404 integrity, and configuration posture — and turn the scored dashboard into a prioritized action plan.",
|
|
258
|
+
arguments: [
|
|
259
|
+
{ name: "site", description: "Named site to audit (omit for default)", required: false },
|
|
260
|
+
{ name: "type", description: "Primary content type to audit", required: false },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
250
263
|
];
|
|
251
264
|
|
|
252
265
|
/**
|
|
@@ -310,6 +323,19 @@ function getPromptMessages(name, args) {
|
|
|
310
323
|
"6. Ask for approval before making any changes."
|
|
311
324
|
}},
|
|
312
325
|
],
|
|
326
|
+
"drupal-full-audit": [
|
|
327
|
+
{ role: "user", content: { type: "text", text:
|
|
328
|
+
`Please run a full site-health audit ${site} and turn it into a prioritized action plan.\n\n` +
|
|
329
|
+
`1. Call drupal_audit_site_health (type: "${type}") for the scored dashboard and overall grade.\n` +
|
|
330
|
+
"2. For any section reporting high-severity findings, drill in with the matching tool for detail:\n" +
|
|
331
|
+
" - links/404: drupal_report_404_log, drupal_report_redirect_health, drupal_report_broken_links (checkLive only with approval).\n" +
|
|
332
|
+
" - config: drupal_audit_config_best_practices, drupal_report_module_audit, drupal_report_permission_audit.\n" +
|
|
333
|
+
" - content: drupal_report_pii_exposure, drupal_report_duplicate_content, drupal_report_readability.\n" +
|
|
334
|
+
"3. For sections reported 'unavailable', note what (server-tool bridge or drush) would enable them — do not treat unavailable as 'passing'.\n" +
|
|
335
|
+
"4. Synthesize a prioritized plan: (a) high-severity/security fixes first, (b) content-quality improvements, (c) process recommendations.\n" +
|
|
336
|
+
"5. Present counts, severity, and specific node/config references; propose redirects for the top 404s. Ask before making any changes."
|
|
337
|
+
}},
|
|
338
|
+
],
|
|
313
339
|
};
|
|
314
340
|
|
|
315
341
|
return new Map(Object.entries(prompts)).get(name) ?? [{ role: "user", content: { type: "text", text: `Run the ${name} workflow ${site}.` } }];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privileged-source resolution for the audit tool groups.
|
|
3
|
+
*
|
|
4
|
+
* Single responsibility: run an audit that needs privileged log/config/module
|
|
5
|
+
* data through the best available source, in priority order — an optional
|
|
6
|
+
* governed server-tool first (when a callback is supplied), the connector's own
|
|
7
|
+
* drush SSH bridge otherwise — and report a clean "unavailable" outcome (never
|
|
8
|
+
* throw) when no source is configured or all fail. The drush bridge makes the
|
|
9
|
+
* audits self-sufficient against stock Drupal; no companion module is required.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether the governed server-tool bridge is configured for a site.
|
|
14
|
+
* @param {object} site Resolved site config.
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export function serverToolsConfigured(site) {
|
|
18
|
+
return Boolean(site?.serverTools?.url);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether the drush SSH bridge is configured for a site.
|
|
23
|
+
* @param {object} site Resolved site config.
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function drushConfigured(site) {
|
|
27
|
+
return Boolean(site?.drushSsh);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run a privileged audit through the first source that succeeds.
|
|
32
|
+
*
|
|
33
|
+
* Tries the optional governed server-tool (when a `serverTool` callback is given
|
|
34
|
+
* and `serverTools` is configured), then the connector's own drush bridge (when a
|
|
35
|
+
* `drush` callback is given and `drushSsh` is configured). Either callback may be
|
|
36
|
+
* omitted when that source can't serve the audit. Failures are accumulated, not
|
|
37
|
+
* thrown, so the caller can surface them in a gated payload.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} site Resolved site config.
|
|
40
|
+
* @param {object} sources
|
|
41
|
+
* @param {(() => Promise<*>)} [sources.serverTool] Server-tool attempt.
|
|
42
|
+
* @param {(() => Promise<*>)} [sources.drush] Drush attempt.
|
|
43
|
+
* @returns {Promise<{source: ("server-tool"|"drush"|null), data?: *, attempts: string[]}>}
|
|
44
|
+
* On success, `source` names the winning path and `data` is its result. On
|
|
45
|
+
* failure, `source` is null and `attempts` explains why each path was skipped
|
|
46
|
+
* or failed.
|
|
47
|
+
*/
|
|
48
|
+
export async function runPrivileged(site, { serverTool, drush } = {}) {
|
|
49
|
+
const attempts = [];
|
|
50
|
+
|
|
51
|
+
if (serverTool && serverToolsConfigured(site)) {
|
|
52
|
+
try { return { source: "server-tool", data: await serverTool(), attempts }; }
|
|
53
|
+
catch (err) { attempts.push(`server-tool: ${err?.message || err}`); }
|
|
54
|
+
} else if (serverTool) {
|
|
55
|
+
attempts.push("server-tool: serverTools.url not configured for this site");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (drush && drushConfigured(site)) {
|
|
59
|
+
try { return { source: "drush", data: await drush(), attempts }; }
|
|
60
|
+
catch (err) { attempts.push(`drush: ${err?.message || err}`); }
|
|
61
|
+
} else if (drush) {
|
|
62
|
+
attempts.push("drush: drushSsh not configured for this site");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { source: null, attempts };
|
|
66
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the audit tool groups — HTML link/embed extraction and
|
|
3
|
+
* link classification, backend-neutral.
|
|
4
|
+
*
|
|
5
|
+
* Single responsibility: turn a node's rendered body HTML into structured link
|
|
6
|
+
* and embed records, and classify a URL as internal/external/other relative to
|
|
7
|
+
* a site's base URL. The link (`reports-links.js`) and content
|
|
8
|
+
* (`reports-content.js`) audits share this one parser so link handling stays
|
|
9
|
+
* consistent across the suite.
|
|
10
|
+
*
|
|
11
|
+
* Detection is regex-based on the stored body markup — a heuristic pass, not a
|
|
12
|
+
* full DOM parse — matching the existing seo/accessibility audits in reports.js.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fieldValue } from "./reports-support.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read a node's body HTML from a canonical entity. Mirrors the body-extraction
|
|
19
|
+
* idiom in reports.js (the body field may be a `{value}` object or a scalar).
|
|
20
|
+
* @param {object} entity Canonical entity.
|
|
21
|
+
* @param {string[]} [candidates] Field names to try, in order.
|
|
22
|
+
* @returns {string} The body HTML, or "" when absent.
|
|
23
|
+
*/
|
|
24
|
+
export function bodyHtml(entity, candidates = ["body"]) {
|
|
25
|
+
const raw = fieldValue(entity, candidates);
|
|
26
|
+
const value = raw && typeof raw === "object" ? raw.value : raw;
|
|
27
|
+
return typeof value === "string" ? value : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract all `href` values from `<a>` tags in an HTML string.
|
|
32
|
+
* @param {string} html Body markup.
|
|
33
|
+
* @returns {string[]} Raw href values in document order (may include dups).
|
|
34
|
+
*/
|
|
35
|
+
export function extractAnchors(html) {
|
|
36
|
+
return matchAttr(html, /<a\b[^>]*?\bhref\s*=\s*["']([^"']+)["']/gi);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract all `src` values from `<img>` tags in an HTML string.
|
|
41
|
+
* @param {string} html Body markup.
|
|
42
|
+
* @returns {string[]} Raw src values in document order (may include dups).
|
|
43
|
+
*/
|
|
44
|
+
export function extractImages(html) {
|
|
45
|
+
return matchAttr(html, /<img\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/gi);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract entity embeds from body markup. Covers the CKEditor media/entity embed
|
|
50
|
+
* shapes Drupal emits — `<drupal-media>`, `<drupal-entity>`, and any element
|
|
51
|
+
* carrying `data-entity-type` + `data-entity-uuid` (entity_embed). Each embed is
|
|
52
|
+
* returned as a `{ entityType, uuid }` ref so callers can probe the target.
|
|
53
|
+
* @param {string} html Body markup.
|
|
54
|
+
* @returns {Array<{entityType: ?string, uuid: string}>} De-duped embed refs.
|
|
55
|
+
*/
|
|
56
|
+
export function extractEmbeds(html) {
|
|
57
|
+
const out = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
// Match any tag that carries a data-entity-uuid attribute; pull the optional
|
|
60
|
+
// sibling data-entity-type from the same tag.
|
|
61
|
+
const tagRe = /<([a-z-]+)\b([^>]*\bdata-entity-uuid\s*=\s*["'][^"']+["'][^>]*)>/gi;
|
|
62
|
+
for (const m of String(html || "").matchAll(tagRe)) {
|
|
63
|
+
const attrs = m[2];
|
|
64
|
+
const uuid = readAttr(attrs, /\bdata-entity-uuid\s*=\s*["']([^"']+)["']/i);
|
|
65
|
+
if (!uuid || seen.has(uuid)) continue;
|
|
66
|
+
seen.add(uuid);
|
|
67
|
+
out.push({ entityType: readAttr(attrs, /\bdata-entity-type\s*=\s*["']([^"']+)["']/i), uuid });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Classify a URL relative to a site base URL.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} url Raw href/src value.
|
|
76
|
+
* @param {string} baseUrl The site's configured base URL (origin).
|
|
77
|
+
* @returns {{kind: "internal"|"external"|"fragment"|"mailto"|"tel"|"other",
|
|
78
|
+
* url: string, path: ?string, host: ?string}} Classification. `path` is set
|
|
79
|
+
* for internal links (origin-relative, no query/fragment); `host` for external.
|
|
80
|
+
*/
|
|
81
|
+
export function classifyLink(url, baseUrl) {
|
|
82
|
+
const raw = (url || "").trim();
|
|
83
|
+
if (!raw) return { kind: "other", url: raw, path: null, host: null };
|
|
84
|
+
if (raw.startsWith("#")) return { kind: "fragment", url: raw, path: null, host: null };
|
|
85
|
+
|
|
86
|
+
const lower = raw.toLowerCase();
|
|
87
|
+
if (lower.startsWith("mailto:")) return { kind: "mailto", url: raw, path: null, host: null };
|
|
88
|
+
if (lower.startsWith("tel:")) return { kind: "tel", url: raw, path: null, host: null };
|
|
89
|
+
if (/^(javascript|data):/i.test(lower)) return { kind: "other", url: raw, path: null, host: null };
|
|
90
|
+
|
|
91
|
+
const baseHost = hostOf(baseUrl);
|
|
92
|
+
|
|
93
|
+
// Root- or path-relative internal links.
|
|
94
|
+
if (raw.startsWith("/") && !raw.startsWith("//")) {
|
|
95
|
+
return { kind: "internal", url: raw, path: normalizePath(raw), host: baseHost };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Protocol-relative or absolute URLs.
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = new URL(raw, baseUrl || undefined);
|
|
102
|
+
} catch {
|
|
103
|
+
return { kind: "other", url: raw, path: null, host: null };
|
|
104
|
+
}
|
|
105
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
106
|
+
return { kind: "other", url: raw, path: null, host: null };
|
|
107
|
+
}
|
|
108
|
+
if (baseHost && parsed.host.toLowerCase() === baseHost.toLowerCase()) {
|
|
109
|
+
return { kind: "internal", url: raw, path: normalizePath(parsed.pathname), host: parsed.host };
|
|
110
|
+
}
|
|
111
|
+
return { kind: "external", url: raw, path: null, host: parsed.host };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Normalize an internal path for comparison: decode, strip query/fragment, drop
|
|
116
|
+
* a trailing slash (except root), and ensure a leading slash.
|
|
117
|
+
* @param {string} path A pathname or root-relative URL.
|
|
118
|
+
* @returns {string} Normalized path beginning with "/".
|
|
119
|
+
*/
|
|
120
|
+
export function normalizePath(path) {
|
|
121
|
+
let p = String(path || "/");
|
|
122
|
+
const fragIdx = p.indexOf("#");
|
|
123
|
+
if (fragIdx !== -1) p = p.slice(0, fragIdx);
|
|
124
|
+
const queryIdx = p.indexOf("?");
|
|
125
|
+
if (queryIdx !== -1) p = p.slice(0, queryIdx);
|
|
126
|
+
try { p = decodeURI(p); } catch { /* leave as-is on malformed escapes */ }
|
|
127
|
+
if (!p.startsWith("/")) p = `/${p}`;
|
|
128
|
+
if (p.length > 1 && p.endsWith("/")) p = p.replace(/\/+$/, "");
|
|
129
|
+
return p || "/";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Pull the host (`host:port`) out of a base URL string, tolerating a bare host.
|
|
134
|
+
* @param {string} baseUrl A configured site base URL.
|
|
135
|
+
* @returns {?string} The host, or null when unparseable/absent.
|
|
136
|
+
*/
|
|
137
|
+
export function hostOf(baseUrl) {
|
|
138
|
+
if (!baseUrl) return null;
|
|
139
|
+
try { return new URL(baseUrl).host; }
|
|
140
|
+
catch {
|
|
141
|
+
try { return new URL(`https://${baseUrl}`).host; }
|
|
142
|
+
catch { return null; }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Run a global attribute-capturing regex over HTML and return capture group 1
|
|
148
|
+
* for every match.
|
|
149
|
+
* @param {string} html Source markup.
|
|
150
|
+
* @param {RegExp} re Global regex whose first group is the attribute value.
|
|
151
|
+
* @returns {string[]} Captured values in document order.
|
|
152
|
+
*/
|
|
153
|
+
function matchAttr(html, re) {
|
|
154
|
+
const out = [];
|
|
155
|
+
if (!html) return out;
|
|
156
|
+
for (const m of String(html).matchAll(re)) out.push(m[1]);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Read a single attribute value out of a tag's attribute string using a literal
|
|
162
|
+
* capturing regex.
|
|
163
|
+
* @param {string} attrs The raw attribute text inside a tag.
|
|
164
|
+
* @param {RegExp} re A regex whose first group captures the attribute value.
|
|
165
|
+
* @returns {?string} The value, or null when the attribute is absent.
|
|
166
|
+
*/
|
|
167
|
+
function readAttr(attrs, re) {
|
|
168
|
+
const m = String(attrs || "").match(re);
|
|
169
|
+
return m ? m[1] : null;
|
|
170
|
+
}
|
|
@@ -71,6 +71,16 @@ export class Backend {
|
|
|
71
71
|
*/
|
|
72
72
|
async getEntity(_ref) { return notImplemented("getEntity"); }
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Read the raw `path` field (alias/pid/langcode) and internal id of an entity,
|
|
76
|
+
* for callers that must round-trip the alias `pid` on an in-place update (the
|
|
77
|
+
* canonical entity only exposes `path.alias` as `url`). Optional capability:
|
|
78
|
+
* the default returns nulls so read-only/path-less backends are safe. See DEV-116.
|
|
79
|
+
* @param {{entityType: string, bundle: string, id: string}} _ref
|
|
80
|
+
* @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
|
|
81
|
+
*/
|
|
82
|
+
async getPathInfo(_ref) { return { alias: null, pid: null, langcode: null, drupalId: null }; }
|
|
83
|
+
|
|
74
84
|
/**
|
|
75
85
|
* Create an entity.
|
|
76
86
|
* @param {{entityType: string, bundle: string, attributes?: object, relationships?: object}} _input
|
|
@@ -253,6 +253,29 @@ export class JsonApiBackend extends Backend {
|
|
|
253
253
|
return data?.data ? this.toCanonical(data.data) : null;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Read the raw `path` field (alias + pid + langcode) and internal id of an
|
|
258
|
+
* entity. The canonical entity only surfaces `path.alias` as `url`, but a
|
|
259
|
+
* correct in-place alias *update* must round-trip the existing alias's `pid`
|
|
260
|
+
* (Drupal `PathItem::postSave` creates a duplicate alias when `pid` is absent)
|
|
261
|
+
* — so this method exposes it. Returns nulls for entities/backends without a
|
|
262
|
+
* path field. See DEV-116.
|
|
263
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
264
|
+
* @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
|
|
265
|
+
*/
|
|
266
|
+
async getPathInfo({ entityType, bundle, id }) {
|
|
267
|
+
validateUuid(id);
|
|
268
|
+
const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`);
|
|
269
|
+
const attrs = data?.data?.attributes ?? {};
|
|
270
|
+
const path = attrs.path ?? null;
|
|
271
|
+
return {
|
|
272
|
+
alias: path?.alias ?? null,
|
|
273
|
+
pid: path?.pid ?? null,
|
|
274
|
+
langcode: path?.langcode ?? attrs.langcode ?? null,
|
|
275
|
+
drupalId: attrs.drupal_internal__nid ?? attrs.drupal_internal__id ?? null,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
256
279
|
/**
|
|
257
280
|
* Issue a JSON:API write, transparently retrying once without the `status`
|
|
258
281
|
* attribute if the target bundle is under a content_moderation workflow.
|