@wootsup/yt-builder-mcp 1.9.0 → 1.11.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.
Files changed (111) hide show
  1. package/README.md +19 -10
  2. package/dist/gateway/advanced-read-tool.d.ts.map +1 -1
  3. package/dist/gateway/advanced-read-tool.js +14 -2
  4. package/dist/gateway/advanced-read-tool.js.map +1 -1
  5. package/dist/gateway/advanced-tool/domains.d.ts.map +1 -1
  6. package/dist/gateway/advanced-tool/domains.js +5 -0
  7. package/dist/gateway/advanced-tool/domains.js.map +1 -1
  8. package/dist/gateway/advanced-tool/execute.d.ts +7 -1
  9. package/dist/gateway/advanced-tool/execute.d.ts.map +1 -1
  10. package/dist/gateway/advanced-tool/execute.js +58 -4
  11. package/dist/gateway/advanced-tool/execute.js.map +1 -1
  12. package/dist/server.d.ts +14 -11
  13. package/dist/server.d.ts.map +1 -1
  14. package/dist/server.js +14 -11
  15. package/dist/server.js.map +1 -1
  16. package/dist/sites/tools/sites-list.d.ts.map +1 -1
  17. package/dist/sites/tools/sites-list.js +22 -2
  18. package/dist/sites/tools/sites-list.js.map +1 -1
  19. package/dist/tools/elements/builders.d.ts.map +1 -1
  20. package/dist/tools/elements/builders.js +69 -31
  21. package/dist/tools/elements/builders.js.map +1 -1
  22. package/dist/tools/elements/handlers-write.d.ts +2 -0
  23. package/dist/tools/elements/handlers-write.d.ts.map +1 -1
  24. package/dist/tools/elements/handlers-write.js +132 -9
  25. package/dist/tools/elements/handlers-write.js.map +1 -1
  26. package/dist/tools/elements/handlers.d.ts +7 -4
  27. package/dist/tools/elements/handlers.d.ts.map +1 -1
  28. package/dist/tools/elements/handlers.js +49 -8
  29. package/dist/tools/elements/handlers.js.map +1 -1
  30. package/dist/tools/elements/schema-validation.d.ts +18 -0
  31. package/dist/tools/elements/schema-validation.d.ts.map +1 -1
  32. package/dist/tools/elements/schema-validation.js +49 -5
  33. package/dist/tools/elements/schema-validation.js.map +1 -1
  34. package/dist/tools/format/sources-format.d.ts +12 -5
  35. package/dist/tools/format/sources-format.d.ts.map +1 -1
  36. package/dist/tools/format/sources-format.js +31 -6
  37. package/dist/tools/format/sources-format.js.map +1 -1
  38. package/dist/tools/health.d.ts.map +1 -1
  39. package/dist/tools/health.js +18 -5
  40. package/dist/tools/health.js.map +1 -1
  41. package/dist/tools/inspection.d.ts.map +1 -1
  42. package/dist/tools/inspection.js +81 -12
  43. package/dist/tools/inspection.js.map +1 -1
  44. package/dist/tools/layout-flatten.d.ts +7 -1
  45. package/dist/tools/layout-flatten.d.ts.map +1 -1
  46. package/dist/tools/layout-flatten.js +24 -13
  47. package/dist/tools/layout-flatten.js.map +1 -1
  48. package/dist/tools/library.d.ts +23 -0
  49. package/dist/tools/library.d.ts.map +1 -1
  50. package/dist/tools/library.js +213 -15
  51. package/dist/tools/library.js.map +1 -1
  52. package/dist/tools/local-content/builders.js +11 -11
  53. package/dist/tools/local-content/builders.js.map +1 -1
  54. package/dist/tools/local-content/handlers.d.ts.map +1 -1
  55. package/dist/tools/local-content/handlers.js +34 -5
  56. package/dist/tools/local-content/handlers.js.map +1 -1
  57. package/dist/tools/local-content/schemas.d.ts +9 -4
  58. package/dist/tools/local-content/schemas.d.ts.map +1 -1
  59. package/dist/tools/local-content/schemas.js +14 -6
  60. package/dist/tools/local-content/schemas.js.map +1 -1
  61. package/dist/tools/multi-items/builders.d.ts.map +1 -1
  62. package/dist/tools/multi-items/builders.js +11 -8
  63. package/dist/tools/multi-items/builders.js.map +1 -1
  64. package/dist/tools/pages/builders.d.ts.map +1 -1
  65. package/dist/tools/pages/builders.js +122 -46
  66. package/dist/tools/pages/builders.js.map +1 -1
  67. package/dist/tools/pages/handlers-audit.d.ts +129 -0
  68. package/dist/tools/pages/handlers-audit.d.ts.map +1 -0
  69. package/dist/tools/pages/handlers-audit.js +704 -0
  70. package/dist/tools/pages/handlers-audit.js.map +1 -0
  71. package/dist/tools/pages/handlers-read.d.ts +8 -3
  72. package/dist/tools/pages/handlers-read.d.ts.map +1 -1
  73. package/dist/tools/pages/handlers-read.js +172 -25
  74. package/dist/tools/pages/handlers-read.js.map +1 -1
  75. package/dist/tools/pages/handlers-write.d.ts +17 -3
  76. package/dist/tools/pages/handlers-write.d.ts.map +1 -1
  77. package/dist/tools/pages/handlers-write.js +79 -26
  78. package/dist/tools/pages/handlers-write.js.map +1 -1
  79. package/dist/tools/pages/index.d.ts +5 -1
  80. package/dist/tools/pages/index.d.ts.map +1 -1
  81. package/dist/tools/pages/index.js +5 -1
  82. package/dist/tools/pages/index.js.map +1 -1
  83. package/dist/tools/pages/schemas.d.ts +319 -1
  84. package/dist/tools/pages/schemas.d.ts.map +1 -1
  85. package/dist/tools/pages/schemas.js +340 -4
  86. package/dist/tools/pages/schemas.js.map +1 -1
  87. package/dist/tools/pages/target.d.ts +162 -0
  88. package/dist/tools/pages/target.d.ts.map +1 -0
  89. package/dist/tools/pages/target.js +239 -0
  90. package/dist/tools/pages/target.js.map +1 -0
  91. package/dist/tools/shared-schemas.d.ts +22 -0
  92. package/dist/tools/shared-schemas.d.ts.map +1 -1
  93. package/dist/tools/shared-schemas.js +22 -0
  94. package/dist/tools/shared-schemas.js.map +1 -1
  95. package/dist/tools/sources/builders.d.ts.map +1 -1
  96. package/dist/tools/sources/builders.js +46 -28
  97. package/dist/tools/sources/builders.js.map +1 -1
  98. package/dist/tools/sources/handlers-bind.d.ts +7 -8
  99. package/dist/tools/sources/handlers-bind.d.ts.map +1 -1
  100. package/dist/tools/sources/handlers-bind.js +106 -7
  101. package/dist/tools/sources/handlers-bind.js.map +1 -1
  102. package/dist/tools/sparse-fields.d.ts +12 -0
  103. package/dist/tools/sparse-fields.d.ts.map +1 -1
  104. package/dist/tools/sparse-fields.js +34 -2
  105. package/dist/tools/sparse-fields.js.map +1 -1
  106. package/dist/tools/tool-builder/results.d.ts.map +1 -1
  107. package/dist/tools/tool-builder/results.js +114 -0
  108. package/dist/tools/tool-builder/results.js.map +1 -1
  109. package/manifest.json +1 -1
  110. package/package.json +1 -1
  111. package/skills/yt-builder-mcp/SKILL.md +735 -44
@@ -12,15 +12,15 @@ description: Drive the YOOtheme Pro Page Builder on WordPress or Joomla 5/6. Dis
12
12
  > WordPress and Joomla 5/6.
13
13
 
14
14
  This skill helps AI assistants drive the YOOtheme Pro Page Builder through the
15
- `@wootsup/yt-builder-mcp` server. The server catalogues 47 typed, scoped,
16
- idempotent tools (49 callable end-to-end with the two gateways). 41 of them
15
+ `@wootsup/yt-builder-mcp` server. The server catalogues 49 typed, scoped,
16
+ idempotent tools (51 callable end-to-end with the two gateways). 41 of them
17
17
  advertised as first-class entries in `tools/list`
18
- (37 essential L1 forwards + 2 direct L3 entries + 2 gateways), and 8 advanced
18
+ (37 essential L1 forwards + 2 direct L3 entries + 2 gateways), and 10 advanced
19
19
  tools reachable through the gateways. The write-capable
20
- `yootheme_builder_advanced` gateway routes all 8; the read-only
20
+ `yootheme_builder_advanced` gateway routes all 10; the read-only
21
21
  `yootheme_builder_advanced_read` gateway routes only the read-only subset
22
22
  (page_get_schema, inspect_sublayout, layout_query, local_content_types,
23
- local_content_read) and is itself
23
+ local_content_read, pages_audit) and is itself
24
24
  read-only-annotated, so MCP hosts can auto-allow pure inspection without a
25
25
  per-call write-approval prompt. This 3-lane split keeps `tools/list` well below
26
26
  the ~40-tool Cursor cap while every catalogued tool stays fully reachable.
@@ -55,7 +55,7 @@ entries when the user types "YT Builder MCP" into the picker:
55
55
 
56
56
  - **`YT Builder MCP for YOOtheme Pro (unofficial)`**: the MCP **server**.
57
57
  Provides the 41 first-class tools (37 essential, 2 direct, 2 gateways).
58
- The `yootheme_builder_advanced` gateway routes all 8 advanced tools; the
58
+ The `yootheme_builder_advanced` gateway routes all 10 advanced tools; the
59
59
  read-only `yootheme_builder_advanced_read` gateway routes the read-only
60
60
  subset without a write-approval prompt.
61
61
  - **`Von YT Builder MCP for YOOtheme Pro`**: the bundled **skill** (this
@@ -125,10 +125,11 @@ The server exposes:
125
125
  step that turns a placeholder-filled skeleton into a finished-looking
126
126
  page (see "Fill image slots with real on-topic photos" under Layout
127
127
  Library).
128
- - **8 advanced captured tools**: everything else (page_get_schema,
128
+ - **10 advanced captured tools**: everything else (page_get_schema,
129
129
  element_unbind_source, clean_implode_directives,
130
- `inspect_sublayout`, `layout_query`, `library_save_as`, and the two Local
131
- Content Read tools `local_content_types` / `local_content_read`).
130
+ `inspect_sublayout`, `layout_query`, `library_save_as`, the two Local
131
+ Content Read tools `local_content_types` / `local_content_read`,
132
+ `pages_audit`, and `library_delete` — the INVERSE of `library_save_as`).
132
133
  Reachable through one gateway tool:
133
134
  `yootheme_builder_advanced({ tool: "<name>", arguments: { ... } })`. For
134
135
  example, to inspect a panel/grid_item `< SUBLAYOUT`:
@@ -143,6 +144,18 @@ The server exposes:
143
144
  It flags `invalid_source_possible: true` when the saved subtree carries
144
145
  bound dynamic sources that may not exist wherever it is later reloaded —
145
146
  re-bind after inserting it elsewhere.
147
+ And to clean up an experimental saved entry (PAGES_LIBRARY_MEDIA-16 — the
148
+ INVERSE of `library_save_as`; `source` defaults to `"ai"`, the ONLY deletable
149
+ store; the curated library is read-only):
150
+ `yootheme_builder_advanced({ tool: "yootheme_builder_library_delete", arguments: { id: "955bd3dc" } })`.
151
+ Pass the `id` from a `library_browse({ source: "ai" })` row; an unknown id
152
+ returns a 404, and `source: "catalog"` returns a 403 (read-only).
153
+ And to run a one-call site-wide SEO/structure/content/links audit
154
+ (read-only, severity-sorted findings + machine-executable fix_hints),
155
+ prefer the read gateway:
156
+ `yootheme_builder_advanced_read({ tool: "yootheme_builder_pages_audit", arguments: { checks: ["seo", "structure"] } })`
157
+ (the write gateway routes it too, if you prefer one entry point:
158
+ `yootheme_builder_advanced({ tool: "yootheme_builder_pages_audit" })`).
146
159
 
147
160
  The core write/refactor + render-verify tools the Glöckle HANDOFF-4/5 ask for
148
161
  are now **L1 — call them directly by name**, not through a gateway:
@@ -184,7 +197,8 @@ The server exposes:
184
197
  - **2 gateway tools**: `yootheme_builder_advanced` (write-capable; can route
185
198
  into delete/unbind, so hosts gate it) and `yootheme_builder_advanced_read`
186
199
  (read-only; routes ONLY the read-only advanced subset — page_get_schema,
187
- inspect_sublayout, layout_query, local_content_types, local_content_read
200
+ inspect_sublayout, layout_query, local_content_types, local_content_read,
201
+ pages_audit —
188
202
  and is read-only-annotated so hosts auto-allow it). Prefer the read gateway
189
203
  for pure inspection:
190
204
  `yootheme_builder_advanced_read({ tool: "yootheme_builder_page_get_schema", arguments: { template_id } })`.
@@ -196,8 +210,8 @@ The server exposes:
196
210
  `yootheme_builder_advanced({ tool: "yootheme_builder_local_content_read", arguments: { kind: "entities", type: "post" } })`.)
197
211
 
198
212
  `tools/list` therefore advertises 41 names (37 + 2 + 2). That's 37 L1
