@wootsup/yt-builder-mcp 1.7.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/client.d.ts +6 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +38 -2
- package/dist/client.js.map +1 -1
- package/dist/gateway/advanced-tool/domains.d.ts +14 -2
- package/dist/gateway/advanced-tool/domains.d.ts.map +1 -1
- package/dist/gateway/advanced-tool/domains.js +20 -5
- package/dist/gateway/advanced-tool/domains.js.map +1 -1
- package/dist/gateway/essentials.d.ts +1 -1
- package/dist/gateway/essentials.d.ts.map +1 -1
- package/dist/gateway/essentials.js +27 -0
- package/dist/gateway/essentials.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/platform/detect.d.ts +1 -1
- package/dist/platform/detect.js +1 -1
- package/dist/platform/index.d.ts +1 -1
- package/dist/platform/index.js +1 -1
- package/dist/proxy/mode.d.ts.map +1 -1
- package/dist/proxy/mode.js +25 -3
- package/dist/proxy/mode.js.map +1 -1
- package/dist/setup-cli.d.ts +8 -0
- package/dist/setup-cli.d.ts.map +1 -1
- package/dist/setup-cli.js +58 -6
- package/dist/setup-cli.js.map +1 -1
- package/dist/setup-npx-spec.d.ts +1 -1
- package/dist/setup-npx-spec.js +1 -1
- package/dist/setup-prompts.d.ts +11 -0
- package/dist/setup-prompts.d.ts.map +1 -1
- package/dist/setup-prompts.js +28 -0
- package/dist/setup-prompts.js.map +1 -1
- package/dist/setup-wizard-defaults.d.ts.map +1 -1
- package/dist/setup-wizard-defaults.js +40 -9
- package/dist/setup-wizard-defaults.js.map +1 -1
- package/dist/setup-wizard-handshake.d.ts +5 -4
- package/dist/setup-wizard-handshake.d.ts.map +1 -1
- package/dist/setup-wizard-handshake.js +28 -24
- package/dist/setup-wizard-handshake.js.map +1 -1
- package/dist/setup-wizard-types.d.ts +48 -9
- package/dist/setup-wizard-types.d.ts.map +1 -1
- package/dist/setup-wizard.d.ts.map +1 -1
- package/dist/setup-wizard.js +62 -10
- package/dist/setup-wizard.js.map +1 -1
- package/dist/sites/env-bridge.d.ts +19 -0
- package/dist/sites/env-bridge.d.ts.map +1 -1
- package/dist/sites/env-bridge.js +29 -15
- package/dist/sites/env-bridge.js.map +1 -1
- package/dist/sites/store.d.ts +1 -1
- package/dist/sites/store.js +1 -1
- package/dist/skill-loader.d.ts +2 -2
- package/dist/skill-loader.js +2 -2
- package/dist/tools/elements/builders.d.ts.map +1 -1
- package/dist/tools/elements/builders.js +21 -2
- package/dist/tools/elements/builders.js.map +1 -1
- package/dist/tools/elements/handlers-write.d.ts +2 -0
- package/dist/tools/elements/handlers-write.d.ts.map +1 -1
- package/dist/tools/elements/handlers-write.js +16 -4
- package/dist/tools/elements/handlers-write.js.map +1 -1
- package/dist/tools/elements/handlers.d.ts +1 -0
- package/dist/tools/elements/handlers.d.ts.map +1 -1
- package/dist/tools/elements/handlers.js +14 -0
- package/dist/tools/elements/handlers.js.map +1 -1
- package/dist/tools/format/pages-format.d.ts.map +1 -1
- package/dist/tools/format/pages-format.js +21 -0
- package/dist/tools/format/pages-format.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +8 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/library.d.ts +279 -0
- package/dist/tools/library.d.ts.map +1 -0
- package/dist/tools/library.js +1060 -0
- package/dist/tools/library.js.map +1 -0
- package/dist/tools/local-content/builders.d.ts +19 -0
- package/dist/tools/local-content/builders.d.ts.map +1 -0
- package/dist/tools/local-content/builders.js +127 -0
- package/dist/tools/local-content/builders.js.map +1 -0
- package/dist/tools/local-content/handlers.d.ts +54 -0
- package/dist/tools/local-content/handlers.d.ts.map +1 -0
- package/dist/tools/local-content/handlers.js +287 -0
- package/dist/tools/local-content/handlers.js.map +1 -0
- package/dist/tools/local-content/index.d.ts +17 -0
- package/dist/tools/local-content/index.d.ts.map +1 -0
- package/dist/tools/local-content/index.js +16 -0
- package/dist/tools/local-content/index.js.map +1 -0
- package/dist/tools/local-content/schemas.d.ts +82 -0
- package/dist/tools/local-content/schemas.d.ts.map +1 -0
- package/dist/tools/local-content/schemas.js +89 -0
- package/dist/tools/local-content/schemas.js.map +1 -0
- package/dist/tools/pages/builders.d.ts.map +1 -1
- package/dist/tools/pages/builders.js +16 -10
- package/dist/tools/pages/builders.js.map +1 -1
- package/dist/tools/sources/handlers-bind.d.ts +2 -0
- package/dist/tools/sources/handlers-bind.d.ts.map +1 -1
- package/dist/tools/sources/handlers-bind.js +18 -0
- package/dist/tools/sources/handlers-bind.js.map +1 -1
- package/manifest.json +4 -3
- package/package.json +4 -3
- package/skills/yt-builder-mcp/SKILL.md +998 -78
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout Library tools — browse + fetch + insert + save the YOOtheme Pro
|
|
3
|
+
* Library catalog (layouts / element presets), PLUS the Image Sourcing pair
|
|
4
|
+
* (Task D1) that fills layout image slots with real photography. Task 2
|
|
5
|
+
* (Layout Library, Spec #2) + Task 3 (Spec #3) + Task 6 (Spec #6) + Task 7
|
|
6
|
+
* (Spec #7) + Task D1 (Image Sourcing).
|
|
7
|
+
*
|
|
8
|
+
* yootheme_builder_library_browse
|
|
9
|
+
* → paginated, taxonomy-filtered catalog rows (tableResult — Wave G.2).
|
|
10
|
+
* yootheme_builder_library_get
|
|
11
|
+
* → a SINGLE item's full tree + derived section list (the natural next
|
|
12
|
+
* call after library_browse — id comes straight off a browse row).
|
|
13
|
+
* yootheme_builder_library_insert
|
|
14
|
+
* → compose + persist a browsed/fetched item onto a page/template
|
|
15
|
+
* through the REAL hardened write chain.
|
|
16
|
+
* yootheme_builder_library_save_as
|
|
17
|
+
* → the REVERSE direction: save a page/subtree INTO "My Layouts".
|
|
18
|
+
* yootheme_builder_image_search
|
|
19
|
+
* → search Unsplash / Pexels (real photography) or YOOtheme Pro Images
|
|
20
|
+
* (curated decorative/background assets) — forwards to the SAME
|
|
21
|
+
* `?key=<theme apikey>` proxy auth `library_browse` uses (Task A0-C1,
|
|
22
|
+
* PHP `ImageSearchService`). Returns candidate rows; `total:0` means
|
|
23
|
+
* no Library API key configured, mirroring browse's empty-catalog hint.
|
|
24
|
+
* yootheme_builder_image_import
|
|
25
|
+
* → sideload ONE chosen photo (typically a `full_url` from
|
|
26
|
+
* image_search) into the site's own Media Library. Returns
|
|
27
|
+
* `{imported:true, local_url}` to bind via element_update_settings,
|
|
28
|
+
* or `{imported:false, reason}` on a caught failure (never a 500).
|
|
29
|
+
*
|
|
30
|
+
* browse/get/image_search are read-only forwards, exactly like the sibling
|
|
31
|
+
* L1 read tools (sources_list / template_summary / pages_list): resolve the
|
|
32
|
+
* site via the pool, GET the plugin REST endpoint, project into a table +
|
|
33
|
+
* strict structuredContent, stamp the result-level `_meta` (site_id/
|
|
34
|
+
* site_url/platform/resolved_via) via withSiteMeta. insert/save_as/
|
|
35
|
+
* image_import are WRITE forwards (withWriteSiteRetry — pre-flights a KNOWN
|
|
36
|
+
* read-only token before any body build, mirroring every other write tool's
|
|
37
|
+
* U2 pattern).
|
|
38
|
+
*
|
|
39
|
+
* ── Tiering (Task 7, deliberate) ─────────────────────────────────────────
|
|
40
|
+
* `library_save_as` is registered as an ADVANCED tool — it is NOT added to
|
|
41
|
+
* `ESSENTIAL_TOOLS` (essentials.ts) and does NOT appear as a first-class
|
|
42
|
+
* `tools/list` entry. `tools/list` sits close to the 28000B token-baseline
|
|
43
|
+
* ceiling (see `tests/perf/token-baseline.test.ts`); a 4th Layout-Library L1
|
|
44
|
+
* tool would risk busting it. `CapturingServer` (gateway/capturing-server.ts)
|
|
45
|
+
* automatically captures ANY registered tool whose name is absent from both
|
|
46
|
+
* `ESSENTIAL_TOOLS` and `DIRECT_TOP_LEVEL_TOOLS` into the L2 advanced
|
|
47
|
+
* registry — reachable via `yootheme_builder_advanced({tool, arguments})` —
|
|
48
|
+
* so simply NOT listing this name in essentials.ts is the entire tiering
|
|
49
|
+
* decision (mirrors how `yootheme_builder_clean_implode_directives` /
|
|
50
|
+
* `yootheme_builder_element_unbind_source` are advanced-only today). The
|
|
51
|
+
* name falls through the gateway's domain taxonomy (domains.ts) to "misc"
|
|
52
|
+
* (no `library` domain exists there) — intentional, NOT a bug (see
|
|
53
|
+
* `domain-order.test.ts`'s own pinned "misc" fallthrough cases): it keeps the
|
|
54
|
+
* gateway's own tools/list-visible description text from growing at all,
|
|
55
|
+
* while `{tool: "yootheme_builder_library_save_as"}` discovery and
|
|
56
|
+
* `{tool, arguments}` execution both remain fully reachable (the gateway
|
|
57
|
+
* dispatches by an exact Map lookup, independent of the domain-grouped
|
|
58
|
+
* DESCRIPTION text — see `gateway/advanced-tool/execute.ts`).
|
|
59
|
+
*
|
|
60
|
+
* @license MIT
|
|
61
|
+
*/
|
|
62
|
+
import { tableResult } from '@getimo/mcp-toolkit';
|
|
63
|
+
import { z } from 'zod';
|
|
64
|
+
import { withSiteRetry, withWriteSiteRetry } from './pool-resolve-helper.js';
|
|
65
|
+
import { SITE_ID_SCHEMA } from './shared-schemas.js';
|
|
66
|
+
import { creating, defineTool, errorResult, jsonResult, mutating, readOnly, structuredResult, withSiteMeta, } from './tool-builder.js';
|
|
67
|
+
// ─── outputSchema (Wave G.2 §4) ────────────────────────────────────
|
|
68
|
+
/**
|
|
69
|
+
* Fixed section-purpose enum (Task 8, Section Corpus Phase 1) — mirrors the
|
|
70
|
+
* PHP-side `SectionPurposeClassifier` / `SectionIndexEntry` purpose values
|
|
71
|
+
* byte-for-byte (design spec §A, Global Constraints). Only applies when
|
|
72
|
+
* `kind="section"`. Declared here (ahead of `LIBRARY_ITEM_SCHEMA`, which
|
|
73
|
+
* consumes it for the browse row's `purpose` field) rather than further
|
|
74
|
+
* down where the original Task 8 pass left it, since Task 8b (this file)
|
|
75
|
+
* now needs it at both the item-schema AND the browse-filter-schema sites.
|
|
76
|
+
*/
|
|
77
|
+
export const SECTION_PURPOSE_VALUES = [
|
|
78
|
+
'hero',
|
|
79
|
+
'features',
|
|
80
|
+
'stats',
|
|
81
|
+
'team',
|
|
82
|
+
'testimonial',
|
|
83
|
+
'cta',
|
|
84
|
+
'gallery',
|
|
85
|
+
'pricing',
|
|
86
|
+
'faq',
|
|
87
|
+
'contact',
|
|
88
|
+
'logos',
|
|
89
|
+
'content',
|
|
90
|
+
];
|
|
91
|
+
/**
|
|
92
|
+
* Track 1 Task 7 (WootsUp AI Layouts) — the `source` axis for
|
|
93
|
+
* browse/get/insert: `"catalog"` (the remote YOOtheme Pro Library, the
|
|
94
|
+
* DEFAULT) or `"ai"` (the plugin's OWN isolated "WootsUp AI Layouts" store,
|
|
95
|
+
* saved via library_save_as, no apikey). Mirrors the PHP-side
|
|
96
|
+
* `LibrarySourceRegistry` keys byte-for-byte.
|
|
97
|
+
*/
|
|
98
|
+
const LIBRARY_SOURCE_VALUES = ['catalog', 'ai'];
|
|
99
|
+
/**
|
|
100
|
+
* The wire default for the `source` axis. It is NEVER forwarded (a catalog
|
|
101
|
+
* browse/get/insert is byte-identical to the pre-Task-7 request — the PHP
|
|
102
|
+
* controller reads the SAME absent-param default), so only `"ai"` ever
|
|
103
|
+
* appears on the wire. Mirrors `LibrarySourceRegistry::DEFAULT_SOURCE`.
|
|
104
|
+
*/
|
|
105
|
+
const DEFAULT_LIBRARY_SOURCE = 'catalog';
|
|
106
|
+
/**
|
|
107
|
+
* Track 1 Task 7 (WootsUp AI Layouts) — the `destination` axis for
|
|
108
|
+
* library_save_as: `"ai"` (save INTO the isolated AI store, the DEFAULT) or
|
|
109
|
+
* `"native"` (the escape hatch — save into YOOtheme's native "My Layouts").
|
|
110
|
+
*/
|
|
111
|
+
const SAVE_DESTINATION_VALUES = ['ai', 'native'];
|
|
112
|
+
/**
|
|
113
|
+
* The wire default for the `destination` axis — the AI store. NEVER forwarded
|
|
114
|
+
* (an ai-default save is byte-identical to the pre-Task-7 body; the PHP
|
|
115
|
+
* `MyLayoutsStore` reads the SAME absent-param `ai` default), so only
|
|
116
|
+
* `"native"` ever appears on the wire.
|
|
117
|
+
*/
|
|
118
|
+
const DEFAULT_SAVE_DESTINATION = 'ai';
|
|
119
|
+
/**
|
|
120
|
+
* Mirrors `CatalogService::mapItem()` (PHP): every key is always PRESENT
|
|
121
|
+
* (never omitted) but each value may be `null` when the raw catalog item
|
|
122
|
+
* did not carry that taxonomy field — hence `.nullable()`, not `.optional()`.
|
|
123
|
+
*/
|
|
124
|
+
const LIBRARY_ITEM_META_SCHEMA = z.object({
|
|
125
|
+
topic: z.string().nullable(),
|
|
126
|
+
type: z.string().nullable(),
|
|
127
|
+
role: z.string().nullable(),
|
|
128
|
+
website: z.string().nullable(),
|
|
129
|
+
image: z.string().nullable(),
|
|
130
|
+
});
|
|
131
|
+
/**
|
|
132
|
+
* Task 8b (Section Corpus Phase 1, PLAN-GAP fix) — a `kind="section"` browse
|
|
133
|
+
* row (`SectionCatalog::mapEntry()`, PHP) carries a DIFFERENT shape than a
|
|
134
|
+
* layout/element row: no `meta` wrapper, TOP-LEVEL `topic`/`type`/`website`,
|
|
135
|
+
* plus `layout_id`/`section_index`/`purpose`/`element_signature`/
|
|
136
|
+
* `visual_effects`/`has_images`/`image_count`/`element_count`. All of these
|
|
137
|
+
* are `.optional()` — additive, never present on a layout/element row — so a
|
|
138
|
+
* strict-outputSchema host still accepts the pre-existing byte-unchanged
|
|
139
|
+
* layout/element shape.
|
|
140
|
+
*/
|
|
141
|
+
const LIBRARY_ITEM_SCHEMA = z.object({
|
|
142
|
+
id: z.string(),
|
|
143
|
+
name: z.string(),
|
|
144
|
+
kind: z.enum(['layout', 'element', 'section']),
|
|
145
|
+
meta: LIBRARY_ITEM_META_SCHEMA,
|
|
146
|
+
thumbnail_url: z.string().nullable(),
|
|
147
|
+
layout_id: z.string().optional(),
|
|
148
|
+
section_index: z.number().optional(),
|
|
149
|
+
purpose: z.enum(SECTION_PURPOSE_VALUES).optional(),
|
|
150
|
+
element_signature: z.string().optional(),
|
|
151
|
+
visual_effects: z.array(z.string()).optional(),
|
|
152
|
+
topic: z.string().nullable().optional(),
|
|
153
|
+
type: z.string().nullable().optional(),
|
|
154
|
+
website: z.string().nullable().optional(),
|
|
155
|
+
has_images: z.boolean().optional(),
|
|
156
|
+
image_count: z.number().optional(),
|
|
157
|
+
// Close-out finding (f): CONTENT elements only — excludes structural
|
|
158
|
+
// section/row/column wrappers. Distinct from library_get's
|
|
159
|
+
// `sections[].element_count` (LIBRARY_SECTION_SCHEMA below), which
|
|
160
|
+
// counts ALL descendants including structural nodes — the same
|
|
161
|
+
// section reports a different count from each; do not assume parity.
|
|
162
|
+
element_count: z
|
|
163
|
+
.number()
|
|
164
|
+
.optional()
|
|
165
|
+
.describe('CONTENT elements only (excludes structural section/row/column wrappers). ' +
|
|
166
|
+
'Distinct from library_get\'s sections[].element_count, which counts ALL descendants.'),
|
|
167
|
+
});
|
|
168
|
+
export const LIBRARY_BROWSE_OUTPUT_SCHEMA = z.object({
|
|
169
|
+
items: z.array(LIBRARY_ITEM_SCHEMA),
|
|
170
|
+
total: z.number(),
|
|
171
|
+
// `CatalogService::browse()` always emits the key; `null` means "last page".
|
|
172
|
+
next_cursor: z.string().nullable(),
|
|
173
|
+
});
|
|
174
|
+
// ─── library_get (Task 3) ───────────────────────────────────────────
|
|
175
|
+
const LIBRARY_SECTION_SCHEMA = z.object({
|
|
176
|
+
index: z.number(),
|
|
177
|
+
name: z.string(),
|
|
178
|
+
path: z.string(),
|
|
179
|
+
element_count: z.number(),
|
|
180
|
+
});
|
|
181
|
+
export const LIBRARY_GET_OUTPUT_SCHEMA = z.object({
|
|
182
|
+
id: z.string(),
|
|
183
|
+
// Task 8b (Section Corpus Phase 1, PLAN-GAP fix): "section" is a valid
|
|
184
|
+
// resolved kind — `LibraryController::getSection()` (PHP) always
|
|
185
|
+
// returns a literal `kind: "section"` for a composite-id fetch.
|
|
186
|
+
kind: z.enum(['layout', 'element', 'section']),
|
|
187
|
+
name: z.string(),
|
|
188
|
+
version: z.string(),
|
|
189
|
+
// The raw fetched tree (`{type, children, version, images, ...}` for a
|
|
190
|
+
// layout; a single element node for a preset) — an arbitrary JSON
|
|
191
|
+
// object, not a fixed shape.
|
|
192
|
+
tree: z.record(z.string(), z.unknown()),
|
|
193
|
+
// Top-level `tree.children` (layouts only — always `[]` for a preset,
|
|
194
|
+
// even when its own tree happens to carry `children`).
|
|
195
|
+
sections: z.array(LIBRARY_SECTION_SCHEMA),
|
|
196
|
+
images_manifest_count: z.number(),
|
|
197
|
+
});
|
|
198
|
+
// ─── library_insert (Task 6) ─────────────────────────────────────────
|
|
199
|
+
/**
|
|
200
|
+
* Mirrors `LibraryInsertService::insert()`'s `$target` union (PHP) exactly:
|
|
201
|
+
* a fresh page, a splice into an EXISTING template/page (layout/section on
|
|
202
|
+
* the layout root, or a preset on a Column), or a splice into a
|
|
203
|
+
* sublayout/fragment element.
|
|
204
|
+
*/
|
|
205
|
+
const LIBRARY_INSERT_TARGET_SCHEMA = z.union([
|
|
206
|
+
z.object({
|
|
207
|
+
new_page: z.object({
|
|
208
|
+
title: z.string().min(1).describe('The new page\'s title (e.g. "Landing Page").'),
|
|
209
|
+
status: z
|
|
210
|
+
.enum(['publish', 'draft', 'pending', 'private', 'future'])
|
|
211
|
+
.optional()
|
|
212
|
+
.describe('Post status. Omit for the platform default (draft).'),
|
|
213
|
+
}),
|
|
214
|
+
}),
|
|
215
|
+
z.object({
|
|
216
|
+
sublayout: z.object({
|
|
217
|
+
template_id: z
|
|
218
|
+
.string()
|
|
219
|
+
.min(1)
|
|
220
|
+
.describe('The `page:<id>` template holding the sublayout/fragment element (from pages_list).'),
|
|
221
|
+
element_path: z
|
|
222
|
+
.string()
|
|
223
|
+
.min(1)
|
|
224
|
+
.describe('JSON-Pointer rel-path to the sublayout/fragment element (e.g. "/children/0/children/0").'),
|
|
225
|
+
}),
|
|
226
|
+
}),
|
|
227
|
+
z.object({
|
|
228
|
+
template_id: z
|
|
229
|
+
.string()
|
|
230
|
+
.min(1)
|
|
231
|
+
.describe('The `page:<id>` template to splice into (from pages_list).'),
|
|
232
|
+
element_path: z
|
|
233
|
+
.string()
|
|
234
|
+
.optional()
|
|
235
|
+
.describe('JSON-Pointer rel-path: the layout ROOT ("" or omitted) for a layout/section item, ' +
|
|
236
|
+
'or a Column for an element preset (kind:"element").'),
|
|
237
|
+
}),
|
|
238
|
+
]);
|
|
239
|
+
/**
|
|
240
|
+
* A DOWNGRADED UI-invariant violation (2026-07-12 fix, "trusted
|
|
241
|
+
* library-insert downgrades enum/type violations to warnings"): a pure
|
|
242
|
+
* value-shape mismatch (`invalid_enum_value` / `type_mismatch`) that
|
|
243
|
+
* YOOtheme's OWN factory content carried but the site's installed YOOtheme
|
|
244
|
+
* schema doesn't declare — a version-skew artifact, allowed through rather
|
|
245
|
+
* than hard-rejecting the whole insert. Every OTHER violation class still
|
|
246
|
+
* 422s (`library.ui_invariant_violation`) exactly as before.
|
|
247
|
+
*/
|
|
248
|
+
const LIBRARY_INSERT_WARNING_SCHEMA = z.object({
|
|
249
|
+
field: z.string(),
|
|
250
|
+
code: z.string(),
|
|
251
|
+
message: z.string(),
|
|
252
|
+
});
|
|
253
|
+
export const LIBRARY_INSERT_OUTPUT_SCHEMA = z.object({
|
|
254
|
+
template_id: z.string(),
|
|
255
|
+
inserted_path: z.string(),
|
|
256
|
+
element_count: z.number(),
|
|
257
|
+
images_mode: z.enum(['placeholder', 'curated']),
|
|
258
|
+
images_resolved: z.object({
|
|
259
|
+
downloaded: z.number(),
|
|
260
|
+
placeholders: z.number(),
|
|
261
|
+
}),
|
|
262
|
+
new_etag: z.string(),
|
|
263
|
+
frontend_url: z.string().nullable(),
|
|
264
|
+
warnings: z.array(LIBRARY_INSERT_WARNING_SCHEMA).optional(),
|
|
265
|
+
});
|
|
266
|
+
const LIBRARY_TABLE_COLUMNS = [
|
|
267
|
+
{ key: 'id', label: 'ID', width: 10, llmOnly: true },
|
|
268
|
+
{ key: 'name', label: 'NAME', width: 26 },
|
|
269
|
+
{ key: 'kind', label: 'KIND', width: 8 },
|
|
270
|
+
{ key: 'meta.topic', label: 'TOPIC', width: 20 },
|
|
271
|
+
{ key: 'meta.role', label: 'ROLE', width: 14 },
|
|
272
|
+
{ key: 'meta.website', label: 'WEBSITE', width: 18 },
|
|
273
|
+
];
|
|
274
|
+
function asNullableString(v) {
|
|
275
|
+
return typeof v === 'string' ? v : null;
|
|
276
|
+
}
|
|
277
|
+
function isSectionPurpose(v) {
|
|
278
|
+
return typeof v === 'string' && SECTION_PURPOSE_VALUES.includes(v);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Task 8b (Section Corpus Phase 1, PLAN-GAP fix) — `requestedKind` is the
|
|
282
|
+
* caller's own `library_browse({kind})` argument. It is needed because a
|
|
283
|
+
* `kind="section"` row from `SectionCatalog::mapEntry()` (PHP) carries NO
|
|
284
|
+
* per-row `kind` field at all (unlike layout/element rows, which always do)
|
|
285
|
+
* — the whole response is homogeneous per browse call, since `kind` is a
|
|
286
|
+
* REST-level filter. So: `r.kind` wins when the row DOES carry one
|
|
287
|
+
* (layout/element, and defensively "section" too, should REST ever add it);
|
|
288
|
+
* otherwise fall back to what the caller asked for; otherwise "layout"
|
|
289
|
+
* (today's pre-Task-8b default, byte-unchanged).
|
|
290
|
+
*/
|
|
291
|
+
function mapLibraryItem(raw, requestedKind) {
|
|
292
|
+
if (raw === null || typeof raw !== 'object')
|
|
293
|
+
return undefined;
|
|
294
|
+
const r = raw;
|
|
295
|
+
const metaRaw = r.meta !== null && typeof r.meta === 'object' ? r.meta : {};
|
|
296
|
+
const kind = r.kind === 'element' ? 'element' : r.kind === 'section' || requestedKind === 'section' ? 'section' : 'layout';
|
|
297
|
+
const base = {
|
|
298
|
+
id: typeof r.id === 'string' ? r.id : '',
|
|
299
|
+
name: typeof r.name === 'string' ? r.name : '',
|
|
300
|
+
kind,
|
|
301
|
+
meta: {
|
|
302
|
+
topic: asNullableString(metaRaw.topic),
|
|
303
|
+
type: asNullableString(metaRaw.type),
|
|
304
|
+
role: asNullableString(metaRaw.role),
|
|
305
|
+
website: asNullableString(metaRaw.website),
|
|
306
|
+
image: asNullableString(metaRaw.image),
|
|
307
|
+
},
|
|
308
|
+
thumbnail_url: asNullableString(r.thumbnail_url),
|
|
309
|
+
};
|
|
310
|
+
// ADDITIVE ONLY: a layout/element row returns exactly `base` — no
|
|
311
|
+
// section keys are ever added, not even as `undefined` — so the
|
|
312
|
+
// pre-Task-8b shape stays byte-unchanged (regression-tested via
|
|
313
|
+
// toStrictEqual in library.test.ts).
|
|
314
|
+
if (kind !== 'section')
|
|
315
|
+
return base;
|
|
316
|
+
return {
|
|
317
|
+
...base,
|
|
318
|
+
layout_id: typeof r.layout_id === 'string' ? r.layout_id : undefined,
|
|
319
|
+
section_index: typeof r.section_index === 'number' ? r.section_index : undefined,
|
|
320
|
+
purpose: isSectionPurpose(r.purpose) ? r.purpose : undefined,
|
|
321
|
+
element_signature: typeof r.element_signature === 'string' ? r.element_signature : undefined,
|
|
322
|
+
visual_effects: Array.isArray(r.visual_effects)
|
|
323
|
+
? r.visual_effects.filter((v) => typeof v === 'string')
|
|
324
|
+
: undefined,
|
|
325
|
+
topic: asNullableString(r.topic),
|
|
326
|
+
type: asNullableString(r.type),
|
|
327
|
+
website: asNullableString(r.website),
|
|
328
|
+
has_images: typeof r.has_images === 'boolean' ? r.has_images : undefined,
|
|
329
|
+
image_count: typeof r.image_count === 'number' ? r.image_count : undefined,
|
|
330
|
+
element_count: typeof r.element_count === 'number' ? r.element_count : undefined,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
export async function handleLibraryBrowse({ client }, { kind, source, topic, type, role, website, element_type, purpose, has_effects, query, limit, cursor }) {
|
|
334
|
+
try {
|
|
335
|
+
const qs = new URLSearchParams();
|
|
336
|
+
if (kind !== undefined)
|
|
337
|
+
qs.set('kind', kind);
|
|
338
|
+
// Only forward a NON-default source (the PHP controller reads the same
|
|
339
|
+
// absent-param `catalog` default) so a catalog browse is byte-identical
|
|
340
|
+
// to the pre-Task-7 request.
|
|
341
|
+
if (source !== undefined && source !== DEFAULT_LIBRARY_SOURCE)
|
|
342
|
+
qs.set('source', source);
|
|
343
|
+
if (typeof topic === 'string' && topic !== '')
|
|
344
|
+
qs.set('topic', topic);
|
|
345
|
+
if (typeof type === 'string' && type !== '')
|
|
346
|
+
qs.set('type', type);
|
|
347
|
+
if (typeof role === 'string' && role !== '')
|
|
348
|
+
qs.set('role', role);
|
|
349
|
+
if (typeof website === 'string' && website !== '')
|
|
350
|
+
qs.set('website', website);
|
|
351
|
+
if (typeof element_type === 'string' && element_type !== '')
|
|
352
|
+
qs.set('element_type', element_type);
|
|
353
|
+
if (purpose !== undefined)
|
|
354
|
+
qs.set('purpose', purpose);
|
|
355
|
+
if (has_effects !== undefined)
|
|
356
|
+
qs.set('has_effects', String(has_effects));
|
|
357
|
+
if (typeof query === 'string' && query !== '')
|
|
358
|
+
qs.set('query', query);
|
|
359
|
+
if (limit !== undefined)
|
|
360
|
+
qs.set('limit', String(limit));
|
|
361
|
+
if (typeof cursor === 'string' && cursor !== '')
|
|
362
|
+
qs.set('cursor', cursor);
|
|
363
|
+
const qstr = qs.toString();
|
|
364
|
+
const data = await client.get(`/library/browse${qstr !== '' ? `?${qstr}` : ''}`);
|
|
365
|
+
const rawItems = Array.isArray(data.items) ? data.items : [];
|
|
366
|
+
const items = rawItems
|
|
367
|
+
.map((item) => mapLibraryItem(item, kind))
|
|
368
|
+
.filter((i) => i !== undefined);
|
|
369
|
+
const total = typeof data.total === 'number' ? data.total : items.length;
|
|
370
|
+
const nextCursor = typeof data.next_cursor === 'string' ? data.next_cursor : null;
|
|
371
|
+
const rows = items;
|
|
372
|
+
const toolkitResult = tableResult(rows, {
|
|
373
|
+
columns: [...LIBRARY_TABLE_COLUMNS],
|
|
374
|
+
header: (count) => `${String(count)} of ${String(total)} library items`,
|
|
375
|
+
footer: 'Activate one on a page with yootheme_builder_element_add / page_dynamize, ' +
|
|
376
|
+
'or pass `cursor` (next_cursor) for the next page.',
|
|
377
|
+
});
|
|
378
|
+
// HARD CONSTRAINT (Task 8): browse NEVER returns an image content
|
|
379
|
+
// block, even for kind="section" rows that carry a thumbnail_url —
|
|
380
|
+
// tableResult() only ever emits text rows. Use
|
|
381
|
+
// yootheme_builder_library_get's include_thumbnail on a shortlisted
|
|
382
|
+
// id for an actual image.
|
|
383
|
+
return structuredResult(toolkitResult, {
|
|
384
|
+
items,
|
|
385
|
+
total,
|
|
386
|
+
next_cursor: nextCursor,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
return errorResult({
|
|
391
|
+
error: e,
|
|
392
|
+
context: { kind: kind ?? 'layout', source: source ?? DEFAULT_LIBRARY_SOURCE, topic, type, role, website, element_type, purpose, has_effects, query },
|
|
393
|
+
hint: 'Run yootheme_builder_diagnose to verify auth. Omit filters to browse the full catalog.',
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function mapLibrarySection(raw) {
|
|
398
|
+
if (raw === null || typeof raw !== 'object')
|
|
399
|
+
return undefined;
|
|
400
|
+
const r = raw;
|
|
401
|
+
if (typeof r.index !== 'number'
|
|
402
|
+
|| typeof r.name !== 'string'
|
|
403
|
+
|| typeof r.path !== 'string'
|
|
404
|
+
|| typeof r.element_count !== 'number') {
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
return { index: r.index, name: r.name, path: r.path, element_count: r.element_count };
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Task 8 (Section Corpus Phase 1) — the REST `/library/get` response's
|
|
411
|
+
* `thumbnail` field (present ONLY when `include_thumbnail=true` was passed
|
|
412
|
+
* AND the server-side fetch succeeded — see `LibraryController::attachThumbnail()`).
|
|
413
|
+
* A missing/malformed field (either key absent, or either value not a
|
|
414
|
+
* non-empty string) is treated as "no thumbnail" — the caller never emits a
|
|
415
|
+
* broken image block.
|
|
416
|
+
*/
|
|
417
|
+
function mapThumbnail(raw) {
|
|
418
|
+
if (raw === null || typeof raw !== 'object')
|
|
419
|
+
return undefined;
|
|
420
|
+
const r = raw;
|
|
421
|
+
if (typeof r.mime_type !== 'string' || r.mime_type === '')
|
|
422
|
+
return undefined;
|
|
423
|
+
if (typeof r.data_base64 !== 'string' || r.data_base64 === '')
|
|
424
|
+
return undefined;
|
|
425
|
+
return { mime_type: r.mime_type, data_base64: r.data_base64 };
|
|
426
|
+
}
|
|
427
|
+
export async function handleLibraryGet({ client }, { id, kind, source, include_thumbnail }) {
|
|
428
|
+
try {
|
|
429
|
+
const qs = new URLSearchParams({ id });
|
|
430
|
+
if (kind !== undefined)
|
|
431
|
+
qs.set('kind', kind);
|
|
432
|
+
// Only forward a NON-default source (see handleLibraryBrowse).
|
|
433
|
+
if (source !== undefined && source !== DEFAULT_LIBRARY_SOURCE)
|
|
434
|
+
qs.set('source', source);
|
|
435
|
+
if (include_thumbnail === true)
|
|
436
|
+
qs.set('include_thumbnail', 'true');
|
|
437
|
+
const data = await client.get(`/library/get?${qs.toString()}`);
|
|
438
|
+
const resolvedKind = data.kind === 'element' ? 'element' : data.kind === 'section' ? 'section' : 'layout';
|
|
439
|
+
const tree = data.tree !== null && typeof data.tree === 'object' && !Array.isArray(data.tree)
|
|
440
|
+
? data.tree
|
|
441
|
+
: {};
|
|
442
|
+
const rawSections = Array.isArray(data.sections) ? data.sections : [];
|
|
443
|
+
const sections = rawSections
|
|
444
|
+
.map(mapLibrarySection)
|
|
445
|
+
.filter((s) => s !== undefined);
|
|
446
|
+
const payload = {
|
|
447
|
+
id: typeof data.id === 'string' ? data.id : id,
|
|
448
|
+
kind: resolvedKind,
|
|
449
|
+
name: typeof data.name === 'string' ? data.name : '',
|
|
450
|
+
version: typeof data.version === 'string' ? data.version : '',
|
|
451
|
+
tree,
|
|
452
|
+
sections,
|
|
453
|
+
images_manifest_count: typeof data.images_manifest_count === 'number' ? data.images_manifest_count : 0,
|
|
454
|
+
};
|
|
455
|
+
// Task 8: an image content block is ADDITIONAL to the JSON text
|
|
456
|
+
// block, never a replacement — `structuredContent` (below) stays
|
|
457
|
+
// exactly the pre-Task-8 shape so a strict-outputSchema host never
|
|
458
|
+
// sees an undeclared key, and a large base64 blob is never
|
|
459
|
+
// duplicated into structuredContent alongside the image block.
|
|
460
|
+
// Gated on the CALLER's own `include_thumbnail === true` (not just
|
|
461
|
+
// "the REST body happened to carry a thumbnail field") — a defensive
|
|
462
|
+
// belt-and-braces on top of the server-side opt-in gate, so a future
|
|
463
|
+
// REST regression can never surprise-inject an unrequested,
|
|
464
|
+
// token-costly image block.
|
|
465
|
+
const thumbnail = include_thumbnail === true ? mapThumbnail(data.thumbnail) : undefined;
|
|
466
|
+
const jsonEnvelope = jsonResult(payload);
|
|
467
|
+
const content = thumbnail !== undefined
|
|
468
|
+
? [...jsonEnvelope.content, { type: 'image', data: thumbnail.data_base64, mimeType: thumbnail.mime_type }]
|
|
469
|
+
: jsonEnvelope.content;
|
|
470
|
+
return structuredResult({ content }, payload);
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
return errorResult({
|
|
474
|
+
error: e,
|
|
475
|
+
context: { id, kind: kind ?? 'layout', source: source ?? DEFAULT_LIBRARY_SOURCE },
|
|
476
|
+
hint: 'Verify the id via yootheme_builder_library_browse first (its items[].id ' +
|
|
477
|
+
'feeds this tool directly) — ids are catalog- and kind-specific.',
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* 2026-07-12 fix ("trusted library-insert downgrades enum/type violations to
|
|
483
|
+
* warnings") — maps the REST `warnings[]` array (each `{field, code,
|
|
484
|
+
* message}`) if present. Mirrors the OTHER `map*` helpers in this file:
|
|
485
|
+
* malformed entries are dropped rather than propagated, and an absent/empty
|
|
486
|
+
* source array maps to `undefined` (the key is OMITTED from the result, not
|
|
487
|
+
* emitted as `[]`) — this is the "if present" contract: pre-fix inserts
|
|
488
|
+
* (a REST body carrying no `warnings` key at all) round-trip byte-unchanged.
|
|
489
|
+
*/
|
|
490
|
+
function mapLibraryInsertWarnings(raw) {
|
|
491
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
492
|
+
return undefined;
|
|
493
|
+
const warnings = [];
|
|
494
|
+
for (const entry of raw) {
|
|
495
|
+
if (entry === null || typeof entry !== 'object')
|
|
496
|
+
continue;
|
|
497
|
+
const w = entry;
|
|
498
|
+
if (typeof w.field !== 'string' || typeof w.code !== 'string' || typeof w.message !== 'string')
|
|
499
|
+
continue;
|
|
500
|
+
warnings.push({ field: w.field, code: w.code, message: w.message });
|
|
501
|
+
}
|
|
502
|
+
return warnings.length > 0 ? warnings : undefined;
|
|
503
|
+
}
|
|
504
|
+
function mapLibraryInsertResult(raw) {
|
|
505
|
+
if (raw === null || typeof raw !== 'object')
|
|
506
|
+
return undefined;
|
|
507
|
+
const r = raw;
|
|
508
|
+
if (typeof r.template_id !== 'string' || typeof r.inserted_path !== 'string')
|
|
509
|
+
return undefined;
|
|
510
|
+
const resolvedRaw = r.images_resolved !== null && typeof r.images_resolved === 'object'
|
|
511
|
+
? r.images_resolved
|
|
512
|
+
: {};
|
|
513
|
+
const warnings = mapLibraryInsertWarnings(r.warnings);
|
|
514
|
+
return {
|
|
515
|
+
template_id: r.template_id,
|
|
516
|
+
inserted_path: r.inserted_path,
|
|
517
|
+
element_count: typeof r.element_count === 'number' ? r.element_count : 0,
|
|
518
|
+
images_mode: r.images_mode === 'curated' ? 'curated' : 'placeholder',
|
|
519
|
+
images_resolved: {
|
|
520
|
+
downloaded: typeof resolvedRaw.downloaded === 'number' ? resolvedRaw.downloaded : 0,
|
|
521
|
+
placeholders: typeof resolvedRaw.placeholders === 'number' ? resolvedRaw.placeholders : 0,
|
|
522
|
+
},
|
|
523
|
+
new_etag: typeof r.new_etag === 'string' ? r.new_etag : '',
|
|
524
|
+
frontend_url: typeof r.frontend_url === 'string' ? r.frontend_url : null,
|
|
525
|
+
...(warnings !== undefined ? { warnings } : {}),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
export async function handleLibraryInsert({ client }, { id, kind, source, target, section_index, images, etag }) {
|
|
529
|
+
const resolvedImages = images ?? 'placeholder';
|
|
530
|
+
try {
|
|
531
|
+
const body = { id, target, images: resolvedImages };
|
|
532
|
+
if (kind !== undefined)
|
|
533
|
+
body.kind = kind;
|
|
534
|
+
// Only forward a NON-default source (see handleLibraryBrowse); the PHP
|
|
535
|
+
// LibraryInsertService reads the same absent-param `catalog` default.
|
|
536
|
+
if (source !== undefined && source !== DEFAULT_LIBRARY_SOURCE)
|
|
537
|
+
body.source = source;
|
|
538
|
+
if (section_index !== undefined)
|
|
539
|
+
body.section_index = section_index;
|
|
540
|
+
if (typeof etag === 'string' && etag !== '')
|
|
541
|
+
body.etag = etag;
|
|
542
|
+
const data = await client.post('/library/insert', { body });
|
|
543
|
+
const mapped = mapLibraryInsertResult(data);
|
|
544
|
+
if (mapped === undefined) {
|
|
545
|
+
throw new Error('Malformed /library/insert response — missing template_id/inserted_path.');
|
|
546
|
+
}
|
|
547
|
+
return structuredResult(jsonResult(mapped), mapped);
|
|
548
|
+
}
|
|
549
|
+
catch (e) {
|
|
550
|
+
return errorResult({
|
|
551
|
+
error: e,
|
|
552
|
+
context: { id, kind: kind ?? 'layout', source: source ?? DEFAULT_LIBRARY_SOURCE, images: resolvedImages },
|
|
553
|
+
hint: 'Verify the id via yootheme_builder_library_get first. A 422 target_not_a_column means ' +
|
|
554
|
+
'a preset (kind:"element") was pointed at a non-Column — scaffold a section→row→column ' +
|
|
555
|
+
'hull first with yootheme_builder_scaffold_section, then point element_path at the new ' +
|
|
556
|
+
'Column. A 412 means the etag is stale — re-read (get_etag/pages_list) and retry.',
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// ─── library_save_as (Task 7) ────────────────────────────────────────────
|
|
561
|
+
const LIBRARY_SAVE_SOURCE_SCHEMA = z.object({
|
|
562
|
+
template_id: z
|
|
563
|
+
.string()
|
|
564
|
+
.min(1)
|
|
565
|
+
.describe('The `page:<id>` template holding the subtree to save (from pages_list).'),
|
|
566
|
+
element_path: z
|
|
567
|
+
.string()
|
|
568
|
+
.optional()
|
|
569
|
+
.describe('JSON-Pointer rel-path to the subtree to save (e.g. "/children/0"). Omit to save the ENTIRE page layout.'),
|
|
570
|
+
});
|
|
571
|
+
export const LIBRARY_SAVE_AS_OUTPUT_SCHEMA = z.object({
|
|
572
|
+
library_id: z.string(),
|
|
573
|
+
name: z.string(),
|
|
574
|
+
storage: z.string(),
|
|
575
|
+
invalid_source_possible: z.boolean(),
|
|
576
|
+
});
|
|
577
|
+
function mapLibrarySaveAsResult(raw) {
|
|
578
|
+
if (raw === null || typeof raw !== 'object')
|
|
579
|
+
return undefined;
|
|
580
|
+
const r = raw;
|
|
581
|
+
if (typeof r.library_id !== 'string' || typeof r.name !== 'string' || typeof r.storage !== 'string') {
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
library_id: r.library_id,
|
|
586
|
+
name: r.name,
|
|
587
|
+
storage: r.storage,
|
|
588
|
+
invalid_source_possible: r.invalid_source_possible === true,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
export async function handleLibrarySaveAs({ client }, { source, name, destination }) {
|
|
592
|
+
try {
|
|
593
|
+
const body = { source, name };
|
|
594
|
+
// Only forward a NON-default destination (the PHP MyLayoutsStore reads
|
|
595
|
+
// the same absent-param `ai` default), so an ai-default save is
|
|
596
|
+
// byte-identical to the pre-Task-7 body — only the `native` escape
|
|
597
|
+
// hatch ever appears on the wire.
|
|
598
|
+
if (destination !== undefined && destination !== DEFAULT_SAVE_DESTINATION)
|
|
599
|
+
body.destination = destination;
|
|
600
|
+
const data = await client.post('/library/save', { body });
|
|
601
|
+
const mapped = mapLibrarySaveAsResult(data);
|
|
602
|
+
if (mapped === undefined) {
|
|
603
|
+
throw new Error('Malformed /library/save response — missing library_id/name/storage.');
|
|
604
|
+
}
|
|
605
|
+
return structuredResult(jsonResult(mapped), mapped);
|
|
606
|
+
}
|
|
607
|
+
catch (e) {
|
|
608
|
+
return errorResult({
|
|
609
|
+
error: e,
|
|
610
|
+
context: { template_id: source.template_id, element_path: source.element_path, name, destination: destination ?? DEFAULT_SAVE_DESTINATION },
|
|
611
|
+
hint: 'Verify template_id via yootheme_builder_pages_list and element_path via ' +
|
|
612
|
+
'yootheme_builder_page_get_layout (its rel_path column). A 404 ' +
|
|
613
|
+
'element_path_not_found means the pointer does not resolve inside that template.',
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// ─── image_search / image_import (Task D1, Image Sourcing) ────────────────
|
|
618
|
+
//
|
|
619
|
+
// Thin GET/POST forwards to `/library/image-search` (Task C1) mirroring
|
|
620
|
+
// library_browse's read-projection pattern, and `/library/image-import`
|
|
621
|
+
// mirroring library_insert's write-forward pattern. Response shapes are
|
|
622
|
+
// pinned by the PHP controller tests
|
|
623
|
+
// (tests/php/unit/Library/LibraryControllerImageSearchTest.php +
|
|
624
|
+
// LibraryControllerImageImportTest.php):
|
|
625
|
+
// GET /library/image-search → {items, total, source, query, hint?}
|
|
626
|
+
// POST /library/image-import → {imported:true, local_url} |
|
|
627
|
+
// {imported:false, reason}
|
|
628
|
+
const IMAGE_SOURCE_VALUES = ['unsplash', 'pexels', 'proimages'];
|
|
629
|
+
/**
|
|
630
|
+
* Cross-source orientation dialect (D1 concern, see the plan doc): the
|
|
631
|
+
* Unsplash proxy accepts "squarish", the Pexels proxy accepts "square" for
|
|
632
|
+
* the SAME concept. `ImageSearchService::buildQueryString()` (PHP) forwards
|
|
633
|
+
* whatever string is supplied verbatim, per-source, with NO translation — so
|
|
634
|
+
* this schema accepts BOTH spellings instead of forcing one source's
|
|
635
|
+
* vocabulary onto the other (which would either reject a legitimate Pexels
|
|
636
|
+
* "square" request or silently mis-filter it).
|
|
637
|
+
*/
|
|
638
|
+
const IMAGE_ORIENTATION_VALUES = ['landscape', 'portrait', 'squarish', 'square'];
|
|
639
|
+
const IMAGE_RESULT_SCHEMA = z.object({
|
|
640
|
+
id: z.string(),
|
|
641
|
+
source: z.string(),
|
|
642
|
+
thumb_url: z.string(),
|
|
643
|
+
full_url: z.string(),
|
|
644
|
+
width: z.number(),
|
|
645
|
+
height: z.number(),
|
|
646
|
+
alt: z.string(),
|
|
647
|
+
author: z.string(),
|
|
648
|
+
author_url: z.string(),
|
|
649
|
+
orientation: z.string(),
|
|
650
|
+
});
|
|
651
|
+
export const IMAGE_SEARCH_OUTPUT_SCHEMA = z.object({
|
|
652
|
+
items: z.array(IMAGE_RESULT_SCHEMA),
|
|
653
|
+
total: z.number(),
|
|
654
|
+
source: z.string(),
|
|
655
|
+
query: z.string(),
|
|
656
|
+
// Present ONLY on the empty-result path (no Library API key configured,
|
|
657
|
+
// or genuinely zero matches — the REST layer cannot and must not
|
|
658
|
+
// distinguish the two; see LibraryControllerImageSearchTest).
|
|
659
|
+
hint: z.string().optional(),
|
|
660
|
+
});
|
|
661
|
+
export const IMAGE_IMPORT_OUTPUT_SCHEMA = z.object({
|
|
662
|
+
imported: z.boolean(),
|
|
663
|
+
// Present only when imported:true.
|
|
664
|
+
local_url: z.string().optional(),
|
|
665
|
+
// Present only when imported:false (a caught importer failure — never a 500).
|
|
666
|
+
reason: z.string().optional(),
|
|
667
|
+
});
|
|
668
|
+
function mapImageResult(raw) {
|
|
669
|
+
if (raw === null || typeof raw !== 'object')
|
|
670
|
+
return undefined;
|
|
671
|
+
const r = raw;
|
|
672
|
+
if (typeof r.id !== 'string' || r.id === '')
|
|
673
|
+
return undefined;
|
|
674
|
+
return {
|
|
675
|
+
id: r.id,
|
|
676
|
+
source: typeof r.source === 'string' ? r.source : '',
|
|
677
|
+
thumb_url: typeof r.thumb_url === 'string' ? r.thumb_url : '',
|
|
678
|
+
full_url: typeof r.full_url === 'string' ? r.full_url : '',
|
|
679
|
+
width: typeof r.width === 'number' ? r.width : 0,
|
|
680
|
+
height: typeof r.height === 'number' ? r.height : 0,
|
|
681
|
+
alt: typeof r.alt === 'string' ? r.alt : '',
|
|
682
|
+
author: typeof r.author === 'string' ? r.author : '',
|
|
683
|
+
author_url: typeof r.author_url === 'string' ? r.author_url : '',
|
|
684
|
+
orientation: typeof r.orientation === 'string' ? r.orientation : '',
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
const IMAGE_TABLE_COLUMNS = [
|
|
688
|
+
{ key: 'source', label: 'SOURCE', width: 9 },
|
|
689
|
+
{ key: 'dimensions', label: 'SIZE', width: 10 },
|
|
690
|
+
{ key: 'author', label: 'AUTHOR', width: 18 },
|
|
691
|
+
{ key: 'orientation', label: 'ORIENT', width: 10 },
|
|
692
|
+
// llmOnly: the copyable next-call value (yootheme_builder_image_import's
|
|
693
|
+
// `url` arg) — excluded from the Rich Card UI (too long for a table
|
|
694
|
+
// cell) but present in the ASCII text the LLM reads. Always present in
|
|
695
|
+
// structuredContent.items[].full_url regardless of table membership.
|
|
696
|
+
{ key: 'full_url', label: 'FULL_URL', width: 50, llmOnly: true },
|
|
697
|
+
];
|
|
698
|
+
export async function handleImageSearch({ client }, { source, query, per_page, orientation, color }) {
|
|
699
|
+
const resolvedSource = source ?? 'unsplash';
|
|
700
|
+
try {
|
|
701
|
+
const qs = new URLSearchParams();
|
|
702
|
+
qs.set('source', resolvedSource);
|
|
703
|
+
qs.set('query', query);
|
|
704
|
+
if (per_page !== undefined)
|
|
705
|
+
qs.set('per_page', String(per_page));
|
|
706
|
+
if (typeof orientation === 'string' && orientation !== '')
|
|
707
|
+
qs.set('orientation', orientation);
|
|
708
|
+
if (typeof color === 'string' && color !== '')
|
|
709
|
+
qs.set('color', color);
|
|
710
|
+
const data = await client.get(`/library/image-search?${qs.toString()}`);
|
|
711
|
+
const rawItems = Array.isArray(data.items) ? data.items : [];
|
|
712
|
+
const items = rawItems.map(mapImageResult).filter((i) => i !== undefined);
|
|
713
|
+
const total = typeof data.total === 'number' ? data.total : items.length;
|
|
714
|
+
const resolvedResponseSource = typeof data.source === 'string' ? data.source : resolvedSource;
|
|
715
|
+
const resolvedQuery = typeof data.query === 'string' ? data.query : query;
|
|
716
|
+
const hint = typeof data.hint === 'string' ? data.hint : undefined;
|
|
717
|
+
const rows = items.map((i) => ({
|
|
718
|
+
...i,
|
|
719
|
+
dimensions: `${String(i.width)}x${String(i.height)}`,
|
|
720
|
+
}));
|
|
721
|
+
const toolkitResult = tableResult(rows, {
|
|
722
|
+
columns: [...IMAGE_TABLE_COLUMNS],
|
|
723
|
+
header: (count) => `${String(count)} of ${String(total)} ${resolvedResponseSource} image${total === 1 ? '' : 's'} for "${resolvedQuery}"`,
|
|
724
|
+
footer: hint
|
|
725
|
+
?? 'Import a chosen row\'s full_url with yootheme_builder_image_import, then bind '
|
|
726
|
+
+ 'the returned local_url via element_update_settings.',
|
|
727
|
+
});
|
|
728
|
+
return structuredResult(toolkitResult, {
|
|
729
|
+
items,
|
|
730
|
+
total,
|
|
731
|
+
source: resolvedResponseSource,
|
|
732
|
+
query: resolvedQuery,
|
|
733
|
+
...(hint !== undefined ? { hint } : {}),
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
catch (e) {
|
|
737
|
+
return errorResult({
|
|
738
|
+
error: e,
|
|
739
|
+
context: { source: resolvedSource, query, per_page, orientation, color },
|
|
740
|
+
hint: 'total:0 usually means no YOOtheme Pro Library API key is configured (the same '
|
|
741
|
+
+ 'theme apikey library_browse/insert use). Run yootheme_builder_diagnose to verify auth.',
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
function mapImageImportResult(raw) {
|
|
746
|
+
if (raw === null || typeof raw !== 'object')
|
|
747
|
+
return undefined;
|
|
748
|
+
const r = raw;
|
|
749
|
+
if (typeof r.imported !== 'boolean')
|
|
750
|
+
return undefined;
|
|
751
|
+
if (r.imported) {
|
|
752
|
+
if (typeof r.local_url !== 'string' || r.local_url === '')
|
|
753
|
+
return undefined;
|
|
754
|
+
return { imported: true, local_url: r.local_url };
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
imported: false,
|
|
758
|
+
reason: typeof r.reason === 'string' && r.reason !== '' ? r.reason : 'Import failed for an unknown reason.',
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
export async function handleImageImport({ client }, { url }) {
|
|
762
|
+
try {
|
|
763
|
+
const data = await client.post('/library/image-import', { body: { url } });
|
|
764
|
+
const mapped = mapImageImportResult(data);
|
|
765
|
+
if (mapped === undefined) {
|
|
766
|
+
throw new Error('Malformed /library/image-import response — missing imported/local_url.');
|
|
767
|
+
}
|
|
768
|
+
return structuredResult(jsonResult(mapped), mapped);
|
|
769
|
+
}
|
|
770
|
+
catch (e) {
|
|
771
|
+
return errorResult({
|
|
772
|
+
error: e,
|
|
773
|
+
context: { url },
|
|
774
|
+
hint: 'Verify the url is a reachable http(s) image URL (typically a full_url from '
|
|
775
|
+
+ 'yootheme_builder_image_search). A read_only_token error means this bearer key '
|
|
776
|
+
+ 'needs the write scope — regenerate it in YT Builder MCP → Keys.',
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
export function buildLibraryTools(pool) {
|
|
781
|
+
return [
|
|
782
|
+
defineTool({
|
|
783
|
+
name: 'yootheme_builder_library_browse',
|
|
784
|
+
description: 'Browse the YOOtheme Pro Library catalog: pre-designed page LAYOUTS, element ' +
|
|
785
|
+
'PRESETS, or purpose-classified SECTIONS (hero/features/cta/...). Filter with ' +
|
|
786
|
+
'`kind` ("layout" default | "element" | "section"), `topic`, `type`/`role`, ' +
|
|
787
|
+
'`element_type`, `purpose`/`has_effects` (sections only), `website`, `query`. ' +
|
|
788
|
+
'Returns `{items, total, next_cursor}`; a "section" id is the composite ' +
|
|
789
|
+
'"<layoutId>#<sectionIndex>" — feeds straight into library_get/library_insert. ' +
|
|
790
|
+
'`total:0` usually means no Library API key configured. Operates on the default ' +
|
|
791
|
+
'site unless site_id is provided.',
|
|
792
|
+
inputSchema: {
|
|
793
|
+
site_id: SITE_ID_SCHEMA,
|
|
794
|
+
kind: z
|
|
795
|
+
.enum(['layout', 'element', 'section'])
|
|
796
|
+
.optional()
|
|
797
|
+
.describe('Catalog to browse: "layout" (full pre-designed pages, default), "element" ' +
|
|
798
|
+
'(single-element presets), or "section" (individual page sections classified ' +
|
|
799
|
+
'by purpose). A "section" row\'s `id` is the COMPOSITE ' +
|
|
800
|
+
'"<layoutId>#<sectionIndex>" (e.g. "8563daec#0") — pass it straight to ' +
|
|
801
|
+
'yootheme_builder_library_get / yootheme_builder_library_insert with kind:"section".'),
|
|
802
|
+
source: z
|
|
803
|
+
.enum(LIBRARY_SOURCE_VALUES)
|
|
804
|
+
.default('catalog')
|
|
805
|
+
.describe('Which library to browse: "catalog" (the remote YOOtheme Pro Library, default) ' +
|
|
806
|
+
'or "ai" (your OWN "WootsUp AI Layouts" saved via library_save_as — no API ' +
|
|
807
|
+
'key needed). The "ai" source honours only `query` + `kind` (layout|element); ' +
|
|
808
|
+
'catalog layout/element taxonomy filters (topic/type/role/website) and ' +
|
|
809
|
+
'kind:"section" are rejected with a 400. (purpose/has_effects are section-only ' +
|
|
810
|
+
'and never apply to a layout/element browse, so — like on the catalog path — ' +
|
|
811
|
+
'they are silently ignored on source:"ai", not rejected.)'),
|
|
812
|
+
topic: z
|
|
813
|
+
.string()
|
|
814
|
+
.min(1)
|
|
815
|
+
.optional()
|
|
816
|
+
.describe('Site industry/topic filter (e.g. "Health & Fitness", "Beauty & Fashion"). Exact match.'),
|
|
817
|
+
type: z
|
|
818
|
+
.string()
|
|
819
|
+
.min(1)
|
|
820
|
+
.optional()
|
|
821
|
+
.describe('Layout site-purpose filter (e.g. "Company", "E-commerce"). Only applies when kind="layout". Exact match.'),
|
|
822
|
+
role: z
|
|
823
|
+
.string()
|
|
824
|
+
.min(1)
|
|
825
|
+
.optional()
|
|
826
|
+
.describe('Page-role filter (e.g. "Home", "Contact", "Services"). Only applies when kind="layout". Exact match.'),
|
|
827
|
+
website: z
|
|
828
|
+
.string()
|
|
829
|
+
.min(1)
|
|
830
|
+
.optional()
|
|
831
|
+
.describe('Source demo-website name filter (e.g. "FC Greenfield", "Makai"). Exact match.'),
|
|
832
|
+
element_type: z
|
|
833
|
+
.string()
|
|
834
|
+
.min(1)
|
|
835
|
+
.optional()
|
|
836
|
+
.describe('Preset element-type filter (e.g. "accordion", "alert"). Only applies when kind="element". Exact match.'),
|
|
837
|
+
purpose: z
|
|
838
|
+
.enum(SECTION_PURPOSE_VALUES)
|
|
839
|
+
.optional()
|
|
840
|
+
.describe('Section-purpose filter (e.g. "hero", "features", "cta"). Only applies when kind="section". Exact match.'),
|
|
841
|
+
has_effects: z
|
|
842
|
+
.boolean()
|
|
843
|
+
.optional()
|
|
844
|
+
.describe('Filter sections by whether they carry a scroll/visual effect (parallax, ' +
|
|
845
|
+
'floating decoration, sticky, viewport-height). Only applies when kind="section".'),
|
|
846
|
+
query: z
|
|
847
|
+
.string()
|
|
848
|
+
.min(1)
|
|
849
|
+
.optional()
|
|
850
|
+
.describe('Case-insensitive substring match on the item name (e.g. "hero").'),
|
|
851
|
+
limit: z
|
|
852
|
+
.number()
|
|
853
|
+
.int()
|
|
854
|
+
.min(1)
|
|
855
|
+
.max(100)
|
|
856
|
+
.optional()
|
|
857
|
+
.describe('Max rows to return (1-100, default 25). The response carries next_cursor when more remain.'),
|
|
858
|
+
cursor: z
|
|
859
|
+
.string()
|
|
860
|
+
.optional()
|
|
861
|
+
.describe('Opaque cursor from a previous response\'s next_cursor — fetches the next page.'),
|
|
862
|
+
},
|
|
863
|
+
outputSchema: LIBRARY_BROWSE_OUTPUT_SCHEMA,
|
|
864
|
+
annotations: readOnly('Browse Layout Library'),
|
|
865
|
+
handler: async ({ site_id, ...rest }) => withSiteRetry(pool, site_id, async (client, site) => withSiteMeta(await handleLibraryBrowse({ client }, rest), site)),
|
|
866
|
+
}),
|
|
867
|
+
defineTool({
|
|
868
|
+
name: 'yootheme_builder_library_get',
|
|
869
|
+
description: 'Fetch a SINGLE YOOtheme Pro Library layout or element preset by `id` — call ' +
|
|
870
|
+
'after yootheme_builder_library_browse (items[].id feeds this). Returns ' +
|
|
871
|
+
'`{id, kind, name, version, tree, sections, images_manifest_count}`: `tree` is ' +
|
|
872
|
+
'the raw JSON; `sections` lists `tree.children` (layouts only, presets return ' +
|
|
873
|
+
'`[]`), each `{index, name, path, element_count}`. `include_thumbnail:true` ' +
|
|
874
|
+
'also returns an image block (costs tokens — see its own description). ' +
|
|
875
|
+
'Read-only; does not insert/bind. Operates on the default site unless site_id is provided.',
|
|
876
|
+
inputSchema: {
|
|
877
|
+
site_id: SITE_ID_SCHEMA,
|
|
878
|
+
id: z
|
|
879
|
+
.string()
|
|
880
|
+
.min(1)
|
|
881
|
+
.describe('The Library item id (e.g. "955bd3dc") — copy from a yootheme_builder_library_browse row\'s `items[].id`.'),
|
|
882
|
+
kind: z
|
|
883
|
+
.enum(['layout', 'element', 'section'])
|
|
884
|
+
.optional()
|
|
885
|
+
.describe('The item\'s catalog: "layout" (default), "element" (preset), or "section" ' +
|
|
886
|
+
'(id is the COMPOSITE "<layoutId>#<sectionIndex>", e.g. "8563daec#2"). ' +
|
|
887
|
+
'Must match the id\'s own kind.'),
|
|
888
|
+
source: z
|
|
889
|
+
.enum(LIBRARY_SOURCE_VALUES)
|
|
890
|
+
.default('catalog')
|
|
891
|
+
.describe('Which library the `id` belongs to: "catalog" (remote YOOtheme Pro Library, ' +
|
|
892
|
+
'default) or "ai" (your OWN "WootsUp AI Layouts"). Must match the source the ' +
|
|
893
|
+
'id came from in yootheme_builder_library_browse.'),
|
|
894
|
+
include_thumbnail: z
|
|
895
|
+
.boolean()
|
|
896
|
+
.default(false)
|
|
897
|
+
.describe('Fetch the item\'s thumbnail and return it as an ADDITIONAL image content block ' +
|
|
898
|
+
'(alongside the JSON). Costs real tokens — measured ~952 tokens for a layout ' +
|
|
899
|
+
'thumbnail (2026-07-12) — so use it ONLY to visually confirm 2-3 shortlisted ' +
|
|
900
|
+
'finalists right before inserting, never for bulk browsing (yootheme_builder_library_browse\'s ' +
|
|
901
|
+
'items[].thumbnail_url is a plain URL and costs nothing). Default false.'),
|
|
902
|
+
},
|
|
903
|
+
outputSchema: LIBRARY_GET_OUTPUT_SCHEMA,
|
|
904
|
+
annotations: readOnly('Get Layout Library Item'),
|
|
905
|
+
handler: async ({ site_id, ...rest }) => withSiteRetry(pool, site_id, async (client, site) => withSiteMeta(await handleLibraryGet({ client }, rest), site)),
|
|
906
|
+
}),
|
|
907
|
+
defineTool({
|
|
908
|
+
name: 'yootheme_builder_library_insert',
|
|
909
|
+
description: 'Insert a YOOtheme Pro Library item (layout, section, or preset) onto a page — ' +
|
|
910
|
+
'call after yootheme_builder_library_get/browse. `target`: new_page (creates a ' +
|
|
911
|
+
'page), {template_id,element_path?} (splices onto the layout root, or a preset ' +
|
|
912
|
+
'onto a Column), or sublayout (splices rows into a fragment). A preset ' +
|
|
913
|
+
'(kind:"element") MUST target a Column — 422 otherwise; scaffold one first with ' +
|
|
914
|
+
'yootheme_builder_scaffold_section. `images`: "placeholder" (default) or ' +
|
|
915
|
+
'"curated" (downloads to Media Library). Optional `etag` locks (412 if stale). ' +
|
|
916
|
+
'Operates on the default site unless site_id is provided.',
|
|
917
|
+
inputSchema: {
|
|
918
|
+
site_id: SITE_ID_SCHEMA,
|
|
919
|
+
id: z
|
|
920
|
+
.string()
|
|
921
|
+
.min(1)
|
|
922
|
+
.describe('The Library item id (e.g. "955bd3dc") — copy from a yootheme_builder_library_browse/library_get result.'),
|
|
923
|
+
kind: z
|
|
924
|
+
.enum(['layout', 'element', 'section'])
|
|
925
|
+
.optional()
|
|
926
|
+
.describe('The item\'s catalog: "layout" (default), "element" (preset, MUST target a ' +
|
|
927
|
+
'Column), or "section" (id is the COMPOSITE "<layoutId>#<sectionIndex>", ' +
|
|
928
|
+
'e.g. "8563daec#2"). Must match the id\'s own kind.'),
|
|
929
|
+
source: z
|
|
930
|
+
.enum(LIBRARY_SOURCE_VALUES)
|
|
931
|
+
.default('catalog')
|
|
932
|
+
.describe('Which library the `id` belongs to: "catalog" (remote YOOtheme Pro Library, ' +
|
|
933
|
+
'default) or "ai" (your OWN "WootsUp AI Layouts", composed AS-SAVED — its ' +
|
|
934
|
+
'images are never re-resolved). Must match the source the id came from.'),
|
|
935
|
+
target: LIBRARY_INSERT_TARGET_SCHEMA.describe('EXACTLY ONE of new_page / {template_id,element_path} / sublayout — see the tool description for the target-rule matrix.'),
|
|
936
|
+
section_index: z
|
|
937
|
+
.number()
|
|
938
|
+
.int()
|
|
939
|
+
.min(0)
|
|
940
|
+
.optional()
|
|
941
|
+
.describe('Reduce a multi-section layout to ONE section before inserting (its index in library_get\'s `sections[]`). Layouts only, ignored for kind:"element".'),
|
|
942
|
+
images: z
|
|
943
|
+
.enum(['placeholder', 'curated'])
|
|
944
|
+
.default('placeholder')
|
|
945
|
+
.describe('"placeholder" (default, safe, no network I/O) or "curated" (downloads + sideloads every unique image into the Media Library).'),
|
|
946
|
+
etag: z
|
|
947
|
+
.string()
|
|
948
|
+
.optional()
|
|
949
|
+
.describe('Optional optimistic-lock ETag (from get_etag/pages_list). Mismatched → 412; caller must re-read and retry.'),
|
|
950
|
+
},
|
|
951
|
+
outputSchema: LIBRARY_INSERT_OUTPUT_SCHEMA,
|
|
952
|
+
annotations: creating('Insert Layout Library Item'),
|
|
953
|
+
handler: async ({ site_id, ...rest }) => withWriteSiteRetry(pool, site_id, 'yootheme_builder_library_insert', async (client, site) => withSiteMeta(await handleLibraryInsert({ client }, rest), site)),
|
|
954
|
+
}),
|
|
955
|
+
// Task 7 (Layout Library, Spec #7) — ADVANCED tool (deliberately NOT
|
|
956
|
+
// added to essentials.ts/ESSENTIAL_TOOLS): reachable ONLY via
|
|
957
|
+
// yootheme_builder_advanced({tool, arguments}), never a first-class
|
|
958
|
+
// tools/list entry. See the module docblock's "Tiering" note.
|
|
959
|
+
defineTool({
|
|
960
|
+
name: 'yootheme_builder_library_save_as',
|
|
961
|
+
description: 'Save a page or subtree into a reusable library — the REVERSE of ' +
|
|
962
|
+
'yootheme_builder_library_insert. `source`: {template_id, ' +
|
|
963
|
+
'element_path?} (omit element_path to save the WHOLE page). ' +
|
|
964
|
+
'`destination`: "ai" (default — your isolated "WootsUp AI Layouts", ' +
|
|
965
|
+
'reusable via library_browse/insert source:"ai") or "native" ' +
|
|
966
|
+
'(YOOtheme\'s built-in My Layouts). Returns `{library_id, name, ' +
|
|
967
|
+
'storage, invalid_source_possible}` (the last flags bound sources ' +
|
|
968
|
+
'that may need re-binding after reload). Advanced/gateway-only. ' +
|
|
969
|
+
'Operates on the default site unless site_id is provided.',
|
|
970
|
+
inputSchema: {
|
|
971
|
+
site_id: SITE_ID_SCHEMA,
|
|
972
|
+
source: LIBRARY_SAVE_SOURCE_SCHEMA.describe('The page/subtree to save.'),
|
|
973
|
+
name: z
|
|
974
|
+
.string()
|
|
975
|
+
.min(1)
|
|
976
|
+
.describe('Display name for the new library entry (e.g. "Hero Section").'),
|
|
977
|
+
destination: z
|
|
978
|
+
.enum(SAVE_DESTINATION_VALUES)
|
|
979
|
+
.default('ai')
|
|
980
|
+
.describe('Where to save: "ai" (default — the isolated "WootsUp AI Layouts" store, ' +
|
|
981
|
+
'reusable by the agent via library_browse/insert with source:"ai", never ' +
|
|
982
|
+
'pollutes YOOtheme\'s native My Layouts) or "native" (the escape hatch: save ' +
|
|
983
|
+
'into YOOtheme\'s built-in "My Layouts").'),
|
|
984
|
+
},
|
|
985
|
+
outputSchema: LIBRARY_SAVE_AS_OUTPUT_SCHEMA,
|
|
986
|
+
annotations: creating('Save to My Layouts'),
|
|
987
|
+
handler: async ({ site_id, ...rest }) => withWriteSiteRetry(pool, site_id, 'yootheme_builder_library_save_as', async (client, site) => withSiteMeta(await handleLibrarySaveAs({ client }, rest), site)),
|
|
988
|
+
}),
|
|
989
|
+
// Task D1 (Image Sourcing) — read-only forward, mirrors library_browse.
|
|
990
|
+
defineTool({
|
|
991
|
+
name: 'yootheme_builder_image_search',
|
|
992
|
+
description: 'Search real on-topic photos (Unsplash/Pexels) or YOOtheme Pro Images to fill '
|
|
993
|
+
+ 'layout image slots with REAL, on-brand photography instead of bare '
|
|
994
|
+
+ 'placeholders. Returns candidate rows with `full_url` (pass straight to '
|
|
995
|
+
+ 'yootheme_builder_image_import) plus dimensions/author/orientation. '
|
|
996
|
+
+ '`total:0` usually means no Library API key is configured (the same theme '
|
|
997
|
+
+ 'apikey library_browse/insert use). Operates on the default site unless '
|
|
998
|
+
+ 'site_id is provided.',
|
|
999
|
+
inputSchema: {
|
|
1000
|
+
site_id: SITE_ID_SCHEMA,
|
|
1001
|
+
source: z
|
|
1002
|
+
.enum(IMAGE_SOURCE_VALUES)
|
|
1003
|
+
.default('unsplash')
|
|
1004
|
+
.describe('Image source. unsplash/pexels = real photography; proimages = YOOtheme '
|
|
1005
|
+
+ 'Pro curated decorative/background assets, not topical photography.'),
|
|
1006
|
+
query: z
|
|
1007
|
+
.string()
|
|
1008
|
+
.min(1)
|
|
1009
|
+
.describe('Search terms, e.g. "coffee roasting", "modern architecture interior". '
|
|
1010
|
+
+ 'Search deliberately by industry + tone, not a generic term like "business".'),
|
|
1011
|
+
per_page: z
|
|
1012
|
+
.number()
|
|
1013
|
+
.int()
|
|
1014
|
+
.min(1)
|
|
1015
|
+
.max(30)
|
|
1016
|
+
.default(12)
|
|
1017
|
+
.describe('How many candidates to return (1-30, default 12).'),
|
|
1018
|
+
orientation: z
|
|
1019
|
+
.enum(IMAGE_ORIENTATION_VALUES)
|
|
1020
|
+
.optional()
|
|
1021
|
+
.describe('landscape for heroes/banners, portrait for cards. "squarish" is the '
|
|
1022
|
+
+ 'Unsplash spelling, "square" is the Pexels spelling for the same shape. '
|
|
1023
|
+
+ 'Both are accepted; ignored for proimages.'),
|
|
1024
|
+
color: z
|
|
1025
|
+
.string()
|
|
1026
|
+
.min(1)
|
|
1027
|
+
.optional()
|
|
1028
|
+
.describe('Optional upstream color filter (e.g. "black_and_white"). Passed through as-is.'),
|
|
1029
|
+
},
|
|
1030
|
+
outputSchema: IMAGE_SEARCH_OUTPUT_SCHEMA,
|
|
1031
|
+
annotations: readOnly('Search Images'),
|
|
1032
|
+
handler: async ({ site_id, ...rest }) => withSiteRetry(pool, site_id, async (client, site) => withSiteMeta(await handleImageSearch({ client }, rest), site)),
|
|
1033
|
+
}),
|
|
1034
|
+
// Task D1 (Image Sourcing) — write forward, mirrors library_insert.
|
|
1035
|
+
// mutating() (writes to the Media Library) but NOT destructive() — it
|
|
1036
|
+
// only creates, never deletes (Global Constraints, plan doc).
|
|
1037
|
+
defineTool({
|
|
1038
|
+
name: 'yootheme_builder_image_import',
|
|
1039
|
+
description: 'Sideload a chosen photo (typically a `full_url` from '
|
|
1040
|
+
+ 'yootheme_builder_image_search) into the site\'s own Media Library. Returns '
|
|
1041
|
+
+ '`{imported:true, local_url}`. Bind local_url into an element via '
|
|
1042
|
+
+ 'element_update_settings({props:{image: local_url}, merge:true}). A caught '
|
|
1043
|
+
+ 'import failure returns `{imported:false, reason}`, never a 500. Operates on '
|
|
1044
|
+
+ 'the default site unless site_id is provided.',
|
|
1045
|
+
inputSchema: {
|
|
1046
|
+
site_id: SITE_ID_SCHEMA,
|
|
1047
|
+
url: z
|
|
1048
|
+
.string()
|
|
1049
|
+
.url()
|
|
1050
|
+
.describe('The image URL to sideload (typically a full_url from '
|
|
1051
|
+
+ 'yootheme_builder_image_search). Downloads it into the site Media '
|
|
1052
|
+
+ 'Library and returns a local URL to bind via element_update_settings.'),
|
|
1053
|
+
},
|
|
1054
|
+
outputSchema: IMAGE_IMPORT_OUTPUT_SCHEMA,
|
|
1055
|
+
annotations: mutating('Import Image'),
|
|
1056
|
+
handler: async ({ site_id, ...rest }) => withWriteSiteRetry(pool, site_id, 'yootheme_builder_image_import', async (client, site) => withSiteMeta(await handleImageImport({ client }, rest), site)),
|
|
1057
|
+
}),
|
|
1058
|
+
];
|
|
1059
|
+
}
|
|
1060
|
+
//# sourceMappingURL=library.js.map
|