199
- essentials + 2 L3 direct + 2 gateways. The total callable surface is 49
200
- (41 advertised + 8 advanced reachable through the gateways). If the AI
213
+ essentials + 2 L3 direct + 2 gateways. The total callable surface is 51
214
+ (41 advertised + 10 advanced reachable through the gateways). If the AI
201
215
  client reports "tool not found", you are almost certainly calling an
202
216
  advanced tool by its raw name. Wrap it in
203
217
  `yootheme_builder_advanced({ tool, arguments })` (or the read gateway for
@@ -266,6 +280,100 @@ serves_context_ids: [11] }`. Use `pages_resolve` when you just want the answer;
266
280
  use the `serves_context_*` columns when you already hold `pages_list` and want to
267
281
  match locally.
268
282
 
283
+ ### Address a page WITHOUT its URL — the `target` object
284
+
285
+ `pages_resolve` and `pages_audit` take the **same** `target` object, so one
286
+ addressing vocabulary covers both "which template renders this?" and "audit just
287
+ this page". Supply **exactly ONE of** these five keys:
288
+
289
+ | Key | Value | Example |
290
+ |-----|-------|---------|
291
+ | `template_id` | An exact template id | `{ template_id: "page:4816" }` |
292
+ | `post_id` | The numeric WP post / Joomla article id | `{ post_id: 4816 }` |
293
+ | `url` | A public frontend URL (same resolver as `pages_resolve({ url })`) | `{ url: "https://example.com/schedule-demo/" }` |
294
+ | `special` | A semantic page ROLE (vocabulary below) | `{ special: "home" }` |
295
+ | `title` | Case-insensitive substring of the page NAME/label | `{ title: "SunWest" }` |
296
+
297
+ **On the wire it stays one object.** MCP-native transports pass `target`
298
+ through as the object above; the REST transport encodes it as **bracket params**
299
+ — `?target[special]=home`, `?target[template_id]=page:4816`,
300
+ `?target[url]=<url-encoded URL>` — which WordPress and Joomla both parse back
301
+ into an array natively. Same key names either way, so what you read in the table
302
+ is what goes over the wire on either CMS. You never build that querystring
303
+ yourself; it matters when you read a server log or reproduce a call with `curl`.
304
+
305
+ **The special vocabulary (all ten tokens):** `home` and `front-page` resolve
306
+ through the platform's front-page configuration (WordPress `show_on_front` /
307
+ `page_on_front`; Joomla's default menu item). `blog` resolves through
308
+ WordPress's `page_for_posts` setting, and on Joomla through the site's blog
309
+ **menu item** (see the caveat below). `search`, `404`, `category`, `tag`,
310
+ `author` and `archive` resolve forward through the template type index.
311
+ `date` is recognised but unsupported on
312
+ BOTH platforms and returns
313
+ `special_not_supported` — say so rather than guessing a date archive.
314
+
315
+ **Three platform caveats worth stating up front:**
316
+
317
+ - On **Joomla**, `author` maps to the **Contact** template
318
+ (`com_contact.contact`) — Joomla has no post-author archive, so the contact
319
+ view is the conventional analogue. Do not report it as a WordPress-style
320
+ author archive.
321
+ - On **Joomla**, `blog` is a **menu item**, not a site setting.
322
+ There is no Joomla equivalent of `page_for_posts`, so the resolver reads the
323
+ published site menu: a `view=featured` item wins outright (it is the
324
+ site-wide news front); otherwise the `view=category&layout=blog` items
325
+ decide. Those items are reduced to the DISTINCT TEMPLATES they resolve to
326
+ before anything is reported: several menu items governed by one template are
327
+ one page, and a blog category governed by no template is dropped rather than
328
+ offered as a dead address. One template left resolves outright — even when a
329
+ dozen menu items led to it. **Two or more return `blog_ambiguous`** (HTTP
330
+ 409) with the same `candidates` shape as `title_ambiguous` — one row per
331
+ distinct blog page, `label` being the menu title the user recognises and `id`
332
+ the governing template, always present and always usable. Show the list and
333
+ let the user choose; then re-address the page with
334
+ `target.template_id`, `target.url` or `target.title`.
335
+ Sites with no blog menu item at all fall back to the post-archive template.
336
+ `special: "home"` is unaffected — it still resolves the default menu item.
337
+ - `date` is unsupported on both platforms (there is no date-archive vocabulary
338
+ yet), which is why it returns a precise `special_not_supported` instead of an
339
+ "unknown token" error.
340
+
341
+ **Typed failures, so you never have to guess what went wrong:** two or more keys
342
+ (or `url` alongside a `target`) → `too_many_targets`; a `title` matching nothing
343
+ → `title_no_match`; a `title` matching two or more pages → `title_ambiguous`, with
344
+ a `candidates` list (max 10) to pick from — show that list to the user instead of
345
+ choosing for them; `special: "blog"` on a Joomla site with several blog menu
346
+ items → `blog_ambiguous`, carrying the same `candidates` list under the same
347
+ rule. A token that is resolvable in principle but matches no
348
+ template on this site → `special_not_resolvable`. Supplying NO address is not an
349
+ error — it falls back to each tool's default: `pages_audit` sweeps the whole
350
+ site, while `pages_resolve` needs one and answers `missing_url`.
351
+
352
+ **Every typed code this error envelope carries, and what to do about it.** Most
353
+ concern the ADDRESS; a couple concern the rest of the request shape. The code is
354
+ the contract; the HTTP status only groups them. A code you do not recognise is
355
+ never a transport failure — report it verbatim rather than retrying blindly.
356
+
357
+ | Code | Status | What happened | What to do |
358
+ |------|--------|---------------|------------|
359
+ | `no_target` | 400 | A `target` WAS supplied but none of its members is a usable address — every member must be a **non-empty scalar**. `target[special][x]=y` (a nested value) and a templated `target[title]=` that rendered empty both land here. | Re-send with exactly one non-empty scalar key. Never re-send without a `target` "to see what happens": that is a whole-site sweep whose findings you would attribute to one page. |
360
+ | `too_many_targets` | 400 | Two or more address keys, or `url` alongside `target`. | Pick one. Use `title` or `pages_list` when you only know the page by name. |
361
+ | `types_conflicts_with_target` | 400 | `types` was sent with a `target`. | Drop `types` — a target already names exactly one page, so there is nothing for a type filter to narrow. |
362
+ | `unknown_check_category` | 400 | A `checks` value that is not one of the four categories (`seo`, `structure`, `content`, `links`) — a typo, a finding CODE passed where a category belongs, or a case variant: the vocabulary is **case-sensitive**, so `SEO` is not `seo`. | Re-send with a real category (the message names both the unknown members and the valid set). This is refused rather than filtered because an unrecognised value selects ZERO checks, and zero checks over a normally-scanned site returns `findings: []` — a confident CLEAN verdict for a typo. **Never read such an answer as clean.** |
363
+ | `no_template_matched_types` | 400 | A site-sweep `types` filter matched NO template on this site — a typo or, most often, a case variant (`PAGE` is not `page`). Unlike `checks`, the `types` vocabulary is **open and site-dependent** (`page`, `article`, `single-post`, `com_content.category`, …), so it cannot be validated against a fixed list — only a zero match is knowable, and it is knowable only after the sweep. | Re-send with one of the types the message lists (it names what this site actually has), or omit `types` to sweep everything. Refused for the same reason as `unknown_check_category`: a filter that selected nothing answered HTTP 200 with `findings: []` and an all-zero summary, which reads as CLEAN. **Never read such an answer as clean.** |
364
+ | `template_not_found` | 404 | The `template_id` names no template carrying a YOOtheme layout on this site. | Re-read the id from `pages_list`; ids are site-specific and change when a page is recreated. |
365
+ | `post_not_found` | 404 | The `post_id` names no WP post / Joomla article with a per-page layout. | Same: confirm via `pages_list`. A post that exists but has no per-page layout is rendered by a THEME template — find that template instead. |
366
+ | `url_no_match` | 404 | The URL is well-formed but resolves to no per-page layout — usually because a theme template renders it. | Use `pages_list` to find the governing template, or `pages_resolve({ url })` for the fuller hint. |
367
+ | `title_no_match` | 404 | No page NAME contains that substring. | Try a shorter substring, or list with `pages_list`. |
368
+ | `special_not_resolvable` | 404 | The token is in the vocabulary and resolvable in principle, but matches no template on THIS site. | Report which role was requested; do not silently fall back to another page. |
369
+ | `title_ambiguous` | 409 | The substring matches two or more pages. | A `candidates` list (max 10) rides along — show it and let the user choose. |
370
+ | `blog_ambiguous` | 409 | Joomla only: several distinct blog pages. | Same `candidates` shape; show it (see the Joomla caveat above). |
371
+ | `special_not_supported` | 422 | The token is recognised but this platform has no such page role (today: `date`, on both). | Say so plainly instead of guessing an analogue. |
372
+ | `target_not_scannable` | 422 | **The target resolved to a real page, but this site does not enumerate it as an auditable page** — a post type or status the page enumerator does not cover, or a trashed item. | **This is NOT a clean result.** An audit that answered 200 with zero findings would read as "your page is fine"; this code exists precisely so it cannot. Audit it via `template_summary`, or pick a page `pages_list` returns. |
373
+
374
+ `server_too_old` is the one code the SERVER never sends — see
375
+ [Audit ONE page instead of the whole site](#audit-one-page-instead-of-the-whole-site).
376
+
269
377
  ## Scopes (Bearer key permissions)
270
378
 
271
379
  Every Bearer key has a scope, set at key creation time:
@@ -474,6 +582,25 @@ templates are for), but confirm the intent — do not edit a `single-post` /
474
582
  templates are defined in the theme itself and are not created via this MCP;
475
583
  you edit the existing ones `pages_list` reports.
476
584
 
585
+ **Reaching a theme template by ROLE instead of by id.** You rarely know a theme
586
+ template's opaque id (`I99YS8Ii`). The `special` tokens of the shared `target`
587
+ object address these templates by what they SERVE, on both `pages_resolve` and
588
+ `pages_audit`:
589
+
590
+ | `target` | Lands on |
591
+ |----------|----------|
592
+ | `target: { special: "home" }` | The configured front page (a `page:<n>` when the site serves a static front page, otherwise the posts/archive template) |
593
+ | `target: { special: "blog" }` / `{ special: "archive" }` | The posts page / `archive-post` template |
594
+ | `target: { special: "404" }` | The `error-404` template |
595
+ | `target: { special: "search" }` | The `search` template (Joomla: `com_finder.search`) |
596
+ | `target: { special: "category" }` / `{ special: "tag" }` | The `taxonomy-category` / `taxonomy-post_tag` template (Joomla: `com_content.category` / `com_tags.tag`) |
597
+ | `target: { special: "author" }` | The author archive — on **Joomla** the Contact template |
598
+
599
+ So "audit the 404 page" is one call with `target: { special: "404" }`, and the
600
+ response's `resolved_target` tells you which template that actually was. The
601
+ same safety rule still applies: a finding on a theme template is a finding on
602
+ EVERY page that template renders.
603
+
477
604
  ## Workflow 1: Build a hero section
478
605
 
479
606
  **Goal:** Add a fresh hero section (heading + sub-heading + CTA button)
@@ -736,6 +863,12 @@ must have **exactly one** `<h1>`). Set it explicitly on every headline:
736
863
  for punch); a big stat number is `title_element: "div"` with
737
864
  `title_style: "heading-medium"`.
738
865
 
866
+ **This `h1` default is specific to `headline`.** Container elements
867
+ (`grid`, `panel`, `slideshow`, and the rest of the item-carrier family)
868
+ default to `h3`, not `h1`, when `title_element` is left unset. See
869
+ "Workflow 6 (SEO audit)" below for the full per-type default table
870
+ `pages_audit` uses to find missing/multiple h1 issues across a whole site.
871
+
739
872
  ### Headline `content` is plain inline text — never wrapped in `<p>`
740
873
 
741
874
  A `headline` renders its `content` **inside** the heading tag, so wrapping it
@@ -1238,7 +1371,563 @@ and the props payload you passed.
1238
1371
 
1239
1372
  ---
1240
1373
 
1241
- ## When something doesn't fit one of these 5 workflows
1374
+ ## Workflow 6: SEO audit (pages_audit)
1375
+
1376
+ **Goal:** Run one deterministic, site-wide sweep for structure, content,
1377
+ SEO, and link issues, drill into the pages a finding names, and fix them
1378
+ one page at a time through the existing write tools this server already
1379
+ exposes. Never analyze findings by hand and never bulk-apply a fix.
1380
+
1381
+ **Canonical tool-call sequence (real parameter names, snake_case):**
1382
+
1383
+ 1. `yootheme_builder_advanced_read({ tool: "yootheme_builder_pages_audit", arguments: { checks: ["seo", "structure"] } })`
1384
+ sweeps the whole site in one call. `pages_audit` is a read-only
1385
+ advanced tool, so reach it through the read gateway (the write
1386
+ gateway routes it too, but the read gateway never triggers a
1387
+ write-approval prompt). Omit `checks`/`types` to run every category
1388
+ against every scanned template.
1389
+ 2. Read the response's `findings` array. Each finding carries a `code`,
1390
+ `category`, `severity`, a `count`, up to 30 affected `pages`, and
1391
+ (where a safe automatic fix exists) a `fix_hint` naming the exact
1392
+ write tool and args template to run. `duplicate-layout` and
1393
+ `title-collision` never carry a `fix_hint` — they need a human
1394
+ decision (rename, merge, or intentionally keep as-is), not an
1395
+ automatic patch.
1396
+ 3. `yootheme_builder_template_summary({ template_id })` drills into one
1397
+ page a finding names. It carries the same `heading_levels`,
1398
+ `images_missing_alt`, and `disabled_elements` counters the audit
1399
+ used, so you can see exactly which nodes are affected before
1400
+ writing.
1401
+ 4. Fix that ONE page via the write tool named in `fix_hint.tool`, with
1402
+ `fix_hint.args_template` as a starting point (`element_update_settings`,
1403
+ `element_add`, etc), ETag-locked like every other write in this
1404
+ server.
1405
+ 5. `yootheme_builder_page_publish({ template_id, etag })` republishes.
1406
+ The response's `quick_check` block (default on) re-checks THIS page
1407
+ with the same per-page codes, so you can confirm the fix landed
1408
+ without a second full sweep.
1409
+
1410
+ **Common pitfalls:**
1411
+
1412
+ - **Promising a full SEO audit.** `pages_audit` covers Builder structure
1413
+ and CMS basics only: heading hierarchy, empty/thin pages, missing
1414
+ image alt text, duplicate layouts, title collisions, non-pretty URL
1415
+ fallbacks, stale content, and dead in-page anchors. Every response
1416
+ carries a fixed disclaimer field:
1417
+ `"Meta title/description/OG live in your CMS SEO plugin — not covered here."`
1418
+ It does not read or evaluate Yoast/RankMath/any SEO-plugin meta
1419
+ title, meta description, or Open Graph tags. Say this scope boundary
1420
+ out loud and point the user at their SEO plugin for that layer.
1421
+ - **Confusing the CONTENT count with `pages_list.elements_count`.**
1422
+ `pages_list.elements_count` counts every node, including
1423
+ `section`/`row`/`column`/`fragment` layout wrappers. `pages_audit`
1424
+ instead uses a narrower CONTENT count (`elements_content` in the
1425
+ evidence) that excludes `section`/`row`/`column`/`fragment`/`layout`
1426
+ wrapper nodes: an empty section nested in three columns is correctly
1427
+ `0` content elements, not "not empty" just because the wrappers
1428
+ exist. Both numbers ride in the evidence (`elements_total` and
1429
+ `elements_content`). `thin-content` fires when `elements_content` is
1430
+ below `thin_content_threshold` (default 8); `empty-page` fires at
1431
+ `elements_content === 0`.
1432
+ - **The h1-default trap.** Only `headline` defaults to `h1` when
1433
+ `title_element` is unset. A container element (`grid`, `panel`,
1434
+ `slideshow`, and the rest) left unset does NOT become an `<h1>` — it
1435
+ becomes an `<h3>` (or stays a non-heading `div` for
1436
+ `description_list`). See the per-type default table below and
1437
+ "`title_style` vs `title_element`" above (that section's "defaults
1438
+ to `h1`" rule is specific to `headline` only). `meta_element` is a
1439
+ SECOND, independent heading channel on the same container types,
1440
+ defaulting to `div` and counting only when explicitly set to
1441
+ `h1`-`h6` with `show_meta` not `false`.
1442
+ - **Treating a bound-but-empty slot as missing.** Bound title/meta
1443
+ slots count as filled even when the static prop text is empty — a
1444
+ title bound to a dynamic source field is not "missing" just because
1445
+ the builder-side prop is blank. The same exemption applies to
1446
+ `image_alt`/`background_image_alt`: `images-missing-alt` never flags
1447
+ a bound image-alt slot, only an unbound, empty, or whitespace-only
1448
+ alt on a static image.
1449
+ - **Flagging every `#`-prefixed value as a dead anchor.** A
1450
+ `dead-anchor` finding fires only for a genuinely dangling `#name`
1451
+ link whose target element `id` does not exist anywhere on the same
1452
+ page. It explicitly excludes a bare `#` (a placeholder, never a real
1453
+ target), `mailto:`/`tel:` links, `http(s)://` external links, and
1454
+ cross-page links like `?page_id=…#…` (that anchor lives on a
1455
+ different page's render and cannot be verified from this page's own
1456
+ `props.id` set).
1457
+ - **Treating `missing-h1` as critical, or double-flagging it.**
1458
+ Severity is calibrated from live empirical review, not per-rule
1459
+ guessing: `no-headings` (zero headings anywhere on the page) is
1460
+ critical, but `missing-h1` (headings exist, but none is an effective
1461
+ h1) is only a warning, and it is suppressed entirely when
1462
+ `no-headings` already fired for the same page. Real sites
1463
+ legitimately have h2/h3-only pages with no h1.
1464
+ - **Treating `quick_check` findings as a publish failure.** `quick_check`
1465
+ is advisory only: publish and save always succeed regardless of what
1466
+ it reports. This is not the blocking render-precheck (which still
1467
+ gates on genuinely broken output) — SEO/structure findings are
1468
+ hints, not corruption. `quick_check` only ever includes checks whose
1469
+ `scope` is `"per-page"`; `duplicate-layout` and `title-collision`
1470
+ need the whole site swept to mean anything, so they never appear
1471
+ here — instead the block carries a fixed note:
1472
+ `"run pages_audit for site-wide checks"`. `page_publish` attaches
1473
+ `quick_check` by default (set `quick_check: false` to opt out, e.g.
1474
+ scripted bulk publishes); `page_save` carries it only when you opt
1475
+ in with `quick_check: true`.
1476
+
1477
+ > **Changed: the advisory block got bigger and stricter.** Three Tier-2 checks
1478
+ > — `anchor-text-quality` (warning), `generic-alt-text` (info) and
1479
+ > `long-heading` (info) — are `per-page`, so they joined `quick_check`
1480
+ > automatically, and every finding now carries per-hit **locators** (breadcrumb,
1481
+ > element name, JSON-Pointer path, snippet). Measured on a page that trips them:
1482
+ > ~7.5 KB of `quick_check`, roughly 3.3 KB of it locators, against ~1.5 KB
1483
+ > before. It is BOUNDED, not open-ended — the per-page hit cap holds the block at
1484
+ > roughly 11 KB even on a 500-element page — and a clean page is still just
1485
+ > `{ clean: true }`.
1486
+ >
1487
+ > **What this means for you, not for the customer.** `anchor-text-quality`
1488
+ > matches the generic-CTA list EXACTLY, and that list is bilingual: `mehr`,
1489
+ > `hier`, `mehr erfahren`, `weiterlesen` alongside `click here` / `read more`.
1490
+ > On a German site those are ordinary house-style buttons, so a perfectly good
1491
+ > publish can come back carrying a `warning`. **Do not report a warning-severity
1492
+ > `anchor-text-quality` as a failed publish, and do not "fix" the customer's
1493
+ > button copy unasked** — say the publish succeeded, mention the advice once,
1494
+ > and move on. There is no per-check switch on the publish path today: the only
1495
+ > lever is `quick_check: false`, which also silences `missing-h1`,
1496
+ > `empty-page`, `images-missing-alt` and `dead-anchor`. Prefer living with the
1497
+ > advisory over turning the block off.
1498
+
1499
+ **Per-type `title_element` default table (the correctness crux of every
1500
+ heading finding):**
1501
+
1502
+ | Element type(s) | `title_element` default | Counts as heading? |
1503
+ |---|---|---|
1504
+ | `headline` | `h1` | Yes, unless explicitly overridden. |
1505
+ | `comments` (WP source) | `h2` | Yes, always active. |
1506
+ | `alert`, `gallery`, `grid`, `map`, `overlay`, `overlay-slider`, `panel`, `panel-slider`, `popover`, `slideshow`, `switcher` | `h3` | Yes, when `show_title` is not `false` and the title is non-empty or bound. |
1507
+ | `description_list` | `div` | No. |
1508
+ | `section` (Section Title) | (no field; hardcoded `<div class="tm-section-title">`) | No, ever (counted only as an informational `section_titles` tally). |
1509
+
1510
+ `div` never counts as a heading whether it came from an explicit value
1511
+ or a type default.
1512
+
1513
+ ### Audit ONE page instead of the whole site
1514
+
1515
+ Pass the shared `target` object (see "Address a page WITHOUT its URL" above) to
1516
+ scope the sweep to a single page — by `template_id`, `post_id`, `url`,
1517
+ `special` or `title`, exactly one of them:
1518
+
1519
+ ```jsonc
1520
+ yootheme_builder_advanced_read({
1521
+ tool: "yootheme_builder_pages_audit",
1522
+ arguments: { target: { special: "home" } }
1523
+ })
1524
+ ```
1525
+
1526
+ What changes in the response:
1527
+
1528
+ - **`scope: "single"`** — the positive proof the sweep was scoped. If you sent a
1529
+ `target` and the response is not scoped, the tool returns
1530
+ `server_too_old` instead of handing you a site sweep dressed up as a page
1531
+ audit: the site's host plugin predates single-target addressing and must be
1532
+ updated. This is the ONE code the server never sends — the client raises it
1533
+ after comparing what it asked for against what came back.
1534
+ - **`target_not_scannable` is a refusal, never a pass.** A `target` can resolve
1535
+ to a real page that this site does not enumerate as an auditable one — a post
1536
+ type or status the page enumerator does not cover, or a trashed item. Rather
1537
+ than answer `200` with zero findings, which reads as "your page is clean", the
1538
+ tool returns `target_not_scannable` (HTTP 422). Tell the user the page could
1539
+ not be audited and why; then audit it via `template_summary`, or pick a page
1540
+ `pages_list` returns. Never present it as a clean result.
1541
+ - **`resolved_target`** — `{ template_id, post_id, type, label, frontend_url,
1542
+ context, resolved_via, matched_by }`. This is the echo of WHICH page a fuzzy
1543
+ address landed on. Always report it: `special: "home"` and
1544
+ `title: "SunWest"` are guesses until the server says what they resolved to.
1545
+ - **`note`** — the shared, verbatim sentence `run pages_audit for site-wide
1546
+ checks`. A single page cannot be compared against other pages, so the two
1547
+ site-scope checks (`duplicate-layout`, `title-collision`) self-filter out.
1548
+ - **Richer per-hit evidence.** Single mode raises the per-page hit cap from 5 to
1549
+ **100 hits per check** and adds a `content_snippet` to every locator (the site
1550
+ sweep omits snippets to keep a whole-site payload small). The text leg also
1551
+ gains a `Where to fix:` block with the locator lines; the site sweep keeps its
1552
+ compact 5-column summary table, with the locators in `structuredContent` only.
1553
+ - **The summary table's count column counts HITS, and says so.** In a site sweep
1554
+ that column is headed `COUNT` and holds the number of affected **pages**. A
1555
+ single-page sweep scanned exactly one page, so that number would be `1` for
1556
+ every finding and tell you nothing — there the column is headed **`HITS`** and
1557
+ holds the number of individual occurrences **on that page** (19 alt-less
1558
+ images ⇒ `19`, not `1`). It is the uncapped total: `hits_truncated` shortens
1559
+ the listed locators, never this figure. Report the `HITS` number as the size
1560
+ of the problem. `structuredContent.findings[].count` is unaffected by the
1561
+ column — it stays the affected-PAGE count in every scope, so read the table
1562
+ (or the page entry's own `hits`) when you want the per-page hit total.
1563
+ - **`hits` is the unambiguous field; `count` means two different things.** On
1564
+ this wire `count` is level-dependent: on a FINDING it counts affected **pages**
1565
+ (so it is `1` for every finding of a single-page audit), and on a **page entry**
1566
+ it counts hits on that page. One name, two meanings — and that ambiguity has
1567
+ already produced a live misread, an agent reporting "one image is affected"
1568
+ when nineteen were. So the host emits **`hits`** next to a page entry's `count`
1569
+ whenever that count really is a hit tally: `findings[].pages[].hits`. Prefer it.
1570
+ It is an alias, never an invention — a page entry with no hit total (the PAGE
1571
+ ITSELF is the hit: `thin-content`, `empty-page`, `duplicate-layout`) carries no
1572
+ `hits` key at all, and a host plugin older than the alias sends only `count`.
1573
+ Fall back to `pages[].count` in both cases, never to `findings[].count`.
1574
+ - **On the plugin-served transport there is no summary table at all.** The
1575
+ `HITS`/`COUNT` column is rendered by the npm server. When the MCP server is the
1576
+ CMS plugin itself, the response is the JSON payload — so nothing labels the
1577
+ columns for you and the guidance above is the only thing standing between you
1578
+ and the misread. Read `findings[].pages[].hits` (falling back to
1579
+ `pages[].count`) and **never report `findings[].count` as the size of a
1580
+ single-page problem**: in single-target mode it is structurally `1`.
1581
+
1582
+ Two request shapes are refused up front, with a typed error rather than a
1583
+ round-trip: more than one address key → `too_many_targets` (pick one; use
1584
+ `title` or `pages_list` when you only know the page by name), and `target`
1585
+ together with `types` → `types_conflicts_with_target` (a target already names
1586
+ exactly one page, so there is nothing for a type filter to narrow). A `title`
1587
+ that matches several pages returns `title_ambiguous` with a candidate list —
1588
+ show it, let the user choose.
1589
+
1590
+ ### Remediation catalog — what each finding means and how to fix it
1591
+
1592
+ The audit reports; YOU explain. Every finding below carries its fixed
1593
+ `category` and `severity` from the server (never re-rank them), and the entries
1594
+ whose fix is mechanical carry a `fix_hint` naming the write tool and an
1595
+ `args_template`. The rest need a human decision — say so instead of inventing a
1596
+ patch.
1597
+
1598
+ #### `empty-page` — structure · critical
1599
+
1600
+ **Why it matters.** A page with zero content elements is a dead end for readers
1601
+ and, once published, a soft-404 signal for crawlers: there is nothing to index,
1602
+ and internal links pointing at it leak crawl budget.
1603
+ **How to fix.** `element_add` at least one content element (headline, text,
1604
+ image). `fix_hint.tool = element_add`.
1605
+ **Severity.** Critical because the page fails at its only job. This is measured
1606
+ on the CONTENT count (`elements_content`), so wrapper-only pages — three nested
1607
+ columns around nothing — are correctly flagged rather than passing as "not
1608
+ empty".
1609
+
1610
+ #### `no-headings` — structure · critical
1611
+
1612
+ **Why it matters.** With no heading of any level the page has no machine-readable
1613
+ outline: search engines get no topical anchor, and screen-reader users lose the
1614
+ heading-jump navigation they rely on to skim a page.
1615
+ **How to fix.** `element_add` a `headline` with `props.title_element = "h1"`
1616
+ (that is the shipped `args_template`), then work down the hierarchy.
1617
+ **Severity.** Critical — it is an accessibility failure, not a style preference,
1618
+ and it is one element away from being fixed.
1619
+
1620
+ #### `missing-h1` — structure · warning
1621
+
1622
+ **Why it matters.** The h1 names what the page is about. Headings exist here but
1623
+ none resolves to an effective h1, so the page's main topic is never stated at the
1624
+ strongest available level.
1625
+ **How to fix.** `element_update_settings` with `props.title_element = "h1"` on
1626
+ the page's `first_headline_path` (the evidence carries it, and the locator points
1627
+ at it). When `first_headline_path` is `null` there is no `headline` element at
1628
+ all — the effective headings come from containers (`grid`, `panel`, …); add a
1629
+ headline rather than promoting a container title to h1 blindly.
1630
+ **Severity.** Only a warning, calibrated on live review: real, healthy sites run
1631
+ h2/h3-only pages. It is also suppressed entirely when `no-headings` already fired
1632
+ for the same page — never report both.
1633
+
1634
+ #### `multiple-h1` — structure · warning
1635
+
1636
+ **Why it matters.** Several h1s dilute the topical signal and flatten the outline
1637
+ a screen reader announces: everything looks equally important, so nothing is.
1638
+ **How to fix.** `element_update_settings` with `props.title_element = "h2"` on
1639
+ every hit except the one you keep. The locator's `heading_source` tells you WHICH
1640
+ channel produced each h1 (`panel-title` vs `panel-meta` — `meta_element` is a
1641
+ second, independent heading channel), which is exactly what the raw path cannot.
1642
+ **Severity.** Warning, not critical — the page still works. Read the evidence's
1643
+ `iterated` flag before you write: `iterated: true` means one authored h1 renders
1644
+ N times through an iterating container or a Multi-Items binding, so the fix is
1645
+ one edit on the item, not N edits; the locator's `×repeat` shows the multiplier.
1646
+
1647
+ #### `heading-skip` — structure · info
1648
+
1649
+ **Why it matters.** Jumping h1 → h3 breaks the document outline: assistive tech
1650
+ reports a missing level, and the hierarchy stops describing the real structure.
1651
+ **How to fix.** No `fix_hint` — the right level depends on intent. Either demote
1652
+ the jumped-to heading (`element_update_settings`, `props.title_element`) or add
1653
+ the missing intermediate level. The evidence lists each skip as
1654
+ `{ path, from, to }`.
1655
+ **Severity.** Info: it is a real hierarchy defect but the page remains usable,
1656
+ and the check is deliberately `headline`-only, so container-started hierarchies
1657
+ are invisible to it. Do not present it as complete heading-hierarchy coverage.
1658
+
1659
+ #### `images-missing-alt` — content · warning
1660
+
1661
+ **Why it matters.** An image with a set source and empty alt text is invisible to
1662
+ screen readers and to image search — the content of the image simply does not
1663
+ exist for anyone not looking at it.
1664
+ **How to fix.** `element_update_settings` with `props.image_alt` (or
1665
+ `props.background_image_alt` on a `popover` — the locator's `prop` field names
1666
+ which one). Describe what the image SHOWS, not that it is an image.
1667
+ **Severity.** Warning. Two exemptions keep it honest: a **bound** alt slot is
1668
+ never flagged (a dynamically-bound alt is not missing just because the builder
1669
+ prop is blank), and a genuinely decorative image may legitimately keep an empty
1670
+ alt — mention that possibility instead of mass-filling every slot.
1671
+
1672
+ #### `thin-content` — content · warning
1673
+
1674
+ **Why it matters.** A page with very few content elements rarely answers the
1675
+ query that brought a visitor to it, and a site full of them dilutes topical
1676
+ authority.
1677
+ **How to fix.** No `fix_hint` — no tool can write substance. Report the
1678
+ `elements_content` count against the threshold and propose merging the page into
1679
+ a stronger one or expanding it.
1680
+ **Severity.** Warning, and threshold-driven (`thin_content_threshold`, default
1681
+ 8): a deliberately minimal landing page can be perfectly healthy. Pages at zero
1682
+ are excluded here — `empty-page` owns that case.
1683
+
1684
+ #### `duplicate-layout` — content · warning (site-wide)
1685
+
1686
+ **Why it matters.** Several pages sharing an identical structural layout is
1687
+ usually a sign of near-duplicate content competing for the same query, or of a
1688
+ template that should have been a theme template.
1689
+ **How to fix.** No `fix_hint` — this needs a human decision: consolidate,
1690
+ differentiate, or accept it (a product-page family SHOULD look alike). Group the
1691
+ report by the evidence's `cluster_id`.
1692
+ **Severity.** Warning, and it compares pages against EACH OTHER, so it only runs
1693
+ in a site sweep — it never appears in a single-page audit or in `quick_check`.
1694
+
1695
+ #### `title-collision` — seo · warning (site-wide)
1696
+
1697
+ **Why it matters.** Two pages with the same title are indistinguishable in search
1698
+ results, in the browser's tab bar and in your own `pages_list` — and they
1699
+ cannibalise each other's ranking for the same term.
1700
+ **How to fix.** No `fix_hint` — renaming is an editorial call. Note that
1701
+ comparison is normalised (lowercased, punctuation collapsed), so "A – B" and
1702
+ "A — B" collide by design; that is not a false positive.
1703
+ **Severity.** Warning, site-scope only — same exclusion as `duplicate-layout`.
1704
+
1705
+ #### `non-pretty-url` — seo · info
1706
+
1707
+ **Why it matters.** A `?page_id=…` / `?p=…` URL carries no keywords, is ugly to
1708
+ share, and signals a permalink configuration that was never finished.
1709
+ **How to fix.** No `fix_hint`, and deliberately so: this is fixed in the CMS
1710
+ (WordPress Settings → Permalinks, Joomla SEF settings / a menu item), NOT with a
1711
+ builder write tool. Say that plainly. The evidence's `reason` reads `non-pretty`
1712
+ on every hit today: publication status is not yet surfaced by the page
1713
+ enumerator, so a DRAFT — which often has no pretty URL yet and needs no action —
1714
+ is reported exactly like a published page. Mention that possibility rather than
1715
+ presenting every hit as actionable.
1716
+ **Severity.** Info: it is a hygiene issue, and the page still resolves.
1717
+
1718
+ #### `stale-content` — seo · info
1719
+
1720
+ **Why it matters.** Content untouched for years drifts out of date; freshness is
1721
+ a weak ranking factor but a strong trust factor for readers.
1722
+ **How to fix.** No `fix_hint` — review and update, or deliberately mark it as
1723
+ evergreen. Report the `modified_at` date, not just the flag.
1724
+ **Severity.** Info, threshold-driven (`stale_after_months`, default 24). A page
1725
+ with an empty `modified_at` is never flagged, so absence of the finding is not
1726
+ proof of freshness.
1727
+
1728
+ #### `dead-anchor` — links · warning
1729
+
1730
+ **Why it matters.** An in-page `#name` link whose target id does not exist on the
1731
+ page silently does nothing when clicked — the visitor concludes the site is
1732
+ broken, and a jump-navigation is often exactly the thing that was supposed to
1733
+ make a long page usable.
1734
+ **How to fix.** No `fix_hint`, because there are two equally valid fixes and only
1735
+ you know which was intended: correct the link, or give the intended target
1736
+ element `props.id = <name>` via `element_update_settings`. The locator carries
1737
+ the missing `name`.
1738
+ **Severity.** Warning. The check is deliberately narrow — a bare `#`,
1739
+ `mailto:`/`tel:`, external `http(s)://` links and cross-page `?page_id=…#…`
1740
+ links are all excluded — so a hit is a genuine dangling anchor, not a heuristic.
1741
+
1742
+ #### `anchor-text-quality` — links · warning
1743
+
1744
+ **Why it matters.** Anchor text is the strongest on-page relevance signal a link
1745
+ can send, and screen-reader users navigate by a flat list of link texts — a
1746
+ screen full of "Mehr" and "Read more" is unusable out of context.
1747
+ **How to fix.** `element_update_settings` with `props.content` naming the
1748
+ destination ("Preise ansehen" instead of "Mehr"); on `grid_item` and `panel` the
1749
+ prop is `title`. For an icon-only or image-only link, the right fix is
1750
+ `props.link_aria_label`, not invented visible text. Only link-bearing elements
1751
+ whose visible-text prop is known are checked — `button`, `button_item`,
1752
+ `grid_item`, `panel`, `headline`, `list_item`, `nav_item`, `subnav_item` — so
1753
+ this is an allowlist, not a sweep over every element carrying a `link`.
1754
+ **Severity.** Warning. The locator's `reason` says which rule fired: `empty` or
1755
+ `generic` (an **exact match** against the DE+EN generic list — `mehr`, `hier`,
1756
+ `weiterlesen`, `click here`, `read more`, `learn more`, …). Exact match only, so
1757
+ "Mehr über SunWest" stays clean.
1758
+
1759
+ `empty` states a precise rule, not "no accessible name at all": the type's
1760
+ **designated** text prop is empty AND the node carries no `icon`, `image`,
1761
+ `link_aria_label` or `link_text`, AND no sibling text that renders inside the
1762
+ same anchor — `content`/`meta` on `grid_item` and `panel` (both move the link
1763
+ onto the whole-card wrapper and render meta + content inside it), `meta` on
1764
+ `nav_item` (its `uk-nav-subtitle` sits inside the anchor). So a content-only
1765
+ linked card is NOT flagged. The remaining allowlisted types
1766
+ (`button`, `button_item`, `headline`, `list_item`, `subnav_item`) have no sibling
1767
+ text prop, so for them the designated prop is the only anchor text. A **bound**
1768
+ text prop is never flagged either, and `social_item` is icon-only by construction
1769
+ and is never checked at all.
1770
+
1771
+ #### `generic-alt-text` — content · info
1772
+
1773
+ **Why it matters.** Alt text like "Bild" or "Logo" names the MEDIUM, not the
1774
+ content: it passes an automated "has alt" check while telling a screen-reader
1775
+ user and an image crawler exactly nothing.
1776
+ **How to fix.** `element_update_settings` with `props.image_alt` describing what
1777
+ the image actually shows ("SunWest Büro in Manresa").
1778
+ **Severity.** Info, and strictly disjoint from `images-missing-alt` (which owns
1779
+ the EMPTY case). Only an **exact match** against the generic list (`image`,
1780
+ `photo`, `picture`, `logo`, `icon`, `bild`, `foto`, `grafik`) fires, so
1781
+ `"ACME Logo"` is already a good alt text and is never flagged. Bound alt/image
1782
+ props are exempt.
1783
+
1784
+ #### `long-heading` — structure · info
1785
+
1786
+ **Why it matters.** A heading is a label, not a sentence. Long ones dilute the
1787
+ keyword focus, get truncated in search results and wrap badly on mobile.
1788
+ **How to fix.** `element_update_settings` shortening `props.content` (on
1789
+ `headline`) or `props.title` (on `panel`, `alert`, `overlay`) and moving the
1790
+ detail into the body text below it. The locator carries the measured `length`.
1791
+ **Severity.** Info — a long heading is a quality signal, never a defect. The
1792
+ threshold is **70 characters**, measured multibyte-safely over the
1793
+ markup-stripped text, on unbound static text only. Scope is deliberately narrow:
1794
+ `headline` plus the standalone title carriers (`alert`, `overlay`, `panel`).
1795
+ Multi-Items containers keep their title text on the paired `*_item` children and
1796
+ are not measured — do not claim full heading-length coverage.
1797
+
1798
+ `anchor-text-quality`, `generic-alt-text` and `long-heading` are all `per-page`
1799
+ checks, so they also ride in the `quick_check` block on `page_publish` /
1800
+ `page_save` alongside the other per-page codes.
1801
+
1802
+ ### The report contract — how to present an audit
1803
+
1804
+ The raw response is a machine payload. What you hand the user follows this
1805
+ shape every time, so two audits of two sites read the same way:
1806
+
1807
+ 1. **Lead with the scope.** When `scope: "single"`, name the page the audit
1808
+ actually ran on from `resolved_target` — label, `template_id`, and
1809
+ `matched_by` when the address was fuzzy ("`special: "home"` resolved to
1810
+ *Startseite* (`page:4816`)"). When the response has no `scope`, it was a site
1811
+ sweep: lead with `scanned.templates` and the `summary` totals.
1812
+ 2. **Order by severity: critical → warning → info.** The server already sorts
1813
+ that way; never re-rank findings by how easy they look to fix.
1814
+ 3. **Group page → category → finding.** One block per affected page, its
1815
+ findings grouped by `category` (`structure`, `content`, `seo`, `links`), so
1816
+ the user can fix one page in one pass instead of hopping between pages per
1817
+ code.
1818
+ 4. **Render every hit from its locator**, in this shape:
1819
+
1820
+ ```
1821
+ element_name (breadcrumb) · heading_source · ×repeat · snippet → Fix: <tool + args>
1822
+ ```
1823
+
1824
+ Drop the parts a given finding does not carry (`heading_source` only exists on
1825
+ the heading checks; `×repeat` only when it is greater than 1; `snippet` only
1826
+ in single-page mode). Take `<tool + args>` from `fix_hint.tool` +
1827
+ `fix_hint.args_template` when the finding has one, and say "needs a decision"
1828
+ when it does not. The `path` is the write address — keep it for the tool call,
1829
+ but it is not the human-facing part of the line.
1830
+ 5. **Name the element. Always.** Every reported finding names the element TYPE
1831
+ and its user-assigned name when one is set (`element_name`, falling back to
1832
+ `element_type`) plus the `breadcrumb` that locates it in the builder. A **bare
1833
+ path list is NOT acceptable output** — `/children/0/children/2` tells the user
1834
+ nothing about which box on their page to click. Add the page's `builder_url`
1835
+ as the "open in builder" link when the response carries one.
1836
+ 6. **State what did and did not run.** On a site sweep, say the site-wide checks
1837
+ ran (`duplicate-layout`, `title-collision` compare pages against each other).
1838
+ On a single-page audit, say they did not — reuse the response's own `note`
1839
+ verbatim: `run pages_audit for site-wide checks`.
1840
+ 7. **Be honest about truncation.** `hits_truncated: true` means the host's hit
1841
+ cap dropped hits, so the listed evidence for that page is incomplete — a
1842
+ single-page re-run returns up to 100 per check. `locators_stripped: true`
1843
+ means the locator prose was shed (or halved) to fit the response budget.
1844
+ They are not synonyms; do not report either as "that's all of them". A
1845
+ top-level `structured_truncated: true` says the WHOLE payload was over its
1846
+ char budget and the shed ladder ran, in this order: the DUPLICATE raw arrays
1847
+ first (every field of `alt_paths` / `anchors` / … also rides on the locator
1848
+ for the same hit, so nothing is lost), then the locator detail — halved
1849
+ before it is dropped — then the remaining raw evidence, then whole evidence
1850
+ pages (`pages_truncated`), so page ids and labels outlive the prose. Both
1851
+ transports shed in exactly that order; it is pinned by a contract test that
1852
+ derives the order from each implementation. Say so, and name the two ways
1853
+ back: raise `max_chars` (it bounds both legs on this tool), or
1854
+ re-run scoped to one page via `target`. `evidence_stripped: true` says the
1855
+ raw per-hit arrays (`paths`, `alt_paths`, `anchors`, …) were dropped from
1856
+ that entry while that page's id, label, hit count and `builder_url` were kept
1857
+ — so the answer stays actionable (name the page, say how many hits, offer the
1858
+ builder link) even when no per-hit detail survived. On a budget too small
1859
+ even for that floor, the plugin-served transport refuses with
1860
+ `max_chars_too_small` and tells you the minimum to ask for — it never returns
1861
+ a half-written payload.
1862
+ 8. **Repeat the scope boundary.** Close with the response's `disclaimer` — meta
1863
+ title/description/OG live in the CMS SEO plugin and are not covered here — so
1864
+ nobody mistakes a clean audit for a clean SEO plugin.
1865
+
1866
+ Beyond that the prose is yours: group, summarise and prioritise however serves
1867
+ the user. Rules 2, 4, 5 and 6 are the parts that must not vary.
1868
+
1869
+ A single-page report that follows the contract reads like this:
1870
+
1871
+ ```
1872
+ Audited: Startseite (page:4816) — matched by special:"home"
1873
+ 2 findings — 0 critical / 2 warning / 0 info
1874
+ Site-wide checks (duplicate-layout, title-collision) did NOT run on a single
1875
+ page: run pages_audit for site-wide checks.
1876
+
1877
+ WARNING · structure · multiple-h1 — page renders 2 h1s [open in builder]
1878
+ • Willkommen bei SunWest (Hero ▸ Headline) · headline
1879
+ → keep this one as the page's h1
1880
+ • Unsere Standorte (Karten ▸ Panel "Standorte") · panel-title · "Unsere Standorte"
1881
+ → Fix: element_update_settings({ template_id: "page:4816",
1882
+ element_path: "/children/1/children/0", props: { title_element: "h2" } })
1883
+ Why: exactly one h1 anchors the page's topic; several dilute it.
1884
+
1885
+ WARNING · content · images-missing-alt — 1 image without alt text
1886
+ • Team photo (Über uns ▸ Grid ▸ Grid Item) · prop image_alt
1887
+ → Fix: element_update_settings({ …, props: { image_alt: "<what it shows>" } })
1888
+ Why: an empty alt makes the image invisible to screen readers and image search.
1889
+
1890
+ Meta title/description/OG live in your CMS SEO plugin — not covered here.
1891
+ ```
1892
+
1893
+ **Worked example (tool-call snippet):**
1894
+
1895
+ ```jsonc
1896
+ // Step 1. Sweep the site for SEO + structure issues.
1897
+ yootheme_builder_advanced_read({
1898
+ tool: "yootheme_builder_pages_audit",
1899
+ arguments: { checks: ["seo", "structure"] }
1900
+ })
1901
+ // A finding: { code: "missing-h1", category: "structure", severity: "warning",
1902
+ // count: 3, pages: [{ id: "page:42", label: "About" }, ...],
1903
+ // fix_hint: { tool: "element_update_settings",
1904
+ // args_template: { props: { title_element: "h1" } }, note: "..." } }
1905
+
1906
+ // Step 3. Drill into the flagged page.
1907
+ yootheme_builder_template_summary({ template_id: "page:42" })
1908
+ // heading_levels: { h1: 0, h2: 2, h3: 5, div: 1 }, images_missing_alt: 2
1909
+
1910
+ // Step 4. Fix that ONE page via the write tool the fix_hint named.
1911
+ yootheme_builder_element_update_settings({
1912
+ template_id: "page:42",
1913
+ element_path: "/children/0/children/0",
1914
+ props: { title_element: "h1" },
1915
+ etag: "abc123"
1916
+ })
1917
+
1918
+ // Step 5. Republish; quick_check re-confirms this page is clean.
1919
+ yootheme_builder_page_publish({ template_id: "page:42", etag: "def456" })
1920
+ // Response: { ..., quick_check: { clean: true } }
1921
+ ```
1922
+
1923
+ **Success criterion:** A re-run of `pages_audit` (or the `quick_check`
1924
+ block on the next publish) no longer lists the fixed finding for that
1925
+ page, and the response's `summary.critical`/`warning`/`info` totals go
1926
+ down.
1927
+
1928
+ ---
1929
+
1930
+ ## When something doesn't fit one of these 6 workflows
1242
1931
 
1243
1932
  - **Move an element** (intra-template reorder/reparent): use
1244
1933
  `yootheme_builder_element_move({ template_id, element_path,
@@ -2016,56 +2705,58 @@ perform.
2016
2705
 
2017
2706
  <!-- TOOL-CATALOG:BEGIN -->
2018
2707
 
2019
- **47 catalogued tools** plus the `yootheme_builder_advanced` (write) + `yootheme_builder_advanced_read` (read-only) gateways = **49 reachable via `tools/list`** (37 L1 + 2 L3 + 2 gateways = 41 advertised; the gateways route to 8 additional advanced tools, bringing the total to 49 callable). Generated by `scripts/extract-tools.mjs` from the compiled `buildAllTools()` registry. Do not hand-edit this section; re-run `npm run build && node scripts/extract-tools.mjs` after changing tool definitions.
2708
+ **49 catalogued tools** plus the `yootheme_builder_advanced` (write) + `yootheme_builder_advanced_read` (read-only) gateways = **51 reachable via `tools/list`** (37 L1 + 2 L3 + 2 gateways = 41 advertised; the gateways route to 10 additional advanced tools, bringing the total to 51 callable). Generated by `scripts/extract-tools.mjs` from the compiled `buildAllTools()` registry. Do not hand-edit this section; re-run `npm run build && node scripts/extract-tools.mjs` after changing tool definitions.
2020
2709
 
2021
2710
  | Tool | Kind | Input keys | Description |
2022
2711
  | --- | --- | --- | --- |
2023
- | `yootheme_builder_clean_implode_directives` | destructive | `confirm`, `element_path`, `etag`, `site_id`, `template_id` | Strips `props.source.props.*.implode` directives from an element binding. Returns audit log + new ETag. Idempotent at the server (cleaned_count: 0 when nothing to remove) but classed as destructive because the affected binding cannot be restored without re-mapping. Always ask the user to confirm first, then call again with `confirm: true`. Requires ETag. Operates on the default site unless site_id is provided. |
2712
+ | `yootheme_builder_clean_implode_directives` | destructive | `confirm`, `element_path`, `etag`, `site_id`, `template_id` | Use when a Multi-Items element renders rows as ONE comma-joined string instead of N repeated items — typically stale `implode` directives left by a plugin upgrade (detect via yootheme_builder_inspect_multi_items_binding). Strips `props.source.props.*.implode` directives from an element binding. Returns audit log + new ETag. Idempotent (cleaned_count: 0 when nothing to remove) but destructive: the binding can't be restored without re-mapping. Ask the user to confirm, then call with `confirm: true`. Requires ETag. Operates on the default site unless site_id is provided. |
2024
2713
  | `yootheme_builder_diagnose` | read+idempotent | `site_id` | Full diagnostic: /health + authenticated /etag probe. Returns site_url, home_url, plugin reachability, Bearer validity in one call. First call when you need to know where the site lives. For per-template URLs see pages_list. Operates on the default site unless site_id is provided. |
2025
- | `yootheme_builder_element_add` | mutating | `children`, `element_type`, `etag`, `index`, `parent_path`, `position`, `props`, `site_id`, `template_id`, `type_name` | Add a new element to a template. Provide `parent_path` (or "" for root), `element_type` (e.g. "headline", "text", "grid"), and optional `props` / `children`. Appends by default; pass `index` (0 = first) to insert before siblings. Returns the new element's JSON-Pointer path. Requires ETag. Operates on the default site unless site_id is provided. |
2714
+ | `yootheme_builder_element_add` | mutating | `children`, `element_type`, `etag`, `index`, `name`, `parent_path`, `position`, `props`, `site_id`, `template_id`, `type_name` | Add a new element to a template. Provide `parent_path` (or "" for root), `element_type` (e.g. "headline", "text", "grid"), and optional `props` / `children`. Content/leaf elements may only be added inside a `column`; only `section` is valid at the root — call yootheme_builder_scaffold_section first for a valid column path. Appends by default; pass `index` (0 = first) to insert. Inserting renumbers later siblings — re-read paths via element_list after any structural write. Returns the new element's JSON-Pointer path. Requires ETag. Operates on the default site unless site_id is provided. |
2026
2715
  | `yootheme_builder_element_bind_source` | idempotent | `arguments`, `bindingLevel`, `directives`, `element_path`, `etag`, `field`, `field_mappings`, `query_arguments`, `relation`, `site_id`, `source_id`, `source_name`, `template_id` | Binds a Builder source to an element (canonical shape: `source` top-level). Pass `field_mappings` in the SAME call; bindingLevel "item" binds a container's first *_item child. Leaf under an iterating *_item → CONSOLIDATE onto item slots (409 slot_collision if taken/not a slot); leaf under a #parent fragment → INHERIT (`__node_item__`); standalone renders empty. List on a grid_item with children → iterator_is_leaf_renderer. Map: bind `map_item`, map `location` to a "lat,lng" field or no markers. Requires ETag. Call directly. Operates on the default site unless site_id is provided. |
2027
- | `yootheme_builder_element_clone` | mutating | `element_path`, `etag`, `site_id`, `template_id` | Clone an element as a sibling (same parent, immediately after the source). Returns the new element's path. Requires ETag. Operates on the default site unless site_id is provided. |
2028
- | `yootheme_builder_element_delete` | destructive | `confirm`, `element_path`, `etag`, `site_id`, `template_id` | PERMANENTLY delete an element + children. Cannot be undone. Pass `confirm: true` (boolean) AND a fresh ETag. If `preview:true` returns despite confirm, check `details.received_confirm` — strings/numbers accepted, but boolean is canonical. Operates on the default site unless site_id is provided. |
2029
- | `yootheme_builder_element_get` | read+idempotent | `element_path`, `site_id`, `template_id` | Get the full element object at a specific JSON-Pointer path, including props and children. Use yootheme_builder_element_list to discover paths. Operates on the default site unless site_id is provided. |
2030
- | `yootheme_builder_element_get_binding` | read+idempotent | `element_path`, `site_id`, `template_id` | Read the source binding attached to an element the bound source name, the field-mappings (which source field feeds which element prop) and the query arguments/directives. Returns the empty object if the element is not bound. Operates on the default site unless site_id is provided. |
2031
- | `yootheme_builder_element_list` | read+idempotent | `cursor`, `depth`, `fields`, `limit`, `max_chars`, `root_path`, `site_id`, `template_id` | List elements in a template as a flat array with JSON-Pointer paths + types. Scope with `root_path`/`depth` for a subtree, paginate with `limit`/`cursor` for large templates. `fields[]` narrows each row; `max_chars` bounds the text output. Operates on the default site unless site_id is provided. |
2032
- | `yootheme_builder_element_move` | idempotent | `element_path`, `etag`, `site_id`, `template_id`, `to_index`, `to_parent_path` | Move an element to a new parent + index in the tree. Useful for reordering or reparenting (e.g. moving a card from one grid column to another). Requires ETag. Operates on the default site unless site_id is provided. |
2033
- | `yootheme_builder_element_set_source` | idempotent | `element_path`, `etag`, `site_id`, `source`, `source_extended`, `template_id` | Surgically SET a precomposed `source` (+ optional `source_extended.props`) on ONE element at a pointer, ETag-locked, leaving every OTHER node unchanged (no 14 KB page_save). Use this — not element_bind_source — when you have the exact source shape OR need `source_extended.props`, the native Multi-Items item-mapping carrier bind_source cannot write. E.g. an FAQ accordion scoped to the term: source.query.name="categoryFaqs.taxonomyCategoryFaq", field={name:"faq"}, source_extended.props maps title/content to #node. ETag req. Operates on the default site unless site_id is provided. |
2034
- | `yootheme_builder_element_type_get_schema` | read+idempotent | `element_type`, `site_id`, `type_name` | Fetch a type's prop schema before `element_add`/`element_update_settings`: an unknown prop key on a known schema is hard-rejected (not silently dropped); the error always lists `valid_prop_keys`, plus a `did_you_mean` hint when a close field exists. Returns `{name,type,label?}`. Use `element_type` (`type_name` is a deprecated alias). Operates on the default site unless site_id is provided. |
2035
- | `yootheme_builder_element_types_list` | read+idempotent | `fields`, `site_id` | List element types registered on this site (built-ins + YOOessentials/uEssentials extras). Names feed `element_type` of element_add. Pass `fields[]` to narrow each row. Keywords: "slider"/"carousel" → `slideshow`/`panel-slider`/`overlay-slider`. Prop keys differ per element (grid_item `title` vs headline `content`) — use element_type_get_schema before element_add/bind. Operates on the default site unless site_id is provided. |
2036
- | `yootheme_builder_element_unbind_source` | destructive | `confirm`, `element_path`, `etag`, `site_id`, `template_id` | Remove the source binding from an element. Clears `props.source`. Destructive in the sense that it may break dynamic-content rendering — always ask the user to confirm. Requires ETag. Operates on the default site unless site_id is provided. |
2037
- | `yootheme_builder_element_update_settings` | idempotent | `element_path`, `etag`, `merge`, `props`, `site_id`, `template_id`, `unset` | Update `props` on an element. Default replaces all props; pass `merge:true` for server-side deep-merge (only request keys overwritten, others survive — avoids read-modify-write races). Requires ETag. Pass `unset:["key",…]` to reset props to their YOOtheme default by removing them. Operates on the default site unless site_id is provided. |
2038
- | `yootheme_builder_get_etag` | read+idempotent | `site_id` | Get the current ETag (state revision) for the YOOtheme builder. Returns sha256+revision string used for optimistic locking on writes. Pass the returned value back as `etag` on any write tool (page_save, page_publish, element_add, element_update_settings, element_clone, element_move, element_delete). The server returns HTTP 412 if the ETag has changed since you read it. Keywords: get etag, current etag, state revision, optimistic lock, version stamp. Operates on the default site unless site_id is provided. |
2716
+ | `yootheme_builder_element_clone` | mutating | `element_path`, `etag`, `site_id`, `template_id` | Clone an element as a sibling (same parent, immediately after the source). Returns the new element's path. Cloning renumbers later siblings — re-read paths via element_list after any structural write. Requires ETag. Operates on the default site unless site_id is provided. |
2717
+ | `yootheme_builder_element_delete` | destructive | `confirm`, `element_path`, `etag`, `site_id`, `template_id` | PERMANENTLY delete an element + children. Cannot be undone. Pass `confirm: true` (boolean) AND a fresh ETag. Deleting renumbers later siblings — re-read paths via element_list after any structural write. If `preview:true` returns despite confirm, check `details.received_confirm` — strings/numbers accepted, but boolean is canonical. Operates on the default site unless site_id is provided. |
2718
+ | `yootheme_builder_element_get` | read+idempotent | `element_path`, `fields`, `max_chars`, `site_id`, `template_id` | Get the full element object at a specific JSON-Pointer path, including props and a `children_count` (NOT the children themselves — use yootheme_builder_element_list with root_path to enumerate children). `fields[]` narrows the returned object; `max_chars` bounds the text output. Address by template_id only — resolve a URL/post first via pages_resolve. Operates on the default site unless site_id is provided. |
2719
+ | `yootheme_builder_element_get_binding` | read+idempotent | `element_path`, `site_id`, `template_id` | Read the source binding attached to an element. Always returns {template_id, element_path, binding, has_binding, field_mapping_count, warning?}: `binding` is the raw source binding (bound source name, field-mappings, query args/directives); `has_binding` is false and `field_mapping_count` 0 when the element is not bound. Operates on the default site unless site_id is provided. |
2720
+ | `yootheme_builder_element_list` | read+idempotent | `cursor`, `depth`, `fields`, `limit`, `max_chars`, `root_path`, `site_id`, `template_id` | List elements in a template as a flat array with JSON-Pointer paths + types + `label` (the builder-assigned name; "" when unset — not that the type lacks one). Scope with `root_path`/`depth` for a subtree, paginate with `limit`/`cursor` for large templates. `fields[]` narrows each row; `max_chars` bounds the text output. template_id only — resolve a URL/post first via pages_resolve. Operates on the default site unless site_id is provided. |
2721
+ | `yootheme_builder_element_move` | idempotent | `element_path`, `etag`, `site_id`, `template_id`, `to_index`, `to_parent_path` | Move an element to a new parent + index in the tree. Useful for reordering or reparenting (e.g. moving a card from one grid column to another). Moving renumbers later siblings — re-read paths via element_list after any structural write. Requires ETag. Operates on the default site unless site_id is provided. |
2722
+ | `yootheme_builder_element_set_source` | idempotent | `element_path`, `etag`, `site_id`, `source`, `source_extended`, `template_id` | Surgically SET a precomposed `source` (+ optional `source_extended.props`) on ONE element at a pointer, ETag-locked (blast-radius 0). Use this — not element_bind_source — when you have the exact source shape OR need `source_extended.props`, the native Multi-Items item-mapping carrier bind_source cannot write. E.g. FAQ accordion scoped to term: query.name="categoryFaqs.taxonomyCategoryFaq", field={name:"faq"}, source_extended.props→#node. Element-prop keys the type does not render echo back as `unknown_prop_keys` (non-fatal). ETag req. Operates on the default site unless site_id is provided. |
2723
+ | `yootheme_builder_element_type_get_schema` | read+idempotent | `element_type`, `fields`, `max_chars`, `site_id`, `type_name` | Fetch a type's prop schema before element_add/element_update_settings: unknown prop keys are hard-rejected (error lists valid_prop_keys + did_you_mean). Returns each field as {name,type,label?,enum?,value_hint?,group?}, field_count, and a binding_contract for container/item types. Read value_hint+enum first — some defaults are SEO-fatal (headline.title_element→h1); checkbox = boolean true/false; group:"runtime-accepted" = honoured at render but hidden in the builder UI. Bound size with fields[]/max_chars. Operates on the default site unless site_id is provided. |
2724
+ | `yootheme_builder_element_types_list` | read+idempotent | `fields`, `site_id` | List element types registered on this site (built-ins + YOOessentials/uEssentials extras). Names feed `element_type` of element_add. Pass `fields[]` to narrow each row. Keywords: "slider"/"carousel" → `slideshow`/`panel-slider`/`overlay-slider`. Prop keys differ per element (grid_item `title` vs headline `content`) — use element_type_get_schema before element_add/bind. Container rows carry `requires_child_type` (grid→grid_item); `has_children`/`has_children_support` are aliases (same value). Operates on the default site unless site_id is provided. |
2725
+ | `yootheme_builder_element_unbind_source` | destructive | `confirm`, `element_path`, `etag`, `site_id`, `template_id` | Remove the source binding from an element. Clears the element's canonical top-level `source` key AND the legacy `props.source`. Destructive in the sense that it may break dynamic-content rendering — always ask the user to confirm. Requires ETag. Operates on the default site unless site_id is provided. |
2726
+ | `yootheme_builder_element_update_settings` | idempotent | `element_path`, `etag`, `merge`, `name`, `props`, `site_id`, `template_id`, `unset` | Update `props` on an element. Default replaces all props; pass `merge:true` for server-side deep-merge (only request keys overwritten, others survive — avoids read-modify-write races). Requires ETag. Pass `unset:["key",…]` to reset props to their YOOtheme default by removing them. Operates on the default site unless site_id is provided. |
2727
+ | `yootheme_builder_get_etag` | read+idempotent | `site_id` | Get the current ETag (state revision) for the YOOtheme builder. Returns a sha256+revision string for optimistic locking. This ETag is SITE-WIDE: it covers the ENTIRE builder state, so ANY write to ANY template (by any client) bumps it a 412 means the site state advanced, not necessarily that YOUR template changed. It is the SAME token the read tools (page_get_layout, element_list) return in their `etag`. Pass it back as `etag` on any write tool; on 412, re-read and retry. Operates on the default site unless site_id is provided. |
2039
2728
  | `yootheme_builder_health` | read+idempotent | `site_id` | Check plugin installed/reachable. Returns plugin version, YT Pro version, REST endpoints. Authenticated payload adds site_url + home_url for deep-linking. See yootheme_builder_diagnose for Bearer-validity + connectivity summary. Operates on the default site unless site_id is provided. |
2040
- | `yootheme_builder_image_import` | idempotent | `site_id`, `url` | Sideload a chosen photo (typically a `full_url` from yootheme_builder_image_search) into the site's own Media Library. Returns `{imported:true, local_url}`. Bind local_url into an element via element_update_settings({props:{image: local_url}, merge:true}). A caught import failure returns `{imported:false, reason}`, never a 500. Operates on the default site unless site_id is provided. |
2729
+ | `yootheme_builder_image_import` | idempotent | `site_id`, `url` | Sideload a photo into the site's own Media Library. Accepts ANY public image URL — it does NOT require a Library API key, so it still works when image_search is unavailable (typically pass a `full_url` from yootheme_builder_image_search). Returns `{imported:true, local_url}` where local_url is a SITE-RELATIVE path (e.g. /wp-content/uploads/...). Bind it via element_update_settings({props:{image: local_url}, merge:true}). A caught import failure returns `{imported:false, reason}`, never a 500. Operates on the default site unless site_id is provided. |
2041
2730
  | `yootheme_builder_image_search` | read+idempotent | `color`, `orientation`, `per_page`, `query`, `site_id`, `source` | Search real on-topic photos (Unsplash/Pexels) or YOOtheme Pro Images to fill layout image slots with REAL, on-brand photography instead of bare placeholders. Returns candidate rows with `full_url` (pass straight to yootheme_builder_image_import) plus dimensions/author/orientation. `total:0` usually means no Library API key is configured (the same theme apikey library_browse/insert use). Operates on the default site unless site_id is provided. |
2042
- | `yootheme_builder_inspect_multi_items_binding` | read+idempotent | `element_path`, `site_id`, `template_id` | Reports Multi-Items binding state: container/item pair (grid↔grid_item, slideshow↔slideshow_item, map↔map_item, …), current binding level (none\|container\|item), and a recommended_fix when the binding sits on the container instead of the child. For map/map_item, a bound item without a `location` ("lat,lng") field is flagged as a blocking map_location_warning (renders 0 markers). A List on a grid_item with nested children → blocking iterator_is_leaf_renderer (children never render). Operates on the default site unless site_id is provided. |
2731
+ | `yootheme_builder_inspect_multi_items_binding` | read+idempotent | `element_path`, `site_id`, `template_id` | Reports Multi-Items binding state: container/item pair (grid↔grid_item, slideshow↔slideshow_item, map↔map_item, …), the binding level (none\|container\|item), and a recommended_fix when it sits on the container. For map/map_item, a bound item without a `location` ("lat,lng") field is flagged as a blocking map_location_warning (renders 0 markers). A List on a grid_item with nested children → blocking iterator_is_leaf_renderer (children never render). If rows render as ONE comma-joined string, see clean_implode_directives. Operates on the default site unless site_id is provided. |
2043
2732
  | `yootheme_builder_inspect_sublayout` | read+idempotent | `element_path`, `site_id`, `template_id` | Inspects a panel/grid_item `< SUBLAYOUT`: lists the `fragment` subtree bindable leaves (with JSON-Pointer paths to bind on — NOT the container) and the render-time inner grid. Flags `ungated_grid_prop` (blocking): grid props set but the gate (title_align=left / image_align=left\|right) unsatisfied → silent no-op. For container↔item binding see inspect_multi_items_binding. Operates on the default site unless site_id is provided. |
2044
- | `yootheme_builder_layout_query` | read+idempotent | `cursor`, `fields`, `has_binding`, `limit`, `max_chars`, `max_depth`, `name_matches`, `site_id`, `template_id`, `type`, `under_section` | Find elements in a template by a structured filter no syntax, no expression language. AND-combine any of: `type` (one type or a list), `name_matches` (case-insensitive substring of the element name), `has_binding` (bound vs unbound), `max_depth` (cap descent — 0 = top-level), `under_section` (only nodes inside a named section). Returns matching rows {path, element_type, label, has_binding}; paginate with `limit`/`cursor`, narrow rows with `fields[]`, bound text with `max_chars`. Operates on the default site unless site_id is provided. |
2733
+ | `yootheme_builder_layout_query` | read+idempotent | `cursor`, `fields`, `has_binding`, `limit`, `max_chars`, `max_depth`, `name_matches`, `site_id`, `template_id`, `type`, `under_section` | Find elements in a template by a structured filter (no expression language). AND-combine any of: `type` (one type or a list), `name_matches` (case-insensitive substring of the element name), `has_binding` (bound vs unbound), `max_depth` (cap descent — 0 = top-level), `under_section` (only nodes inside a named section). template_id only — resolve a URL/post first via pages_resolve. Returns matching rows {path, element_type, label, has_binding}; paginate with `limit`/`cursor`, narrow rows with `fields[]`, bound text with `max_chars`. Operates on the default site unless site_id is provided. |
2045
2734
  | `yootheme_builder_library_browse` | read+idempotent | `cursor`, `element_type`, `has_effects`, `kind`, `limit`, `purpose`, `query`, `role`, `site_id`, `source`, `topic`, `type`, `website` | Browse the YOOtheme Pro Library catalog: pre-designed page LAYOUTS, element PRESETS, or purpose-classified SECTIONS (hero/features/cta/...). Filter with `kind` ("layout" default \| "element" \| "section"), `topic`, `type`/`role`, `element_type`, `purpose`/`has_effects` (sections only), `website`, `query`. Returns `{items, total, next_cursor}`; a "section" id is the composite "<layoutId>#<sectionIndex>" — feeds straight into library_get/library_insert. `total:0` usually means no Library API key configured. Operates on the default site unless site_id is provided. |
2735
+ | `yootheme_builder_library_delete` | destructive | `confirm`, `id`, `site_id`, `source` | Delete ONE saved entry from your "WootsUp AI Layouts" store (the isolated library yootheme_builder_library_save_as writes into) — clean up experimental sections/layouts the agent saved. Pass the `id` from a library_browse(source:"ai") row. `source` defaults to "ai", the ONLY deletable store; the curated library is read-only, so source:"catalog" returns a 403. IRREVERSIBLE — requires confirm:true. Returns `{deleted, id, deleted_count, source}`; an unknown id returns a 404. Advanced/gateway-only. Operates on the default site unless site_id is provided. |
2046
2736
  | `yootheme_builder_library_get` | read+idempotent | `id`, `include_thumbnail`, `kind`, `site_id`, `source` | Fetch a SINGLE YOOtheme Pro Library layout or element preset by `id` — call after yootheme_builder_library_browse (items[].id feeds this). Returns `{id, kind, name, version, tree, sections, images_manifest_count}`: `tree` is the raw JSON; `sections` lists `tree.children` (layouts only, presets return `[]`), each `{index, name, path, element_count}`. `include_thumbnail:true` also returns an image block (costs tokens — see its own description). Read-only; does not insert/bind. Operates on the default site unless site_id is provided. |
2047
2737
  | `yootheme_builder_library_insert` | mutating | `etag`, `id`, `images`, `kind`, `section_index`, `site_id`, `source`, `target` | Insert a YOOtheme Pro Library item (layout, section, or preset) onto a page — call after yootheme_builder_library_get/browse. `target`: new_page (creates a page), {template_id,element_path?} (splices onto the layout root, or a preset onto a Column), or sublayout (splices rows into a fragment). A preset (kind:"element") MUST target a Column — 422 otherwise; scaffold one first with yootheme_builder_scaffold_section. `images`: "placeholder" (default) or "curated" (downloads to Media Library). Optional `etag` locks (412 if stale). Operates on the default site unless site_id is provided. |
2048
2738
  | `yootheme_builder_library_save_as` | mutating | `destination`, `name`, `site_id`, `source` | Save a page or subtree into a reusable library — the REVERSE of yootheme_builder_library_insert. `source`: {template_id, element_path?} (omit element_path to save the WHOLE page). `destination`: "ai" (default — your isolated "WootsUp AI Layouts", reusable via library_browse/insert source:"ai") or "native" (YOOtheme's built-in My Layouts). Returns `{library_id, name, storage, invalid_source_possible}` (the last flags bound sources that may need re-binding after reload). Advanced/gateway-only. Operates on the default site unless site_id is provided. |
2049
2739
  | `yootheme_builder_local_content_read` | read+idempotent | `fields`, `include_children`, `kind`, `limit`, `max_chars`, `offset`, `parent_id`, `search`, `site_id`, `status`, `taxonomy`, `type` | Read native records for one `kind`: entities (posts/pages/CPT/articles), terms (TERMS — ids that archive-scope a Grid), users, fields (ACF/com_fields defs), menus, or media. Published-only + paginated: {items,total,next_offset}; pass next_offset back as `offset`. Archive-scoping flow: read {kind:"terms",taxonomy:"category"} → pick a term id → bind a source, pass query_arguments:{id:<term_id>}. CALL local_content_types FIRST for valid type/taxonomy. E.g. {kind:"entities",type:"product",search:"shoe"}. Slim with `fields[]`. Operates on the default site unless site_id is provided. |
2050
- | `yootheme_builder_local_content_types` | read+idempotent | `fields`, `kind`, `max_chars`, `site_id` | Discover the site's NATIVE content landscape — post types/articles, taxonomies (categories/tags) and custom-field groups (ACF/com_fields) native to WordPress/Joomla (no API Mapper). Returns {content_types, taxonomies, field_groups, capabilities}; rows carry `bindable` + `source_name`. CALL FIRST to see WHAT exists, then local_content_read for records, then element_bind_source to wire the source. Narrow with `kind`, slim with `fields[]`. E.g. {kind:"content_types"} → [{name:"post",label:"Posts",bindable:true,source_name:"wp_posts"}]. Operates on the default site unless site_id is provided. |
2051
- | `yootheme_builder_page_dynamize` | mutating | `conditions`, `element_path`, `etag`, `leaf_map`, `list_source_name`, `page`, `remove_leaves`, `section_name`, `site_id`, `source_map`, `template_id` | One-call way to dynamize a designed section: copies the layout byte-identically and swaps only what you map. Use INSTEAD of hand-editing JSON. Give a REFERENCE (section_name from template_summary, or element_path) + list_source_name + a leaf_map (original leaf field-name → flat flow field; #parent + tuned props kept). A section with several source blocks (Played/Next/Later) dynamizes in ONE call via source_map ({match, source_name, leaf_map?}[]). Sub-query panels: key by "<subquery>.<leaf>". Returns page + `summary`. Requires page.title. Operates on the default site unless site_id is provided. |
2052
- | `yootheme_builder_page_get_layout` | read+idempotent | `cursor`, `fields`, `flat`, `force`, `max_chars`, `mode`, `post_id`, `site_id`, `template_id`, `url` | Get the layout tree for one template — address it by `template_id` (from pages_list), `post_id` (numeric post/article id), or `url`. For dynamize/bind prefer `mode:"bindings_only"` (binding skeleton, no style props). Default `mode:"full"` returns nested `{layout, etag}`; `flat:true` gives a depth-first `{elements:[...]}` array (with `fields[]`). A repeated unchanged read auto-skips (`force:true` re-fetches). `max_chars` bounds the text; a clipped read returns `next_cursor` (pass back as `cursor`) to page the rest. Operates on the default site unless site_id is provided. |
2740
+ | `yootheme_builder_local_content_types` | read+idempotent | `fields`, `kind`, `max_chars`, `site_id` | Discover the site's NATIVE content landscape — post types/articles, taxonomies and custom-field groups (ACF/com_fields), native to WP/Joomla (no API Mapper). Returns {content_types, taxonomies, field_groups, capabilities}; rows carry `bindable` + `source_name` (null for native types bind those with element_bind_source by the content-type `name`, e.g. "post"). CALL FIRST, then local_content_read for records. Narrow with `kind`, slim with `fields[]`. E.g. {kind:"content_types"}. Operates on the default site unless site_id is provided. |
2741
+ | `yootheme_builder_page_dynamize` | mutating | `conditions`, `dry_run`, `element_path`, `etag`, `leaf_map`, `list_source_name`, `page`, `remove_leaves`, `section_name`, `site_id`, `source_map`, `template_id` | One-call way to dynamize a designed section: copies the layout byte-identically and swaps only what you map. Use INSTEAD of hand-editing JSON. Give a REFERENCE (section_name from template_summary, or element_path) + list_source_name + a leaf_map (original leaf field-name → flat flow field; #parent + tuned props kept). A section with several source blocks dynamizes in ONE call via source_map ({match, source_name, leaf_map?}[]). Sub-query panels: key by "<subquery>.<leaf>". Returns page + `summary`. Preview with dry_run:true. Operates on the default site unless site_id is provided. |
2742
+ | `yootheme_builder_page_get_layout` | read+idempotent | `cursor`, `fields`, `flat`, `force`, `max_chars`, `mode`, `post_id`, `site_id`, `template_id`, `url` | Get the layout tree for one template — address it by `template_id` (from pages_list), `post_id`, or `url`. `mode:"bindings_only"` for dynamize/bind; `mode:"full"` (default) is nested `{layout, etag}`, `flat:true` a depth-first array. `max_chars` bounds the text; a clipped read returns `next_cursor` to page the rest. For a site-wide SEO/structure/content/links audit (h1, image alt, thin pages, dead links) do NOT loop this tool page-by-page call yootheme_builder_pages_audit (one sweep via yootheme_builder_advanced_read). Operates on the default site unless site_id is provided. |
2053
2743
  | `yootheme_builder_page_get_schema` | read+idempotent | `fields`, `max_chars`, `post_id`, `site_id`, `template_id`, `url` | Get the flat schema for a template — nodes with their JSON-Pointer paths and element types. Lighter than page_get_layout, enough to locate elements before editing. Address by `template_id` (pages_list), `post_id`, or `url`. `fields[]` slims each node; `max_chars` bounds output. Operates on the default site unless site_id is provided. |
2054
- | `yootheme_builder_page_publish` | idempotent | `etag`, `site_id`, `template_id` | Publish a template — persist state, flush YT + WP caches, snapshot the published-state ETag. ETag optional — when provided, 412 on conflict; when omitted, last-write-wins. Recommended for collaborative edits. Operates on the default site unless site_id is provided. |
2055
- | `yootheme_builder_page_save` | idempotent | `dry_run`, `etag`, `layout`, `site_id`, `template_id` | Re-run save-transforms + flush caches. Optionally REPLACE the template tree by passing the INNER {type:"layout",children:[...]} tree as `layout` — NOT the full template node page_get_layout returns (rejected 422, would blank the page). Validated + ETag-locked; on reject nothing persists. An unrenderable layout is refused PRE-WRITE (422). `dry_run:true` preflights; the success response carries `previous_layout` for 1-call rollback. Omit `layout` to re-run transforms only. ETag optional (412 on conflict; else last-write-wins). Operates on the default site unless site_id is provided. |
2744
+ | `yootheme_builder_page_publish` | idempotent | `etag`, `quick_check`, `site_id`, `template_id` | Publish a template — persist state, flush YT + WP caches, snapshot the published-state ETag. YOOtheme templates already publish on save, so this is a cache-flush + state-snapshot op. Neither publish nor save changes the CMS post status — a draft page stays invisible to visitors; set status at creation via pages_create/library_insert. ETag optional — when provided, 412 on conflict; when omitted, last-write-wins. Operates on the default site unless site_id is provided. |
2745
+ | `yootheme_builder_page_save` | idempotent | `dry_run`, `etag`, `layout`, `quick_check`, `site_id`, `template_id` | Re-run save-transforms + flush caches. Optionally REPLACE the template tree by passing the INNER {type:"layout",children:[...]} tree as `layout` — page_get_layout's `.layout` child, NOT the whole response it returns (rejected 422, would blank the page). Validated + ETag-locked; nothing persists on reject. Does NOT change CMS post status a draft stays invisible to visitors; set status via pages_create. `dry_run:true` preflights; success carries `previous_layout` for rollback. ETag optional (412 on conflict; else last-write-wins). Operates on the default site unless site_id is provided. |
2746
+ | `yootheme_builder_pages_audit` | read+idempotent | `checks`, `max_chars`, `site_id`, `stale_after_months`, `target`, `thin_content_threshold`, `types` | One-call site-wide SEO/structure/content/links audit — severity-sorted findings with fix_hints (each a props FRAGMENT to merge, not a ready-made call — supply template_id + path from the finding locator). Covers h1/headings, thin pages, image alt, duplicate layouts, title collisions, non-pretty URLs, dead anchors. A SITE-wide sweep caps hits/page and drops per-hit content_snippet; scope to ONE page via `target` for the full set (cap 100). Filter with `types`/`checks`. Disclaimer: SEO-plugin meta (title/description/OG) NOT covered. Operates on the default site unless site_id is provided. |
2056
2747
  | `yootheme_builder_pages_create` | idempotent | `dry_run`, `layout`, `menu_item`, `published`, `site_id`, `slug`, `status`, `title` | Create a page (WordPress) or article (Joomla 5/6) with a YOOtheme per-page layout. Required: title. Optional: slug, status (default draft), published (default false → live on true: WP publish / Joomla state 1), layout (JSON), menu_item (Joomla SEF route), dry_run (preflight, no persist). Returns page_id (WP) / article_id (Joomla), slug, template_id (page:{id} or article:{id}), etag, warnings. Joomla: a published article has NO SEF route until a menu item points at it — pass menu_item, else frontend_url may 404. Operates on the default site unless site_id is provided. |
2057
2748
  | `yootheme_builder_pages_delete` | destructive | `confirm`, `force`, `post_id`, `site_id` | Delete a page (WordPress) or article (Joomla 5/6) with its YOOtheme per-page layout. Requires confirm:true (gate against accidental deletion). Default: trash (recoverable). force:true: permanent delete. Refuses non-page/article types with 403 (Layer 9 defense). Returns deleted:true, page_id (WP) / article_id (Joomla), force_deleted, bumped etag. Operates on the default site unless site_id is provided. |
2058
2749
  | `yootheme_builder_pages_list` | read+idempotent | `cursor`, `fields`, `label_contains`, `limit`, `max_chars`, `site_id` | List pages, templates, and layouts in the YOOtheme Pro builder (id, label, type, elements_count, frontend_url, builder_url, serves_context_kind, serves_context_ids per row). CALL THIS FIRST to discover template IDs before page_get_layout / element_list / page_get_schema. Paginates: up to `limit` rows (default 50) plus `total` and a `next_cursor` (pass it back as `cursor`) when more remain. Filter with `label_contains`. Slim rows with `fields:["id","label"]`; bound output with `max_chars`. Operates on the default site unless site_id is provided. |
2059
- | `yootheme_builder_pages_resolve` | read+idempotent | `site_id`, `url` | Resolve a public page/article/archive URL to the template_id that renders it — paste a URL, get the layout id. Pretty/SEF permalinks resolve on WordPress AND Joomla (incl. deep sub-category paths), not only raw ?option= URLs. A singular page → its per-page layout; a category/tag/CPT/author archive → the governing THEME TEMPLATE, and for a category/term the SPECIFICALLY-ASSIGNED template (honoring include-child-categories), not a generic one. Returns {template_id, post_id, type, frontend_url, context, resolved_via}. Operates on the default site unless site_id is provided. |
2060
- | `yootheme_builder_rename_source` | idempotent | `etag`, `field_map`, `from`, `site_id`, `source_map`, `template_id`, `to` | Template-wide RENAME of a source / type / field name across EVERY binding in a template, in ONE ETag-locked write (not a whole-layout page_save) — preserving each binding's arguments, props and field_mappings. Use after a taxonomy/CPT rename (faq→help) breaks every bound layout. Pass `from`/`to` for one source, and/or `source_map`/`field_map` to rename a relation + its field tokens together; the dotted "<source>.<relation>" form is rewritten segment-by-segment. Returns {replaced_count, changed_paths, new_etag}. ETag req. Operates on the default site unless site_id is provided. |
2061
- | `yootheme_builder_render_preview` | read+idempotent | `expect`, `site_id`, `template_id`, `url` | Render-check a saved template over the authenticated channel — does it actually render, with how many items, or is it blank? Address by `template_id` (from pages_list) OR `url` (resolved for you). Returns {rendered, item_count, empty, error?, cache_purge_hint}; with `expect`, also {matched, snippet}. Use AFTER a page_save / element_bind_source to catch a silently-empty live page (a bad binding "saves" then renders blank); the swallowed YOOtheme render error is surfaced in `error`. Read-only — renders without persisting. Operates on the default site unless site_id is provided. |
2062
- | `yootheme_builder_scaffold_section` | mutating | `column_widths`, `columns`, `etag`, `site_id`, `template_id` | Create a VALID section → row → column hull in ONE atomic call and return the column JSON-Pointer path(s) to drop content into. This is the EASIEST correct way to start a new layout block: YOOtheme content must live in section row column, and adding content anywhere else (root, bare section, bare row) is rejected. Pass `columns` (1-12, default 1) for a multi-column row; columns are auto-distributed equal-width so they render side-by-side, or pass `column_widths` (one token per column) for an asymmetric row. Requires ETag. Operates on the default site unless site_id is provided. |
2063
- | `yootheme_builder_sites_list` | read+idempotent | `site_id` | List all sites configured in this multi-site MCP installation. Returns site_id + URL + platform (wordpress\|joomla) + default flag per row. CALL THIS FIRST when working with a fresh MCP connection to discover available site_ids before targeting one with any other tool. Read-only, no REST calls. Keywords: list sites, list connections, list installations, discover site_id, available sites, configured sites, what sites exist, multi-site index. (site_id is accepted for schema-uniformity but ignored by this tool.) |
2750
+ | `yootheme_builder_pages_resolve` | read+idempotent | `site_id`, `target`, `url` | Resolve a public page/article/archive URL to the template_id that renders it — paste a URL, get the layout id. Pretty/SEF permalinks resolve on WordPress AND Joomla (incl. deep sub-category paths), not only raw ?option= URLs. A singular page → its per-page layout; a category/tag/CPT/author archive → the governing THEME TEMPLATE, and for a category/term the SPECIFICALLY-ASSIGNED template (honoring include-child-categories), not a generic one. Or a `target`. Returns {template_id, post_id, type, frontend_url, context, resolved_via}. Operates on the default site unless site_id is provided. |
2751
+ | `yootheme_builder_rename_source` | idempotent | `dry_run`, `etag`, `field_map`, `from`, `site_id`, `source_map`, `template_id`, `to` | Template-wide RENAME of a source / type / field name across EVERY binding in a template, in ONE ETag-locked write — preserving each binding's arguments, props and field_mappings. Use after a taxonomy/CPT rename (faq→help) breaks every bound layout. Pass `from`/`to` for one source, and/or `source_map`/`field_map` to rename a relation + its field tokens together; the dotted "<source>.<relation>" form is rewritten segment-by-segment. Returns {replaced_count, changed_paths, new_etag}. Preview with dry_run:true. ETag req. Operates on the default site unless site_id is provided. |
2752
+ | `yootheme_builder_render_preview` | read+idempotent | `expect`, `post_id`, `site_id`, `template_id`, `url` | Render-check a saved template — does it actually render, with how many items, or is it blank? Address by `template_id` (from pages_list), `post_id`, or `url` (resolved for you) — exactly one. `item_count` = items yielded by the template's dynamic/Multi-Items bindings (rendered repeat-source rows); 0 with empty:true means a binding returned nothing. Returns {rendered, item_count, empty, error?, cache_purge_hint}; with `expect`, also {matched, snippet}. Use AFTER a save/bind to catch a silently-empty page. Read-only. Operates on the default site unless site_id is provided. |
2753
+ | `yootheme_builder_scaffold_section` | mutating | `column_widths`, `columns`, `etag`, `site_id`, `template_id` | Create a VALID section → row → column hull in ONE call; returns the column JSON-Pointer path(s) for content. The correct way to start a layout block: only `section` may sit at the layout root; a bare section accepts `row`, a bare row accepts `column`; leaf/content elements are rejected anywhere except inside a `column`. Pass `columns` (1-12, default 1); columns get width tokens so they render side-by-side (hand-built columns default to full width), or `column_widths` (one token per column) for an asymmetric row. Requires ETag. Operates on the default site unless site_id is provided. |
2754
+ | `yootheme_builder_sites_list` | read+idempotent | `site_id` | List all sites configured in this multi-site MCP installation. Returns site_id + URL + platform (wordpress\|joomla) + default flag per row. CALL THIS FIRST when working with a fresh MCP connection to discover available site_ids before targeting one with any other tool. Read-only, no REST calls. Keywords: list sites, list connections, list installations, discover site_id, available sites, configured sites, what sites exist, multi-site index. The `default_site_id` is the write target when a call omits site_id. (site_id is accepted for schema-uniformity but ignored by this tool.) |
2064
2755
  | `yootheme_builder_sites_test` | read+idempotent | `site_id` | Verify connectivity to ONE site: probes /health (no auth) + /etag (auth) in parallel; returns plugin_reachable + bearer_valid. `site_id` is REQUIRED. Use sites_list to find IDs. |
2065
- | `yootheme_builder_source_get_schema` | read+idempotent | `max_chars`, `name`, `site_id` | Introspect ONE data source: its query methods + arguments + RELATIONS (e.g. Term→Posts: categoryFaqs.taxonomyCategoryFaq → field "faq") + a provides_archive_context flag (id:0 = current archive term, auto-scoped). Each argument may carry `enum_values` (allowed values, e.g. ["asc","desc"]); pick from those, don't guess. CALL BEFORE binding on an archive/category template — turns a ~25-call code-dive into one. Keywords: source schema, relations, archive context, enum values. Get `name` from yootheme_builder_sources_list. Operates on the default site unless site_id is provided. |
2756
+ | `yootheme_builder_source_get_schema` | read+idempotent | `max_chars`, `name`, `site_id` | Introspect ONE data source: its query methods + arguments + RELATIONS (e.g. Term→Posts: categoryFaqs.taxonomyCategoryFaq → field "faq") + a provides_archive_context flag (id:0 = current archive term, auto-scoped). SOME arguments carry `enum_values` (the allowed values); when an arg has none it is a free string — pick from enum_values when present, never invent values. CALL BEFORE binding on an archive/category template. Keywords: source schema, relations, archive context, enum values. Get `name` from yootheme_builder_sources_list. Operates on the default site unless site_id is provided. |
2066
2757
  | `yootheme_builder_sources_list` | read+idempotent | `cursor`, `fields`, `group`, `kind`, `limit`, `max_chars`, `name_contains`, `site_id` | List all data sources, feeds, and dynamic content sources available in the YOOtheme Pro builder. Returns name + label + origin (apimapper / wordpress / joomla / essentials) per source. CALL THIS BEFORE binding any element to a data source. Then bind directly with yootheme_builder_element_bind_source. Keywords: list sources, list feeds, list bindings, dynamic content, what sources exist. Paginates (`limit`/`cursor`/`total`); scope with `group`/`kind`/`name_contains`; slim with `fields[]`; bound output with `max_chars`. Operates on the default site unless site_id is provided. |
2067
- | `yootheme_builder_template_set_type` | idempotent | `etag`, `site_id`, `template_id`, `type` | Reassign which archive/single a YOOtheme THEME template applies to — set its `type` condition (the value pages_list shows) in ONE ETag-locked write. Use after a taxonomy/CPT rename orphans a template (e.g. re-type "taxonomy-category_faq" → "taxonomy-category_help", "single-gloeckle_faq" → "single-gloeckle_help"). Returns {type, previous_type, new_etag} previous_type is the read of the prior assignment, folded in. THEME templates only: a page:/article: layout is refused (422). ETag req. Operates on the default site unless site_id is provided. |
2068
- | `yootheme_builder_template_summary` | read+idempotent | `site_id`, `template_id` | Token-efficient template overview: element counts by type, binding count, max nesting depth, and named landmark sections — computed server-side in one call. Use this to grasp a large template before pulling element_list or page_get_layout. Example: yootheme_builder_template_summary({ template_id: "default" }). Operates on the default site unless site_id is provided. |
2758
+ | `yootheme_builder_template_set_type` | idempotent | `dry_run`, `etag`, `site_id`, `template_id`, `type` | Reassign which archive/single a YOOtheme THEME template applies to — set its `type` condition (the value pages_list shows) in ONE ETag-locked write. Use after a taxonomy/CPT rename orphans a template (e.g. re-type "taxonomy-category_faq" → "taxonomy-category_help"). Returns {type, previous_type, new_etag}. Site-wide + immediate preview with dry_run:true, reversible via previous_type. THEME templates only: page:/article: is refused (422). ETag req. Operates on the default site unless site_id is provided. |
2759
+ | `yootheme_builder_template_summary` | read+idempotent | `site_id`, `template_id` | Token-efficient template overview: element counts by type, binding count, max nesting depth, and named landmark sections — computed server-side in one call. Use this to grasp a large template before pulling element_list or page_get_layout. template_id only — resolve a URL/post first via pages_resolve. Example: yootheme_builder_template_summary({ template_id: "default" }). Operates on the default site unless site_id is provided. |
2069
2760
  | `yootheme_builder_use_site` | idempotent | `probe`, `site_id` | Switch the ACTIVE site for this multi-site session. After this call, every tool that omits `site_id` targets the site you picked here. NOTE: this default is PROCESS-WIDE — concurrent clients sharing this MCP server inherit it; pass `site_id` per call for isolation between parallel agents. Runs a /health + /etag probe by default; pass `probe: false` to switch offline. A degraded site is a soft warning (the switch still happens). `site_id` is REQUIRED. Keywords: switch/change/set active/target site, select installation, use profile. Use yootheme_builder_sites_list first to find IDs. |
2070
2761
 
2071
2762
  <!-- TOOL-CATALOG:END -->